From 05e2156843aeedd167d67703df1dd0ef78d010c2 Mon Sep 17 00:00:00 2001 From: windpacer Date: Sat, 9 May 2026 04:28:10 +0900 Subject: [PATCH] mcp-server warning clear --- .mcp.json | 9 + .../Mcp/McpServerHostedService.cs | 103 -- .../Graph_Pipeline_Phase1.md | 137 -- .../Graph_Pipeline_Phase2.md | 126 -- .../Graph_Pipeline_Phase3.md | 125 -- .../Graph_Pipeline_Phase4.md | 103 -- .../2026-05-02_0448/Graph_Pipeline_Phase2.md | 145 -- .../Graph_Pipeline_Phase3.md | 158 -- .../Graph_Pipeline_Phase4.md | 145 -- .../2026-05-02_08-40/mcp-server/server.py | 1101 ------------- .../Application/Services/PidGraphService.cs | 91 -- .../src/Web/Controllers/PidGraphController.cs | 100 -- .../src/Web/wwwroot/js/pid-viewer.js | 241 --- .../src/Web/wwwroot/js/pid-viewer.js | 308 ---- .../Mcp/McpServerHostedService.cs | 93 -- .../OpcUa/ExperionOpcServerService.cs | 301 ---- .../OpcUa/ExperionFastService.cs | 341 ---- .../OpcUa/ExperionRealtimeService.cs | 496 ------ .../src/Infrastructure/Mcp/McpClient.cs | 217 --- .../Application/Services/PidGraphService.cs | 97 -- .../src/Web/Controllers/PidGraphController.cs | 115 -- .../Graph_Pipeline_Phase1.md | 219 --- .../Graph_Pipeline_Phase2.md | 180 -- .../Graph_Pipeline_Phase3.md | 211 --- .../Graph_Pipeline_Phase4.md | 197 --- .../Graph_Pipeline_Phase5.md | 138 -- .../mcp-server/worker/rag_worker.py | 228 --- .../mcp-server/worker/nl2sql_worker.py | 277 ---- .../2026-05-03-030910/mcp-server/server.py | 1442 ---------------- .../2026-05-03-030956/mcp-server/server.py | 1446 ----------------- .../mcp-server/worker/pid_worker.py | 466 ------ .../mcp-server/worker/rag_worker.py | 229 --- .../mcp-server/worker/pid_worker.py | 466 ------ .../2026-05-03_012847/mcp-server/server.py | 1101 ------------- .../mcp-server/worker/pid_worker.py | 609 ------- .../mcp-server/pipeline/topology.py | 123 -- .../mcp-server/pipeline/topology.py | 125 -- .../mcp-server/pipeline/topology.py | 147 -- .../mcp-server/pipeline/mapper.py | 122 -- .../mcp-server/pipeline/topology.py | 168 -- .../mcp-server/pipeline/extractor.py | 173 -- .../mcp-server/server.py | 564 ++++--- .../mcp-server/worker/nl2sql_worker.py | 126 +- .../Database/ExperionDbContext.cs | 140 ++ .../OpcUa/MetadataLoaderService.cs | 131 ++ .../src/Web/wwwroot/js/app.js | 178 +- mcp-server-진단-문제점-개선방안.md | 407 +++++ mcp-server/server.py | 435 +---- mcp-server/worker/nl2sql_worker.py | 10 +- plans/enum-metadata-optimize-coding-plan.md | 187 ++- .../Services/PidExtractorService.cs | 8 +- src/Infrastructure/OpcUa/ExperionOpcClient.cs | 10 +- .../OpcUa/ExperionRealtimeService.cs | 2 + src/Web/Controllers/PidGraphController.cs | 8 +- src/Web/Controllers/TextToSqlController.cs | 10 +- 55 files changed, 1555 insertions(+), 13280 deletions(-) create mode 100644 .mcp.json delete mode 100644 .rooBackup/2026-04-30-204126/src/Infrastructure/Mcp/McpServerHostedService.cs delete mode 100644 .rooBackup/2026-05-02_0133/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase1.md delete mode 100644 .rooBackup/2026-05-02_0139/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase2.md delete mode 100644 .rooBackup/2026-05-02_0146/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md delete mode 100644 .rooBackup/2026-05-02_0152/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md delete mode 100644 .rooBackup/2026-05-02_0448/Graph_Pipeline_Phase2.md delete mode 100644 .rooBackup/2026-05-02_0517/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md delete mode 100644 .rooBackup/2026-05-02_0530/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md delete mode 100644 .rooBackup/2026-05-02_08-40/mcp-server/server.py delete mode 100644 .rooBackup/2026-05-02_10-02/src/Core/Application/Services/PidGraphService.cs delete mode 100644 .rooBackup/2026-05-02_10-02/src/Web/Controllers/PidGraphController.cs delete mode 100644 .rooBackup/2026-05-02_10-06/src/Web/wwwroot/js/pid-viewer.js delete mode 100644 .rooBackup/2026-05-02_10-15/src/Web/wwwroot/js/pid-viewer.js delete mode 100644 .rooBackup/2026-05-02_10-51/src/Infrastructure/Mcp/McpServerHostedService.cs delete mode 100644 .rooBackup/2026-05-02_10-52/src/Infrastructure/OpcUa/ExperionOpcServerService.cs delete mode 100644 .rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionFastService.cs delete mode 100644 .rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionRealtimeService.cs delete mode 100644 .rooBackup/2026-05-02_11-45/src/Infrastructure/Mcp/McpClient.cs delete mode 100644 .rooBackup/2026-05-02_11-53/src/Core/Application/Services/PidGraphService.cs delete mode 100644 .rooBackup/2026-05-02_11-56/src/Web/Controllers/PidGraphController.cs delete mode 100644 .rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase1.md delete mode 100644 .rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase2.md delete mode 100644 .rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase3.md delete mode 100644 .rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase4.md delete mode 100644 .rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase5.md delete mode 100644 .rooBackup/2026-05-03-030707/mcp-server/worker/rag_worker.py delete mode 100644 .rooBackup/2026-05-03-030821/mcp-server/worker/nl2sql_worker.py delete mode 100644 .rooBackup/2026-05-03-030910/mcp-server/server.py delete mode 100644 .rooBackup/2026-05-03-030956/mcp-server/server.py delete mode 100644 .rooBackup/2026-05-03-031200/mcp-server/worker/pid_worker.py delete mode 100644 .rooBackup/2026-05-03-031200/mcp-server/worker/rag_worker.py delete mode 100644 .rooBackup/2026-05-03-141200/mcp-server/worker/pid_worker.py delete mode 100644 .rooBackup/2026-05-03_012847/mcp-server/server.py delete mode 100644 .rooBackup/2026-05-03_013114/mcp-server/worker/pid_worker.py delete mode 100644 .rooBackup/2026-05-03_08-40/mcp-server/pipeline/topology.py delete mode 100644 .rooBackup/2026-05-03_08-42/mcp-server/pipeline/topology.py delete mode 100644 .rooBackup/2026-05-03_08-46/mcp-server/pipeline/topology.py delete mode 100644 .rooBackup/2026-05-03_08-49/mcp-server/pipeline/mapper.py delete mode 100644 .rooBackup/2026-05-03_08-52/mcp-server/pipeline/topology.py delete mode 100644 .rooBackup/2026-05-03_08-56/mcp-server/pipeline/extractor.py rename .rooBackup/{2026-05-03-031119 => enum-opt-202605081758}/mcp-server/server.py (78%) rename .rooBackup/{2026-05-03-031200 => enum-opt-202605081758}/mcp-server/worker/nl2sql_worker.py (59%) rename .rooBackup/{2026-04-30-15-03-15 => enum-opt-202605081758}/src/Infrastructure/Database/ExperionDbContext.cs (89%) create mode 100644 .rooBackup/enum-opt-202605081758/src/Infrastructure/OpcUa/MetadataLoaderService.cs rename .rooBackup/{2026-05-02_11-30 => enum-opt-202605081758}/src/Web/wwwroot/js/app.js (94%) create mode 100644 mcp-server-진단-문제점-개선방안.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..bbe35a2 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "iiot-rag": { + "command": "uv", + "args": ["run", "--directory", "mcp-server", "python", "server.py"], + "type": "stdio" + } + } +} diff --git a/.rooBackup/2026-04-30-204126/src/Infrastructure/Mcp/McpServerHostedService.cs b/.rooBackup/2026-04-30-204126/src/Infrastructure/Mcp/McpServerHostedService.cs deleted file mode 100644 index 7004dd0..0000000 --- a/.rooBackup/2026-04-30-204126/src/Infrastructure/Mcp/McpServerHostedService.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Diagnostics; - -namespace ExperionCrawler.Infrastructure.Mcp; - -public class McpServerHostedService : IHostedService -{ - private readonly McpClient _mcpClient; - private readonly ILogger _logger; - private readonly string _workingDirectory; - private Process? _process; - - public McpServerHostedService( - McpClient mcpClient, - ILogger logger, - IConfiguration config) - { - _mcpClient = mcpClient; - _logger = logger; - var dir = config["McpServer:WorkingDirectory"] ?? "../../mcp-server"; - _workingDirectory = Path.IsPathRooted(dir) - ? dir - : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), dir)); - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - // 이미 외부에서 실행 중이면 새 프로세스 띄우지 않음 - if (await _mcpClient.PingAsync()) - { - _logger.LogInformation("[McpServer] 이미 실행 중 (localhost:5001) — 기존 프로세스 사용"); - return; - } - - if (!Directory.Exists(_workingDirectory)) - { - _logger.LogWarning("[McpServer] 디렉터리 없음: {Dir} — MCP 서버 시작 스킵", _workingDirectory); - return; - } - - _logger.LogInformation("[McpServer] Python MCP 서버 시작 중... ({Dir})", _workingDirectory); - - _process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "uv", - Arguments = "run server.py --http", - WorkingDirectory = _workingDirectory, - UseShellExecute = false, - } - }; - - try - { - _process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "[McpServer] 프로세스 시작 실패 (uv 설치 여부 확인)"); - return; - } - - // 최대 30초 대기 (1초 간격 health check) - for (int i = 0; i < 30; i++) - { - try { await Task.Delay(1000, cancellationToken); } catch { return; } - if (_process.HasExited) - { - _logger.LogWarning("[McpServer] 프로세스가 예기치 않게 종료됨 (exit code: {Code})", _process.ExitCode); - return; - } - if (await _mcpClient.PingAsync()) - { - _logger.LogInformation("[McpServer] 준비 완료 (localhost:5001, {Sec}초 소요)", i + 1); - return; - } - } - _logger.LogWarning("[McpServer] 30초 내 응답 없음 — 백그라운드에서 계속 기다림"); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - try - { - if (_process is { HasExited: false }) - { - _process.Kill(entireProcessTree: true); - _process.WaitForExit(3000); - _logger.LogInformation("[McpServer] Python MCP 서버 종료됨"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[McpServer] 종료 중 오류"); - } - finally - { - _process?.Dispose(); - _process = null; - } - return Task.CompletedTask; - } -} diff --git a/.rooBackup/2026-05-02_0133/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase1.md b/.rooBackup/2026-05-02_0133/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase1.md deleted file mode 100644 index 35aa9f2..0000000 --- a/.rooBackup/2026-05-02_0133/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase1.md +++ /dev/null @@ -1,137 +0,0 @@ -# 🛠️ Graph Pipeline Phase 1: 기하학적 데이터 추출 (Geometric Extraction) - -이 문서는 P&ID Graph Pipeline의 첫 번째 단계인 **기하학적 데이터 추출**의 상세 구현 계획을 다룹니다. 목표는 단순한 텍스트 추출을 넘어, 도면 내 모든 객체의 **물리적 위치(좌표)**와 **기하학적 속성**을 보존하여 이후 위상 모델링(Topology Modeling)이 가능하도록 하는 것입니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `ezdxf` | DXF 파일 파싱 및 엔티티 추출 | 핵심 라이브러리 | -| `shapely` | 기하학적 연산 (Intersection, Distance, Bounding Box) | 좌표 기반 분석 필수 | -| `numpy` | 대량의 좌표 데이터 계산 및 행렬 연산 | 성능 최적화 | -| `pandas` | 추출된 객체 데이터의 구조화 및 CSV/JSON 저장 | 데이터 관리 | -| `pydantic` | 추출 데이터의 스키마 정의 및 유효성 검증 | 데이터 무결성 보장 | -| `pytesseract` / `pdf2image` | PDF 도면의 영역 기반 OCR 추출 | PDF 처리 시 필요 | - -### 1.2 설치 명령어 -```bash -pip install ezdxf shapely numpy pandas pydantic pytesseract pdf2image -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 데이터 모델 (Schema) -모든 추출 객체는 다음과 같은 공통 속성을 갖는 `GeometricEntity` 모델을 따릅니다. - -```python -from pydantic import BaseModel -from typing import List, Optional, Union, Tuple - -class BoundingBox(BaseModel): - min_x: float - min_y: float - max_x: float - max_y: float - center: Tuple[float, float] - -class GeometricEntity(BaseModel): - entity_id: str - entity_type: str # TEXT, LINE, CIRCLE, POLYLINE, ARC - layer: str - bbox: BoundingBox - properties: dict # 텍스트 값, 색상, 선 굵기 등 - coordinates: List[Tuple[float, float]] # 시작점, 끝점 또는 정점 리스트 -``` - -### 2.2 처리 파이프라인 흐름 -1. **DXF Load:** `ezdxf.readfile()`을 통해 도면 로드. -2. **Entity Iteration:** 모든 레이어의 엔티티를 순회하며 타입별 분류. -3. **Coordinate Extraction:** - * `TEXT`: 삽입점(Insertion Point) 및 텍스트 길이를 이용한 BBox 계산. - * `LINE`: 시작점(Start)과 끝점(End) 추출. - * `POLYLINE`: 모든 정점(Vertices) 리스트 추출. - * `CIRCLE/ARC`: 중심점(Center)과 반지름(Radius) 추출. -4. **Spatial Normalization:** 도면 좌표계를 분석 시스템 좌표계로 정규화. -5. **Structured Export:** JSON 또는 DB(PostgreSQL/PostGIS)에 저장. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 DXF 기하학적 추출 핵심 코드 -```python -import ezdxf -from shapely.geometry import box, LineString, Point -from typing import List - -class PidGeometricExtractor: - def __init__(self, file_path: str): - self.doc = ezdxf.readfile(file_path) - self.msp = self.doc.modelspace() - - def get_bbox(self, entity): - """엔티티의 Bounding Box를 계산하여 shapely box 객체로 반환""" - if entity.dxftype() == 'TEXT': - # 텍스트의 경우 삽입점과 텍스트 길이를 기반으로 단순화된 BBox 생성 - p = entity.dxf.insert - return box(p.x, p.y, p.x + 10, p.y + 5) # 실제로는 폰트 크기 반영 필요 - elif entity.dxftype() == 'LINE': - start = entity.dxf.start - end = entity.dxf.end - return box(min(start.x, end.x), min(start.y, end.y), - max(start.x, end.x), max(start.y, end.y)) - # ... 기타 타입 구현 - return None - - def extract_all(self) -> List[dict]: - results = [] - for entity in self.msp: - bbox_obj = self.get_bbox(entity) - if bbox_obj: - results.append({ - "id": entity.dxf.handle, - "type": entity.dxftype(), - "layer": entity.dxf.layer, - "bbox": { - "min_x": bbox_obj.bounds[0], - "min_y": bbox_obj.bounds[1], - "max_x": bbox_obj.bounds[2], - "max_y": bbox_obj.bounds[3] - }, - "value": getattr(entity.dxf, 'text', None) - }) - return results - -# 사용 예시 -extractor = PidGeometricExtractor("plant_drawing.dxf") -geometric_data = extractor.extract_all() -``` - -### 3.2 유틸리티 함수: 인접성 체크 (Proximity Utility) -추후 2단계(위상 모델링)에서 사용할 핵심 유틸리티입니다. - -```python -from shapely.geometry import Point - -def is_near(entity_a_bbox, entity_b_bbox, threshold=5.0): - """두 객체의 Bounding Box 간의 최단 거리가 임계값 이내인지 확인""" - return entity_a_bbox.distance(entity_b_bbox) <= threshold - -def is_inside(point, bbox): - """특정 점이 Bounding Box 내부에 있는지 확인""" - return bbox.contains(Point(point)) -``` - ---- - -## 🚀 4. Phase 1 완료 기준 (Definition of Done) - -- [ ] DXF 파일 내 모든 `TEXT`, `LINE`, `POLYLINE`의 좌표 데이터가 누락 없이 추출되는가? -- [ ] 각 객체별로 정확한 `Bounding Box`가 계산되어 저장되는가? -- [ ] 추출된 데이터가 `GeometricEntity` 스키마에 맞게 JSON 형태로 저장되는가? -- [ ] (선택 사항) PDF 도면의 경우 OCR을 통해 텍스트의 좌표값이 추출되는가? diff --git a/.rooBackup/2026-05-02_0139/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase2.md b/.rooBackup/2026-05-02_0139/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase2.md deleted file mode 100644 index 026bc2e..0000000 --- a/.rooBackup/2026-05-02_0139/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase2.md +++ /dev/null @@ -1,126 +0,0 @@ -# 🕸️ Graph Pipeline Phase 2: 위상 모델링 (Topology Modeling) - -이 문서는 P&ID Graph Pipeline의 두 번째 단계인 **위상 모델링**의 상세 구현 계획을 다룹니다. 1단계에서 추출한 기하학적 객체(좌표, BBox)를 기반으로, 설비 간의 **연결성(Connectivity)**과 **흐름(Flow)**을 정의하는 지식 그래프(Knowledge Graph)를 구축하는 것이 목표입니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `networkx` | 그래프 데이터 구조 생성 및 알고리즘 분석 | 핵심 라이브러리 | -| `shapely` | 객체 간 거리 계산 및 포함 관계 분석 | 1단계와 연계 | -| `scikit-learn` | (선택) KD-Tree를 이용한 고속 근접 이웃 검색 | 대규모 도면 최적화 | -| `matplotlib` | 생성된 그래프의 위상 구조 시각화 검증 | 디버깅용 | - -### 1.2 설치 명령어 -```bash -pip install networkx shapely scikit-learn matplotlib -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 그래프 정의 (Graph Definition) -* **노드 (Nodes):** - * `Equipment`: 펌프, 탱크, 열교환기 등 (속성: ID, 타입, BBox) - * `Instrument`: 전송기, 밸브, 게이지 등 (속성: ID, 타입, BBox) - * `Tag`: 텍스트 기반 태그 (속성: TagName, Value) -* **엣지 (Edges):** - * `Pipe`: 설비-설비, 설비-계기 간의 물리적 연결 (속성: LineNumber, 방향성) - * `Association`: 태그-설비 간의 논리적 연결 (속성: 관계 타입 - 예: 'belongs_to') - -### 2.2 위상 추론 로직 (Topology Inference) -1. **태그-설비 결합 (Tag-to-Entity Binding):** - * 태그 텍스트의 BBox와 가장 가까운 심볼(Equipment/Instrument)을 찾아 `Association` 엣지를 생성합니다. -2. **배관 연결성 분석 (Line Connectivity):** - * `LINE` 또는 `POLYLINE`의 끝점이 특정 설비의 BBox 내부에 있거나 임계 거리($\epsilon$) 이내에 있으면 두 노드를 `Pipe` 엣지로 연결합니다. -3. **흐름 방향성 부여 (Flow Direction):** - * 화살표 심볼의 방향 또는 공정 흐름 규칙을 분석하여 엣지에 `source` $\rightarrow$ `target` 방향을 설정합니다. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 그래프 구축 핵심 코드 -```python -import networkx as nx -from shapely.geometry import box, Point - -class PidTopologyBuilder: - def __init__(self, geometric_data): - self.data = geometric_data # Phase 1에서 추출된 JSON 데이터 - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - self.G.add_node(item['id'], - type=item['type'], - bbox=box(*item['bbox'].values()), - value=item.get('value')) - - # 2. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] != 'TEXT'] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 3. 배관 기반 물리적 연결 (Pipe) - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'POLYLINE']] - for line in lines: - connected_nodes = self._find_connected_nodes(line, equipments) - if len(connected_nodes) >= 2: - # 라인을 통해 연결된 두 설비 간 엣지 생성 - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe') - - def _find_nearest_equipment(self, tag_id, equipment_ids): - tag_bbox = self.G.nodes[tag_id]['bbox'] - min_dist = float('inf') - nearest = None - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - if dist < min_dist: - min_dist = dist - nearest = eq_id - return nearest if min_dist < 50.0 else None # 임계값 50.0 - - def _find_connected_nodes(self, line_id, equipment_ids): - # 라인의 시작/끝점이 어떤 설비 BBox에 포함되는지 확인 - # (실제 구현 시 line의 coordinates 활용) - return [eq for eq in equipment_ids if self.G.nodes[eq]['bbox'].intersects(self.G.nodes[line_id]['bbox'])] - -# 실행 -builder = PidTopologyBuilder(geometric_data) -builder.build_graph() -graph = builder.G -``` - -### 3.2 위상 분석 유틸리티: 영향도 분석 (Impact Analysis) -```python -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) - -# 예: P-101 펌프 고장 시 영향 분석 -affected = analyze_impact(graph, "node_P101") -print(f"Impacted Equipment: {affected}") -``` - ---- - -## 🚀 4. Phase 2 완료 기준 (Definition of Done) - -- [ ] 모든 설비와 계기가 그래프의 **노드(Node)**로 변환되었는가? -- [ ] 태그와 설비 간의 **논리적 연결(Association)**이 정확하게 매핑되었는가? -- [ ] 배관(Line)을 통해 설비 간의 **물리적 연결(Pipe Edge)**이 생성되었는가? -- [ ] `nx.descendants` 등을 통해 특정 노드로부터의 **흐름 추적(Flow Tracing)**이 가능한가? -- [ ] 생성된 그래프 구조가 JSON(GraphML 등) 형태로 저장되어 Phase 3로 전달 가능한가? diff --git a/.rooBackup/2026-05-02_0146/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md b/.rooBackup/2026-05-02_0146/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md deleted file mode 100644 index 1a4ac70..0000000 --- a/.rooBackup/2026-05-02_0146/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md +++ /dev/null @@ -1,125 +0,0 @@ -# 🧠 Graph Pipeline Phase 3: 지능형 매핑 및 검증 (Intelligent Mapping & Validation) - -이 문서는 P&ID Graph Pipeline의 세 번째 단계인 **지능형 매핑 및 검증**의 상세 구현 계획을 다룹니다. 2단계에서 구축한 위상 그래프(Topology Graph)를 활용하여, 도면 상의 가상 노드들을 실제 Experion 시스템의 **실시간 태그(Real-time Tags)**와 정밀하게 연결하고 그 타당성을 검증하는 것이 목표입니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `openai` / `langchain` | LLM API 연동 및 프롬프트 체이닝 | 매핑 추론 및 검증 핵심 | -| `fuzzywuzzy` / `rapidfuzz` | 태그 이름 간의 문자열 유사도 계산 | 1차 후보군 추출용 | -| `networkx` | 그래프 기반 인접 노드(Context) 추출 | 2단계 그래프 활용 | -| `pydantic` | 매핑 결과의 구조화 및 유효성 검사 | 데이터 정규화 | -| `requests` | ExperionCrawler API (C#)와 통신 | 실제 태그 리스트 조회 | - -### 1.2 설치 명령어 -```bash -pip install openai langchain rapidfuzz networkx pydantic requests -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 매핑 파이프라인 (Mapping Pipeline) -단순 이름 매칭의 한계를 극복하기 위해 **[후보 추출 $\rightarrow$ 맥락 분석 $\rightarrow$ LLM 확정]**의 3단계 프로세스를 거칩니다. - -1. **1차 후보 추출 (Candidate Generation):** - * 도면의 태그 텍스트와 Experion 시스템의 전체 태그 리스트를 `RapidFuzz`로 비교하여 유사도 상위 N개를 추출합니다. -2. **맥락 정보 수집 (Context Gathering):** - * 해당 노드의 그래프 상 인접 노드(1-hop, 2-hop) 정보를 수집합니다. - * 예: "현재 노드는 `PT-101`이며, 상류에 `P-101(Pump)`이 있고 하류에 `V-101(Valve)`이 있음." -3. **LLM 기반 최종 매핑 (LLM-based Resolution):** - * 후보 태그 리스트와 위상 맥락을 LLM에게 전달하여 가장 타당한 태그를 선택하게 합니다. - -### 2.2 상호 검증 로직 (Cross-Validation) -매핑된 결과가 실제 공정 데이터와 일치하는지 검증합니다. -* **위상적 일관성:** 도면에서 `A $\rightarrow$ B` 순서라면, 실제 데이터에서도 `A`의 변화가 `B`에 영향을 주는지 상관관계 분석. -* **속성 일치성:** 도면의 심볼 타입(예: Pressure Transmitter)과 실제 태그의 속성(예: Engineering Unit = 'bar' 또는 'psi')이 일치하는지 확인. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 맥락 기반 매핑 엔진 -```python -import networkx as nx -from rapidfuzz import process, fuzz -from openai import OpenAI - -client = OpenAI(api_key="your-api-key") - -class IntelligentMapper: - def __init__(self, graph, system_tags): - self.graph = graph # Phase 2에서 생성된 NetworkX 그래프 - self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트 - - def get_node_context(self, node_id): - """노드의 주변 위상 정보를 텍스트로 변환""" - neighbors = list(self.graph.neighbors(node_id)) - context = [] - for n in neighbors: - attr = self.graph.nodes[n] - context.append(f"Connected to {attr.get('value', n)} (Type: {attr.get('type')})") - return ", ".join(context) - - def resolve_tag(self, node_id): - # 1. 1차 후보 추출 (Fuzzy Matching) - tag_text = self.graph.nodes[node_id].get('value', '') - candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5) - - # 2. 맥락 정보 수집 - context = self.get_node_context(node_id) - - # 3. LLM에게 최종 판단 요청 - prompt = f""" - P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다. - 위상 맥락: {context} - 후보 리스트: {candidates} - - 위 맥락을 고려할 때 가장 적절한 시스템 태그 하나만 반환하세요. - 이유가 불분명하면 'UNKNOWN'을 반환하세요. - """ - - response = client.chat.completions.create( - model="gpt-4-turbo", - messages=[{"role": "user", "content": prompt}] - ) - return response.choices[0].message.content - -# 사용 예시 -mapper = IntelligentMapper(graph, ["FIC-101.PV", "PT-101.PV", "P-101.STATUS"]) -final_tag = mapper.resolve_tag("node_tag_123") -print(f"Resolved Tag: {final_tag}") -``` - -### 3.2 검증 유틸리티: 속성 일치 확인 -```python -def validate_mapping(resolved_tag, symbol_type, tag_metadata): - """심볼 타입과 실제 태그 메타데이터의 일치 여부 검증""" - type_map = { - "Pressure Transmitter": ["pressure", "bar", "psi", "pa"], - "Flow Meter": ["flow", "m3/h", "lpm"], - "Temperature Sensor": ["temp", "celsius", "k"] - } - - expected_keywords = type_map.get(symbol_type, []) - actual_desc = tag_metadata.get('description', '').lower() - - # 메타데이터 설명에 기대 키워드가 포함되어 있는지 확인 - is_valid = any(kw in actual_desc for kw in expected_keywords) - return is_valid -``` - ---- - -## 🚀 4. Phase 3 완료 기준 (Definition of Done) - -- [ ] 모든 도면 노드에 대해 **1차 후보군(Candidates)**이 자동으로 생성되는가? -- [ ] `NetworkX` 그래프를 통해 **인접 노드 맥락(Context)**이 정확히 추출되는가? -- [ ] LLM이 맥락을 반영하여 **최종 태그를 결정**하고 그 근거를 제시하는가? -- [ ] 매핑된 태그의 **메타데이터(Unit, Description)**와 도면 심볼 타입 간의 일치성이 검증되는가? -- [ ] 최종 매핑 결과가 `(도면노드ID, 시스템태그, 신뢰도, 검증결과)` 형태로 저장되는가? diff --git a/.rooBackup/2026-05-02_0152/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md b/.rooBackup/2026-05-02_0152/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md deleted file mode 100644 index 06ebed4..0000000 --- a/.rooBackup/2026-05-02_0152/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md +++ /dev/null @@ -1,103 +0,0 @@ -# 🎨 Graph Pipeline Phase 4: 활용 및 시각화 (Application & Visualization) - -이 문서는 P&ID Graph Pipeline의 최종 단계인 **활용 및 시각화**의 상세 구현 계획을 다룹니다. 앞선 단계에서 구축한 [기하학적 데이터 $\rightarrow$ 위상 그래프 $\rightarrow$ 시스템 태그 매핑] 결과물을 결합하여, 운영자가 도면 상에서 실시간 공정 상태를 파악하고 장애 영향도를 분석할 수 있는 인터페이스를 구현하는 것이 목표입니다. - ---- - -## 📦 1. 필수 패키지 및 기술 스택 - -### 1.1 프론트엔드 (Visualization) -| 기술/라이브러리 | 용도 | 비고 | -|---|---|---| -| `SVG / Canvas API` | P&ID 도면 렌더링 및 데이터 오버레이 | 벡터 기반 정밀 렌더링 | -| `Cytoscape.js` / `D3.js` | 위상 그래프 시각화 및 인터랙티브 탐색 | 그래프 분석 뷰어 | -| `Vue.js` / `React` | 전체 UI 프레임워크 및 상태 관리 | `src/Web` 구조와 통합 | -| `Axios` / `WebSocket` | 실시간 OPC UA 데이터 수신 및 API 통신 | 실시간 업데이트 | - -### 1.2 백엔드 (API & Analysis) -| 기술/라이브러리 | 용도 | 비고 | -|---|---|---| -| `ASP.NET Core` | Graph API 및 분석 엔드포인트 제공 | `ExperionCrawler` 메인 서버 | -| `NetworkX` (Python) | 영향도 분석 및 경로 추적 알고리즘 실행 | 분석 엔진 (Phase 2 활용) | -| `FastAPI` / `Flask` | Python 분석 엔진과 C# 서버 간의 브릿지 | 분석 마이크로서비스 | - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 실시간 데이터 오버레이 (Real-time Overlay) -도면의 좌표 정보와 매핑된 시스템 태그를 연결하여 실시간 값을 표시합니다. -1. **매핑 데이터 로드:** `(도면노드ID, 시스템태그, 좌표)` 리스트를 프론트엔드로 전달. -2. **실시간 스트리밍:** `OPC UA` $\rightarrow$ `C# Server` $\rightarrow$ `WebSocket` $\rightarrow$ `Frontend`. -3. **동적 렌더링:** 태그 값이 변경되면 해당 좌표의 SVG 요소 색상을 변경하거나 툴팁에 현재 값을 표시. - -### 2.2 영향도 분석 엔진 (Impact Analysis Engine) -특정 설비의 이상 발생 시 하류(Downstream) 영향을 계산합니다. -1. **분석 요청:** 사용자가 도면에서 특정 노드(예: 펌프 P-101)를 클릭. -2. **그래프 탐색:** Python 분석 엔진에서 `nx.descendants(G, 'P-101')` 실행. -3. **결과 반환:** 영향받는 모든 노드 ID 리스트와 경로(Path)를 반환. -4. **시각적 강조:** 도면 상에서 영향 경로를 하이라이트(예: 빨간색 선) 처리. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 [Backend] 영향도 분석 API (C# $\rightarrow$ Python Bridge) -```csharp -// src/Web/Controllers/PidGraphController.cs -[HttpGet("impact/{nodeId}")] -public async Task GetImpactAnalysis(string nodeId) -{ - // Python 분석 마이크로서비스에 요청 - var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}"); - var result = await response.Content.ReadFromJsonAsync(); - - return Ok(result); -} -``` - -### 3.2 [Frontend] SVG 데이터 오버레이 (JavaScript) -```javascript -// src/Web/wwwroot/js/pid-viewer.js -async function updateRealtimeValues(tagData) { - // tagData: { "PT-101.PV": 12.5, "FT-101.PV": 150.2 } - for (const [tag, value] of Object.entries(tagData)) { - const element = document.getElementById(`tag-node-${tag}`); - if (element) { - // 값에 따라 색상 변경 (예: 임계치 초과 시 빨간색) - element.style.fill = value > threshold ? 'red' : 'green'; - element.setAttribute('data-value', value); - - // 툴팁 업데이트 - const tooltip = document.getElementById('pid-tooltip'); - tooltip.innerText = `${tag}: ${value}`; - } - } -} -``` - -### 3.3 [Analysis] 경로 추적 유틸리티 (Python) -```python -import networkx as nx - -def get_propagation_path(graph, start_node, end_node): - """장애 전파 경로를 최단 경로 기반으로 추출""" - try: - path = nx.shortest_path(graph, source=start_node, target=end_node) - return path - except nx.NetworkXNoPath: - return None - -# 예: P-101에서 V-105까지의 영향 경로 추출 -path = get_propagation_path(topology_graph, "P-101", "V-105") -``` - ---- - -## 🚀 4. Phase 4 완료 기준 (Definition of Done) - -- [ ] P&ID 도면(SVG/Canvas) 위에 **실시간 OPC UA 값**이 정확한 좌표에 표시되는가? -- [ ] 특정 노드 클릭 시 **하류 영향도 분석(Impact Analysis)** 결과가 시각적으로 하이라이트 되는가? -- [ ] C# 메인 서버와 Python 분석 엔진 간의 **API 통신**이 지연 없이 이루어지는가? -- [ ] 운영자가 도면을 통해 **이상 징후의 전파 경로**를 직관적으로 파악할 수 있는가? -- [ ] 전체 파이프라인(`추출 $\rightarrow$ 모델링 $\rightarrow$ 매핑 $\rightarrow$ 시각화`)이 통합되어 동작하는가? diff --git a/.rooBackup/2026-05-02_0448/Graph_Pipeline_Phase2.md b/.rooBackup/2026-05-02_0448/Graph_Pipeline_Phase2.md deleted file mode 100644 index f17a212..0000000 --- a/.rooBackup/2026-05-02_0448/Graph_Pipeline_Phase2.md +++ /dev/null @@ -1,145 +0,0 @@ -# 🕸️ Graph Pipeline Phase 2: 위상 모델링 (Topology Modeling) - -이 문서는 P&ID Graph Pipeline의 두 번째 단계인 **위상 모델링**의 상세 구현 계획을 다룹니다. 1단계에서 추출한 기하학적 객체(좌표, BBox)를 기반으로, 설비 간의 **연결성(Connectivity)**과 **흐름(Flow)**을 정의하는 지식 그래프(Knowledge Graph)를 구축하는 것이 목표입니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `networkx` | 그래프 데이터 구조 생성 및 알고리즘 분석 | 핵심 라이브러리 | -| `shapely` | 객체 간 거리 계산 및 포함 관계 분석 | 1단계와 연계 | -| `scikit-learn` | (선택) KD-Tree를 이용한 고속 근접 이웃 검색 | 대규모 도면 최적화 | -| `matplotlib` | 생성된 그래프의 위상 구조 시각화 검증 | 디버깅용 | - -### 1.2 설치 명령어 -```bash -pip install networkx shapely scikit-learn matplotlib -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 그래프 정의 (Graph Definition) -* **노드 (Nodes):** - * `Equipment`: 펌프, 탱크, 열교환기 등 (속성: ID, 타입, BBox) - * `Instrument`: 전송기, 밸브, 게이지 등 (속성: ID, 타입, BBox) - * `Tag`: 텍스트 기반 태그 (속성: TagName, Value) -* **엣지 (Edges):** - * `Pipe`: 설비-설비, 설비-계기 간의 물리적 연결 (속성: LineNumber, 방향성) - * `Association`: 태그-설비 간의 논리적 연결 (속성: 관계 타입 - 예: 'belongs_to') - -### 2.2 위상 추론 로직 (Topology Inference) -1. **태그-설비 결합 (Tag-to-Entity Binding):** - * 태그 텍스트의 BBox와 가장 가까운 심볼(Equipment/Instrument)을 찾아 `Association` 엣지를 생성합니다. -2. **배관 연결성 분석 (Line Connectivity):** - * `LINE` 또는 `POLYLINE`의 끝점이 특정 설비의 BBox 내부에 있거나 임계 거리($\epsilon$) 이내에 있으면 두 노드를 `Pipe` 엣지로 연결합니다. -3. **흐름 방향성 부여 (Flow Direction):** - * 화살표 심볼의 방향 또는 공정 흐름 규칙을 분석하여 엣지에 `source` $\rightarrow$ `target` 방향을 설정합니다. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 그래프 구축 핵심 코드 -```python -import networkx as nx -from shapely.geometry import box, Point - -class PidTopologyBuilder: - def __init__(self, geometric_data, all_extracted_tags=None): - """ - Phase 5 병렬 아키텍처 반영: - - geometric_data: Phase 1에서 추출된 기하학적 데이터 - - all_extracted_tags: 여러 Worker(Phase 3)가 분산 추출한 태그 리스트의 통합본 (flatten_results 결과) - """ - self.data = geometric_data - self.all_tags = all_extracted_tags if all_extracted_tags else [] - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - self.G.add_node(item['id'], - type=item['type'], - bbox=box(*item['bbox'].values()), - value=item.get('value')) - - # 2. 분산 추출된 태그 통합 및 노드 추가 (Phase 5 반영) - for tag in self.all_tags: - # tag: { "id": "...", "tagName": "...", "bbox": {...}, "type": "TEXT" } - self.G.add_node(tag['id'], - type='TEXT', - bbox=box(*tag['bbox'].values()), - value=tag.get('tagName')) - - # 3. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] != 'TEXT'] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 3. 배관 기반 물리적 연결 (Pipe) - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'POLYLINE']] - for line in lines: - connected_nodes = self._find_connected_nodes(line, equipments) - if len(connected_nodes) >= 2: - # 라인을 통해 연결된 두 설비 간 엣지 생성 - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe') - - def _find_nearest_equipment(self, tag_id, equipment_ids): - tag_bbox = self.G.nodes[tag_id]['bbox'] - min_dist = float('inf') - nearest = None - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - if dist < min_dist: - min_dist = dist - nearest = eq_id - return nearest if min_dist < 50.0 else None # 임계값 50.0 - - def _find_connected_nodes(self, line_id, equipment_ids): - # 라인의 시작/끝점이 어떤 설비 BBox에 포함되는지 확인 - # (실제 구현 시 line의 coordinates 활용) - return [eq for eq in equipment_ids if self.G.nodes[eq]['bbox'].intersects(self.G.nodes[line_id]['bbox'])] - -# 실행 (Phase 5 Orchestrator 관점) -# 1. Phase 1 결과 로드 -# 2. Phase 3 Worker들의 결과를 flatten_results()로 통합 -all_tags = flatten_results([worker1_res, worker2_res, worker3_res, worker4_res, worker5_res]) - -builder = PidTopologyBuilder(geometric_data, all_extracted_tags=all_tags) -builder.build_graph() -graph = builder.G -``` - -### 3.2 위상 분석 유틸리티: 영향도 분석 (Impact Analysis) -```python -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) - -# 예: P-101 펌프 고장 시 영향 분석 -affected = analyze_impact(graph, "node_P101") -print(f"Impacted Equipment: {affected}") -``` - ---- - -## 🚀 4. Phase 2 완료 기준 (Definition of Done) - -- [ ] 모든 설비와 계기가 그래프의 **노드(Node)**로 변환되었는가? -- [ ] 분산 추출된 태그 리스트가 `flatten_results`를 통해 통합되어 그래프에 반영되었는가? (Phase 5 반영) -- [ ] 태그와 설비 간의 **논리적 연결(Association)**이 정확하게 매핑되었는가? -- [ ] 배관(Line)을 통해 설비 간의 **물리적 연결(Pipe Edge)**이 생성되었는가? -- [ ] `nx.descendants` 등을 통해 특정 노드로부터의 **흐름 추적(Flow Tracing)**이 가능한가? -- [ ] 생성된 그래프 구조가 JSON(GraphML 등) 형태로 저장되어 Phase 3로 전달 가능한가? diff --git a/.rooBackup/2026-05-02_0517/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md b/.rooBackup/2026-05-02_0517/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md deleted file mode 100644 index d985a68..0000000 --- a/.rooBackup/2026-05-02_0517/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase3.md +++ /dev/null @@ -1,158 +0,0 @@ -# 🧠 Graph Pipeline Phase 3: 지능형 매핑 및 검증 (Intelligent Mapping & Validation) - -이 문서는 P&ID Graph Pipeline의 세 번째 단계인 **지능형 매핑 및 검증**의 상세 구현 계획을 다룹니다. 2단계에서 구축한 위상 그래프(Topology Graph)를 활용하여, 도면 상의 가상 노드들을 실제 Experion 시스템의 **실시간 태그(Real-time Tags)**와 정밀하게 연결하고 그 타당성을 검증하는 것이 목표입니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `openai` / `langchain` | LLM API 연동 및 프롬프트 체이닝 | 매핑 추론 및 검증 핵심 | -| `fuzzywuzzy` / `rapidfuzz` | 태그 이름 간의 문자열 유사도 계산 | 1차 후보군 추출용 | -| `networkx` | 그래프 기반 인접 노드(Context) 추출 | 2단계 그래프 활용 | -| `pydantic` | 매핑 결과의 구조화 및 유효성 검사 | 데이터 정규화 | -| `requests` | ExperionCrawler API (C#)와 통신 | 실제 태그 리스트 조회 | - -### 1.2 설치 명령어 -```bash -pip install openai langchain rapidfuzz networkx pydantic requests -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 매핑 파이프라인 (Mapping Pipeline) -단순 이름 매칭의 한계를 극복하기 위해 **[후보 추출 $\rightarrow$ 맥락 분석 $\rightarrow$ LLM 확정]**의 3단계 프로세스를 거칩니다. - -1. **1차 후보 추출 (Candidate Generation):** - * 도면의 태그 텍스트와 Experion 시스템의 전체 태그 리스트를 `RapidFuzz`로 비교하여 유사도 상위 N개를 추출합니다. -2. **맥락 정보 수집 (Context Gathering):** - * 해당 노드의 그래프 상 인접 노드(1-hop, 2-hop) 정보를 수집합니다. - * 예: "현재 노드는 `PT-101`이며, 상류에 `P-101(Pump)`이 있고 하류에 `V-101(Valve)`이 있음." -3. **LLM 기반 최종 매핑 (LLM-based Resolution):** - * 후보 태그 리스트와 위상 맥락을 LLM에게 전달하여 가장 타당한 태그를 선택하게 합니다. - -### 2.2 상호 검증 로직 (Cross-Validation) -매핑된 결과가 실제 공정 데이터와 일치하는지 검증합니다. -* **위상적 일관성:** 도면에서 `A $\rightarrow$ B` 순서라면, 실제 데이터에서도 `A`의 변화가 `B`에 영향을 주는지 상관관계 분석. -* **속성 일치성:** 도면의 심볼 타입(예: Pressure Transmitter)과 실제 태그의 속성(예: Engineering Unit = 'bar' 또는 'psi')이 일치하는지 확인. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 맥락 기반 매핑 엔진 -```python -import networkx as nx -import asyncio -from rapidfuzz import process, fuzz -from openai import AsyncOpenAI # 비동기 클라이언트로 변경 - -client = AsyncOpenAI(api_key="your-api-key") - -class IntelligentMapper: - def __init__(self, graph, system_tags): - self.graph = graph # Phase 2에서 생성된 NetworkX 그래프 - self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트 - - def get_node_context(self, node_id): - """노드의 주변 위상 정보를 텍스트로 변환""" - neighbors = list(self.graph.neighbors(node_id)) - context = [] - for n in neighbors: - attr = self.graph.nodes[n] - context.append(f"Connected to {attr.get('value', n)} (Type: {attr.get('type')})") - return ", ".join(context) - - async def _resolve_generic(self, node_id, category_prompt): - """공통 매핑 로직 (비동기)""" - tag_text = self.graph.nodes[node_id].get('value', '') - candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5) - context = self.get_node_context(node_id) - - prompt = f""" - {category_prompt} - P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다. - 위상 맥락: {context} - 후보 리스트: {candidates} - - 위 맥락을 고려할 때 가장 적절한 시스템 태그 하나만 반환하세요. - 이유가 불분명하면 'UNKNOWN'을 반환하세요. - """ - - response = await client.chat.completions.create( - model="gpt-4-turbo", - messages=[{"role": "user", "content": prompt}] - ) - return response.choices[0].message.content - - # --- 전문화된 Worker 함수들 (Phase 5 병렬 처리 반영) --- - - async def extract_transmitters(self, node_ids): - """전송기(Transmitter) 전문 매핑 Worker""" - prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다." - return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids} - - async def extract_valves(self, node_ids): - """밸브(Valve) 전문 매핑 Worker""" - prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다." - return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids} - - async def extract_equipment(self, node_ids): - """주요 설비(Pump, Tank 등) 전문 매핑 Worker""" - prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다." - return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids} - -# 사용 예시 (Phase 5 Orchestrator 관점) -async def main(): - mapper = IntelligentMapper(graph, ["FIC-101.PV", "PT-101.PV", "P-101.STATUS"]) - - # 분류별로 노드 그룹화 (예시) - transmitter_nodes = ["node_1", "node_2"] - valve_nodes = ["node_3", "node_4"] - equipment_nodes = ["node_5"] - - # asyncio.gather를 통한 병렬 호출 - results = await asyncio.gather( - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes) - ) - - # 결과 통합 (flatten) - final_mapping = {**results[0], **results[1], **results[2]} - print(f"Parallel Resolved Mapping: {final_mapping}") - -asyncio.run(main()) -``` - -### 3.2 검증 유틸리티: 속성 일치 확인 -```python -def validate_mapping(resolved_tag, symbol_type, tag_metadata): - """심볼 타입과 실제 태그 메타데이터의 일치 여부 검증""" - type_map = { - "Pressure Transmitter": ["pressure", "bar", "psi", "pa"], - "Flow Meter": ["flow", "m3/h", "lpm"], - "Temperature Sensor": ["temp", "celsius", "k"] - } - - expected_keywords = type_map.get(symbol_type, []) - actual_desc = tag_metadata.get('description', '').lower() - - # 메타데이터 설명에 기대 키워드가 포함되어 있는지 확인 - is_valid = any(kw in actual_desc for kw in expected_keywords) - return is_valid -``` - ---- - -## 🚀 4. Phase 3 완료 기준 (Definition of Done) - -- [ ] 모든 도면 노드에 대해 **1차 후보군(Candidates)**이 자동으로 생성되는가? -- [ ] `NetworkX` 그래프를 통해 **인접 노드 맥락(Context)**이 정확히 추출되는가? -- [ ] LLM이 맥락을 반영하여 **최종 태그를 결정**하고 그 근거를 제시하는가? -- [ ] 매핑된 태그의 **메타데이터(Unit, Description)**와 도면 심볼 타입 간의 일치성이 검증되는가? -- [ ] 최종 매핑 결과가 `(도면노드ID, 시스템태그, 신뢰도, 검증결과)` 형태로 저장되는가? diff --git a/.rooBackup/2026-05-02_0530/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md b/.rooBackup/2026-05-02_0530/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md deleted file mode 100644 index 71f23be..0000000 --- a/.rooBackup/2026-05-02_0530/futurePlan/End-to-End P&ID Graph Pipeline/Graph_Pipeline_Phase4.md +++ /dev/null @@ -1,145 +0,0 @@ -# 🎨 Graph Pipeline Phase 4: 활용 및 시각화 (Application & Visualization) - -이 문서는 P&ID Graph Pipeline의 최종 단계인 **활용 및 시각화**의 상세 구현 계획을 다룹니다. 앞선 단계에서 구축한 [기하학적 데이터 $\rightarrow$ 위상 그래프 $\rightarrow$ 시스템 태그 매핑] 결과물을 결합하여, 운영자가 도면 상에서 실시간 공정 상태를 파악하고 장애 영향도를 분석할 수 있는 인터페이스를 구현하는 것이 목표입니다. - ---- - -## 📦 1. 필수 패키지 및 기술 스택 - -### 1.1 프론트엔드 (Visualization) -| 기술/라이브러리 | 용도 | 비고 | -|---|---|---| -| `SVG / Canvas API` | P&ID 도면 렌더링 및 데이터 오버레이 | 벡터 기반 정밀 렌더링 | -| `Cytoscape.js` / `D3.js` | 위상 그래프 시각화 및 인터랙티브 탐색 | 그래프 분석 뷰어 | -| `Vue.js` / `React` | 전체 UI 프레임워크 및 상태 관리 | `src/Web` 구조와 통합 | -| `Axios` / `WebSocket` | 실시간 OPC UA 데이터 수신 및 API 통신 | 실시간 업데이트 | - -### 1.2 백엔드 (API & Analysis) -| 기술/라이브러리 | 용도 | 비고 | -|---|---|---| -| `ASP.NET Core` | Graph API 및 분석 엔드포인트 제공 | `ExperionCrawler` 메인 서버 | -| `NetworkX` (Python) | 영향도 분석 및 경로 추적 알고리즘 실행 | 분석 엔진 (Phase 2 활용) | -| `FastAPI` / `Flask` | Python 분석 엔진과 C# 서버 간의 브릿지 | 분석 마이크로서비스 | - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 실시간 데이터 오버레이 (Real-time Overlay) -도면의 좌표 정보와 매핑된 시스템 태그를 연결하여 실시간 값을 표시합니다. -1. **매핑 데이터 로드:** `(도면노드ID, 시스템태그, 좌표)` 리스트를 프론트엔드로 전달. -2. **실시간 스트리밍:** `OPC UA` $\rightarrow$ `C# Server` $\rightarrow$ `WebSocket` $\rightarrow$ `Frontend`. -3. **동적 렌더링:** 태그 값이 변경되면 해당 좌표의 SVG 요소 색상을 변경하거나 툴팁에 현재 값을 표시. - -### 2.2 영향도 분석 엔진 (Impact Analysis Engine) -특정 설비의 이상 발생 시 하류(Downstream) 영향을 계산합니다. -1. **분석 요청:** 사용자가 도면에서 특정 노드(예: 펌프 P-101)를 클릭. -2. **그래프 탐색:** Python 분석 엔진에서 `nx.descendants(G, 'P-101')` 실행. -3. **결과 반환:** 영향받는 모든 노드 ID 리스트와 경로(Path)를 반환. -4. **시각적 강조:** 도면 상에서 영향 경로를 하이라이트(예: 빨간색 선) 처리. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 [Backend] 영향도 분석 API (C# $\rightarrow$ Python Bridge) -```csharp -// src/Web/Controllers/PidGraphController.cs - -// 1. 분석 상태 추적을 위한 DTO -public record AnalysisStatus(string taskId, double progress, string status, string message); - -// 2. 실시간 진행 상태 조회 API (Phase 5 병렬 처리 반영) -[HttpGet("status/{taskId}")] -public async Task GetAnalysisStatus(string taskId) -{ - // Orchestrator가 관리하는 작업 상태 저장소(Redis/MemoryCache)에서 조회 - var status = await _statusService.GetStatusAsync(taskId); - if (status == null) return NotFound(); - - return Ok(new { - taskId = status.TaskId, - progress = status.Progress, // 0.0 ~ 1.0 - status = status.Status, // "Processing", "Completed", "Failed" - message = status.Message - }); -} - -[HttpGet("impact/{nodeId}")] -public async Task GetImpactAnalysis(string nodeId) -{ - // Python 분석 마이크로서비스에 요청 - var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}"); - var result = await response.Content.ReadFromJsonAsync(); - - return Ok(result); -} -``` - -### 3.2 [Frontend] SVG 데이터 오버레이 및 진행률 표시 (JavaScript) -```javascript -// src/Web/wwwroot/js/pid-viewer.js - -// 1. 실시간 값 업데이트 -async function updateRealtimeValues(tagData) { - for (const [tag, value] of Object.entries(tagData)) { - const element = document.getElementById(`tag-node-${tag}`); - if (element) { - element.style.fill = value > threshold ? 'red' : 'green'; - element.setAttribute('data-value', value); - const tooltip = document.getElementById('pid-tooltip'); - tooltip.innerText = `${tag}: ${value}`; - } - } -} - -// 2. 분석 진행 상태 표시 (Phase 5 병렬 처리 반영) -async function trackAnalysisProgress(taskId) { - const progressBar = document.getElementById('analysis-progress-bar'); - const statusText = document.getElementById('analysis-status-text'); - - const pollStatus = async () => { - const response = await fetch(`/api/pid/status/${taskId}`); - const data = await response.json(); - - // 프로그레스 바 업데이트 (예: 20% -> 40% -> 100%) - progressBar.style.width = `${data.progress * 100}%`; - statusText.innerText = `분석 중... ${Math.round(data.progress * 100)}% (${data.message})`; - - if (data.status !== 'Completed' && data.status !== 'Failed') { - setTimeout(pollStatus, 1000); // 1초 간격 폴링 - } else { - statusText.innerText = data.status === 'Completed' ? '분석 완료!' : '분석 실패'; - } - }; - - pollStatus(); -} -``` - -### 3.3 [Analysis] 경로 추적 유틸리티 (Python) -```python -import networkx as nx - -def get_propagation_path(graph, start_node, end_node): - """장애 전파 경로를 최단 경로 기반으로 추출""" - try: - path = nx.shortest_path(graph, source=start_node, target=end_node) - return path - except nx.NetworkXNoPath: - return None - -# 예: P-101에서 V-105까지의 영향 경로 추출 -path = get_propagation_path(topology_graph, "P-101", "V-105") -``` - ---- - -## 🚀 4. Phase 4 완료 기준 (Definition of Done) - -- [ ] P&ID 도면(SVG/Canvas) 위에 **실시간 OPC UA 값**이 정확한 좌표에 표시되는가? -- [ ] 병렬 처리 중인 분석 작업의 **진행 상태(Progress Bar)**가 UI에 실시간으로 반영되는가? (Phase 5 반영) -- [ ] 특정 노드 클릭 시 **하류 영향도 분석(Impact Analysis)** 결과가 시각적으로 하이라이트 되는가? -- [ ] C# 메인 서버와 Python 분석 엔진 간의 **API 통신**이 지연 없이 이루어지는가? -- [ ] 운영자가 도면을 통해 **이상 징후의 전파 경로**를 직관적으로 파악할 수 있는가? -- [ ] 전체 파이프라인(`추출 $\rightarrow$ 모델링 $\rightarrow$ 매핑 $\rightarrow$ 시각화`)이 통합되어 동작하는가? diff --git a/.rooBackup/2026-05-02_08-40/mcp-server/server.py b/.rooBackup/2026-05-02_08-40/mcp-server/server.py deleted file mode 100644 index cc706ee..0000000 --- a/.rooBackup/2026-05-02_08-40/mcp-server/server.py +++ /dev/null @@ -1,1101 +0,0 @@ -#!/usr/bin/env python3 -""" -ExperionCrawler Unified MCP Server -- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen3-Coder-Next-FP8 -- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행 -- 사용처: - stdio 모드 (기본): Claude Code MCP / Roo Code MCP - HTTP 모드 (--http): C# McpClient (localhost:5001) -""" - -from __future__ import annotations -import sys -import json -import logging -import httpx -from functools import lru_cache -from mcp.server.fastmcp import FastMCP - -logging.basicConfig(level=logging.WARNING, stream=sys.stderr) - -# ── 설정 ────────────────────────────────────────────────────────────────────── -QDRANT_URL = "http://localhost:6333" -OLLAMA_URL = "http://localhost:11434" -EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" - -# Qdrant 컬렉션 -COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 -COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks) - -# PostgreSQL 연결 -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 # 초 - -# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식 -mcp = FastMCP( - "iiot-rag", - port=5001, - json_response=True, - stateless_http=True, -) - -# Pipeline Imports -from pipeline.extractor import PidGeometricExtractor -from pipeline.topology import PidTopologyBuilder -from pipeline.mapper import IntelligentMapper -from pipeline.analyzer import PidAnalysisEngine -import networkx as nx -import os -import asyncio - -# ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── - -def _embed(text: str) -> list[float]: - """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - with httpx.Client(timeout=30) as client: - resp = client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] - -# ── LLM (vLLM / Qwen3-Coder-Next-FP8) ─────────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -# ── PaddleOCR 싱글톤 (PDF fallback용) ────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _ocr(): - """PaddleOCR 인스턴스 (한/영, GPU). 첫 호출 시 ~50MB 모델 다운로드.""" - from paddleocr import PaddleOCR - import os - - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - ocr = PaddleOCR( - use_angle_cls=True, - lang="korean", - use_gpu=use_gpu, - show_log=False, - ) - return ocr - except Exception as e: - # GPU 실패 시 CPU 폴백 - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise e - - -# ── DXF/PDF 텍스트 추출 헬퍼 ─────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - """ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거).""" - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - """PyMuPDF로 PDF 파일에서 텍스트 추출.""" - import fitz # pymupdf - doc = fitz.open(filepath) - texts = [] - for page in doc: - texts.append(page.get_text()) - return "\n".join(texts) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - """PaddleOCR로 PDF에서 이미지 추출 후 OCR (고정밀도).""" - import fitz # pymupdf - from PIL import Image - import numpy as np - - doc = fitz.open(filepath) - all_texts = [] - - for page_idx, page in enumerate(doc): - # 페이지를 이미지로 변환 - mat = fitz.Matrix(300 / 72) # 300 DPI - pix = page.get_pixmap(matrix=mat) - img_data = pix.tobytes("png") - img = Image.open(__import__("io").BytesIO(img_data)) - - # OCR 실행 - result = _ocr().ocr(np.array(img), cls=True) - if result[0]: - for line in result[0]: - all_texts.append(line[1][0]) - - return "\n".join(all_texts) - - -def _convert_dwg_to_dxf_dxflib(filepath: str) -> str: - """libreoffice로 DWG를 DXF로 변환.""" - import subprocess - import os - - dxf_path = filepath.replace(".dwg", ".dxf") - - try: - # LibreOffice로 변환 - result = subprocess.run( - [ - "libreoffice", - "--headless", - "--convert-to", "dxf:AutoCAD DXF", - "--outdir", os.path.dirname(filepath) or ".", - filepath - ], - check=True, - timeout=120, - capture_output=True, - text=True - ) - - if os.path.exists(dxf_path): - return dxf_path - else: - raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.") - - except subprocess.CalledProcessError as e: - raise Exception(f"LibreOffice 변환 실패: {e.stderr}") - - -# ── Qdrant 검색 헬퍼 ────────────────────────────────────────────────────────── - -def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str: - vec = _embed(query) - with httpx.Client(timeout=20) as client: - resp = client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": vec, - "limit": top_k, - "with_payload": True, - "score_threshold": threshold, - }, - ) - resp.raise_for_status() - hits = resp.json().get("result", []) - - if not hits: - return "관련 결과 없음." - - parts = [] - for h in hits: - p = h.get("payload", {}) - file_path = p.get("filePath", p.get("path", "unknown")) - chunk = p.get("codeChunk", p.get("content", p.get("text", ""))) - start_line = p.get("startLine", "") - loc = f"{file_path}:{start_line}" if start_line else file_path - parts.append(f"[score={h['score']:.3f}] {loc}\n```\n{chunk[:700]}\n```") - - return "\n\n---\n\n".join(parts) - -# ── DB 헬퍼 ────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - """PostgreSQL DB 연결 획득.""" - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - - -def _validate_sql(sql: str) -> tuple[bool, str]: - """SQL 안전 검증 — SELECT만 허용, 위험 키워드 차단.""" - if len(sql) > 2000: - return False, "쿼리 길이 2000자를 초과했습니다." - dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'] - sql_upper = sql.upper() - for kw in dangerous: - if kw in sql_upper: - return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다." - if not sql_upper.strip().startswith('SELECT'): - return False, "단순 SELECT 쿼리만 허용됩니다." - if '..' in sql or '~' in sql: - return False, "파일 경로 표현은 허용되지 않습니다." - return True, "" - - -# DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용 -_DB_SCHEMA = """ -PostgreSQL 시계열 데이터베이스 스키마 - -테이블: history_table (시계열 이력) - tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분 - node_id TEXT - OPC UA 노드 ID - value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요 - recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초 - -테이블: realtime_table (실시간 최신값) - tagname TEXT - 태그명 (모두 소문자) - node_id TEXT - OPC UA 노드 ID - livevalue TEXT - 현재값 - timestamp TIMESTAMPTZ - 최종 갱신 시각 - -N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): - 1분 버킷: date_trunc('minute', recorded_at) AS bucket - 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket - 5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket - 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket - N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket - -예시 (2분 간격, 여러 태그): - SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, - tagname, AVG(value::double precision) AS avg_val - FROM history_table - WHERE tagname IN ('tag1', 'tag2') - AND recorded_at >= NOW() - INTERVAL '3 hours' - GROUP BY bucket, tagname ORDER BY bucket, tagname - -규칙: - - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) - - tagname은 모두 소문자로 정확히 입력 - - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수 - - time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용 -""" - -# ── RAG 도구 ───────────────────────────────────────────────────────────────── - -@mcp.tool() -def search_codebase(query: str, top_k: int = 6) -> str: - """ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드). - Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함. - - 사용 시점: ExperionCrawler 코드의 구현 방법, 버그, 구조를 알고 싶을 때. - ⚠️ Experion HS R530 제품 동작/설정/스펙을 알고 싶으면 search_r530_docs 사용. - - Args: - query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스") - top_k: 반환 결과 수 (기본 6) - """ - return _search(COL_CODEBASE, query, top_k) - - -@mcp.tool() -def search_r530_docs(query: str, top_k: int = 5) -> str: - """Honeywell Experion HS R530 공식 제품 문서 검색. - ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함. - - 사용 시점: Experion HS R530의 OPC UA 설정, 인증서, 보안 정책, 포인트 주소 형식, - 채널/컨트롤러 속성, 문제해결 등 제품 스펙과 동작을 알고 싶을 때. - - Args: - query: 검색어 (예: "certificate configuration", "endpoint security policy") - top_k: 반환 결과 수 (기본 5) - """ - return _search(COL_OPC_DOCS, query, top_k) - - -@mcp.tool() -def ask_iiot_llm(question: str, context: str = "") -> str: - """Qwen3-Coder-Next에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). - - 사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨 - 종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문. - - Args: - question: 질문 내용 - context: (선택) search_codebase 또는 search_r530_docs 검색 결과 - """ - system = ( - "당신은 IIoT(산업용 IoT), OPC UA, Honeywell Experion PKS/HS R530 전문가입니다.\n" - "컨텍스트가 제공된 경우 컨텍스트를 우선 근거로 삼아 한국어로 답변합니다.\n" - "컨텍스트 출처가 'Experion HS R530 공식 문서'인지 'ExperionCrawler 코드'인지 명확히 구분하여 설명합니다." - ) - user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user_msg}, - ], - max_tokens=2048, - temperature=0.1, - ) - return resp.choices[0].message.content or "(응답 없음)" - - -@mcp.tool() -def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """검색 → Qwen3-Coder-Next 답변 생성 (통합 RAG). - - 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). - ExperionCrawler 코드도 함께 보려면 search_code=True 추가. - - Args: - question: 질문 - search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True) - search_code: ExperionCrawler 소스코드 검색 여부 (기본 False) - """ - context_parts: list[str] = [] - if search_docs: - context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}") - if search_code: - context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}") - return ask_iiot_llm(question, "\n\n".join(context_parts)) - - -# ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── - -@mcp.tool() -def run_sql(sql: str) -> str: - """SQL 쿼리 실행 (SELECT만 허용). - - Args: - sql: 실행할 SELECT SQL 문자열 - - Returns: - JSON: { success, columns, count, data } 또는 { success, error } - """ - valid, err = _validate_sql(sql) - if not valid: - return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) - - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute(sql) - rows = cur.fetchall() - columns = [desc[0] for desc in cur.description] - result_data = [dict(zip(columns, row)) for row in rows] - return json.dumps({ - "success": True, - "columns": columns, - "count": len(result_data), - "data": result_data - }, ensure_ascii=False, default=str) - except Exception as e: - return json.dumps({"success": False, "error": f"SQL 실행 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """과거 값(PV) 히스토리 조회. - - Args: - tag_names: 태그 이름 목록 (예: ["ficq-6113.pv", "ti-6101.pv"]) - time_from: 시작 시간 (ISO 8601, 예: "2026-04-01T00:00:00") - time_to: 종료 시간 (ISO 8601, 예: "2026-04-02T00:00:00") - limit: 반환 행 수 제한 (기본 100, 최대 5000) - - Returns: - JSON: { success, tag_names, time_range, limit, data } - """ - try: - limit = min(limit, 5000) - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, recorded_at, value - FROM history_table - WHERE tagname = ANY(%s) - AND recorded_at >= %s AND recorded_at <= %s - ORDER BY recorded_at, tagname - LIMIT %s""", - (tag_names, time_from, time_to, limit) - ) - rows = cur.fetchall() - data = [{"tag_name": r[0], "timestamp": r[1].isoformat(), "value": r[2]} for r in rows] - return json.dumps({ - "success": True, - "tag_names": tag_names, - "time_range": f"{time_from} ~ {time_to}", - "count": len(data), - "data": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"히스토리 쿼리 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def get_tag_metadata(query: str, limit: int = 10) -> str: - """태그 메타데이터 검색 (realtime_table 기반). - - Args: - query: 태그명 검색어 (패턴 매칭) - limit: 반환 태그 수 제한 (기본 10) - - Returns: - JSON: { success, query, count, tags } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, livevalue, timestamp, node_id - FROM realtime_table - WHERE tagname ILIKE %s - ORDER BY tagname LIMIT %s""", - (f"%{query}%", limit) - ) - rows = cur.fetchall() - tags = [{"tag_name": r[0], "current_value": r[1], - "last_updated": r[2].isoformat() if r[2] else None, - "node_id": r[3]} for r in rows] - return json.dumps({"success": True, "query": query, "count": len(tags), "tags": tags}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"태그 메타데이터 검색 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def list_drawings(unit_no: str | None = None) -> str: - """단위별 도면 목록 조회 (node_map_master.name 기반). - - Args: - unit_no: 단위 번호 접두사 (예: "A", "B"). None이면 전체 목록 - - Returns: - JSON: { success, unit_no, count, names } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - if unit_no: - cur.execute( - "SELECT DISTINCT name FROM node_map_master WHERE name ILIKE %s ORDER BY name LIMIT 100", - (f"{unit_no}%",) - ) - else: - cur.execute("SELECT DISTINCT name FROM node_map_master ORDER BY name LIMIT 100") - rows = cur.fetchall() - return json.dumps({"success": True, "unit_no": unit_no, - "count": len(rows), "names": [r[0] for r in rows]}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"도면 목록 조회 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_with_nl(question: str) -> str: - """자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다. - - Args: - question: 자연어 질문 (예: "FICQ-6113.PV 최근 1시간 값을 1분 단위로 표시") - - Returns: - JSON: { sql, success, columns, count, data } 또는 { sql, success, error } - """ - system = ( - "You are a PostgreSQL SQL expert.\n" - "Convert the user's question into a SELECT SQL using the schema below.\n" - "IMPORTANT rules:\n" - "- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n" - "- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n" - "- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n" - "- INTERVAL rule:\n" - " * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n" - " use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n" - " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" - " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" - "- Current year is 2026. '4월 27일' means 2026-04-27.\n" - "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" - "- value column is TEXT; cast with ::double precision only when aggregating.\n" - "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" - "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" - "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" - f"{_DB_SCHEMA}" - ) - try: - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": question}, - ], - max_tokens=8192, - temperature=0.1, - ) - sql = (resp.choices[0].message.content or "").strip() - # 마크다운 코드 블록 제거 - if sql.startswith("```"): - lines = sql.splitlines() - sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() - if not sql: - return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) - except Exception as e: - return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False) - - # SQL 실행 - raw = run_sql(sql) - result = json.loads(raw) - result["sql"] = sql - - # long format → pivot 변환 (tagname 컬럼이 있으면 자동 PIVOT) - if result.get("success") and "data" in result: - cols = result.get("columns", []) - data = result["data"] - if "tagname" in cols and data: - time_col = next((c for c in cols if c not in ("tagname", "value", "livevalue", "avg_val")), None) - val_col = next((c for c in ("avg_val", "value") if c in cols), cols[-1]) - if time_col: - tag_names_list = sorted(dict.fromkeys(row["tagname"] for row in data)) - pivoted: dict = {} - for row in data: - key = str(row[time_col]) - if key not in pivoted: - pivoted[key] = {time_col: row[time_col]} - pivoted[key][row["tagname"]] = row.get(val_col) - result["data"] = list(pivoted.values()) - result["columns"] = [time_col] + tag_names_list - result["count"] = len(result["data"]) - - return json.dumps(result, ensure_ascii=False, default=str) - - -# ── P&ID 추출 도구 ────────────────────────────────────────────────────────────── - -@mcp.tool() -def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 도면(DXF/PDF)에서 태그 정보를 추출합니다. - - Args: - text: DXF/PDF에서 추출한 텍스트 - source_type: 'dxf' 또는 'pdf' - - Returns: - JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] } - """ - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV","lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo (e.g. FCV, P, T, VG, BT, DP, PSV)\n" - "- equipmentName: descriptive name if present in text near the tag, else null\n" - "- lineNumber: null unless a line number is explicitly associated\n" - "- pidDrawingNo: null unless a drawing number is explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous ones\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - - import logging - import re - import json as json_module - - try: - truncated_text = text[:100000] if len(text) > 100000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # finish_reason=length 로 잘린 경우: 마지막 완전한 객체까지 살린 뒤 배열 닫기 - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 유효한 JSON 배열 추출 (가장 긴 균형 잡힌 [...] 선택) - def _extract_array(s: str) -> str: - depth = 0; start = -1; best = "" - for i, c in enumerate(s): - if c == '[': - if depth == 0: start = i - depth += 1 - elif c == ']': - depth -= 1 - if depth == 0 and start >= 0: - cand = s[start:i+1] - if len(cand) > len(best): best = cand - return best if best else "[]" - - raw = _extract_array(raw) - - # JSON 파싱 — 실패 시 개별 객체 추출로 폴백 - try: - data = json_module.loads(raw) - except json_module.JSONDecodeError: - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json_module.loads(obj)) - except json_module.JSONDecodeError: - pass - if not data: - return json_module.dumps({"success": False, "count": 0, "tags": []}, ensure_ascii=False) - - logging.info(f"[extract_pid_tags] source={source_type} count={len(data) if isinstance(data, list) else 0}") - - return json_module.dumps({ - "success": True, - "count": len(data), - "tags": data - }, ensure_ascii=False, indent=2) - - except Exception as e: - logging.error(f"P&ID 태그 추출 실패: {e}") - logging.error(f"Raw response: {raw[:1000]}") - return json.dumps({"success": False, "error": f"P&ID 태그 추출 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 태그를 Experion 태그에 매핑합니다. - - Args: - pid_tags: P&ID에서 추출한 태그 목록 (예: ["FT-101", "PT-201"]) - experion_tags: Experion 시스템 태그 목록 (예: ["ficq-6113.pv", "pt-201.pv"]) - - Returns: - JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] } - """ - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "IMPORTANT rules:\n" - "- pidTag: The original P&ID tag from input\n" - "- experionTag: The matched Experion tag (lowercase, with .pv/.sp/.mv suffix)\n" - "- confidence: 0.0 to 1.0 based on match quality\n" - "- If no good match found, set confidence < 0.5 and leave experionTag null\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no matches found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - import re - import json as json_module - - try: - pid_str = "\n".join(pid_tags) - experion_str = "\n".join(experion_tags) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - match = re.search(r'\[.*\]', raw, re.DOTALL) - raw = match.group(0) if match else "[]" - - data = json_module.loads(raw) - return json_module.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - - except Exception as e: - return json.dumps({"success": False, "error": f"P&ID 태그 매핑 실패: {e}"}, ensure_ascii=False) - - -# ── P&ID 파싱 도구 (DXF/PDF/DWG) ─────────────────────────────────────────────── - - -@mcp.tool() -def parse_pid_dxf(filepath: str) -> str: - """ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: DXF 파일 경로 - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], # 제한 - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"DXF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """PyMuPDF 기반 PDF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: PDF 파일 경로 - use_ocr: OCR 사용 여부 (기본 True, 고정밀도) - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - if use_ocr: - text = _extract_text_from_pdf_ocr(filepath) - else: - text = _extract_text_from_pdf(filepath) - - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"PDF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """ - 분산 처리 기법을 적용한 P&ID 그래프 생성 툴. - 전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다. - """ - try: - # 1. 전처리 (Phase 1: Geometric Extraction) - extractor = PidGeometricExtractor(filepath) - geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json" - geo_data_list = extractor.extract_and_save(geo_data_path) - - # geo_data_list는 경로를 반환하므로 다시 로드 - with open(geo_data_path, 'r', encoding='utf-8') as f: - geo_data = json.load(f) - - # 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping) - # 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값) - # 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함 - system_tags = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"Failed to fetch system tags: {e}") - - # 그래프 임시 생성 (Mapper가 위상 정보를 사용하므로 필요) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Mapper 설정 - from openai import AsyncOpenAI - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - # 분류별 노드 분리 - nodes = list(builder.G.nodes()) - transmitter_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FIT', 'FT', 'LT', 'PT', 'TE']] # 단순화된 필터 - valve_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FCV', 'LCV', 'TCV', 'PCV', 'XV']] - equipment_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('type') not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - # 병렬 호출 (vLLM Batching 유도) - tasks = [ - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes) - ] - extracted_results = await asyncio.gather(*tasks) - - # 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - # TopologyBuilder가 기대하는 형식으로 변환 - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": node_data['bbox'].bounds if hasattr(node_data['bbox'], 'bounds') else node_data['bbox'], - "clean_value": mapping.resolved_tag - }) - - # 3. 최종 위상 모델링 (Phase 2) - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - # 4. 저장 - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = f"mcp-server/storage/{graph_id}" - final_builder.save_graph(graph_path) - - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges() - }, ensure_ascii=False) - - except Exception as e: - logging.error(f"build_pid_graph_parallel failed: {e}") - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -@mcp.tool() -def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """ - 구축된 그래프를 기반으로 특정 설비 장애 시 영향도 분석을 수행합니다. - """ - try: - graph_path = f"mcp-server/storage/{graph_id}" - mapping_path = graph_path.replace("_graph.json", "_mapping.json") # 매핑 파일이 따로 저장된다고 가정 - - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - - return json.dumps(result, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False) - -@mcp.tool() -def parse_pid_drawing(filepath: str) -> str: - """확장자 자동 감지하여 P&ID 도면 파싱. - - Args: - filepath: DXF/DWG/PDF 파일 경로 - - Returns: - JSON: { success, text, count, tags, format } - """ - import os - ext = os.path.splitext(filepath)[1].lower() - - if ext == ".dxf": - return parse_pid_dxf(filepath) - elif ext == ".dwg": - # DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다. - # Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다. - return json.dumps({ - "success": False, - "error": "DWG 파일은 현재 직접 파싱할 수 없습니다.\n" + - "사전에 DXF로 변환하여 업로드해 주세요.\n" + - "\n변환 방법:\n" + - "1. Windows에서 AutoCAD 또는 ODA File Converter 사용\n" + - "2. 온라인 DWG → DXF 변환기 사용\n" + - "3. LibreOffice Draw (Windows/macOS 전용) 사용" - }, ensure_ascii=False) - elif ext == ".pdf": - return parse_pid_pdf(filepath) - else: - return json.dumps({ - "success": False, - "error": f"Unsupported format: {ext}. Supported: .dxf, .dwg, .pdf" - }, ensure_ascii=False) - - -# ── 엔트리포인트 ────────────────────────────────────────────────────────────── - -def main(): - """HTTP 모드로 실행 — C# McpClient (localhost:5001) 용.""" - mcp.run(transport="streamable-http") - - -if __name__ == "__main__": - # --http 플래그: HTTP 모드 (C# McpClient 용) - # 플래그 없음: stdio 모드 (Claude Code / Roo Code MCP 용) - if "--http" in sys.argv: - mcp.run(transport="streamable-http") - else: - mcp.run(transport="stdio") diff --git a/.rooBackup/2026-05-02_10-02/src/Core/Application/Services/PidGraphService.cs b/.rooBackup/2026-05-02_10-02/src/Core/Application/Services/PidGraphService.cs deleted file mode 100644 index ab56024..0000000 --- a/.rooBackup/2026-05-02_10-02/src/Core/Application/Services/PidGraphService.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Text.Json; -using ExperionCrawler.Infrastructure.Mcp; -using ExperionCrawler.Core.Application.DTOs; - -namespace ExperionCrawler.Core.Application.Services; - -public interface IPidGraphService -{ - Task BuildPidGraphAsync(string filepath); - Task AnalyzeImpactAsync(string graphId, string nodeId); -} - -public class PidGraphService : IPidGraphService -{ - private readonly McpClient _mcpClient; - private readonly ILogger _logger; - - public PidGraphService(McpClient mcpClient, ILogger logger) - { - _mcpClient = mcpClient; - _logger = logger; - } - - public async Task BuildPidGraphAsync(string filepath) - { - try - { - var args = new Dictionary - { - ["filepath"] = filepath - }; - - var jsonResponse = await _mcpClient.CallToolAsync("build_pid_graph_parallel", args); - var result = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - return result ?? throw new Exception("Failed to deserialize MCP response"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error building PID graph for file {Filepath}", filepath); - return new PidGraphBuildResult { Success = false, Error = ex.Message }; - } - } - - public async Task AnalyzeImpactAsync(string graphId, string nodeId) - { - try - { - var args = new Dictionary - { - ["graph_id"] = graphId, - ["start_node_id"] = nodeId - }; - - var jsonResponse = await _mcpClient.CallToolAsync("analyze_pid_impact", args); - var result = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - return result ?? throw new Exception("Failed to deserialize MCP response"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error analyzing impact for graph {GraphId} node {NodeId}", graphId, nodeId); - return new PidImpactResult { Success = false, Error = ex.Message }; - } - } -} - -public class PidGraphBuildResult -{ - public bool Success { get; set; } - public string? GraphId { get; set; } - public string? GraphPath { get; set; } - public int Nodes { get; set; } - public int Edges { get; set; } - public string? Error { get; set; } -} - -public class PidImpactResult -{ - public bool Success { get; set; } - public string? StartNode { get; set; } - public Dictionary? ImpactedNodes { get; set; } - public List>? Paths { get; set; } - public string? Error { get; set; } -} diff --git a/.rooBackup/2026-05-02_10-02/src/Web/Controllers/PidGraphController.cs b/.rooBackup/2026-05-02_10-02/src/Web/Controllers/PidGraphController.cs deleted file mode 100644 index 5538d77..0000000 --- a/.rooBackup/2026-05-02_10-02/src/Web/Controllers/PidGraphController.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using ExperionCrawler.Core.Application.DTOs; -using ExperionCrawler.Core.Application.Services; -using System.Net.Http.Json; -using System.Collections.Concurrent; - -namespace ExperionCrawler.Web.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class PidGraphController : ControllerBase -{ - private readonly IPidGraphService _pidGraphService; - private readonly ILogger _logger; - - // 간단한 인메모리 상태 저장소 (실제 운영 환경에서는 Redis/DistributedCache 권장) - private static readonly ConcurrentDictionary _statusStore = new(); - - public PidGraphController(IPidGraphService pidGraphService, ILogger logger) - { - _pidGraphService = pidGraphService; - _logger = logger; - } - - [HttpGet("impact/{graphId}/{nodeId}")] - public async Task GetImpactAnalysis(string graphId, string nodeId) - { - try - { - _logger.LogInformation("Requesting impact analysis for graph: {GraphId}, node: {NodeId}", graphId, nodeId); - - var result = await _pidGraphService.AnalyzeImpactAsync(graphId, nodeId); - - if (!result.Success) - { - return NotFound(new { error = result.Error }); - } - - // 프론트엔드 camelCase 규칙 준수 - return Ok(new - { - startNode = result.StartNode, - impactedNodes = result.ImpactedNodes, - paths = result.Paths - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error during impact analysis"); - return StatusCode(500, new { error = "Internal server error", details = ex.Message }); - } - } - - [HttpGet("status/{taskId}")] - public IActionResult GetAnalysisStatus(string taskId) - { - if (_statusStore.TryGetValue(taskId, out var status)) - { - return Ok(new - { - taskId = status.TaskId, - progress = status.Progress, - status = status.Status, - message = status.Message - }); - } - return NotFound(); - } - - // 그래프 생성 API - [HttpPost("build")] - public async Task BuildGraph([FromBody] BuildGraphRequest request) - { - if (string.IsNullOrEmpty(request.Filepath)) - return BadRequest(new { error = "Filepath is required" }); - - try - { - var result = await _pidGraphService.BuildPidGraphAsync(request.Filepath); - - if (!result.Success) - return StatusCode(500, new { error = result.Error }); - - return Ok(new - { - success = result.Success, - graphId = result.GraphId, - nodes = result.Nodes, - edges = result.Edges - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during graph build request"); - return StatusCode(500, new { error = "Internal server error", details = ex.Message }); - } - } - - public record BuildGraphRequest(string Filepath); -} diff --git a/.rooBackup/2026-05-02_10-06/src/Web/wwwroot/js/pid-viewer.js b/.rooBackup/2026-05-02_10-06/src/Web/wwwroot/js/pid-viewer.js deleted file mode 100644 index c07154d..0000000 --- a/.rooBackup/2026-05-02_10-06/src/Web/wwwroot/js/pid-viewer.js +++ /dev/null @@ -1,241 +0,0 @@ - -/* ── P&ID Graph Viewer Logic ────────────────────────────────────────── */ -let pidCanvas, pidCtx; -let pidNodeMap = new Map(); // { nodeId: {x, y, label, ...} } -let pidTopology = null; -let pidImpactResult = null; -let pidZoom = 1.0; -let pidOffset = { x: 0, y: 0 }; -let pidIsDragging = false; -let pidLastMouse = { x: 0, y: 0 }; - -async function pidInit() { - pidCanvas = document.getElementById('pid-canvas'); - if (!pidCanvas) return; - pidCtx = pidCanvas.getContext('2d'); - - window.addEventListener('resize', pidResize); - pidResize(); - - // 마우스 이벤트 설정 - pidCanvas.addEventListener('mousedown', pidOnMouseDown); - pidCanvas.addEventListener('mousemove', pidOnMouseMove); - pidCanvas.addEventListener('mouseup', pidOnMouseUp); - pidCanvas.addEventListener('wheel', pidOnWheel); - pidCanvas.addEventListener('click', pidOnClick); -} - -function pidResize() { - const wrap = pidCanvas.parentElement; - pidCanvas.width = wrap.clientWidth; - pidCanvas.height = wrap.clientHeight; - pidRender(); -} - -async function pidLoadDrawing() { - setGlobal('busy', '도면 데이터 로드 중'); - try { - // 1. 기하학적 데이터 로드 (Phase 1 결과물) - const geoRes = await api('GET', '/api/pid/geometry'); // 가상 엔드포인트 (필요시 구현) - // 실제로는 파일에서 직접 읽거나 API를 통해 가져옴. - // 여기서는 예시로 shared_geo_data.json 형태의 데이터를 가정 - const geoData = await (await fetch('/futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json')).json(); - - pidNodeMap.clear(); - geoData.nodes.forEach(n => { - pidNodeMap.set(n.id, n); - }); - - // 2. 위상 데이터 로드 (Phase 2 결과물) - const topoData = await (await fetch('/futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json')).json(); - pidTopology = topoData; - - document.getElementById('pid-status-txt').textContent = `도면 로드 완료: ${pidNodeMap.size}개 노드`; - pidRender(); - setGlobal('ok', '로드 완료'); - } catch (e) { - console.error(e); - document.getElementById('pid-status-txt').textContent = '도면 로드 실패'; - setGlobal('err', '로드 실패'); - } -} - -function pidRender() { - if (!pidCtx) return; - pidCtx.clearRect(0, 0, pidCanvas.width, pidCanvas.height); - pidCtx.save(); - pidCtx.translate(pidOffset.x, pidOffset.y); - pidCtx.scale(pidZoom, pidZoom); - - // 1. 엣지(배관) 렌더링 - if (pidTopology && pidTopology.edges) { - pidCtx.strokeStyle = '#555'; - pidCtx.lineWidth = 1 / pidZoom; - pidCtx.beginPath(); - pidTopology.edges.forEach(edge => { - const s = pidNodeMap.get(edge.source); - const t = pidNodeMap.get(edge.target); - if (s && t) { - // 영향도 분석 결과에 포함된 경로라면 하이라이트 - if (pidImpactResult && pidImpactResult.paths?.some(p => p.includes(edge.source) && p.includes(edge.target))) { - pidCtx.strokeStyle = '#ff4444'; - pidCtx.lineWidth = 3 / pidZoom; - } else { - pidCtx.strokeStyle = '#555'; - pidCtx.lineWidth = 1 / pidZoom; - } - pidCtx.moveTo(s.x, s.y); - pidCtx.lineTo(t.x, t.y); - } - }); - pidCtx.stroke(); - } - - // 2. 노드(설비) 렌더링 - pidNodeMap.forEach((node, id) => { - const isImpacted = pidImpactResult?.impactedNodes?.[id] !== undefined; - const depth = pidImpactResult?.impactedNodes?.[id] || 0; - - pidCtx.fillStyle = isImpacted ? `rgba(255, ${Math.max(0, 255 - depth * 50)}, 0, 0.8)` : '#aaa'; - pidCtx.beginPath(); - pidCtx.arc(node.x, node.y, 4 / pidZoom, 0, Math.PI * 2); - pidCtx.fill(); - - if (pidZoom > 1.5) { - pidCtx.fillStyle = '#fff'; - pidCtx.font = `${10 / pidZoom}px Arial`; - pidCtx.fillText(node.label || id, node.x + 5 / pidZoom, node.y - 5 / pidZoom); - } - }); - - pidCtx.restore(); -} - -// --- 인터랙션 이벤트 --- - -function pidOnMouseDown(e) { - pidIsDragging = true; - pidLastMouse = { x: e.clientX, y: e.clientY }; -} - -function pidOnMouseMove(e) { - if (pidIsDragging) { - pidOffset.x += e.clientX - pidLastMouse.x; - pidOffset.y += e.clientY - pidLastMouse.y; - pidLastMouse = { x: e.clientX, y: e.clientY }; - pidRender(); - } - - // 툴팁 처리 - const rect = pidCanvas.getBoundingClientRect(); - const worldX = (e.clientX - rect.left - pidOffset.x) / pidZoom; - const worldY = (e.clientY - rect.top - pidOffset.y) / pidZoom; - - let found = null; - pidNodeMap.forEach((node, id) => { - const dist = Math.hypot(node.x - worldX, node.y - worldY); - if (dist < 10 / pidZoom) found = { id, ...node }; - }); - - const tooltip = document.getElementById('pid-tooltip'); - if (found) { - tooltip.classList.remove('hidden'); - tooltip.style.left = (e.clientX - rect.left + 10) + 'px'; - tooltip.style.top = (e.clientY - rect.top + 10) + 'px'; - tooltip.innerHTML = `${found.label || found.id}
ID: ${found.id}`; - } else { - tooltip.classList.add('hidden'); - } -} - -function pidOnMouseUp() { - pidIsDragging = false; -} - -function pidOnWheel(e) { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - pidZoom *= delta; - pidZoom = Math.min(Math.max(pidZoom, 0.1), 10); - pidRender(); -} - -async function pidOnClick(e) { - const rect = pidCanvas.getBoundingClientRect(); - const worldX = (e.clientX - rect.left - pidOffset.x) / pidZoom; - const worldY = (e.clientY - rect.top - pidOffset.y) / pidZoom; - - let clickedNode = null; - pidNodeMap.forEach((node, id) => { - const dist = Math.hypot(node.x - worldX, node.y - worldY); - if (dist < 10 / pidZoom) clickedNode = id; - }); - - if (clickedNode) { - const node = pidNodeMap.get(clickedNode); - document.getElementById('pid-node-info').innerHTML = ` - 노드 ID: ${clickedNode}
- 라벨: ${node.label || '-'}
- 좌표: (${node.x}, ${node.y}) - `; - await pidRequestImpactAnalysis(clickedNode); - } -} - -async function pidRequestImpactAnalysis(nodeId) { - const statusTxt = document.getElementById('pid-status-txt'); - const progWrap = document.getElementById('pid-progress-wrap'); - const progBar = document.getElementById('pid-progress-bar'); - - statusTxt.textContent = `분석 요청 중: ${nodeId}...`; - progWrap.classList.remove('hidden'); - progBar.style.width = '0%'; - - try { - // 1. 분석 시작 요청 (현재는 graphId가 필요하므로, 로드된 topoData의 ID나 파일명을 사용해야 함) - // 여기서는 단순화를 위해 현재 로드된 도면의 graphId를 가정하거나, - // 실제로는 pidLoadDrawing 시점에 graphId를 저장해두어야 함. - const graphId = "No-10_Plant_PID_graph.json"; // 예시 ID - const startRes = await api('GET', `/api/pidgraph/impact/${graphId}/${nodeId}`); - - // 2. 결과 처리 (이제 API가 즉시 결과를 반환하므로 폴링 불필요) - pidImpactResult = startRes; - pidRender(); - pidRenderImpactList(startRes); - statusTxt.textContent = '분석 완료'; - progWrap.classList.add('hidden'); - } catch (e) { - statusTxt.textContent = '분석 오류 발생'; - progWrap.classList.add('hidden'); - console.error(e); - } -} - -function pidRenderImpactList(result) { - const list = document.getElementById('pid-impact-items'); - list.innerHTML = ''; - - const sortedNodes = Object.entries(result.impactedNodes) - .sort((a, b) => a[1] - b[1]); - - sortedNodes.forEach(([id, depth]) => { - const li = document.createElement('li'); - li.innerHTML = `${id}Depth: ${depth}`; - li.onclick = () => { - const node = pidNodeMap.get(id); - if (node) { - pidOffset.x = pidCanvas.width/2 - node.x * pidZoom; - pidOffset.y = pidCanvas.height/2 - node.y * pidZoom; - pidRender(); - } - }; - list.appendChild(li); - }); -} - -function pidClearAnalysis() { - pidImpactResult = null; - document.getElementById('pid-impact-items').innerHTML = ''; - document.getElementById('pid-node-info').textContent = '노드를 클릭하면 상세 정보가 표시됩니다.'; - pidRender(); -} diff --git a/.rooBackup/2026-05-02_10-15/src/Web/wwwroot/js/pid-viewer.js b/.rooBackup/2026-05-02_10-15/src/Web/wwwroot/js/pid-viewer.js deleted file mode 100644 index 5a3f839..0000000 --- a/.rooBackup/2026-05-02_10-15/src/Web/wwwroot/js/pid-viewer.js +++ /dev/null @@ -1,308 +0,0 @@ - -/* ── P&ID Graph Viewer Logic ────────────────────────────────────────── */ -let pidCanvas, pidCtx; -let pidNodeMap = new Map(); // { nodeId: {x, y, label, ...} } -let pidTopology = null; -let pidImpactResult = null; -let pidZoom = 1.0; -let pidOffset = { x: 0, y: 0 }; -let pidIsDragging = false; -let pidLastMouse = { x: 0, y: 0 }; - -async function pidInit() { - pidCanvas = document.getElementById('pid-canvas'); - if (!pidCanvas) return; - pidCtx = pidCanvas.getContext('2d'); - - window.addEventListener('resize', pidResize); - pidResize(); - - // 마우스 이벤트 설정 - pidCanvas.addEventListener('mousedown', pidOnMouseDown); - pidCanvas.addEventListener('mousemove', pidOnMouseMove); - pidCanvas.addEventListener('mouseup', pidOnMouseUp); - pidCanvas.addEventListener('wheel', pidOnWheel); - pidCanvas.addEventListener('click', pidOnClick); -} - -function pidResize() { - const wrap = pidCanvas.parentElement; - pidCanvas.width = wrap.clientWidth; - pidCanvas.height = wrap.clientHeight; - pidRender(); -} - -async function pidLoadDrawing() { - setGlobal('busy', '도면 데이터 로드 중'); - try { - // 1. 기하학적 데이터 로드 (Phase 1 결과물) - const geoRes = await api('GET', '/api/pid/geometry'); // 가상 엔드포인트 (필요시 구현) - // 실제로는 파일에서 직접 읽거나 API를 통해 가져옴. - // 여기서는 예시로 shared_geo_data.json 형태의 데이터를 가정 - const geoData = await (await fetch('/futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json')).json(); - - pidNodeMap.clear(); - geoData.nodes.forEach(n => { - pidNodeMap.set(n.id, n); - }); - - // 2. 위상 데이터 로드 (Phase 2 결과물) - const topoData = await (await fetch('/futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json')).json(); - pidTopology = topoData; - - document.getElementById('pid-status-txt').textContent = `도면 로드 완료: ${pidNodeMap.size}개 노드`; - pidRender(); - setGlobal('ok', '로드 완료'); - } catch (e) { - console.error(e); - document.getElementById('pid-status-txt').textContent = '도면 로드 실패'; - setGlobal('err', '로드 실패'); - } -} - -async function pidBuildGraph(filepath) { - const statusTxt = document.getElementById('pid-status-txt'); - const progWrap = document.getElementById('pid-progress-wrap'); - const progBar = document.getElementById('pid-progress-bar'); - - let startTime = Date.now(); - let timerInterval = null; - - const updateTimer = () => { - const elapsed = Math.floor((Date.now() - startTime) / 1000); - const mins = String(Math.floor(elapsed / 60)).padStart(2, '0'); - const secs = String(elapsed % 60).padStart(2, '0'); - const timeStr = `[${mins}:${secs}] `; - - // 현재 메시지 유지하면서 시간만 업데이트 - const currentMsg = statusTxt.textContent; - if (!currentMsg.startsWith('[')) { - statusTxt.textContent = timeStr + currentMsg; - } else { - statusTxt.textContent = timeStr + currentMsg.substring(currentMsg.indexOf(']') + 1); - } - }; - - try { - progWrap.classList.remove('hidden'); - progBar.style.width = '0%'; - - // 타이머 시작 - timerInterval = setInterval(updateTimer, 1000); - updateTimer(); - - // 1. 빌드 요청 - statusTxt.textContent = '추출 요청 중...'; - const res = await api('POST', '/api/pidgraph/build', { filepath }); - const taskId = res.taskId; - - // 2. 폴링 시작 - let completed = false; - while (!completed) { - const statusRes = await api('GET', `/api/pidgraph/status/${taskId}`); - - progBar.style.width = `${statusRes.progress}%`; - statusTxt.textContent = statusRes.message; - updateTimer(); // 메시지 변경 후 타이머 다시 적용 - - if (statusRes.status === 'Completed') { - completed = true; - setGlobal('ok', '추출 완료'); - } else if (statusRes.status === 'Failed') { - throw new Error(statusRes.message); - } - - await new Promise(resolve => setTimeout(resolve, 1000)); - } - - statusTxt.textContent = '추출이 성공적으로 완료되었습니다.'; - progWrap.classList.add('hidden'); - } catch (e) { - console.error(e); - statusTxt.textContent = `오류 발생: ${e.message}`; - progWrap.classList.add('hidden'); - setGlobal('err', '추출 실패'); - } finally { - if (timerInterval) clearInterval(timerInterval); - } -} - -function pidRender() { - if (!pidCtx) return; - pidCtx.clearRect(0, 0, pidCanvas.width, pidCanvas.height); - pidCtx.save(); - pidCtx.translate(pidOffset.x, pidOffset.y); - pidCtx.scale(pidZoom, pidZoom); - - // 1. 엣지(배관) 렌더링 - if (pidTopology && pidTopology.edges) { - pidCtx.strokeStyle = '#555'; - pidCtx.lineWidth = 1 / pidZoom; - pidCtx.beginPath(); - pidTopology.edges.forEach(edge => { - const s = pidNodeMap.get(edge.source); - const t = pidNodeMap.get(edge.target); - if (s && t) { - // 영향도 분석 결과에 포함된 경로라면 하이라이트 - if (pidImpactResult && pidImpactResult.paths?.some(p => p.includes(edge.source) && p.includes(edge.target))) { - pidCtx.strokeStyle = '#ff4444'; - pidCtx.lineWidth = 3 / pidZoom; - } else { - pidCtx.strokeStyle = '#555'; - pidCtx.lineWidth = 1 / pidZoom; - } - pidCtx.moveTo(s.x, s.y); - pidCtx.lineTo(t.x, t.y); - } - }); - pidCtx.stroke(); - } - - // 2. 노드(설비) 렌더링 - pidNodeMap.forEach((node, id) => { - const isImpacted = pidImpactResult?.impactedNodes?.[id] !== undefined; - const depth = pidImpactResult?.impactedNodes?.[id] || 0; - - pidCtx.fillStyle = isImpacted ? `rgba(255, ${Math.max(0, 255 - depth * 50)}, 0, 0.8)` : '#aaa'; - pidCtx.beginPath(); - pidCtx.arc(node.x, node.y, 4 / pidZoom, 0, Math.PI * 2); - pidCtx.fill(); - - if (pidZoom > 1.5) { - pidCtx.fillStyle = '#fff'; - pidCtx.font = `${10 / pidZoom}px Arial`; - pidCtx.fillText(node.label || id, node.x + 5 / pidZoom, node.y - 5 / pidZoom); - } - }); - - pidCtx.restore(); -} - -// --- 인터랙션 이벤트 --- - -function pidOnMouseDown(e) { - pidIsDragging = true; - pidLastMouse = { x: e.clientX, y: e.clientY }; -} - -function pidOnMouseMove(e) { - if (pidIsDragging) { - pidOffset.x += e.clientX - pidLastMouse.x; - pidOffset.y += e.clientY - pidLastMouse.y; - pidLastMouse = { x: e.clientX, y: e.clientY }; - pidRender(); - } - - // 툴팁 처리 - const rect = pidCanvas.getBoundingClientRect(); - const worldX = (e.clientX - rect.left - pidOffset.x) / pidZoom; - const worldY = (e.clientY - rect.top - pidOffset.y) / pidZoom; - - let found = null; - pidNodeMap.forEach((node, id) => { - const dist = Math.hypot(node.x - worldX, node.y - worldY); - if (dist < 10 / pidZoom) found = { id, ...node }; - }); - - const tooltip = document.getElementById('pid-tooltip'); - if (found) { - tooltip.classList.remove('hidden'); - tooltip.style.left = (e.clientX - rect.left + 10) + 'px'; - tooltip.style.top = (e.clientY - rect.top + 10) + 'px'; - tooltip.innerHTML = `${found.label || found.id}
ID: ${found.id}`; - } else { - tooltip.classList.add('hidden'); - } -} - -function pidOnMouseUp() { - pidIsDragging = false; -} - -function pidOnWheel(e) { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - pidZoom *= delta; - pidZoom = Math.min(Math.max(pidZoom, 0.1), 10); - pidRender(); -} - -async function pidOnClick(e) { - const rect = pidCanvas.getBoundingClientRect(); - const worldX = (e.clientX - rect.left - pidOffset.x) / pidZoom; - const worldY = (e.clientY - rect.top - pidOffset.y) / pidZoom; - - let clickedNode = null; - pidNodeMap.forEach((node, id) => { - const dist = Math.hypot(node.x - worldX, node.y - worldY); - if (dist < 10 / pidZoom) clickedNode = id; - }); - - if (clickedNode) { - const node = pidNodeMap.get(clickedNode); - document.getElementById('pid-node-info').innerHTML = ` - 노드 ID: ${clickedNode}
- 라벨: ${node.label || '-'}
- 좌표: (${node.x}, ${node.y}) - `; - await pidRequestImpactAnalysis(clickedNode); - } -} - -async function pidRequestImpactAnalysis(nodeId) { - const statusTxt = document.getElementById('pid-status-txt'); - const progWrap = document.getElementById('pid-progress-wrap'); - const progBar = document.getElementById('pid-progress-bar'); - - statusTxt.textContent = `분석 요청 중: ${nodeId}...`; - progWrap.classList.remove('hidden'); - progBar.style.width = '0%'; - - try { - // 1. 분석 시작 요청 (현재는 graphId가 필요하므로, 로드된 topoData의 ID나 파일명을 사용해야 함) - // 여기서는 단순화를 위해 현재 로드된 도면의 graphId를 가정하거나, - // 실제로는 pidLoadDrawing 시점에 graphId를 저장해두어야 함. - const graphId = "No-10_Plant_PID_graph.json"; // 예시 ID - const startRes = await api('GET', `/api/pidgraph/impact/${graphId}/${nodeId}`); - - // 2. 결과 처리 (이제 API가 즉시 결과를 반환하므로 폴링 불필요) - pidImpactResult = startRes; - pidRender(); - pidRenderImpactList(startRes); - statusTxt.textContent = '분석 완료'; - progWrap.classList.add('hidden'); - } catch (e) { - statusTxt.textContent = '분석 오류 발생'; - progWrap.classList.add('hidden'); - console.error(e); - } -} - -function pidRenderImpactList(result) { - const list = document.getElementById('pid-impact-items'); - list.innerHTML = ''; - - const sortedNodes = Object.entries(result.impactedNodes) - .sort((a, b) => a[1] - b[1]); - - sortedNodes.forEach(([id, depth]) => { - const li = document.createElement('li'); - li.innerHTML = `${id}Depth: ${depth}`; - li.onclick = () => { - const node = pidNodeMap.get(id); - if (node) { - pidOffset.x = pidCanvas.width/2 - node.x * pidZoom; - pidOffset.y = pidCanvas.height/2 - node.y * pidZoom; - pidRender(); - } - }; - list.appendChild(li); - }); -} - -function pidClearAnalysis() { - pidImpactResult = null; - document.getElementById('pid-impact-items').innerHTML = ''; - document.getElementById('pid-node-info').textContent = '노드를 클릭하면 상세 정보가 표시됩니다.'; - pidRender(); -} diff --git a/.rooBackup/2026-05-02_10-51/src/Infrastructure/Mcp/McpServerHostedService.cs b/.rooBackup/2026-05-02_10-51/src/Infrastructure/Mcp/McpServerHostedService.cs deleted file mode 100644 index c597d77..0000000 --- a/.rooBackup/2026-05-02_10-51/src/Infrastructure/Mcp/McpServerHostedService.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Diagnostics; - -namespace ExperionCrawler.Infrastructure.Mcp; - -public class McpServerHostedService : IHostedService -{ - private readonly McpClient _mcpClient; - private readonly ILogger _logger; - private readonly string _workingDirectory; - private Process? _process; - - public McpServerHostedService( - McpClient mcpClient, - ILogger logger, - IConfiguration config) - { - _mcpClient = mcpClient; - _logger = logger; - var dir = config["McpServer:WorkingDirectory"] ?? "../../mcp-server"; - _workingDirectory = Path.IsPathRooted(dir) - ? dir - : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), dir)); - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - if (!Directory.Exists(_workingDirectory)) - { - _logger.LogWarning("[McpServer] 디렉터리 없음: {Dir} — MCP 서버 시작 스킵", _workingDirectory); - } - else - { - // 이미 MCP 서버가 실행 중이면 시작하지 않음 - if (await _mcpClient.PingAsync()) - { - _logger.LogInformation("[McpServer] 이미 실행 중 (localhost:5001) — 기존 프로세스 사용"); - return; - } - else - { - _logger.LogInformation("[McpServer] Python MCP 서버 시작 중... ({Dir})", _workingDirectory); - - _process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "uv", - Arguments = "run server.py --http", - WorkingDirectory = _workingDirectory, - UseShellExecute = false, - } - }; - - try - { - _process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "[McpServer] 프로세스 시작 실패 (uv 설치 여부 확인)"); - } - - // MCP 서버가 포트를 bind하기 위해 약간 대기 (0.5초) - await Task.Delay(500, cancellationToken); - - // 최대 30초 대기 (1초 간격 health check) - for (int i = 0; i < 30; i++) - { - try { await Task.Delay(1000, cancellationToken); } catch { return; } - if (_process.HasExited) - { - _logger.LogWarning("[McpServer] 프로세스가 예기치 않게 종료됨 (exit code: {Code})", _process.ExitCode); - return; - } - if (await _mcpClient.PingAsync()) - { - _logger.LogInformation("[McpServer] 준비 완료 (localhost:5001, {Sec}초 소요)", i + 1); - return; - } - } - _logger.LogWarning("[McpServer] 30초 내 응답 없음 — 백그라운드에서 계속 기다림"); - } - } - } - - public Task StopAsync(CancellationToken cancellationToken) - { - // 앱 종료 시 MCP 서버 강제 종료 로직 제거 - // (사용자가 수동으로 종료하거나, 프로세스가 자동으로 종료되도록 허용) - _logger.LogInformation("[McpServer] 앱 종료 — MCP 서버 종료 로직 제거됨"); - return Task.CompletedTask; - } -} diff --git a/.rooBackup/2026-05-02_10-52/src/Infrastructure/OpcUa/ExperionOpcServerService.cs b/.rooBackup/2026-05-02_10-52/src/Infrastructure/OpcUa/ExperionOpcServerService.cs deleted file mode 100644 index b4a0451..0000000 --- a/.rooBackup/2026-05-02_10-52/src/Infrastructure/OpcUa/ExperionOpcServerService.cs +++ /dev/null @@ -1,301 +0,0 @@ -using ExperionCrawler.Core.Application.Interfaces; -using ExperionCrawler.Core.Domain.Entities; -using ExperionCrawler.Infrastructure.Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Opc.Ua; -using Opc.Ua.Server; - -namespace ExperionCrawler.Infrastructure.OpcUa; - -// ── StandardServer 서브클래스 ───────────────────────────────────────────────── - -/// 커스텀 NodeManager를 주입한 StandardServer 파생 클래스. -internal sealed class ExperionStandardServer : StandardServer -{ - internal ExperionOpcServerNodeManager? NodeManager { get; private set; } - - protected override MasterNodeManager CreateMasterNodeManager( - IServerInternal server, ApplicationConfiguration configuration) - { - NodeManager = new ExperionOpcServerNodeManager(server, configuration); - return new MasterNodeManager(server, configuration, null, NodeManager); - } - - protected override ServerProperties LoadServerProperties() => new() - { - ManufacturerName = "ExperionCrawler", - ProductName = "ExperionCrawler OPC UA Server", - ProductUri = "urn:ExperionCrawler:OpcUaServer", - SoftwareVersion = "1.0.0", - BuildNumber = "1", - BuildDate = DateTime.UtcNow - }; -} - -// ── OPC UA 서버 서비스 ──────────────────────────────────────────────────────── - -/// -/// ExperionCrawler OPC UA 서버 서비스. -/// IHostedService 와 IExperionOpcServerService 를 모두 구현한다. -/// - IHostedService.StartAsync : 자동 시작 플래그 파일이 있으면 서버 시작 (앱 재기동용) -/// - IHostedService.StopAsync : 앱 종료 — 플래그 파일 유지 (재기동 시 자동 재시작) -/// - StartServerAsync : UI 시작 버튼 — 서버 시작 + 플래그 파일 저장 -/// - StopServerAsync : UI 중지 버튼 — 서버 중지 + 플래그 파일 삭제 -/// -public class ExperionOpcServerService : IExperionOpcServerService, IHostedService, IDisposable -{ - private readonly ILogger _logger; - private readonly IServiceScopeFactory _scopeFactory; - private readonly IConfiguration _configuration; - - private ExperionStandardServer? _server; - private ExperionOpcServerNodeManager? _nodeManager; - - private volatile bool _running; - private DateTime? _startedAt; - private string _endpointUrl = string.Empty; - - private static readonly string FlagPath = - Path.GetFullPath("opcserver_autostart.json"); - - public ExperionOpcServerService( - ILogger logger, - IServiceScopeFactory scopeFactory, - IConfiguration configuration) - { - _logger = logger; - _scopeFactory = scopeFactory; - _configuration = configuration; - } - - // ── IHostedService ──────────────────────────────────────────────────────── - - async Task IHostedService.StartAsync(CancellationToken ct) - { - if (!File.Exists(FlagPath)) return; - try - { - _logger.LogInformation("[OpcServer] 자동 시작 플래그 감지 — OPC UA 서버 자동 시작"); - await StartInternalAsync(saveFlag: false, ct); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[OpcServer] 자동 시작 실패 — 무시하고 계속"); - } - } - - Task IHostedService.StopAsync(CancellationToken ct) - { - // 앱 종료 시: 서버 인스턴스 정리만, 플래그 파일은 유지 → 재기동 후 자동 시작 - StopInternal(deleteFlag: false); - return Task.CompletedTask; - } - - // ── IExperionOpcServerService ──────────────────────────────────────────── - - public async Task StartServerAsync() - { - await StartInternalAsync(saveFlag: true, CancellationToken.None); - } - - public Task StopServerAsync() - { - // UI 중지 버튼: 플래그 삭제 → 재기동 시 자동 시작 안 함 - StopInternal(deleteFlag: true); - return Task.CompletedTask; - } - - public OpcServerStatus GetStatus() - { - int clientCount = 0; - try - { - clientCount = _server?.CurrentInstance?.SessionManager?.GetSessions()?.Count ?? 0; - } - catch { /* ignore */ } - - return new OpcServerStatus( - _running, clientCount, - _nodeManager?.TagNodeCount ?? 0, - _endpointUrl, _startedAt); - } - - public void UpdateNodeValue(string tagname, string? value, DateTime timestamp) - => _nodeManager?.UpdateNodeValue(tagname, value, timestamp); - - public void RebuildAddressSpace(IEnumerable points) - => _nodeManager?.RebuildAddressSpace(points); - - // ── 내부 구현 ───────────────────────────────────────────────────────────── - - private async Task StartInternalAsync(bool saveFlag, CancellationToken ct) - { - if (_running) - { - _logger.LogWarning("[OpcServer] 이미 실행 중입니다."); - return; - } - - var config = BuildServerConfig(); - _server = new ExperionStandardServer(); - - // 설정 적용 후 서버 시작 - await _server.StartAsync(config); - _nodeManager = _server.NodeManager; - - _running = true; - _startedAt = DateTime.UtcNow; - var port = _configuration.GetValue("OpcUaServer:Port", 4841); - _endpointUrl = $"opc.tcp://0.0.0.0:{port}"; - _logger.LogInformation("[OpcServer] 서버 시작: {Url}", _endpointUrl); - - // DB에서 realtime 포인트 조회 후 주소 공간 구성 - await RebuildFromDbAsync(); - - if (saveFlag) - { - try { await File.WriteAllTextAsync(FlagPath, "{}", ct); } - catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 플래그 저장 실패 (무시)"); } - } - - _nodeManager?.UpdateServerStatus("Running", _nodeManager.TagNodeCount); - } - - private void StopInternal(bool deleteFlag) - { - if (!_running) return; - try - { - _nodeManager?.UpdateServerStatus("Stopped", 0); -#pragma warning disable CS0618 // 'Stop()' is obsolete - _server?.Stop(); -#pragma warning restore CS0618 // 'Stop()' is obsolete - } - catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 서버 Stop() 중 오류 (무시)"); } - - _server = null; - _nodeManager = null; - _running = false; - _startedAt = null; - - if (deleteFlag) - { - try { if (File.Exists(FlagPath)) File.Delete(FlagPath); } - catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 플래그 삭제 실패 (무시)"); } - _logger.LogInformation("[OpcServer] 서버 중지 완료 (자동 재시작 플래그 삭제)"); - } - else - { - _logger.LogInformation("[OpcServer] 서버 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)"); - } - } - - private async Task RebuildFromDbAsync() - { - if (_nodeManager == null) return; - try - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var points = (await db.GetRealtimePointsAsync()).ToList(); - var dtMap = await db.GetRealtimeNodeDataTypesAsync(); - _nodeManager.RebuildAddressSpace(points, dtMap); - _logger.LogInformation("[OpcServer] 주소 공간 구성: {Count}개 태그 노드", points.Count); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[OpcServer] 주소 공간 구성 실패 (무시)"); - } - } - - private ApplicationConfiguration BuildServerConfig() - { - var port = _configuration.GetValue("OpcUaServer:Port", 4841); - var enableSec = _configuration.GetValue("OpcUaServer:EnableSecurity", false); - var allowAnon = _configuration.GetValue("OpcUaServer:AllowAnonymous", true); - - // 기본 클라이언트 인증서 재사용 (ExperionCertificateService 불변) - var hostName = System.Net.Dns.GetHostName(); - var cert = ExperionCertificateService.TryLoadCertificate(hostName); - - var userTokenPolicies = new UserTokenPolicyCollection(); - if (allowAnon) - userTokenPolicies.Add(new UserTokenPolicy(UserTokenType.Anonymous) { PolicyId = "Anonymous" }); - - var secPolicies = new ServerSecurityPolicyCollection - { - new() { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None } - }; - if (enableSec) - { - secPolicies.Add(new() { - SecurityMode = MessageSecurityMode.SignAndEncrypt, - SecurityPolicyUri = SecurityPolicies.Basic256Sha256 - }); - userTokenPolicies.Add(new UserTokenPolicy(UserTokenType.UserName) - { PolicyId = "UserName", SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }); - } - - return new ApplicationConfiguration - { - ApplicationName = "ExperionCrawlerServer", - ApplicationType = ApplicationType.ClientAndServer, - ApplicationUri = $"urn:{hostName}:ExperionCrawlerServer", - SecurityConfiguration = new SecurityConfiguration - { - ApplicationCertificate = cert != null - ? new CertificateIdentifier { Certificate = cert } - : new CertificateIdentifier(), - TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.GetFullPath("pki/trusted") }, - TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.GetFullPath("pki/issuers") }, - RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.GetFullPath("pki/rejected") }, - AutoAcceptUntrustedCertificates = true, - AddAppCertToTrustedStore = true - }, - TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 }, - ServerConfiguration = new ServerConfiguration - { - BaseAddresses = new StringCollection { $"opc.tcp://0.0.0.0:{port}" }, - SecurityPolicies = secPolicies, - UserTokenPolicies = userTokenPolicies, - MaxSessionCount = 100, - MaxSubscriptionCount = 500, - DiagnosticsEnabled = false - } - }; - } - - public async ValueTask DisposeAsync() - { - if (_server != null) - { - try { await _server.StopAsync(CancellationToken.None).ConfigureAwait(false); } - catch (Exception ex) - { - _logger.LogWarning(ex, "[OpcServer] StopAsync 중 예외 발생"); - } - _server = null; - } - } - - void IDisposable.Dispose() - { - try - { -#pragma warning disable CS0618 // 'Stop()' is obsolete - _server?.Stop(); -#pragma warning restore CS0618 // 'Stop()' is obsolete - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[OpcServer] Dispose 중 예외 발생 - 리소스 모니터링 필요"); - } - finally - { - _server = null; - } - } -} diff --git a/.rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionFastService.cs b/.rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionFastService.cs deleted file mode 100644 index 6bd72d9..0000000 --- a/.rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionFastService.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using ExperionCrawler.Core.Application.Interfaces; -using ExperionCrawler.Core.Domain.Entities; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ExperionCrawler.Infrastructure.OpcUa; - -/// -/// fastRecord 데이터 수집 서비스. -/// realtime_table에서 지정한 샘플링 간격마다 태그 값을 복사하여 fast_records 테이블에 저장. -/// OPC UA 직접 연결 없이 기존 실시간 구독 결과(realtime_table)를 재활용. -/// -public class ExperionFastService : IExperionFastService, IHostedService, IDisposable -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - private readonly ConcurrentDictionary _sessions = new(); - - private CancellationTokenSource? _cts; - private Task? _monitorTask; - - private const int MaxConcurrentSessions = 3; - private const int MaxRowsPerSession = 5_000_000; - private const int MonitorIntervalMs = 1_000; - - private static readonly int[] AllowedSamplingMs = [1000, 5000, 10000, 30000, 60000]; - - public ExperionFastService( - IServiceScopeFactory scopeFactory, - ILogger logger) - { - _scopeFactory = scopeFactory; - _logger = logger; - } - - // ── IHostedService ──────────────────────────────────────────────────────── - - public async Task StartAsync(CancellationToken cancellationToken) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var sessions = await db.GetFastSessionsAsync(); - - foreach (var s in sessions.Where(s => s.Status == "Running")) - { - _logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id); - await db.UpdateFastSessionStatusAsync(s.Id, "Failed"); - } - - _cts = new CancellationTokenSource(); - _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token); - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - _cts?.Cancel(); - if (_monitorTask != null) - await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - } - - public void Dispose() => _cts?.Dispose(); - - // ── IExperionFastService ────────────────────────────────────────────────── - - public async Task StartSessionAsync(FastSessionStartRequest request) - { - if (request.TagList.Length == 0 || request.TagList.Length > 8) - throw new ArgumentException("태그는 1~8개까지 가능합니다."); - - if (!AllowedSamplingMs.Contains(request.SamplingMs)) - throw new ArgumentException( - $"샘플링 간격은 {string.Join('/', AllowedSamplingMs.Select(ms => ms / 1000 + "s"))} 중 하나여야 합니다."); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running"); - if (runningCount >= MaxConcurrentSessions) - throw new InvalidOperationException($"동시 실행 가능한 세션은 {MaxConcurrentSessions}개까지입니다."); - - // 태그가 realtime_table에 존재하는지 검증 - var realtimeRecords = (await db.GetRealtimeRecordsByTagNamesAsync(request.TagList)).ToList(); - var found = realtimeRecords.Select(r => r.TagName).ToHashSet(); - foreach (var tag in request.TagList) - { - if (!found.Contains(tag)) - throw new ArgumentException($"태그 '{tag}'이 realtime_table에 없습니다. 포인트빌더에서 추가 후 구독을 시작하세요."); - } - - var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest( - Name: request.Name, - SamplingMs: request.SamplingMs, - DurationSec: request.DurationSec, - TagList: request.TagList, - RetentionDays: request.RetentionDays)); - - var ctx = new FastSessionContext - { - SessionId = session.Id, - TagList = request.TagList, - SamplingMs = request.SamplingMs, - DurationSec = request.DurationSec, - StartedAt = DateTime.UtcNow, - LastSampledAt = DateTime.MinValue - }; - - _sessions[session.Id] = ctx; - - _logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s", - session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec); - - return MapToInfo(session); - } - - public async Task StopSessionAsync(int sessionId) - { - if (!_sessions.TryGetValue(sessionId, out var ctx)) - throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다."); - - ctx.Cancel = true; - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.UpdateFastSessionStatusAsync(sessionId, "Completed"); - await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows); - - _sessions.TryRemove(sessionId, out _); - _logger.LogInformation("[Fast] 세션 {Id} 중지 — 총 {Count}행", sessionId, ctx.TotalRows); - } - - public async Task DeleteSessionAsync(int sessionId) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.DeleteFastSessionAsync(sessionId); - _sessions.TryRemove(sessionId, out _); - } - - public async Task PinSessionAsync(int sessionId, bool pinned) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.UpdateFastSessionPinnedAsync(sessionId, pinned); - } - - public async Task GetSessionAsync(int sessionId) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var session = await db.GetFastSessionAsync(sessionId); - return session == null ? null : MapToInfo(session); - } - - public async Task> GetSessionsAsync() - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - return (await db.GetFastSessionsAsync()).Select(MapToInfo); - } - - public async Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long") - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - return await db.GetFastRecordsAsync(sessionId, from, to); - } - - public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to); - } - - // ── Private ──────────────────────────────────────────────────────────────── - - private async Task MonitorLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try - { - await Task.Delay(MonitorIntervalMs, ct); - - foreach (var kvp in _sessions.ToList()) - { - var ctx = kvp.Value; - if (ctx.Cancel) continue; - - if ((DateTime.UtcNow - ctx.StartedAt).TotalSeconds >= ctx.DurationSec) - { - ctx.Cancel = true; - await CompleteSessionAsync(ctx.SessionId, ctx.TotalRows, "Completed"); - continue; - } - - if ((DateTime.UtcNow - ctx.LastSampledAt).TotalMilliseconds >= ctx.SamplingMs) - { - ctx.LastSampledAt = DateTime.UtcNow; - await SampleAsync(ctx); - } - } - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - _logger.LogError(ex, "[Fast] 모니터링 루프 오류"); - } - } - } - - private async Task SampleAsync(FastSessionContext ctx) - { - try - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var realtimeRecords = await db.GetRealtimeRecordsByTagNamesAsync(ctx.TagList); - var now = DateTime.UtcNow; - var records = realtimeRecords - .Select(r => new FastRecord - { - SessionId = ctx.SessionId, - RecordedAt = now, - TagName = r.TagName, - Value = r.LiveValue - }) - .ToList(); - - if (records.Count == 0) return; - - await db.BatchInsertFastRecordsAsync(records); - ctx.TotalRows += records.Count; - await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows); - - if (ctx.TotalRows >= MaxRowsPerSession) - { - ctx.Cancel = true; - await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached"); - _sessions.TryRemove(ctx.SessionId, out _); - _logger.LogWarning("[Fast] 세션 {Id} RowLimitReached ({Max}행)", ctx.SessionId, MaxRowsPerSession); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[Fast] 세션 {Id} 샘플링 오류", ctx.SessionId); - } - } - - private async Task CompleteSessionAsync(int sessionId, int totalRows, string status) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.UpdateFastSessionStatusAsync(sessionId, status); - await db.UpdateFastSessionRowCountAsync(sessionId, totalRows); - _sessions.TryRemove(sessionId, out _); - _logger.LogInformation("[Fast] 세션 {Id} {Status} — 총 {Count}행", sessionId, status, totalRows); - } - - private static FastSessionInfo MapToInfo(FastSession s) => new( - Id: s.Id, - Name: s.Name, - StartedAt: s.StartedAt, - EndedAt: s.EndedAt, - Status: s.Status, - SamplingMs: s.SamplingMs, - DurationSec: s.DurationSec, - TagList: JsonSerializer.Deserialize(s.TagList) ?? [], - RowCount: s.RowCount, - RetentionDays: s.RetentionDays, - Pinned: s.Pinned); - - private sealed class FastSessionContext - { - public int SessionId { get; set; } - public string[] TagList { get; set; } = []; - public int SamplingMs { get; set; } - public int DurationSec { get; set; } - public DateTime StartedAt { get; set; } - public DateTime LastSampledAt { get; set; } - public int TotalRows { get; set; } - public bool Cancel { get; set; } - } -} - -/// -/// 만료된 FastSession을 정리하는 BackgroundService. -/// 매일 03:00 UTC에 실행. pinned = true 세션과 retention_days = null 세션은 제외. -/// -public class ExperionFastCleanupService : BackgroundService -{ - private readonly IServiceProvider _sp; - private readonly ILogger _logger; - - public ExperionFastCleanupService( - IServiceProvider sp, - ILogger logger) - { - _sp = sp; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - var now = DateTime.UtcNow; - var next = now.Date.AddDays(1).AddHours(3); - var delay = next - now; - if (delay < TimeSpan.Zero) delay = TimeSpan.Zero; - - try { await Task.Delay(delay, stoppingToken); } - catch (OperationCanceledException) { break; } - - try - { - using var scope = _sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var sessions = await db.GetFastSessionsAsync(); - var cutoff = DateTime.UtcNow; - - foreach (var s in sessions.Where(s => - !s.Pinned && - s.RetentionDays.HasValue && - s.StartedAt.AddDays(s.RetentionDays.Value) < cutoff)) - { - _logger.LogInformation("[FastCleanup] 세션 {Id} 삭제 (retention {Days}일 초과)", s.Id, s.RetentionDays); - await db.DeleteFastSessionAsync(s.Id); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[FastCleanup] 정리 작업 오류"); - } - } - } -} diff --git a/.rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionRealtimeService.cs b/.rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionRealtimeService.cs deleted file mode 100644 index 9048e45..0000000 --- a/.rooBackup/2026-05-02_10-53/src/Infrastructure/OpcUa/ExperionRealtimeService.cs +++ /dev/null @@ -1,496 +0,0 @@ -using System.Collections.Concurrent; -using System.Text; -using System.Text.Json; -using ExperionCrawler.Core.Application.Interfaces; -using ExperionCrawler.Core.Domain.Entities; -using ExperionCrawler.Infrastructure.Certificates; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Opc.Ua; -using Opc.Ua.Client; -using ISession = Opc.Ua.Client.ISession; -using StatusCodes = Opc.Ua.StatusCodes; - -namespace ExperionCrawler.Infrastructure.OpcUa; - -/// -/// OPC UA Subscription 기반 실시간 livevalue 업데이트 서비스. -/// 값이 변경될 때만 콜백을 받아 realtime_table 을 갱신합니다. -/// -public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, IDisposable -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private readonly IServiceProvider _sp; - private readonly IOpcUaConfigProvider _configProvider; - - private ISession? _session; - private Subscription? _subscription; - private CancellationTokenSource? _cts; - private Task? _monitorTask; - private Task? _flushTask; - - // 콜백에서 최신 값만 기록 (노드당 1개 유지) → 500ms 배치 flush - private readonly ConcurrentDictionary - _pendingUpdates = new(); - - // nodeId → RealtimePoint 매핑 (FlushLoop에서 tagname을 찾기 위해 사용) - private Dictionary _pointCache = new(); - - // OPC UA 서버 서비스 (순환 참조 방지를 위해 lazy resolve) - private IExperionOpcServerService? _opcServer; - - private volatile bool _running; - private int _subscribedCount; - private string _statusMsg = "중지됨"; - private ExperionServerConfig? _currentCfg; - private volatile bool _restarting = false; // 재진입 방지 플래그 - - // 자동 재시작 플래그 파일 경로 - private static readonly string FlagPath = - Path.GetFullPath("realtime_autostart.json"); - - public ExperionRealtimeService( - IServiceScopeFactory scopeFactory, - ILogger logger, - IServiceProvider sp, - IOpcUaConfigProvider configProvider) - { - _scopeFactory = scopeFactory; - _logger = logger; - _sp = sp; - _configProvider = configProvider; - } - - // ── IHostedService ──────────────────────────────────────────────────────── - - public async Task StartAsync(CancellationToken cancellationToken) - { - // 앱 기동 시 플래그 파일이 있으면 자동 구독 시작 - if (!File.Exists(FlagPath)) return; - try - { - var json = await File.ReadAllTextAsync(FlagPath, cancellationToken); - var cfg = JsonSerializer.Deserialize(json); - if (cfg != null) - { - _logger.LogInformation("[Realtime] 자동 재시작 플래그 감지 — 구독 자동 시작"); - await StartAsync(cfg); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[Realtime] 자동 재시작 플래그 읽기 실패 — 무시"); - } - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - // 앱 종료(Ctrl+C 등) 시: 플래그 파일은 유지 → 재기동 시 자동 재시작 - _cts?.Cancel(); - var tasks = new[] { _monitorTask, _flushTask } - .Where(t => t != null).Select(t => t!).ToArray(); - if (tasks.Length > 0) - await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - _running = false; - _logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)"); - } - - // ── IExperionRealtimeService ────────────────────────────────────────────── - - public async Task StartAsync(ExperionServerConfig cfg) - { - if (_running || _restarting) - { - _logger.LogWarning("[Realtime] 이미 실행 중 또는 재시작 중. 무시합니다."); - return; - } - - _restarting = true; - try - { - if (_running) - { - _logger.LogWarning("[Realtime] 이미 실행 중. 재시작합니다."); - await StopAsync(); - } - } - finally - { - _restarting = false; - } - - // 플래그 파일 저장 (앱 재기동 시 자동 재시작용) - try - { - var json = JsonSerializer.Serialize(cfg); - await File.WriteAllTextAsync(FlagPath, json); - _logger.LogInformation("[Realtime] 자동 재시작 플래그 저장: {Path}", FlagPath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[Realtime] 플래그 파일 저장 실패 (무시)"); - } - - _currentCfg = cfg; - _cts = new CancellationTokenSource(); - _monitorTask = Task.Run(() => RunLoopAsync(_cts.Token)); - _logger.LogInformation("[Realtime] 구독 시작 요청: {Url}", cfg.EndpointUrl); - } - - public async Task StopAsync() - { - if (_restarting) - { - _logger.LogWarning("[Realtime] 재시작 중이므로 StopAsync 무시 (restarting 플래그 취소)"); - return; - } - - // 플래그 파일 삭제 (자동 재시작 비활성화) - try - { - if (File.Exists(FlagPath)) File.Delete(FlagPath); - _logger.LogInformation("[Realtime] 자동 재시작 플래그 삭제"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[Realtime] 플래그 파일 삭제 실패 (무시)"); - } - - _cts?.Cancel(); - - var tasks = new List(); - if (_monitorTask != null) tasks.Add(_monitorTask); - if (_flushTask != null) tasks.Add(_flushTask); - if (tasks.Count > 0) - await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); - - await CleanupSessionAsync(); - _pendingUpdates.Clear(); - _running = false; - _subscribedCount = 0; - _statusMsg = "중지됨"; - _logger.LogInformation("[Realtime] 구독 중지 완료"); - } - - public RealtimeServiceStatus GetStatus() - => new(_running, _subscribedCount, _statusMsg); - - public async Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId) - { - // 구독 중이 아니면 DB에만 저장된 상태 — 다음 구독 시작 시 자동 포함 - if (!_running || _subscription == null) - return (true, "구독 중 아님 — 다음 구독 시작 시 자동 포함됩니다."); - await Task.CompletedTask; - - var item = new MonitoredItem(_subscription.DefaultItem) - { - StartNodeId = new NodeId(nodeId), - AttributeId = Attributes.Value, - SamplingInterval = 500, - QueueSize = 1, - DiscardOldest = true, - DisplayName = nodeId - }; - item.Notification += OnNotification; - _subscription.AddItem(item); - - try - { - // OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증 -#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete - _subscription.ApplyChanges(); -#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete - - // 서버 응답 상태 확인 (Error가 null이면 정상) - if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode)) - { - // 유효하지 않은 node_id → subscription에서 제거 - _subscription.RemoveItem(item); -#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete - _subscription.ApplyChanges(); -#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete - - var code = item.Status.Error.StatusCode; - _logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code); - return (false, $"OPC UA 서버가 노드를 거부했습니다: {code}"); - } - - _subscribedCount++; - _statusMsg = $"구독 중 ({_subscribedCount}개 포인트)"; - _logger.LogInformation("[Realtime] 핫 추가 성공: {NodeId}", nodeId); - return (true, "구독에 즉시 추가되었습니다."); - } - catch (Exception ex) - { - _subscription.RemoveItem(item); - _logger.LogError(ex, "[Realtime] MonitoredItem 추가 실패: {NodeId}", nodeId); - return (false, $"MonitoredItem 추가 중 오류: {ex.Message}"); - } - } - - // ── 내부 루프 ───────────────────────────────────────────────────────────── - - private async Task RunLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try - { - await ConnectAndSubscribeAsync(ct); - - // 세션이 살아있는 동안 KeepAlive 대기 - while (!ct.IsCancellationRequested && - _session != null && _session.Connected) - { - await Task.Delay(5_000, ct); - } - } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - _running = false; - _statusMsg = $"재연결 대기 중: {ex.Message}"; - _logger.LogWarning(ex, "[Realtime] 연결 오류, 30초 후 재시도"); - await CleanupSessionAsync(); - try { await Task.Delay(30_000, ct); } - catch (OperationCanceledException) { break; } - } - } - - _running = false; - _statusMsg = "중지됨"; - } - - private async Task ConnectAndSubscribeAsync(CancellationToken ct) - { - if (_currentCfg == null) return; - - _statusMsg = "연결 중..."; - _logger.LogInformation("[Realtime] OPC UA 접속 시도: {Url}", _currentCfg.EndpointUrl); - - var appConfig = await BuildConfigAsync(_currentCfg); - var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct); - _session = await CreateSessionAsync(appConfig, endpoint, _currentCfg); - - _logger.LogInformation("[Realtime] 세션 생성 완료"); - - // realtime_table 의 node_id 목록 조회 - List points; - using (var scope = _scopeFactory.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - points = (await db.GetRealtimePointsAsync()).ToList(); - } - - if (points.Count == 0) - { - _statusMsg = "포인트 없음 (포인트빌더에서 먼저 빌드하세요)"; - _logger.LogWarning("[Realtime] realtime_table 이 비어 있습니다."); - return; - } - - // Subscription 생성 - _subscription = new Subscription(_session.DefaultSubscription) - { - PublishingInterval = 1_000, - KeepAliveCount = 10, - LifetimeCount = 100, - MaxNotificationsPerPublish = 1000, - PublishingEnabled = true, - Priority = 0 - }; - - // MonitoredItem 등록 - foreach (var pt in points) - { - var item = new MonitoredItem(_subscription.DefaultItem) - { - StartNodeId = new NodeId(pt.NodeId), - AttributeId = Attributes.Value, - SamplingInterval = 500, - QueueSize = 1, - DiscardOldest = true, - DisplayName = pt.NodeId - }; - item.Notification += OnNotification; - _subscription.AddItem(item); - } - - _session.AddSubscription(_subscription); -#pragma warning disable CS0618 // 'Create()' is obsolete - _subscription.Create(); -#pragma warning restore CS0618 // 'Create()' is obsolete - - // nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용) - _pointCache = points.ToDictionary(p => p.NodeId, p => p); - - _subscribedCount = points.Count; - _running = true; - _statusMsg = $"구독 중 ({_subscribedCount}개 포인트)"; - _logger.LogInformation("[Realtime] 구독 완료: {Count}개 포인트", _subscribedCount); - - // 배치 flush 태스크 시작 (콜백 → dictionary → 500ms 단위 배치 DB 업데이트) - _flushTask = Task.Run(() => FlushLoopAsync(ct), ct); - } - - // 콜백: Task.Run 없이 dictionary에만 기록 (최신 값 덮어쓰기) - private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e) - { - foreach (var val in item.DequeueValues()) - { - var nodeId = item.DisplayName; - var value = val.Value?.ToString(); - var timestamp = val.SourceTimestamp == DateTime.MinValue ? DateTime.UtcNow : val.SourceTimestamp; - _pendingUpdates[nodeId] = (value, timestamp); - } - } - - // 배치 flush 루프 — 500ms 주기, 단일 DbContext로 일괄 업데이트 - private async Task FlushLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try { await Task.Delay(500, ct); } - catch (OperationCanceledException) { break; } - await FlushPendingAsync(); - } - // 종료 시 남은 항목 최종 flush - await FlushPendingAsync(); - } - - private async Task FlushPendingAsync() - { - if (_pendingUpdates.IsEmpty) return; - - // 스냅샷 후 제거 (새 콜백은 계속 dictionary에 추가 가능) - var snapshot = _pendingUpdates.ToArray(); - foreach (var kv in snapshot) - _pendingUpdates.TryRemove(kv.Key, out _); - - var updates = snapshot - .Select(kv => new LiveValueUpdate(kv.Key, kv.Value.value, kv.Value.timestamp)) - .ToList(); - - try - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var count = await db.BatchUpdateLiveValuesAsync(updates); - _logger.LogDebug("[Realtime] 배치 업데이트: {Count}/{Total}건", - count, updates.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패"); - } - - // OPC UA 서버 노드 값 갱신 (lazy resolve — 순환 참조 방지) - try - { - _opcServer ??= _sp.GetService(); - if (_opcServer?.GetStatus().Running == true) - { - foreach (var u in updates) - { - if (_pointCache.TryGetValue(u.NodeId, out var pt)) - _opcServer.UpdateNodeValue(pt.TagName, u.Value, u.Timestamp); - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "[Realtime] OPC 서버 노드 값 갱신 실패 (무시)"); - } - } - - private async Task CleanupSessionAsync() - { - try - { - if (_subscription != null) - { -#pragma warning disable CS0618 // 'Delete()' is obsolete - _subscription.Delete(true); -#pragma warning restore CS0618 // 'Delete()' is obsolete - _subscription = null; - } - if (_session != null) - { - if (_session.Connected) - await _session.CloseAsync(); - _session.Dispose(); - _session = null; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[Realtime] 세션 정리 중 오류 (무시)"); - } - } - - // ── OPC UA 헬퍼 ───────────────────────────────────────────────────────────── - - private async Task BuildConfigAsync(ExperionServerConfig cfg) - { - return await _configProvider.GetConfigAsync(cfg); - } - - private static async Task SelectEndpointAsync( - ApplicationConfiguration appConfig, string endpointUrl, - CancellationToken ct = default) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); - - var endpointConfig = EndpointConfiguration.Create(appConfig); - using var discovery = await DiscoveryClient.CreateAsync( - appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, timeoutCts.Token); - var endpoints = await discovery.GetEndpointsAsync(null); - var selected = endpoints - .OrderByDescending(e => e.SecurityLevel) - .FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256")) - ?? endpoints[0]; - return new ConfiguredEndpoint(null, selected, endpointConfig); - } - - // OPC UA Session 생성 (비동기) - private static async Task CreateSessionAsync( - ApplicationConfiguration appConfig, - ConfiguredEndpoint endpoint, - ExperionServerConfig cfg) - { - var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password)); - return await new DefaultSessionFactory(null).CreateAsync( - appConfig, - endpoint, - false, - "ExperionRealtimeSession", - 60_000, - identity, - null, - CancellationToken.None); - } - - private volatile bool _disposed = false; - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _cts?.Cancel(); - // StopAsync에서 이미 Task.WhenAll로 대기하므로, Dispose에서는 await 없이 정리만 수행 - // CleanupSessionAsync는 이미 완료된 상태를 가정 - try - { - CleanupSessionAsync().GetAwaiter().GetResult(); - } - catch - { - // Ignore exceptions during disposal - } - _cts?.Dispose(); - } -} diff --git a/.rooBackup/2026-05-02_11-45/src/Infrastructure/Mcp/McpClient.cs b/.rooBackup/2026-05-02_11-45/src/Infrastructure/Mcp/McpClient.cs deleted file mode 100644 index a5404fd..0000000 --- a/.rooBackup/2026-05-02_11-45/src/Infrastructure/Mcp/McpClient.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ExperionCrawler.Infrastructure.Mcp; - -/// -/// Python FastMCP 서버 (localhost:5001)와 JSON-RPC over HTTP로 통신하는 저수준 클라이언트. -/// 모델 클래스(McpResponse 등)도 여기서 단일 관리한다. -/// -public class McpClient -{ - private readonly HttpClient _httpClient; - private const string BaseUrl = "http://localhost:5001"; - - private static readonly JsonSerializerOptions _jsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public McpClient(HttpClient? httpClient = null) - { - _httpClient = httpClient ?? new HttpClient - { - BaseAddress = new Uri(BaseUrl), - Timeout = TimeSpan.FromSeconds(1800) - }; - } - - public async Task PingAsync() - { - try - { - // FastMCP는 /health 대신 /mcp 엔드포인트를 제공함 - // 406은 Accept 헤더 문제이지만, MCP 서버가 실행 중이라는 의미 - var response = await _httpClient.GetAsync("/mcp"); - return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotAcceptable; - } - catch - { - return false; - } - } - - public async Task> ListToolsAsync() - { - var request = new - { - jsonrpc = "2.0", - id = Guid.NewGuid().ToString(), - method = "tools/list" - }; - - var response = await SendRequestAsync(request); - if (response?.result?.tools == null) - return []; - - return [.. response.result.tools]; - } - - public async Task CallToolAsync(string toolName, Dictionary arguments) - { - var request = new - { - jsonrpc = "2.0", - id = Guid.NewGuid().ToString(), - method = "tools/call", - @params = new { name = toolName, arguments = arguments } - }; - - try - { - var response = await SendRequestAsync(request); - var content = response?.result?.content; - if (content == null || content.Length == 0) - return "호출 결과 없음"; - - var sb = new StringBuilder(); - foreach (var item in content) - { - if (item.type == "text") - sb.AppendLine(item.text); - else if (item.type == "image") - sb.AppendLine($"[이미지: {item.data ?? "blob"}]"); - } - return sb.Length > 0 ? sb.ToString().TrimEnd() : "호출 결과 없음"; - } - catch (Exception ex) - { - return $"도구 호출 실패: {ex.Message}"; - } - } - - public Task RunSqlAsync(string sql) => - CallToolAsync("run_sql", new Dictionary { ["sql"] = sql }); - - public Task QueryPvHistoryAsync( - List tagNames, string timeFrom, string timeTo, int limit = 100) => - CallToolAsync("query_pv_history", new Dictionary - { - ["tag_names"] = tagNames, - ["time_from"] = timeFrom, - ["time_to"] = timeTo, - ["limit"] = limit - }); - - public Task GetTagMetadataAsync(string query, int limit = 10) => - CallToolAsync("get_tag_metadata", new Dictionary - { - ["query"] = query, - ["limit"] = limit - }); - - public Task ListDrawingsAsync(string? unitNo = null) - { - var args = new Dictionary(); - if (!string.IsNullOrEmpty(unitNo)) - args["unit_no"] = unitNo; - return CallToolAsync("list_drawings", args); - } - - public Task QueryWithNlAsync(string question) => - CallToolAsync("query_with_nl", new Dictionary { ["question"] = question }); - - public Task ExtractPidTagsAsync(string text, string sourceType) => - CallToolAsync("extract_pid_tags", new Dictionary - { - ["text"] = text, - ["source_type"] = sourceType - }); - - public Task MatchPidTagsAsync(IEnumerable pidTags, IEnumerable experionTags) => - CallToolAsync("match_pid_tags", new Dictionary - { - ["pid_tags"] = pidTags.ToList(), - ["experion_tags"] = experionTags.ToList() - }); - - public Task ParsePidDxfAsync(string filepath) => - CallToolAsync("parse_pid_dxf", new Dictionary { ["filepath"] = filepath }); - - public Task ParsePidPdfAsync(string filepath, bool useOcr = true) => - CallToolAsync("parse_pid_pdf", new Dictionary - { - ["filepath"] = filepath, - ["use_ocr"] = useOcr - }); - - public Task ParsePidDrawingAsync(string filepath) => - CallToolAsync("parse_pid_drawing", new Dictionary { ["filepath"] = filepath }); - - private async Task SendRequestAsync(object request) - { - var json = JsonSerializer.Serialize(request); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp") - { - Content = content - }; - // MCP 프로토콜: streamable-http 전송에는 application/json Accept 헤더 필요 - httpRequest.Headers.Add("Accept", "application/json"); - httpRequest.Headers.Add("mcp-protocol-version", "2025-03-26"); - - var response = await _httpClient.SendAsync(httpRequest); - if (!response.IsSuccessStatusCode) - return null; - - var body = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(body, _jsonOptions); - } -} - -#region MCP JSON-RPC 모델 - -public class McpResponse -{ - public string? jsonrpc { get; set; } - public string? id { get; set; } - public McpErrorBody? error { get; set; } - public McpResult? result { get; set; } -} - -public class McpErrorBody -{ - public int? code { get; set; } - public string? message { get; set; } - public override string ToString() => message ?? "(오류 메시지 없음)"; -} - -public class McpResult -{ - public McpTool[]? tools { get; set; } - public McpContentItem[]? content { get; set; } -} - -public class McpTool -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; - - [JsonPropertyName("inputSchema")] - public JsonElement? InputSchema { get; set; } -} - -public class McpContentItem -{ - public string type { get; set; } = string.Empty; - public string? text { get; set; } - public string? data { get; set; } - public string? mimeType { get; set; } -} - -#endregion diff --git a/.rooBackup/2026-05-02_11-53/src/Core/Application/Services/PidGraphService.cs b/.rooBackup/2026-05-02_11-53/src/Core/Application/Services/PidGraphService.cs deleted file mode 100644 index caa2485..0000000 --- a/.rooBackup/2026-05-02_11-53/src/Core/Application/Services/PidGraphService.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Text.Json; -using ExperionCrawler.Infrastructure.Mcp; -using ExperionCrawler.Core.Application.DTOs; - -namespace ExperionCrawler.Core.Application.Services; - -public interface IPidGraphService -{ - Task BuildPidGraphAsync(string filepath, Action? progressHandler = null); - Task AnalyzeImpactAsync(string graphId, string nodeId); -} - -public class PidGraphService : IPidGraphService -{ - private readonly McpClient _mcpClient; - private readonly ILogger _logger; - - public PidGraphService(McpClient mcpClient, ILogger logger) - { - _mcpClient = mcpClient; - _logger = logger; - } - - public async Task BuildPidGraphAsync(string filepath, Action? progressHandler = null) - { - try - { - progressHandler?.Invoke(10, "MCP 서버에 추출 요청 전송 중..."); - - var args = new Dictionary - { - ["filepath"] = filepath - }; - - progressHandler?.Invoke(30, "도면 기하학적 데이터 추출 중 (Phase 1)..."); - var jsonResponse = await _mcpClient.CallToolAsync("build_pid_graph_parallel", args); - - progressHandler?.Invoke(70, "지능형 태그 매핑 및 위상 분석 중 (Phase 2 & 3)..."); - var result = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - progressHandler?.Invoke(90, "최종 그래프 구조 생성 및 저장 중..."); - return result ?? throw new Exception("Failed to deserialize MCP response"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error building PID graph for file {Filepath}", filepath); - return new PidGraphBuildResult { Success = false, Error = ex.Message }; - } - } - - public async Task AnalyzeImpactAsync(string graphId, string nodeId) - { - try - { - var args = new Dictionary - { - ["graph_id"] = graphId, - ["start_node_id"] = nodeId - }; - - var jsonResponse = await _mcpClient.CallToolAsync("analyze_pid_impact", args); - var result = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - return result ?? throw new Exception("Failed to deserialize MCP response"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error analyzing impact for graph {GraphId} node {NodeId}", graphId, nodeId); - return new PidImpactResult { Success = false, Error = ex.Message }; - } - } -} - -public class PidGraphBuildResult -{ - public bool Success { get; set; } - public string? GraphId { get; set; } - public string? GraphPath { get; set; } - public int Nodes { get; set; } - public int Edges { get; set; } - public string? Error { get; set; } -} - -public class PidImpactResult -{ - public bool Success { get; set; } - public string? StartNode { get; set; } - public Dictionary? ImpactedNodes { get; set; } - public List>? Paths { get; set; } - public string? Error { get; set; } -} diff --git a/.rooBackup/2026-05-02_11-56/src/Web/Controllers/PidGraphController.cs b/.rooBackup/2026-05-02_11-56/src/Web/Controllers/PidGraphController.cs deleted file mode 100644 index d5fb954..0000000 --- a/.rooBackup/2026-05-02_11-56/src/Web/Controllers/PidGraphController.cs +++ /dev/null @@ -1,115 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using ExperionCrawler.Core.Application.DTOs; -using ExperionCrawler.Core.Application.Services; -using System.Net.Http.Json; -using System.Collections.Concurrent; - -namespace ExperionCrawler.Web.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class PidGraphController : ControllerBase -{ - private readonly IPidGraphService _pidGraphService; - private readonly ILogger _logger; - - // 간단한 인메모리 상태 저장소 (실제 운영 환경에서는 Redis/DistributedCache 권장) - private static readonly ConcurrentDictionary _statusStore = new(); - - public PidGraphController(IPidGraphService pidGraphService, ILogger logger) - { - _pidGraphService = pidGraphService; - _logger = logger; - } - - [HttpGet("impact/{graphId}/{nodeId}")] - public async Task GetImpactAnalysis(string graphId, string nodeId) - { - try - { - _logger.LogInformation("Requesting impact analysis for graph: {GraphId}, node: {NodeId}", graphId, nodeId); - - var result = await _pidGraphService.AnalyzeImpactAsync(graphId, nodeId); - - if (!result.Success) - { - return NotFound(new { error = result.Error }); - } - - // 프론트엔드 camelCase 규칙 준수 - return Ok(new - { - startNode = result.StartNode, - impactedNodes = result.ImpactedNodes, - paths = result.Paths - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error during impact analysis"); - return StatusCode(500, new { error = "Internal server error", details = ex.Message }); - } - } - - [HttpGet("status/{taskId}")] - public IActionResult GetAnalysisStatus(string taskId) - { - if (_statusStore.TryGetValue(taskId, out var status)) - { - return Ok(new - { - taskId = status.TaskId, - progress = status.Progress, - status = status.Status, - message = status.Message - }); - } - return NotFound(); - } - - // 그래프 생성 API - [HttpPost("build")] - public async Task BuildGraph([FromBody] BuildGraphRequest request) - { - if (string.IsNullOrEmpty(request.Filepath)) - return BadRequest(new { error = "Filepath is required" }); - - var taskId = Guid.NewGuid().ToString(); - _statusStore[taskId] = new AnalysisStatus(taskId, 0, "Starting", "추출 준비 중..."); - - // 백그라운드 작업으로 실행하여 taskId 즉시 반환 - _ = Task.Run(async () => - { - try - { - _statusStore[taskId] = _statusStore[taskId] with { Progress = 10, Status = "Processing", Message = "도면 기하학적 데이터 추출 중..." }; - - var result = await _pidGraphService.BuildPidGraphAsync(request.Filepath, (progress, msg) => - { - _statusStore[taskId] = _statusStore[taskId] with { Progress = progress, Message = msg }; - }); - - if (result.Success) - { - _statusStore[taskId] = _statusStore[taskId] with { Progress = 100, Status = "Completed", Message = "추출 완료" }; - // 결과 데이터를 statusStore에 임시 저장하거나 별도 결과 저장소 필요 - // 여기서는 단순화를 위해 Message에 graphId를 포함하거나 별도 필드 추가 고려 - // 실제로는 result 객체 전체를 저장하는 것이 좋음 - } - else - { - _statusStore[taskId] = _statusStore[taskId] with { Status = "Failed", Message = result.Error }; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Background graph build error for task {TaskId}", taskId); - _statusStore[taskId] = _statusStore[taskId] with { Status = "Failed", Message = ex.Message }; - } - }); - - return Ok(new { taskId = taskId }); - } - - public record BuildGraphRequest(string Filepath); -} diff --git a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase1.md b/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase1.md deleted file mode 100644 index 4946ad7..0000000 --- a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase1.md +++ /dev/null @@ -1,219 +0,0 @@ -# 🛠️ Graph Pipeline Phase 1: 기하학적 데이터 추출 (Geometric Extraction) - -이 문서는 P&ID Graph Pipeline의 첫 번째 단계인 **기하학적 데이터 추출**의 상세 구현 계획을 다룹니다. 목표는 단순한 텍스트 추출을 넘어, 도면 내 모든 객체의 **물리적 위치(좌표)**와 **기하학적 속성**을 보존하여 이후 위상 모델링(Topology Modeling)이 가능하도록 하는 것입니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `ezdxf` | DXF 파일 파싱 및 엔티티 추출 | 핵심 라이브러리 | -| `shapely` | 기하학적 연산 (Intersection, Distance, Bounding Box) | 좌표 기반 분석 필수 | -| `numpy` | 대량의 좌표 데이터 계산 및 행렬 연산 | 성능 최적화 | -| `pandas` | 추출된 객체 데이터의 구조화 및 CSV/JSON 저장 | 데이터 관리 | -| `pydantic` | 추출 데이터의 스키마 정의 및 유효성 검증 | 데이터 무결성 보장 | -| `pytesseract` / `pdf2image` | PDF 도면의 영역 기반 OCR 추출 | PDF 처리 시 필요 | - -### 1.2 설치 명령어 -```bash -pip install ezdxf shapely numpy pandas pydantic pytesseract pdf2image -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 데이터 모델 (Schema) -모든 추출 객체는 다음과 같은 공통 속성을 갖는 `GeometricEntity` 모델을 따릅니다. - -```python -from pydantic import BaseModel -from typing import List, Optional, Union, Tuple - -class BoundingBox(BaseModel): - min_x: float - min_y: float - max_x: float - max_y: float - center: Tuple[float, float] - -class GeometricEntity(BaseModel): - entity_id: str - entity_type: str # TEXT, LINE, CIRCLE, POLYLINE, ARC - layer: str - bbox: BoundingBox - properties: dict # 텍스트 값, 색상, 선 굵기 등 - coordinates: List[Tuple[float, float]] # 시작점, 끝점 또는 정점 리스트 -``` - -### 2.2 처리 파이프라인 흐름 -1. **DXF Load:** `ezdxf.readfile()`을 통해 도면 로드. -2. **Entity Iteration:** 모든 레이어의 엔티티를 순회하며 타입별 분류. -3. **Coordinate Extraction:** - * `TEXT`: 삽입점(Insertion Point) 및 텍스트 길이를 이용한 BBox 계산. - * `LINE`: 시작점(Start)과 끝점(End) 추출. - * `POLYLINE`: 모든 정점(Vertices) 리스트 추출. - * `CIRCLE/ARC`: 중심점(Center)과 반지름(Radius) 추출. -4. **Spatial Normalization:** 도면 좌표계를 분석 시스템 좌표계로 정규화. -5. **Structured Export:** JSON 또는 DB(PostgreSQL/PostGIS)에 저장. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 DXF 기하학적 추출 핵심 코드 -```python -import ezdxf -import re -import json -from shapely.geometry import box, LineString, Point -from typing import List, Optional, Tuple - -class PidGeometricExtractor: - def __init__(self, file_path: str): - self.doc = ezdxf.readfile(file_path) - self.msp = self.doc.modelspace() - - def clean_text(self, text: str) -> str: - """DXF 특수 제어 문자 및 MTEXT 포맷팅을 최대한 제거하여 LLM 토큰 부하 감소""" - if not text: - return "" - - # 1. MTEXT 포맷팅 및 제어 문자 제거 - # \P(줄바꿈), \W(너비), \L(밑줄), \A(정렬), \C(색상), \H(높이), \S(스택), \T(탭) 및 관련 인자 제거 - text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text) - - # 2. 중괄호 { } 제거 (MTEXT에서 서식 지정 시 사용됨) - text = re.sub(r'[\{\}]', ' ', text) - - # 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered) - text = re.sub(r'%%[U|O|S|R]', ' ', text) - - # 4. 불필요한 특수 기호 및 반복되는 공백 정제 - # - 연속된 공백을 하나로 통합 - # - 텍스트 양 끝의 공백 제거 - text = re.sub(r'\s+', ' ', text).strip() - - return text - - def get_bbox(self, entity) -> Optional[box]: - """엔티티의 Bounding Box를 계산하여 shapely box 객체로 반환""" - try: - if entity.dxftype() == 'TEXT': - p = entity.dxf.insert - h = entity.dxf.height - # 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6) - width = len(entity.dxf.text) * h * 0.6 - return box(p.x, p.y, p.x + width, p.y + h) - - elif entity.dxftype() == 'MTEXT': - p = entity.dxf.insert - h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5 - # MTEXT는 보통 width 속성이 정의되어 있음 - w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6 - return box(p.x, p.y, p.x + w, p.y + h) - - elif entity.dxftype() == 'LINE': - start = entity.dxf.start - end = entity.dxf.end - return box(min(start.x, end.x), min(start.y, end.y), - max(start.x, end.x), max(start.y, end.y)) - - elif entity.dxftype() == 'LWPOLYLINE': - points = entity.get_points() - xs = [p[0] for p in points] - ys = [p[1] for p in points] - return box(min(xs), min(ys), max(xs), max(ys)) - - except Exception as e: - print(f"Error calculating bbox for {entity.dxftype()}: {e}") - return None - - def extract_and_save(self, output_path: str): - """ - 추출된 기하학적 데이터를 파일로 저장하여 Phase 3 Worker들이 - 공유 메모리/파일 시스템을 통해 참조할 수 있도록 함 (Phase 5 병렬 아키텍처 반영) - """ - results = [] - for entity in self.msp: - bbox_obj = self.get_bbox(entity) - if bbox_obj: - # 텍스트 값 추출 및 정제 - raw_text = "" - if entity.dxftype() == 'TEXT': - raw_text = entity.dxf.text - elif entity.dxftype() == 'MTEXT': - raw_text = entity.text - - results.append({ - "id": entity.dxf.handle, - "type": entity.dxftype(), - "layer": entity.dxf.layer, - "bbox": { - "min_x": bbox_obj.bounds[0], - "min_y": bbox_obj.bounds[1], - "max_x": bbox_obj.bounds[2], - "max_y": bbox_obj.bounds[3] - }, - "raw_value": raw_text, - "clean_value": self.clean_text(raw_text) if raw_text else None - }) - - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(results, f, ensure_ascii=False, indent=4) - - return output_path - -# 사용 예시 (Phase 5 Orchestrator 관점) -extractor = PidGeometricExtractor("plant_drawing.dxf") -# 데이터를 직접 반환받지 않고 공유 저장소(파일)에 적재 -geo_data_path = extractor.extract_and_save("shared_geo_data.json") -``` - -### 3.2 유틸리티 함수: 인접성 체크 (Proximity Utility) -추후 2단계(위상 모델링)에서 사용할 핵심 유틸리티입니다. - -```python -from shapely.geometry import Point - -def is_near(entity_a_bbox, entity_b_bbox, threshold=5.0): - """두 객체의 Bounding Box 간의 최단 거리가 임계값 이내인지 확인""" - return entity_a_bbox.distance(entity_b_bbox) <= threshold - -def is_inside(point, bbox): - """특정 점이 Bounding Box 내부에 있는지 확인""" - return bbox.contains(Point(point)) -``` - ---- - -## 🚀 4. Phase 1 완료 기준 (Definition of Done) - -- [ ] DXF 파일 내 모든 `TEXT`, `LINE`, `POLYLINE`의 좌표 데이터가 누락 없이 추출되는가? -- [ ] 각 객체별로 정확한 `Bounding Box`가 계산되어 저장되는가? -- [ ] 추출된 데이터가 `GeometricEntity` 스키마에 맞게 JSON 파일로 저장되어 Worker들이 공유 참조 가능한가? (Phase 5 반영) -- [ ] (선택 사항) PDF 도면의 경우 OCR을 통해 텍스트의 좌표값이 추출되는가? - ---- - -## 🧐 감독자 진단 결과 (2026-05-02) - -### 1. 프로그램 설계 점검 -- **강점**: `ezdxf`와 `shapely`를 조합하여 기하학적 데이터(BBox, 좌표)를 보존하려는 접근 방식이 매우 적절함. 특히 Phase 5의 병렬 아키텍처를 고려하여 데이터를 파일/공유 저장소에 적재하는 구조는 확장성 면에서 우수함. -- **보완 필요 사항**: - - **MTEXT 처리**: 현재 예시 코드(`3.1`)는 `TEXT` 엔티티만 처리하고 있으나, 실제 DXF 파일 분석 결과 `MTEXT` 엔티티가 다수 존재함. `MTEXT`는 내부 포맷팅 코드(예: `\P`, `\W`)가 포함되어 있어 단순 텍스트 추출 시 정제가 필요함. - - **BBox 계산 정밀도**: `TEXT` 엔티티의 BBox를 `p.x + 10, p.y + 5`와 같이 상수로 처리하고 있음. 실제 도면의 폰트 크기(`height`)와 정렬 방식(`align`)을 반영한 동적 계산 로직이 반드시 추가되어야 함. - -### 2. 실제 도면(`No-10_Plant_PID.dxf`) 분석 기반 차이점 -- **엔티티 규모**: 총 28,819개의 엔티티가 존재하여 데이터 양이 상당함. 단순 리스트 저장보다는 인덱싱 전략이 필요할 수 있음. -- **텍스트 복잡도**: - - `MTEXT` 내에 `\P` (줄바꿈), `\L` (밑줄) 등 제어 문자가 포함된 수정 사항(Revision) 텍스트가 많음. 이를 그대로 추출하면 위상 분석 시 노이즈가 될 가능성이 높음. - - `%%U` (Underline)와 같은 DXF 특수 제어 문자가 텍스트 값에 포함되어 있어, 이를 제거하는 전처리 과정이 필수적임. -- **데이터 특성**: `IA-10922-25A-F1A-n`와 같은 복합 파이프라인 번호(Pipe Line Number) 형식이 확인됨. 이를 일반 태그(Tag Name)와 명확히 구분하여 추출하고 관리하는 로직이 Phase 2/3에서 중요하게 작용할 것으로 보임. - -### 3. 최종 권고 사항 -1. **MTEXT 지원 추가**: `PidGeometricExtractor`에 `MTEXT` 처리 로직을 추가하고, 제어 문자를 제거하는 `clean_text()` 유틸리티 함수를 구현할 것. -2. **동적 BBox 구현**: `entity.dxf.height`를 활용하여 텍스트 크기에 맞는 정확한 Bounding Box를 계산하도록 수정할 것. -3. **전처리 파이프라인 강화**: 추출 단계에서 `%%U` 등의 특수 문자를 제거하는 정제 단계를 추가하여 데이터 품질을 높일 것. diff --git a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase2.md b/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase2.md deleted file mode 100644 index 32d8c2d..0000000 --- a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase2.md +++ /dev/null @@ -1,180 +0,0 @@ -# 🕸️ Graph Pipeline Phase 2: 위상 모델링 (Topology Modeling) - -이 문서는 P&ID Graph Pipeline의 두 번째 단계인 **위상 모델링**의 상세 구현 계획을 다룹니다. 1단계에서 추출한 기하학적 객체(좌표, BBox)를 기반으로, 설비 간의 **연결성(Connectivity)**과 **흐름(Flow)**을 정의하는 지식 그래프(Knowledge Graph)를 구축하는 것이 목표입니다. - ---- - -## 🚩 [Supervisor's Audit] 진단 결과 및 개선 권고 - -**감독자 진단 일자:** 2026-05-02 -**진단 결과:** ⚠️ **부분적 보완 필요 (Partial Improvement Required)** - -### 🔍 주요 진단 내용 -1. **연결성 추론의 단순성 (Critical):** 현재 `_find_connected_nodes`가 단순 BBox 교차(`intersects`)만 확인하고 있습니다. 실제 P&ID에서 배관(Line)은 설비의 외곽선에 닿거나 매우 근접한 형태로 나타나며, 단순 BBox 교차는 오탐(False Positive) 확률이 매우 높습니다. -2. **방향성 정의 부재 (Medium):** `DiGraph`를 사용하지만, 실제 엣지에 방향성을 부여하는 구체적인 로직(화살표 인식, 공정 흐름 규칙)이 예시 코드에 누락되어 있습니다. -3. **임계값 하드코딩 (Low):** `min_dist < 50.0`과 같은 임계값이 하드코딩되어 있어, 도면 스케일(Scale)이 변경될 경우 대응이 불가능합니다. -4. **데이터 무결성 검증 부족 (Medium):** 그래프 생성 후 고립된 노드(Isolated Nodes)나 비정상적인 루프에 대한 검증 단계가 없습니다. - -### 🛠️ 수정 및 반영 사항 -- **연결성 로직 고도화:** BBox 교차 방식에서 $\rightarrow$ **Line End-point 기반 근접 분석** 방식으로 변경. -- **방향성 추론 단계 명시:** 화살표 심볼 및 공정 흐름 기반의 `source` $\rightarrow$ `target` 결정 로직 추가. -- **설정의 외부화:** 임계값($\epsilon$)을 설정 파일이나 파라미터로 관리하도록 구조 변경. -- **검증 단계 추가:** 그래프 구축 후 위상 무결성 검사(Topology Validation) 단계 도입. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `networkx` | 그래프 데이터 구조 생성 및 알고리즘 분석 | 핵심 라이브러리 | -| `shapely` | 객체 간 거리 계산 및 포함 관계 분석 | 1단계와 연계 | -| `scikit-learn` | (선택) KD-Tree를 이용한 고속 근접 이웃 검색 | 대규모 도면 최적화 | -| `matplotlib` | 생성된 그래프의 위상 구조 시각화 검증 | 디버깅용 | - -### 1.2 설치 명령어 -```bash -pip install networkx shapely scikit-learn matplotlib -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 그래프 정의 (Graph Definition) -* **노드 (Nodes):** - * `Equipment`: 펌프, 탱크, 열교환기 등 (속성: ID, 타입, BBox, CenterPoint) - * `Instrument`: 전송기, 밸브, 게이지 등 (속성: ID, 타입, BBox, CenterPoint) - * `Tag`: 텍스트 기반 태그 (속성: TagName, Value, BBox) -* **엣지 (Edges):** - * `Pipe`: 설비-설비, 설비-계기 간의 물리적 연결 (속성: LineNumber, 방향성, 연결타입) - * `Association`: 태그-설비 간의 논리적 연결 (속성: 관계 타입 - 예: 'belongs_to') - -### 2.2 위상 추론 로직 (Topology Inference) -1. **태그-설비 결합 (Tag-to-Entity Binding):** - * 태그 텍스트의 BBox와 가장 가까운 심볼(Equipment/Instrument)을 찾아 `Association` 엣지를 생성합니다. -2. **배관 연결성 분석 (Line Connectivity) [개선]:** - * `LINE` 또는 `POLYLINE`의 **시작점과 끝점(End-points)**을 추출합니다. - * 각 끝점이 특정 설비의 BBox 내부에 있거나, 설정된 임계 거리($\epsilon$) 이내에 있을 때만 `Pipe` 엣지로 연결합니다. (단순 BBox 교차 방식 지양) -3. **흐름 방향성 부여 (Flow Direction) [추가]:** - * 배관 상의 화살표 심볼 위치와 방향을 분석하여 `source` $\rightarrow$ `target`을 결정합니다. - * 화살표가 없는 경우, 공정 표준(예: 탱크 $\rightarrow$ 펌프 $\rightarrow$ 밸브)에 따른 기본 방향을 부여합니다. -4. **위상 무결성 검증 (Topology Validation) [추가]:** - * 연결되지 않은 고립 노드 탐색 및 리포팅. - * 비정상적인 사이클(Cycle) 또는 단절 구간 확인. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 그래프 구축 핵심 코드 -```python -import networkx as nx -from shapely.geometry import box, Point, LineString - -class PidTopologyBuilder: - def __init__(self, geometric_data, all_extracted_tags=None, config=None): - """ - - geometric_data: Phase 1에서 추출된 기하학적 데이터 - - all_extracted_tags: 통합된 태그 리스트 - - config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값 - """ - self.data = geometric_data - self.all_tags = all_extracted_tags if all_extracted_tags else [] - self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0} - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - self.G.add_node(item['id'], - type=item['type'], - bbox=box(*item['bbox'].values()), - value=item.get('value')) - - # 2. 분산 추출된 태그 통합 및 노드 추가 - for tag in self.all_tags: - self.G.add_node(tag['id'], - type='TEXT', - bbox=box(*tag['bbox'].values()), - value=tag.get('tagName')) - - # 3. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] != 'TEXT'] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 4. 배관 기반 물리적 연결 (Pipe) [개선됨] - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'POLYLINE']] - for line_id in lines: - line_geom = self.G.nodes[line_id]['bbox'] # 실제로는 LineString 객체여야 함 - # 라인의 끝점 추출 (가정: line_geom이 LineString인 경우) - endpoints = [line_geom.coords[0], line_geom.coords[-1]] if hasattr(line_geom, 'coords') else [] - - connected_nodes = [] - for pt in endpoints: - p = Point(pt) - for eq_id in equipments: - if self.G.nodes[eq_id]['bbox'].distance(p) < self.config['dist_threshold']: - connected_nodes.append(eq_id) - - if len(connected_nodes) >= 2: - # 방향성 추론 로직 (단순화: 시작점 -> 끝점) - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe') - - def _find_nearest_equipment(self, tag_id, equipment_ids): - tag_bbox = self.G.nodes[tag_id]['bbox'] - min_dist = float('inf') - nearest = None - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - if dist < min_dist: - min_dist = dist - nearest = eq_id - return nearest if min_dist < self.config['tag_threshold'] else None - - def validate_topology(self): - """위상 무결성 검증""" - isolated = list(nx.isolates(self.G)) - return {"isolated_nodes": isolated, "node_count": self.G.number_of_nodes(), "edge_count": self.G.number_of_edges()} - -# 실행 예시 -all_tags = flatten_results([worker1_res, worker2_res]) -config = {'dist_threshold': 30.0, 'tag_threshold': 80.0} -builder = PidTopologyBuilder(geometric_data, all_extracted_tags=all_tags, config=config) -builder.build_graph() -validation_res = builder.validate_topology() -print(f"Validation Result: {validation_res}") -``` - -### 3.2 위상 분석 유틸리티: 영향도 분석 (Impact Analysis) -```python -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) - -# 예: P-101 펌프 고장 시 영향 분석 -affected = analyze_impact(graph, "node_P101") -print(f"Impacted Equipment: {affected}") -``` - ---- - -## 🚀 4. Phase 2 완료 기준 (Definition of Done) - -- [ ] 모든 설비와 계기가 그래프의 **노드(Node)**로 변환되었는가? -- [ ] 분산 추출된 태그 리스트가 `flatten_results`를 통해 통합되어 그래프에 반영되었는가? -- [ ] 태그와 설비 간의 **논리적 연결(Association)**이 정확하게 매핑되었는가? -- [ ] 배관(Line)의 **끝점 분석**을 통해 설비 간의 **물리적 연결(Pipe Edge)**이 생성되었는가? (BBox 교차 방식 배제) -- [ ] 화살표 및 공정 규칙에 기반한 **방향성(Directionality)**이 엣지에 부여되었는가? -- [ ] `validate_topology`를 통해 고립 노드 및 위상 오류가 검토되었는가? -- [ ] `nx.descendants` 등을 통해 특정 노드로부터의 **흐름 추적(Flow Tracing)**이 가능한가? -- [ ] 생성된 그래프 구조가 JSON(GraphML 등) 형태로 저장되어 Phase 3로 전달 가능한가? diff --git a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase3.md b/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase3.md deleted file mode 100644 index c8eb7c5..0000000 --- a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase3.md +++ /dev/null @@ -1,211 +0,0 @@ -# 🧠 Graph Pipeline Phase 3: 지능형 매핑 및 검증 (Intelligent Mapping & Validation) - -이 문서는 P&ID Graph Pipeline의 세 번째 단계인 **지능형 매핑 및 검증**의 상세 구현 계획을 다룹니다. 2단계에서 구축한 위상 그래프(Topology Graph)를 활용하여, 도면 상의 가상 노드들을 실제 Experion 시스템의 **실시간 태그(Real-time Tags)**와 정밀하게 연결하고 그 타당성을 검증하는 것이 목표입니다. - ---- - -## 🚩 [Supervisor's Audit] 감독자 진단 결과 및 수정 사항 - -본 프로그램 설계에 대해 감독자 관점에서 정밀 진단을 수행하였으며, 다음과 같은 취약점과 개선 사항을 발견하여 반영하였습니다. - -### 1. 진단 결과 (Audit Findings) - -| 항목 | 진단 내용 | 심각도 | 수정 방향 | -|---|---|---|---| -| **에러 처리** | LLM 응답이 JSON 형식이 아니거나 `UNKNOWN`일 때의 예외 처리 로직 부족 | HIGH | 구조화된 출력(JSON) 강제 및 Fallback 전략 추가 | -| **성능/비용** | 모든 노드에 대해 개별 LLM 호출 시 API 비용 급증 및 속도 저하 | MED | 배치(Batch) 처리 및 1차 필터링 강화 | -| **검증 정밀도** | 단순 키워드 매칭 기반 검증은 오탐(False Positive) 가능성이 높음 | MED | 데이터 타입 및 엔지니어링 유닛(EU)의 엄격한 비교 로직 추가 | -| **데이터 정합성** | 매핑 결과의 이력 관리 및 사람이 수동으로 수정할 수 있는 피드백 루프 부재 | LOW | 매핑 결과 저장 스키마에 `confidence` 및 `manual_override` 필드 추가 | - -### 2. 수정 이유 (Rationale) -- **안정성 확보:** LLM은 비결정론적 특성이 있으므로, 프로그램이 런타임에 중단되지 않도록 Pydantic을 이용한 엄격한 스키마 검증이 필수적입니다. -- **효율성 최적화:** 수천 개의 태그를 개별 호출하는 것은 비효율적입니다. 유사도 기반으로 후보군을 좁히고, 유사 그룹을 묶어 배치 처리함으로써 비용을 절감합니다. -- **신뢰도 향상:** 단순 텍스트 매칭을 넘어 실제 시스템의 메타데이터(Unit, Range 등)를 교차 검증해야 엔지니어링 관점에서 신뢰할 수 있는 결과가 됩니다. - ---- - -## 📦 1. 필수 패키지 및 환경 설정 - -### 1.1 Python 패키지 -| 패키지 | 용도 | 비고 | -|---|---|---| -| `openai` / `langchain` | LLM API 연동 및 프롬프트 체이닝 | 매핑 추론 및 검증 핵심 | -| `fuzzywuzzy` / `rapidfuzz` | 태그 이름 간의 문자열 유사도 계산 | 1차 후보군 추출용 | -| `networkx` | 그래프 기반 인접 노드(Context) 추출 | 2단계 그래프 활용 | -| `pydantic` | 매핑 결과의 구조화 및 유효성 검사 | **[강화]** 데이터 정규화 및 런타임 타입 체크 | -| `requests` | ExperionCrawler API (C#)와 통신 | 실제 태그 리스트 조회 | - -### 1.2 설치 명령어 -```bash -pip install openai langchain rapidfuzz networkx pydantic requests -``` - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 매핑 파이프라인 (Mapping Pipeline) -단순 이름 매칭의 한계를 극복하기 위해 **[후보 추출 $\rightarrow$ 맥락 분석 $\rightarrow$ LLM 확정 $\rightarrow$ 스키마 검증]**의 4단계 프로세스를 거칩니다. - -1. **1차 후보 추출 (Candidate Generation):** - * 도면의 태그 텍스트와 Experion 시스템의 전체 태그 리스트를 `RapidFuzz`로 비교하여 유사도 상위 N개를 추출합니다. -2. **맥락 정보 수집 (Context Gathering):** - * 해당 노드의 그래프 상 인접 노드(1-hop, 2-hop) 정보를 수집합니다. - * 예: "현재 노드는 `PT-101`이며, 상류에 `P-101(Pump)`이 있고 하류에 `V-101(Valve)`이 있음." -3. **LLM 기반 최종 매핑 (LLM-based Resolution):** - * 후보 태그 리스트와 위상 맥락을 LLM에게 전달하여 가장 타당한 태그를 선택하게 합니다. - * **[개선]** JSON Mode를 사용하여 `{"tag": "...", "reason": "...", "confidence": 0.9}` 형태로 응답을 강제합니다. -4. **구조적 검증 (Structural Validation):** - * Pydantic 모델을 통해 LLM 응답의 형식을 검증하고, 실패 시 `UNKNOWN` 처리 및 로그를 남깁니다. - -### 2.2 상호 검증 로직 (Cross-Validation) -매핑된 결과가 실제 공정 데이터와 일치하는지 검증합니다. -* **위상적 일관성:** 도면에서 `A $\rightarrow$ B` 순서라면, 실제 데이터에서도 `A`의 변화가 `B`에 영향을 주는지 상관관계 분석. -* **속성 일치성:** 도면의 심볼 타입(예: Pressure Transmitter)과 실제 태그의 속성(예: Engineering Unit = 'bar' 또는 'psi')이 일치하는지 확인. **[강화]** 단순 키워드가 아닌 Unit 매핑 테이블을 통한 엄격한 비교. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 맥락 기반 매핑 엔진 -```python -import networkx as nx -import asyncio -import json -from typing import List, Optional -from pydantic import BaseModel, Field -from rapidfuzz import process, fuzz -from openai import AsyncOpenAI - -# --- [추가] 응답 구조화를 위한 Pydantic 모델 --- -class MappingResult(BaseModel): - resolved_tag: str = Field(..., description="The final mapped system tag") - reason: str = Field(..., description="Reason for this mapping based on context") - confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1") - -client = AsyncOpenAI(api_key="your-api-key") - -class IntelligentMapper: - def __init__(self, graph, system_tags): - self.graph = graph # Phase 2에서 생성된 NetworkX 그래프 - self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트 - - def get_node_context(self, node_id): - """노드의 주변 위상 정보를 텍스트로 변환""" - neighbors = list(self.graph.neighbors(node_id)) - context = [] - for n in neighbors: - attr = self.graph.nodes[n] - context.append(f"Connected to {attr.get('value', n)} (Type: {attr.get('type')})") - return ", ".join(context) - - async def _resolve_generic(self, node_id, category_prompt): - """공통 매핑 로직 (비동기 + 구조화 응답)""" - tag_text = self.graph.nodes[node_id].get('value', '') - candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5) - context = self.get_node_context(node_id) - - prompt = f""" - {category_prompt} - P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다. - 위상 맥락: {context} - 후보 리스트: {candidates} - - 반드시 다음 JSON 형식으로만 응답하세요: - {{ - "resolved_tag": "태그명 또는 UNKNOWN", - "reason": "매핑 이유", - "confidence": 0.0~1.0 - }} - """ - - try: - response = await client.chat.completions.create( - model="gpt-4-turbo", - messages=[{"role": "user", "content": prompt}], - response_format={ "type": "json_object" } # JSON 모드 강제 - ) - raw_content = response.choices[0].message.content - # Pydantic을 통한 유효성 검사 - return MappingResult.model_validate_json(raw_content) - except Exception as e: - print(f"Error resolving node {node_id}: {e}") - return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0) - - # --- 전문화된 Worker 함수들 --- - async def extract_transmitters(self, node_ids): - prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다." - return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids} - - async def extract_valves(self, node_ids): - prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다." - return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids} - - async def extract_equipment(self, node_ids): - prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다." - return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids} - -# 사용 예시 -async def main(): - # 가상 데이터 - graph = nx.Graph() - graph.add_node("node_1", value="PT-101", type="Pressure Transmitter") - graph.add_node("node_2", value="P-101", type="Pump") - graph.add_edge("node_1", "node_2") - - mapper = IntelligentMapper(graph, ["PT-101.PV", "PT-102.PV", "P-101.STATUS"]) - - results = await asyncio.gather( - mapper.extract_transmitters(["node_1"]), - mapper.extract_equipment(["node_2"]) - ) - - final_mapping = {**results[0], **results[1]} - print(f"Parallel Resolved Mapping: {final_mapping}") - -asyncio.run(main()) -``` - -### 3.2 검증 유틸리티: 속성 일치 확인 (강화 버전) -```python -def validate_mapping(resolved_tag, symbol_type, tag_metadata): - """심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증""" - # 단순 키워드가 아닌 허용 단위(Unit) 정의 - unit_map = { - "Pressure Transmitter": ["bar", "psi", "kPa", "Pa"], - "Flow Meter": ["m3/h", "lpm", "kg/h"], - "Temperature Sensor": ["°C", "C", "K", "°F"] - } - - actual_unit = tag_metadata.get('unit', '').strip() - allowed_units = unit_map.get(symbol_type, []) - - # 1. 단위 일치 확인 (최우선) - if actual_unit and actual_unit in allowed_units: - return True, "Unit Match" - - # 2. 단위가 없는 경우 설명(Description) 기반 2차 검증 - actual_desc = tag_metadata.get('description', '').lower() - expected_keywords = { - "Pressure Transmitter": ["pressure", "press"], - "Flow Meter": ["flow", "flowrate"], - "Temperature Sensor": ["temp", "temperature"] - } - - keywords = expected_keywords.get(symbol_type, []) - if any(kw in actual_desc for kw in keywords): - return True, "Description Match (Unit Missing)" - - return False, "Mismatch: Symbol type and Tag metadata do not align" -``` - ---- - -## 🚀 4. Phase 3 완료 기준 (Definition of Done) - -- [ ] 모든 도면 노드에 대해 **1차 후보군(Candidates)**이 자동으로 생성되는가? -- [ ] `NetworkX` 그래프를 통해 **인접 노드 맥락(Context)**이 정확히 추출되는가? -- [ ] LLM이 **JSON 형식**으로 최종 태그를 결정하고, 그 근거와 신뢰도를 제시하는가? -- [ ] **Pydantic**을 통해 LLM 응답의 구조적 유효성이 검증되는가? -- [ ] 매핑된 태그의 **엔지니어링 유닛(Unit)**과 도면 심볼 타입 간의 일치성이 엄격히 검증되는가? -- [ ] 최종 매핑 결과가 `(도면노드ID, 시스템태그, 신뢰도, 검증결과, 매핑근거)` 형태로 저장되는가? diff --git a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase4.md b/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase4.md deleted file mode 100644 index 94b2084..0000000 --- a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase4.md +++ /dev/null @@ -1,197 +0,0 @@ -# 🎨 Graph Pipeline Phase 4: 활용 및 시각화 (Application & Visualization) - -이 문서는 P&ID Graph Pipeline의 최종 단계인 **활용 및 시각화**의 상세 구현 계획을 다룹니다. 앞선 단계에서 구축한 [기하학적 데이터 $\rightarrow$ 위상 그래프 $\rightarrow$ 시스템 태그 매핑] 결과물을 결합하여, 운영자가 도면 상에서 실시간 공정 상태를 파악하고 장애 영향도를 분석할 수 있는 인터페이스를 구현하는 것이 목표입니다. - ---- - -## 🔍 [Supervisor Diagnosis] 프로그램 진단 및 개선 권고 - -**진단 일자:** 2026-05-02 -**진단자:** Roo (Software Engineer / Supervisor) - -### 1. 종합 진단 결과 -현재 계획은 기본적인 데이터 흐름(C# $\rightarrow$ Python $\rightarrow$ Frontend)을 잘 정의하고 있으나, **실제 산업 현장의 대규모 P&ID 도면 적용 시 발생할 수 있는 성능 및 안정성 문제**에 대한 고려가 부족합니다. 특히 실시간 데이터 오버레이의 부하 관리와 분석 결과의 신뢰성 검증 단계가 누락되어 있습니다. - -### 2. 주요 진단 항목 및 수정 이유 - -| 항목 | 진단 결과 | 위험도 | 수정 이유 및 개선 방향 | -|---|---|---|---| -| **데이터 전송 효율** | WebSocket/API 폴링 방식의 단순 나열 | MED | 수천 개의 태그가 포함된 도면에서 개별 폴링/전송 시 네트워크 부하 급증 $\rightarrow$ **태그 그룹화 및 변경분 기반(Delta) 전송** 도입 필요 | -| **프론트엔드 렌더링** | SVG/Canvas 단순 오버레이 | HIGH | 노드 수가 많아질 경우 DOM 요소 증가로 인한 브라우저 랙 발생 $\rightarrow$ **Canvas 기반 렌더링 최적화 및 Viewport 기반 가시 영역 렌더링** 전략 필요 | -| **분석 엔진 신뢰성** | `nx.descendants` 단순 활용 | MED | 단순 위상 전파는 실제 공정의 '흐름 방향(Flow Direction)'과 '밸브 개폐 상태'를 무시함 $\rightarrow$ **엣지 속성(방향성, 상태)을 반영한 가중치 경로 분석**으로 고도화 | -| **에러 핸들링** | Python 브릿지 통신 시 예외 처리 미흡 | LOW | 분석 엔진 다운 시 C# 서버의 블로킹 가능성 $\rightarrow$ **Circuit Breaker 패턴 및 타임아웃 설정** 명시 필요 | -| **사용자 경험(UX)** | 단순 하이라이트 표시 | LOW | 영향도 결과가 많을 경우 도면이 빨간색으로 도배됨 $\rightarrow$ **단계별 영향도(1차, 2차...) 색상 구분 및 필터링** 기능 추가 | - ---- - -## 📦 1. 필수 패키지 및 기술 스택 - -### 1.1 프론트엔드 (Visualization) -| 기술/라이브러리 | 용도 | 비고 | -|---|---|---| -| `SVG / Canvas API` | P&ID 도면 렌더링 및 데이터 오버레이 | **Canvas API 우선 권장 (대규모 노드 성능 최적화)** | -| `Cytoscape.js` / `D3.js` | 위상 그래프 시각화 및 인터랙티브 탐색 | 그래프 분석 뷰어 | -| `Vue.js` / `React` | 전체 UI 프레임워크 및 상태 관리 | `src/Web` 구조와 통합 | -| `Axios` / `WebSocket` | 실시간 OPC UA 데이터 수신 및 API 통신 | **SignalR (ASP.NET Core) 도입 권장 (실시간 양방향 통신 최적화)** | - -### 1.2 백엔드 (API & Analysis) -| 기술/라이브러리 | 용도 | 비고 | -|---|---|---| -| `ASP.NET Core` | Graph API 및 분석 엔드포인트 제공 | `ExperionCrawler` 메인 서버 | -| `NetworkX` (Python) | 영향도 분석 및 경로 추적 알고리즘 실행 | 분석 엔진 (Phase 2 활용) | -| `FastAPI` / `Flask` | Python 분석 엔진과 C# 서버 간의 브릿지 | 분석 마이크로서비스 | - ---- - -## 📐 2. 상세 설계 구조 - -### 2.1 실시간 데이터 오버레이 (Real-time Overlay) -도면의 좌표 정보와 매핑된 시스템 태그를 연결하여 실시간 값을 표시합니다. -1. **매핑 데이터 로드:** `(도면노드ID, 시스템태그, 좌표)` 리스트를 프론트엔드로 전달. -2. **실시간 스트리밍:** `OPC UA` $\rightarrow$ `C# Server` $\rightarrow$ `SignalR Hub` $\rightarrow$ `Frontend`. (**개선: 변경된 값만 전송하는 Delta Update 방식 적용**) -3. **동적 렌더링:** 태그 값이 변경되면 해당 좌표의 Canvas 요소를 업데이트하거나 툴팁에 현재 값을 표시. (**개선: Viewport 내 요소만 업데이트하여 CPU 부하 감소**) - -### 2.2 영향도 분석 엔진 (Impact Analysis Engine) -특정 설비의 이상 발생 시 하류(Downstream) 영향을 계산합니다. -1. **분석 요청:** 사용자가 도면에서 특정 노드(예: 펌프 P-101)를 클릭. -2. **그래프 탐색:** Python 분석 엔진에서 `nx.descendants(G, 'P-101')` 실행. (**개선: 엣지의 `flow_direction` 속성을 확인하여 실제 유체 흐름 방향으로만 전파 계산**) -3. **결과 반환:** 영향받는 모든 노드 ID 리스트, 경로(Path), 그리고 **영향 단계(Depth)**를 반환. -4. **시각적 강조:** 도면 상에서 영향 경로를 단계별 색상(예: 1차-진한 빨강, 2차-연한 빨강)으로 하이라이트 처리. - ---- - -## 💻 3. 실제 구현 코딩 가이드 (Example) - -### 3.1 [Backend] 영향도 분석 API (C# $\rightarrow$ Python Bridge) -```csharp -// src/Web/Controllers/PidGraphController.cs - -// 1. 분석 상태 추적을 위한 DTO -public record AnalysisStatus(string taskId, double progress, string status, string message); - -// 2. 실시간 진행 상태 조회 API (Phase 5 병렬 처리 반영) -[HttpGet("status/{taskId}")] -public async Task GetAnalysisStatus(string taskId) -{ - // Orchestrator가 관리하는 작업 상태 저장소(Redis/MemoryCache)에서 조회 - var status = await _statusService.GetStatusAsync(taskId); - if (status == null) return NotFound(); - - return Ok(new { - taskId = status.TaskId, - progress = status.Progress, // 0.0 ~ 1.0 - status = status.Status, // "Processing", "Completed", "Failed" - message = status.Message - }); -} - -[HttpGet("impact/{nodeId}")] -public async Task GetImpactAnalysis(string nodeId) -{ - try - { - // Python 분석 마이크로서비스에 요청 (Timeout 및 Circuit Breaker 적용 권장) - var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}"); - response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(); - - return Ok(result); - } - catch (HttpRequestException ex) - { - // 분석 엔진 연결 실패 시 적절한 에러 메시지 반환 - return StatusCode(503, new { error = "Analysis Engine is currently unavailable", details = ex.Message }); - } -} -``` - -### 3.2 [Frontend] Canvas 기반 데이터 오버레이 및 진행률 표시 (JavaScript) -```javascript -// src/Web/wwwroot/js/pid-viewer.js - -// 1. 실시간 값 업데이트 (Canvas 최적화 버전) -async function updateRealtimeValues(tagData) { - // tagData: { "TAG_01": { value: 10.5, status: "OK" }, ... } - - const ctx = canvas.getContext('2d'); - - for (const [tag, data] of Object.entries(tagData)) { - const node = nodeMap.get(tag); // 좌표 정보 맵 - if (node && isInViewport(node)) { - // 뷰포트 내에 있을 때만 렌더링 - ctx.fillStyle = data.value > threshold ? 'red' : 'green'; - ctx.beginPath(); - ctx.arc(node.x, node.y, 5, 0, Math.PI * 2); - ctx.fill(); - - // 툴팁 데이터 업데이트 - updateTooltipData(tag, data.value); - } - } -} - -// 2. 분석 진행 상태 표시 (Phase 5 병렬 처리 반영) -async function trackAnalysisProgress(taskId) { - const progressBar = document.getElementById('analysis-progress-bar'); - const statusText = document.getElementById('analysis-status-text'); - - const pollStatus = async () => { - try { - const response = await fetch(`/api/pid/status/${taskId}`); - const data = await response.json(); - - // 프로그레스 바 업데이트 - progressBar.style.width = `${data.progress * 100}%`; - statusText.innerText = `분석 중... ${Math.round(data.progress * 100)}% (${data.message})`; - - if (data.status !== 'Completed' && data.status !== 'Failed') { - setTimeout(pollStatus, 1000); // 1초 간격 폴링 - } else { - statusText.innerText = data.status === 'Completed' ? '분석 완료!' : '분석 실패'; - } - } catch (e) { - statusText.innerText = '상태 조회 중 오류 발생'; - } - }; - - pollStatus(); -} -``` - -### 3.3 [Analysis] 흐름 방향 반영 경로 추적 (Python) -```python -import networkx as nx - -def get_propagation_path_with_flow(graph, start_node): - """ - 단순 descendants가 아닌, 엣지의 방향성(flow_direction)과 - 상태(valve_open)를 고려한 실제 영향 전파 경로 추출 - """ - # 1. 유효한 엣지만 필터링 (방향이 맞고 밸브가 열려있는 경로) - valid_edges = [ - (u, v, d) for u, v, d in graph.edges(data=True) - if d.get('flow_direction') == 'forward' and d.get('valve_status') == 'open' - ] - filtered_graph = nx.DiGraph() - filtered_graph.add_edges_from(valid_edges) - - # 2. 전파 단계별 노드 추출 (BFS) - propagation_levels = nx.single_source_shortest_path_length(filtered_graph, start_node) - - # { node_id: distance } 형태로 반환하여 프론트엔드에서 색상 구분 가능하게 함 - return propagation_levels - -# 예: P-101에서 시작되는 실제 유체 흐름 기반 영향도 분석 -impact_map = get_propagation_path_with_flow(topology_graph, "P-101") -``` - ---- - -## 🚀 4. Phase 4 완료 기준 (Definition of Done) - -- [ ] P&ID 도면(Canvas) 위에 **실시간 OPC UA 값**이 정확한 좌표에 표시되며, 뷰포트 최적화가 적용되었는가? -- [ ] **SignalR 또는 Delta Update**를 통해 네트워크 부하를 최소화하며 실시간 데이터를 수신하는가? -- [ ] 병렬 처리 중인 분석 작업의 **진행 상태(Progress Bar)**가 UI에 실시간으로 반영되는가? -- [ ] 특정 노드 클릭 시 **유체 흐름 방향이 반영된 영향도 분석** 결과가 단계별 색상으로 하이라이트 되는가? -- [ ] C# 서버와 Python 엔진 간 통신에 **타임아웃 및 예외 처리**가 적용되어 시스템 안정성이 확보되었는가? -- [ ] 전체 파이프라인(`추출 $\rightarrow$ 모델링 $\rightarrow$ 매핑 $\rightarrow$ 시각화`)이 통합되어 동작하는가? diff --git a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase5.md b/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase5.md deleted file mode 100644 index 4d8b073..0000000 --- a/.rooBackup/2026-05-02_pipeline_sync/Graph_Pipeline_Phase5.md +++ /dev/null @@ -1,138 +0,0 @@ -# 🔌 Graph Pipeline Phase 5: MCP 서버 통합 및 고성능 병렬 아키텍처 (MCP Integration & Parallel Processing) - -이 문서는 앞서 설계한 1~4단계의 Graph Pipeline을 현재 프로젝트의 **Unified MCP Server (`mcp-server/server.py`)**에 통합하는 방안을 다룹니다. 특히, 대용량 도면 처리 시 발생하는 지연과 버퍼 문제를 해결하기 위해 `PID_Parser_Plan_Revision.md`의 **분산 처리 기법**과 vLLM의 **Continuous Batching** 특성을 극대화한 병렬 아키텍처를 적용합니다. - ---- - -## 🏗️ 1. 통합 아키텍처 설계 - -### 1.1 고성능 병렬 데이터 흐름 (Parallel End-to-End Flow) -단일 순차 요청 방식에서 벗어나, **[전처리 $\rightarrow$ 병렬 분산 추출 $\rightarrow$ 통합 후처리]** 구조로 전환합니다. - -`Frontend (UI)` $\rightarrow$ `C# Server (API)` $\rightarrow$ `MCP Server (Orchestrator)` $\rightarrow$ `Parallel Worker Tools (vLLM Batching)` $\rightarrow$ `Result Aggregator` $\rightarrow$ `C# Server` - -1. **요청:** 사용자가 UI에서 도면 분석 시작 버튼 클릭. -2. **전처리 (Orchestrator):** MCP 서버가 DXF를 로드하여 기하학적 데이터를 추출하고, 분석 대상(Transmitter, Valve, Pump 등)별로 데이터를 분할합니다. -3. **병렬 호출 (Continuous Batching):** - * 분할된 데이터를 기반으로 여러 개의 MCP 툴(또는 동일 툴의 다중 요청)을 **동시에(Asynchronously)** 호출합니다. - * vLLM 서버는 이 다수의 요청을 **Continuous Batching**으로 묶어 처리함으로써, 개별 요청 시보다 전체 처리량(Throughput)을 획기적으로 높입니다. -4. **통합 및 저장 (Aggregator):** 각 분산 툴이 반환한 결과를 취합하여 최종 위상 그래프를 구축하고 DB에 저장합니다. - -### 1.2 MCP 서버 내 역할 분담 (분산 처리 모델) -`PID_Parser_Plan_Revision.md`를 반영하여, 기능을 세분화하고 병렬 실행 가능하게 설계합니다. - -| 구분 | MCP Tool / Module | 역할 | 병렬 처리 전략 | -|---|---|---|---| -| **Orchestrator** | `orchestrate_pid_pipeline` | 전체 공정 제어, 데이터 분할 및 결과 취합 | Asyncio 기반 비동기 제어 | -| **Worker 1** | `extract_transmitters` | FIT, FT, LT, PT, TE 추출 | vLLM Batching 요청 | -| **Worker 2** | `extract_valves` | FCV, LCV, TCV, PCV, XV 추출 | vLLM Batching 요청 | -| **Worker 3** | `extract_gauges` | PG, TG, LG 추출 | vLLM Batching 요청 | -| **Worker 4** | `extract_equipment` | Column, Tank, Filter, Drum, Heat Exchanger 등 추출 | vLLM Batching 요청 | -| **Worker 5** | `extract_pumps` | P-xxxx, VP-xxxx 추출 | vLLM Batching 요청 | -| **Analyzer** | `analyze_pid_impact` | 구축된 그래프 기반 영향도 분석 | Graph Algorithm (CPU) | - ---- - -## 💻 2. MCP 서버 통합 구현 가이드 - -### 2.1 비동기 병렬 처리 설계 (Asyncio + vLLM Batching) -`FastMCP` 환경에서 `asyncio.gather`를 사용하여 여러 추출 툴을 동시에 호출함으로써 vLLM의 Continuous Batching 효율을 극대화합니다. - -```python -# mcp-server/server.py 통합 설계 (개념 코드) -import asyncio -from typing import List - -async def run_parallel_extraction(geo_data): - """ - 분류별 추출 툴을 병렬로 호출하여 vLLM Batching 유도 - """ - # 각 분류별 프롬프트와 데이터 준비 - tasks = [ - extract_transmitters_async(geo_data), - extract_valves_async(geo_data), - extract_gauges_async(geo_data), - extract_equipment_async(geo_data), - extract_pumps_async(geo_data) - ] - - # 동시에 요청을 던져 vLLM이 내부적으로 Batch 처리하게 함 - results = await asyncio.gather(*tasks) - return results - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """ - 분산 처리 기법을 적용한 P&ID 그래프 생성 툴 - """ - # 1. 전처리 (Phase 1) - extractor = PidGeometricExtractor(filepath) - geo_data = extractor.extract_all() - - # 2. 병렬 분산 추출 (vLLM Batching 활용) - # 각 Worker 툴들이 LLM에 요청을 보낼 때 vLLM이 이를 묶어서 처리함 - extracted_parts = await run_parallel_extraction(geo_data) - - # 3. 결과 통합 및 위상 모델링 (Phase 2) - all_tags = flatten_results(extracted_parts) - builder = PidTopologyBuilder(geo_data, all_tags) - builder.build_graph() - - # 4. 저장 - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - nx.write_graphml(builder.G, f"storage/{graph_id}") - - return json.dumps({"success": True, "graph_id": graph_id, "nodes": builder.G.number_of_nodes()}) -``` - -### 2.2 C# 서버와의 인터페이스 (`McpClient` 활용) -C# 서버는 `src/Infrastructure/Mcp/McpClient.cs`를 통해 위 툴들을 호출합니다. - -### 2.2 C# 서버와의 인터페이스 (`McpClient` 활용) -C# 서버는 `src/Infrastructure/Mcp/McpClient.cs`를 통해 위 툴들을 호출합니다. - -```csharp -// src/Core/Application/Services/PidGraphService.cs (신규 서비스) -public async Task GetImpactAnalysisAsync(string graphId, string nodeId) -{ - var request = new McpToolRequest { - ToolName = "analyze_pid_impact", - Arguments = new { graph_id = graphId, start_node_id = nodeId } - }; - - var jsonResponse = await _mcpClient.CallToolAsync(request); - return JsonSerializer.Deserialize(jsonResponse); -} -``` - ---- - -## 🛠️ 3. 프로그램 구성 및 배포 전략 - -### 3.1 디렉토리 구조 확장 -```text -mcp-server/ -├── server.py # MCP 메인 서버 (툴 정의) -├── pipeline/ # Graph Pipeline 핵심 로직 (Phase 1~4) -│ ├── __init__.py -│ ├── extractor.py # Phase 1: Geometric Extraction -│ ├── topology.py # Phase 2: Topology Modeling -│ ├── mapper.py # Phase 3: Intelligent Mapping -│ └── analyzer.py # Phase 4: Impact Analysis -└── storage/ # 생성된 그래프 파일 (.graphml) 저장소 -``` - -### 3.2 실행 프로세스 -1. **MCP 서버 기동:** `python mcp-server/server.py --http` (포트 5001) -2. **C# 서버 기동:** `dotnet run` (포트 5000) -3. **통신:** C# 서버 $\xrightarrow{HTTP/JSON}$ MCP 서버 $\xrightarrow{Python\ Libs}$ 결과 반환. - ---- - -## 🚀 4. 최종 완료 기준 (Definition of Done) - -- [ ] `mcp-server/server.py`에 `build_pid_graph`, `analyze_pid_impact` 등 핵심 툴이 정의되었는가? -- [ ] Phase 1~4의 Python 로직이 `mcp-server/pipeline/` 모듈로 구조화되어 통합되었는가? -- [ ] C# `McpClient`를 통해 MCP 서버의 그래프 분석 툴을 호출하고 결과를 수신할 수 있는가? -- [ ] 도면 업로드 $\rightarrow$ 그래프 생성 $\rightarrow$ 태그 매핑 $\rightarrow$ 영향도 분석으로 이어지는 **End-to-End 파이프라인**이 완성되었는가? -- [ ] 모든 과정이 `json_response=True` 및 `stateless_http=True` 설정 하에 안정적으로 동작하는가? diff --git a/.rooBackup/2026-05-03-030707/mcp-server/worker/rag_worker.py b/.rooBackup/2026-05-03-030707/mcp-server/worker/rag_worker.py deleted file mode 100644 index 506c9b3..0000000 --- a/.rooBackup/2026-05-03-030707/mcp-server/worker/rag_worker.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 -"""RAG 전용 워커 프로세스 - -Usage: python rag_worker.py - -담당 도구: - search_codebase, search_r530_docs, ask_iiot_llm, rag_query - -특징: - - Ollama Embedding + Qdrant 검색 + vLLM LLM 조합 - - 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시) - - 생명주기: 메인 서버 종료 시까지 유지 -""" - -from __future__ import annotations -import sys -import os - -# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근) -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import logging -import asyncio - -from fastapi import FastAPI, Request -import uvicorn -import httpx - -# ── 설정 ───────────────────────────────────────────────────────────────────── - -OLLAMA_URL = "http://localhost:11434" -QDRANT_URL = "http://localhost:6333" -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" -EMBED_MODEL = "nomic-embed-text" - -COL_CODEBASE = "ws-65f457145aee80b2" -COL_OPC_DOCS = "experion-opc-docs" - -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [rag_worker] %(levelname)s %(message)s", -) - -app = FastAPI() - -# ── HTTP 클라이언트 싱글톤 ──────────────────────────────────────────────────── - -@asyncio.cache -def _get_http_client(): - return httpx.AsyncClient(timeout=30) - -# ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── - -async def _embed(text: str) -> list[float]: - """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - async with _get_http_client() as client: - resp = await client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] - -# ── Qdrant 검색 ────────────────────────────────────────────────────────────── - -async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]: - """Qdrant에서 벡터 유사도 검색.""" - async with _get_http_client() as client: - resp = await client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": query_vector, - "limit": top_k, - "with_payload": True, - }, - ) - resp.raise_for_status() - return resp.json().get("result", []) - -# ── LLM (vLLM) ─────────────────────────────────────────────────────────────── - -@asyncio.cache -def _llm_client(): - from openai import AsyncOpenAI - return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - -async def _ask_llm(question: str, context: str = "") -> str: - """vLLM LLM으로 질문 응답.""" - client = _llm_client() - - if context: - prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요. - -컨텍스트: -{context} - -질문: -{question} - -답변:""" - else: - prompt = question - - response = await client.chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": prompt}, - ], - max_tokens=4096, - temperature=0.1, - ) - return response.choices[0].message.content - -# ── RAG 도구 구현 ───────────────────────────────────────────────────────────── - -@app.get("/health") -async def health(): - """워커 헬스체크.""" - return {"status": "ok"} - -@app.post("/execute") -async def execute(request: Request): - """HTTP 요청을 MCP 도구 호출로 변환.""" - body = await request.json() - tool = body["tool"] - params = body["params"] - - try: - if tool == "search_codebase": - result = await _search_codebase(**params) - elif tool == "search_r530_docs": - result = await _search_r530_docs(**params) - elif tool == "ask_iiot_llm": - result = await _ask_iiot_llm(**params) - elif tool == "rag_query": - result = await _rag_query(**params) - else: - return {"success": False, "error": f"Unknown tool: {tool}"} - - return result - except Exception as e: - logging.error(f"Error executing {tool}: {e}") - return {"success": False, "error": str(e)} - -async def _search_codebase(query: str, top_k: int = 6) -> str: - """소스코드 검색.""" - query_vector = await _embed(query) - results = await _qdrant_search(COL_CODEBASE, query_vector, top_k) - - items = [] - for hit in results: - payload = hit.get("payload", {}) - items.append({ - "score": hit.get("score", 0), - "file": payload.get("file", "unknown"), - "content": payload.get("content", "")[:500], - }) - - return { - "success": True, - "count": len(items), - "items": items, - } - -async def _search_r530_docs(query: str, top_k: int = 5) -> str: - """Experion HS R530 공식 문서 검색.""" - query_vector = await _embed(query) - results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k) - - items = [] - for hit in results: - payload = hit.get("payload", {}) - items.append({ - "score": hit.get("score", 0), - "title": payload.get("title", "unknown"), - "content": payload.get("content", "")[:500], - }) - - return { - "success": True, - "count": len(items), - "items": items, - } - -async def _ask_iiot_llm(question: str, context: str = "") -> str: - """IIoT/OPC UA 질문 응답.""" - answer = await _ask_llm(question, context) - return { - "success": True, - "question": question, - "answer": answer, - } - -async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """통합 RAG 검색.""" - contexts = [] - - if search_code: - query_vector = await _embed(question) - code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3) - for hit in code_results: - contexts.append(hit.get("payload", {}).get("content", "")) - - if search_docs: - query_vector = await _embed(question) - doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3) - for hit in doc_results: - contexts.append(hit.get("payload", {}).get("content", "")) - - context = "\n\n".join(contexts[:5]) - answer = await _ask_llm(question, context) - - return { - "success": True, - "question": question, - "context_count": len(contexts), - "answer": answer, - } - -# ── 메인 ───────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002 - logging.info(f"Starting RAG worker on port {port}") - uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/.rooBackup/2026-05-03-030821/mcp-server/worker/nl2sql_worker.py b/.rooBackup/2026-05-03-030821/mcp-server/worker/nl2sql_worker.py deleted file mode 100644 index e1146ca..0000000 --- a/.rooBackup/2026-05-03-030821/mcp-server/worker/nl2sql_worker.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -"""NL2SQL 전용 워커 프로세스 - -Usage: python nl2sql_worker.py - -담당 도구: - run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl - -특징: - - PostgreSQL 직접 연결 - - LLM SQL 생성 + DB 실행 분리 - - 메모리: ~1GB (SQL 생성용 LLM) - - 생명주기: 메인 서버 종료 시까지 유지 -""" - -from __future__ import annotations -import sys -import os - -# mcp-server 디렉토리를 Python 경로에 추가 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import logging -import asyncio - -from fastapi import FastAPI, Request -import uvicorn -import httpx - -# ── 설정 ───────────────────────────────────────────────────────────────────── - -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 - -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" - -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s", -) - -app = FastAPI() - -# ── DB 연결 풀 ─────────────────────────────────────────────────────────────── - -def _get_db_connection(): - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - -# ── LLM 클라이언트 ─────────────────────────────────────────────────────────── - -@asyncio.cache -def _llm_client(): - from openai import AsyncOpenAI - return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - -async def _generate_sql(natural_language: str) -> str: - """자연어를 SQL로 변환.""" - client = _llm_client() - - prompt = f"""다음 자연어 질문을 PostgreSQL SQL 쿼리로 변환하세요. -데이터베이스는 TimescaleDB 기반의 IIoT 플랫폼입니다. - -질문: -{natural_language} - -SQL 쿼리 (SELECT 문만, 설명 없이):""" - - response = await client.chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": "You are a PostgreSQL SQL expert. Generate only valid SQL queries."}, - {"role": "user", "content": prompt}, - ], - max_tokens=1024, - temperature=0.1, - ) - return response.choices[0].message.content.strip() - -# ── NL2SQL 도구 구현 ───────────────────────────────────────────────────────── - -@app.get("/health") -async def health(): - """워커 헬스체크.""" - return {"status": "ok"} - -@app.post("/execute") -async def execute(request: Request): - """HTTP 요청을 MCP 도구 호출로 변환.""" - body = await request.json() - tool = body["tool"] - params = body["params"] - - try: - if tool == "run_sql": - result = await _run_sql(**params) - elif tool == "query_pv_history": - result = await _query_pv_history(**params) - elif tool == "get_tag_metadata": - result = await _get_tag_metadata(**params) - elif tool == "list_drawings": - result = await _list_drawings(**params) - elif tool == "query_with_nl": - result = await _query_with_nl(**params) - else: - return {"success": False, "error": f"Unknown tool: {tool}"} - - return result - except Exception as e: - logging.error(f"Error executing {tool}: {e}") - return {"success": False, "error": str(e)} - -async def _run_sql(sql: str) -> str: - """SQL 실행.""" - conn = _get_db_connection() - try: - with conn.cursor() as cur: - cur.execute(sql) - if cur.description: - columns = [desc[0] for desc in cur.description] - rows = cur.fetchall() - data = [dict(zip(columns, row)) for row in rows] - return { - "success": True, - "columns": columns, - "count": len(data), - "data": data, - } - else: - conn.commit() - return { - "success": True, - "message": f"Query executed successfully. {cur.rowcount} rows affected.", - } - finally: - conn.close() - -async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """과거 값(PV) 히스토리 조회.""" - if not tag_names: - return {"success": False, "error": "tag_names is required"} - - conn = _get_db_connection() - try: - with conn.cursor() as cur: - # TimescaleDB의 time_bucket 함수 사용 - cur.execute( - """ - SELECT time_bucket('1 min', ts) AS time, tag_name, value - FROM realtime_table - WHERE tag_name = ANY(%s) - AND ts >= %s - AND ts <= %s - ORDER BY time DESC - LIMIT %s - """, - (tag_names, time_from, time_to, limit), - ) - columns = ["time", "tag_name", "value"] - rows = cur.fetchall() - data = [dict(zip(columns, row)) for row in rows] - return { - "success": True, - "tag_names": tag_names, - "time_range": {"from": time_from, "to": time_to}, - "limit": limit, - "count": len(data), - "data": data, - } - finally: - conn.close() - -async def _get_tag_metadata(query: str, limit: int = 10) -> str: - """태그 메타데이터 검색.""" - conn = _get_db_connection() - try: - with conn.cursor() as cur: - cur.execute( - """ - SELECT DISTINCT tag_name, unit, description - FROM realtime_table - WHERE tag_name ILIKE %s - ORDER BY tag_name - LIMIT %s - """, - (f"%{query}%", limit), - ) - columns = ["tag_name", "unit", "description"] - rows = cur.fetchall() - data = [dict(zip(columns, row)) for row in rows] - return { - "success": True, - "query": query, - "count": len(data), - "tags": data, - } - finally: - conn.close() - -async def _list_drawings(unit_no: str = None) -> str: - """단위별 도면 목록 조회.""" - conn = _get_db_connection() - try: - with conn.cursor() as cur: - if unit_no: - cur.execute( - """ - SELECT DISTINCT name - FROM node_map_master - WHERE name LIKE %s - ORDER BY name - """, - (f"{unit_no}%",), - ) - else: - cur.execute( - """ - SELECT DISTINCT name - FROM node_map_master - ORDER BY name - """ - ) - columns = ["name"] - rows = cur.fetchall() - data = [dict(zip(columns, row[0])) for row in rows] - return { - "success": True, - "unit_no": unit_no, - "count": len(data), - "names": [d["name"] for d in data], - } - finally: - conn.close() - -async def _query_with_nl(question: str) -> str: - """자연어로 SQL 쿼리 실행.""" - sql = await _generate_sql(question) - - conn = _get_db_connection() - try: - with conn.cursor() as cur: - cur.execute(sql) - if cur.description: - columns = [desc[0] for desc in cur.description] - rows = cur.fetchall() - data = [dict(zip(columns, row)) for row in rows] - return { - "success": True, - "sql": sql, - "columns": columns, - "count": len(data), - "data": data, - } - else: - conn.commit() - return { - "success": True, - "sql": sql, - "message": f"Query executed successfully. {cur.rowcount} rows affected.", - } - except Exception as db_error: - return { - "success": False, - "sql": sql, - "error": str(db_error), - } - finally: - conn.close() - -# ── 메인 ───────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003 - logging.info(f"Starting NL2SQL worker on port {port}") - uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/.rooBackup/2026-05-03-030910/mcp-server/server.py b/.rooBackup/2026-05-03-030910/mcp-server/server.py deleted file mode 100644 index b122dd0..0000000 --- a/.rooBackup/2026-05-03-030910/mcp-server/server.py +++ /dev/null @@ -1,1442 +0,0 @@ -#!/usr/bin/env python3 -""" -ExperionCrawler Unified MCP Server -- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen/Qwen3-Coder-Next-FP8 -- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행 -- 사용처: - stdio 모드 (기본): Claude Code MCP / Roo Code MCP - HTTP 모드 (--http): C# McpClient (localhost:5001) -""" - -from __future__ import annotations -import sys -import json -import logging -import httpx -from functools import lru_cache -from mcp.server.fastmcp import FastMCP - -logging.basicConfig(level=logging.WARNING, stream=sys.stderr) - -# ── 설정 ────────────────────────────────────────────────────────────────────── -QDRANT_URL = "http://localhost:6333" -OLLAMA_URL = "http://localhost:11434" -EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" - -# Qdrant 컬렉션 -COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 -COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks) - -# PostgreSQL 연결 -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 # 초 - -# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식 -mcp = FastMCP( - "iiot-rag", - port=5001, - json_response=True, - stateless_http=True, -) - -# Pipeline Imports -from pipeline.extractor import PidGeometricExtractor -from pipeline.topology import PidTopologyBuilder -from pipeline.mapper import IntelligentMapper -from pipeline.analyzer import PidAnalysisEngine -import networkx as nx -import os -import asyncio -import subprocess -import atexit -import signal -from dataclasses import dataclass -from typing import Dict, Optional -from functools import cache - -# ── ProcessManager ───────────────────────────────────────────────────────────── - -@dataclass -class WorkerProcess: - process: subprocess.Popen - port: int - status: str # "running", "stopped", "error" - one_shot: bool = False # 요청 후 프로세스 종료 여부 (P&ID 워커용) - - -class ProcessManager: - """워커 프로세스 관리자.""" - - def __init__(self): - self.workers: Dict[str, WorkerProcess] = {} - self._locks: Dict[str, asyncio.Lock] = {} - self._pid_sem = asyncio.Semaphore(1) # P&ID는 1개 동시 실행만 허용 - self._worker_ports = {"rag": 5002, "nl2sql": 5003, "pid": 5004} - - # 정리 훅 등록 - atexit.register(self._cleanup) - signal.signal(signal.SIGTERM, lambda *_: self._cleanup()) - signal.signal(signal.SIGINT, lambda *_: self._cleanup()) - - def _get_available_port(self, worker_type: str) -> int: - """워커 타입에 대한 포트 반환.""" - return self._worker_ports.get(worker_type, 5002) - - def _classify_tool(self, tool_name: str) -> str: - """도구 이름을 워커 타입으로 분류.""" - rag_tools = {"search_codebase", "search_r530_docs", "ask_iiot_llm", "rag_query"} - nl2sql_tools = {"run_sql", "query_pv_history", "get_tag_metadata", "list_drawings", "query_with_nl"} - pid_tools = { - "extract_pid_tags", "match_pid_tags", "parse_pid_dxf", "parse_pid_pdf", - "parse_pid_drawing", "build_pid_graph_parallel", "analyze_pid_impact" - } - - if tool_name in rag_tools: - return "rag" - elif tool_name in nl2sql_tools: - return "nl2sql" - elif tool_name in pid_tools: - return "pid" - else: - return "default" - - async def start_worker(self, worker_type: str, one_shot: bool = False) -> WorkerProcess: - """서브 프로세스 시작. - - Args: - worker_type: 워커 타입 (rag, nl2sql, pid) - one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용) - """ - port = self._get_available_port(worker_type) - cmd = [ - sys.executable, - f"mcp-server/worker/{worker_type}_worker.py", - str(port) - ] - - # 로그 파일 열기 - log_dir = os.path.join(os.path.dirname(__file__), "logs") - os.makedirs(log_dir, exist_ok=True) - log_file = open(os.path.join(log_dir, f"{worker_type}_worker.log"), "a") - - proc = subprocess.Popen( - cmd, - stdout=log_file, - stderr=log_file, - ) - - # 헬스체크 루프 (최대 15초 대기) - for _ in range(30): # 0.5초 * 30 = 15초 - await asyncio.sleep(0.5) - if proc.poll() is not None: - log_file.close() - raise RuntimeError(f"{worker_type} 워커가 시작 직후 종료됨") - try: - async with httpx.AsyncClient(timeout=1) as client: - await client.get(f"http://localhost:{port}/health") - break # 헬스체크 성공 - except Exception: - continue - else: - proc.kill() - log_file.close() - raise RuntimeError(f"{worker_type} 워커 시작 타임아웃") - - worker = WorkerProcess( - process=proc, - port=port, - status="running", - one_shot=one_shot - ) - self.workers[worker_type] = worker - log_file.close() - return worker - - async def stop_worker(self, worker_type: str): - """서브 프로세스 종료.""" - if worker_type in self.workers: - proc = self.workers[worker_type].process - proc.terminate() - await asyncio.sleep(0.5) - if proc.poll() is None: - proc.kill() - del self.workers[worker_type] - - async def get_worker(self, tool_name: str, one_shot: bool = False) -> WorkerProcess: - """도구 이름에 해당하는 워커 프로세스 반환 (자동 시작). - - Args: - tool_name: 도구 이름 - one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용) - """ - worker_type = self._classify_tool(tool_name) - - if worker_type not in self._locks: - self._locks[worker_type] = asyncio.Lock() - - async with self._locks[worker_type]: - if worker_type not in self.workers: - return await self.start_worker(worker_type, one_shot) - - proc = self.workers[worker_type].process - if proc.poll() is not None: - del self.workers[worker_type] - return await self.start_worker(worker_type, one_shot) - - return self.workers[worker_type] - - def _cleanup(self): - """모든 워커 프로세스 정리.""" - for wtype, worker in list(self.workers.items()): - try: - worker.process.terminate() - except Exception: - pass - self.workers.clear() - - -# 전역 ProcessManager 인스턴스 -process_manager = ProcessManager() - -# ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── - -def _embed(text: str) -> list[float]: - """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - with httpx.Client(timeout=30) as client: - resp = client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] - -# ── LLM (vLLM / Qwen/Qwen3-Coder-Next-FP8) ───────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -# ── PaddleOCR 싱글톤 (PDF fallback용) ────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _ocr(): - """PaddleOCR 인스턴스 (한/영, GPU). 첫 호출 시 ~50MB 모델 다운로드.""" - from paddleocr import PaddleOCR - import os - - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - ocr = PaddleOCR( - use_angle_cls=True, - lang="korean", - use_gpu=use_gpu, - show_log=False, - ) - return ocr - except Exception as e: - # GPU 실패 시 CPU 폴백 - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise e - - -# ── DXF/PDF 텍스트 추출 헬퍼 ─────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - """ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거).""" - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - """PyMuPDF로 PDF 파일에서 텍스트 추출.""" - import fitz # pymupdf - doc = fitz.open(filepath) - texts = [] - for page in doc: - texts.append(page.get_text()) - return "\n".join(texts) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - """PaddleOCR로 PDF에서 이미지 추출 후 OCR (고정밀도).""" - import fitz # pymupdf - from PIL import Image - import numpy as np - - doc = fitz.open(filepath) - all_texts = [] - - for page_idx, page in enumerate(doc): - # 페이지를 이미지로 변환 - mat = fitz.Matrix(300 / 72) # 300 DPI - pix = page.get_pixmap(matrix=mat) - img_data = pix.tobytes("png") - img = Image.open(__import__("io").BytesIO(img_data)) - - # OCR 실행 - result = _ocr().ocr(np.array(img), cls=True) - if result[0]: - for line in result[0]: - all_texts.append(line[1][0]) - - return "\n".join(all_texts) - - -def _convert_dwg_to_dxf_dxflib(filepath: str) -> str: - """libreoffice로 DWG를 DXF로 변환.""" - import subprocess - import os - - dxf_path = filepath.replace(".dwg", ".dxf") - - try: - # LibreOffice로 변환 - result = subprocess.run( - [ - "libreoffice", - "--headless", - "--convert-to", "dxf:AutoCAD DXF", - "--outdir", os.path.dirname(filepath) or ".", - filepath - ], - check=True, - timeout=120, - capture_output=True, - text=True - ) - - if os.path.exists(dxf_path): - return dxf_path - else: - raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.") - - except subprocess.CalledProcessError as e: - raise Exception(f"LibreOffice 변환 실패: {e.stderr}") - - -# ── Qdrant 검색 헬퍼 ────────────────────────────────────────────────────────── - -def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str: - vec = _embed(query) - with httpx.Client(timeout=20) as client: - resp = client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": vec, - "limit": top_k, - "with_payload": True, - "score_threshold": threshold, - }, - ) - resp.raise_for_status() - hits = resp.json().get("result", []) - - if not hits: - return "관련 결과 없음." - - parts = [] - for h in hits: - p = h.get("payload", {}) - file_path = p.get("filePath", p.get("path", "unknown")) - chunk = p.get("codeChunk", p.get("content", p.get("text", ""))) - start_line = p.get("startLine", "") - loc = f"{file_path}:{start_line}" if start_line else file_path - parts.append(f"[score={h['score']:.3f}] {loc}\n```\n{chunk[:700]}\n```") - - return "\n\n---\n\n".join(parts) - -# ── DB 헬퍼 ────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - """PostgreSQL DB 연결 획득.""" - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - - -def _validate_sql(sql: str) -> tuple[bool, str]: - """SQL 안전 검증 — SELECT만 허용, 위험 키워드 차단.""" - if len(sql) > 2000: - return False, "쿼리 길이 2000자를 초과했습니다." - dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'] - sql_upper = sql.upper() - for kw in dangerous: - if kw in sql_upper: - return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다." - if not sql_upper.strip().startswith('SELECT'): - return False, "단순 SELECT 쿼리만 허용됩니다." - if '..' in sql or '~' in sql: - return False, "파일 경로 표현은 허용되지 않습니다." - return True, "" - - -# DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용 -_DB_SCHEMA = """ -PostgreSQL 시계열 데이터베이스 스키마 - -테이블: history_table (시계열 이력) - tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분 - node_id TEXT - OPC UA 노드 ID - value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요 - recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초 - -테이블: realtime_table (실시간 최신값) - tagname TEXT - 태그명 (모두 소문자) - node_id TEXT - OPC UA 노드 ID - livevalue TEXT - 현재값 - timestamp TIMESTAMPTZ - 최종 갱신 시각 - -N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): - 1분 버킷: date_trunc('minute', recorded_at) AS bucket - 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket - 5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket - 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket - N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket - -예시 (2분 간격, 여러 태그): - SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, - tagname, AVG(value::double precision) AS avg_val - FROM history_table - WHERE tagname IN ('tag1', 'tag2') - AND recorded_at >= NOW() - INTERVAL '3 hours' - GROUP BY bucket, tagname ORDER BY bucket, tagname - -규칙: - - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) - - tagname은 모두 소문자로 정확히 입력 - - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수 - - time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용 -""" - -# ── RAG 도구 ───────────────────────────────────────────────────────────────── - -@mcp.tool() -def search_codebase(query: str, top_k: int = 6) -> str: - """ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드). - Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함. - - 사용 시점: ExperionCrawler 코드의 구현 방법, 버그, 구조를 알고 싶을 때. - ⚠️ Experion HS R530 제품 동작/설정/스펙을 알고 싶으면 search_r530_docs 사용. - - Args: - query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스") - top_k: 반환 결과 수 (기본 6) - """ - return _search(COL_CODEBASE, query, top_k) - - -@mcp.tool() -def search_r530_docs(query: str, top_k: int = 5) -> str: - """Honeywell Experion HS R530 공식 제품 문서 검색. - ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함. - - 사용 시점: Experion HS R530의 OPC UA 설정, 인증서, 보안 정책, 포인트 주소 형식, - 채널/컨트롤러 속성, 문제해결 등 제품 스펙과 동작을 알고 싶을 때. - - Args: - query: 검색어 (예: "certificate configuration", "endpoint security policy") - top_k: 반환 결과 수 (기본 5) - """ - return _search(COL_OPC_DOCS, query, top_k) - - -@mcp.tool() -def ask_iiot_llm(question: str, context: str = "") -> str: - """Qwen/Qwen3-Coder-Next-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). - - 사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨 - 종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문. - - Args: - question: 질문 내용 - context: (선택) search_codebase 또는 search_r530_docs 검색 결과 - """ - system = ( - "당신은 IIoT(산업용 IoT), OPC UA, Honeywell Experion PKS/HS R530 전문가입니다.\n" - "컨텍스트가 제공된 경우 컨텍스트를 우선 근거로 삼아 한국어로 답변합니다.\n" - "컨텍스트 출처가 'Experion HS R530 공식 문서'인지 'ExperionCrawler 코드'인지 명확히 구분하여 설명합니다." - ) - user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user_msg}, - ], - max_tokens=2048, - temperature=0.1, - ) - return resp.choices[0].message.content or "(응답 없음)" - - -@mcp.tool() -def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """검색 → Qwen/Qwen3-Coder-Next-FP8 답변 생성 (통합 RAG). - - 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). - ExperionCrawler 코드도 함께 보려면 search_code=True 추가. - - Args: - question: 질문 - search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True) - search_code: ExperionCrawler 소스코드 검색 여부 (기본 False) - """ - context_parts: list[str] = [] - if search_docs: - context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}") - if search_code: - context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}") - return ask_iiot_llm(question, "\n\n".join(context_parts)) - - -# ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── - -@mcp.tool() -def run_sql(sql: str) -> str: - """SQL 쿼리 실행 (SELECT만 허용). - - Args: - sql: 실행할 SELECT SQL 문자열 - - Returns: - JSON: { success, columns, count, data } 또는 { success, error } - """ - valid, err = _validate_sql(sql) - if not valid: - return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) - - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute(sql) - rows = cur.fetchall() - columns = [desc[0] for desc in cur.description] - result_data = [dict(zip(columns, row)) for row in rows] - return json.dumps({ - "success": True, - "columns": columns, - "count": len(result_data), - "data": result_data - }, ensure_ascii=False, default=str) - except Exception as e: - return json.dumps({"success": False, "error": f"SQL 실행 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """과거 값(PV) 히스토리 조회. - - Args: - tag_names: 태그 이름 목록 (예: ["ficq-6113.pv", "ti-6101.pv"]) - time_from: 시작 시간 (ISO 8601, 예: "2026-04-01T00:00:00") - time_to: 종료 시간 (ISO 8601, 예: "2026-04-02T00:00:00") - limit: 반환 행 수 제한 (기본 100, 최대 5000) - - Returns: - JSON: { success, tag_names, time_range, limit, data } - """ - try: - limit = min(limit, 5000) - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, recorded_at, value - FROM history_table - WHERE tagname = ANY(%s) - AND recorded_at >= %s AND recorded_at <= %s - ORDER BY recorded_at, tagname - LIMIT %s""", - (tag_names, time_from, time_to, limit) - ) - rows = cur.fetchall() - data = [{"tag_name": r[0], "timestamp": r[1].isoformat(), "value": r[2]} for r in rows] - return json.dumps({ - "success": True, - "tag_names": tag_names, - "time_range": f"{time_from} ~ {time_to}", - "count": len(data), - "data": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"히스토리 쿼리 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def get_tag_metadata(query: str, limit: int = 10) -> str: - """태그 메타데이터 검색 (realtime_table 기반). - - Args: - query: 태그명 검색어 (패턴 매칭) - limit: 반환 태그 수 제한 (기본 10) - - Returns: - JSON: { success, query, count, tags } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, livevalue, timestamp, node_id - FROM realtime_table - WHERE tagname ILIKE %s - ORDER BY tagname LIMIT %s""", - (f"%{query}%", limit) - ) - rows = cur.fetchall() - tags = [{"tag_name": r[0], "current_value": r[1], - "last_updated": r[2].isoformat() if r[2] else None, - "node_id": r[3]} for r in rows] - return json.dumps({"success": True, "query": query, "count": len(tags), "tags": tags}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"태그 메타데이터 검색 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def list_drawings(unit_no: str | None = None) -> str: - """단위별 도면 목록 조회 (node_map_master.name 기반). - - Args: - unit_no: 단위 번호 접두사 (예: "A", "B"). None이면 전체 목록 - - Returns: - JSON: { success, unit_no, count, names } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - if unit_no: - cur.execute( - "SELECT DISTINCT name FROM node_map_master WHERE name ILIKE %s ORDER BY name LIMIT 100", - (f"{unit_no}%",) - ) - else: - cur.execute("SELECT DISTINCT name FROM node_map_master ORDER BY name LIMIT 100") - rows = cur.fetchall() - return json.dumps({"success": True, "unit_no": unit_no, - "count": len(rows), "names": [r[0] for r in rows]}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"도면 목록 조회 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_with_nl(question: str) -> str: - """자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다. - - Args: - question: 자연어 질문 (예: "FICQ-6113.PV 최근 1시간 값을 1분 단위로 표시") - - Returns: - JSON: { sql, success, columns, count, data } 또는 { sql, success, error } - """ - system = ( - "You are a PostgreSQL SQL expert.\n" - "Convert the user's question into a SELECT SQL using the schema below.\n" - "IMPORTANT rules:\n" - "- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n" - "- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n" - "- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n" - "- INTERVAL rule:\n" - " * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n" - " use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n" - " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" - " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" - "- Current year is 2026. '4월 27일' means 2026-04-27.\n" - "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" - "- value column is TEXT; cast with ::double precision only when aggregating.\n" - "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" - "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" - "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" - f"{_DB_SCHEMA}" - ) - try: - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": question}, - ], - max_tokens=8192, - temperature=0.1, - ) - sql = (resp.choices[0].message.content or "").strip() - # 마크다운 코드 블록 제거 - if sql.startswith("```"): - lines = sql.splitlines() - sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() - if not sql: - return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) - except Exception as e: - return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False) - - # SQL 실행 - raw = run_sql(sql) - result = json.loads(raw) - result["sql"] = sql - - # long format → pivot 변환 (tagname 컬럼이 있으면 자동 PIVOT) - if result.get("success") and "data" in result: - cols = result.get("columns", []) - data = result["data"] - if "tagname" in cols and data: - time_col = next((c for c in cols if c not in ("tagname", "value", "livevalue", "avg_val")), None) - val_col = next((c for c in ("avg_val", "value") if c in cols), cols[-1]) - if time_col: - tag_names_list = sorted(dict.fromkeys(row["tagname"] for row in data)) - pivoted: dict = {} - for row in data: - key = str(row[time_col]) - if key not in pivoted: - pivoted[key] = {time_col: row[time_col]} - pivoted[key][row["tagname"]] = row.get(val_col) - result["data"] = list(pivoted.values()) - result["columns"] = [time_col] + tag_names_list - result["count"] = len(result["data"]) - - return json.dumps(result, ensure_ascii=False, default=str) - - -# ── P&ID 추출 도구 ────────────────────────────────────────────────────────────── - -@mcp.tool() -def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 도면(DXF/PDF)에서 태그 정보를 추출합니다. - - Args: - text: DXF/PDF에서 추출한 텍스트 - source_type: 'dxf' 또는 'pdf' - - Returns: - JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] } - """ - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV","lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo (e.g. FCV, P, T, VG, BT, DP, PSV)\n" - "- equipmentName: descriptive name if present in text near the tag, else null\n" - "- lineNumber: null unless a line number is explicitly associated\n" - "- pidDrawingNo: null unless a drawing number is explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous ones\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - - import logging - import re - import json as json_module - - try: - truncated_text = text[:100000] if len(text) > 100000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # finish_reason=length 로 잘린 경우: 마지막 완전한 객체까지 살린 뒤 배열 닫기 - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 유효한 JSON 배열 추출 (가장 긴 균형 잡힌 [...] 선택) - def _extract_array(s: str) -> str: - depth = 0; start = -1; best = "" - for i, c in enumerate(s): - if c == '[': - if depth == 0: start = i - depth += 1 - elif c == ']': - depth -= 1 - if depth == 0 and start >= 0: - cand = s[start:i+1] - if len(cand) > len(best): best = cand - return best if best else "[]" - - raw = _extract_array(raw) - - # JSON 파싱 — 실패 시 개별 객체 추출로 폴백 - try: - data = json_module.loads(raw) - except json_module.JSONDecodeError: - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json_module.loads(obj)) - except json_module.JSONDecodeError: - pass - if not data: - return json_module.dumps({"success": False, "count": 0, "tags": []}, ensure_ascii=False) - - logging.info(f"[extract_pid_tags] source={source_type} count={len(data) if isinstance(data, list) else 0}") - - return json_module.dumps({ - "success": True, - "count": len(data), - "tags": data - }, ensure_ascii=False, indent=2) - - except Exception as e: - logging.error(f"P&ID 태그 추출 실패: {e}") - logging.error(f"Raw response: {raw[:1000]}") - return json.dumps({"success": False, "error": f"P&ID 태그 추출 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 태그를 Experion 태그에 매핑합니다. - - Args: - pid_tags: P&ID에서 추출한 태그 목록 (예: ["FT-101", "PT-201"]) - experion_tags: Experion 시스템 태그 목록 (예: ["ficq-6113.pv", "pt-201.pv"]) - - Returns: - JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] } - """ - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "IMPORTANT rules:\n" - "- pidTag: The original P&ID tag from input\n" - "- experionTag: The matched Experion tag (lowercase, with .pv/.sp/.mv suffix)\n" - "- confidence: 0.0 to 1.0 based on match quality\n" - "- If no good match found, set confidence < 0.5 and leave experionTag null\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no matches found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - import re - import json as json_module - - try: - pid_str = "\n".join(pid_tags) - experion_str = "\n".join(experion_tags) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - match = re.search(r'\[.*\]', raw, re.DOTALL) - raw = match.group(0) if match else "[]" - - data = json_module.loads(raw) - return json_module.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - - except Exception as e: - return json.dumps({"success": False, "error": f"P&ID 태그 매핑 실패: {e}"}, ensure_ascii=False) - - -# ── P&ID 파싱 도구 (DXF/PDF/DWG) ─────────────────────────────────────────────── - - -@mcp.tool() -def parse_pid_dxf(filepath: str) -> str: - """ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: DXF 파일 경로 - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], # 제한 - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"DXF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """PyMuPDF 기반 PDF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: PDF 파일 경로 - use_ocr: OCR 사용 여부 (기본 True, 고정밀도) - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - if use_ocr: - text = _extract_text_from_pdf_ocr(filepath) - else: - text = _extract_text_from_pdf(filepath) - - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"PDF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """ - 분산 처리 기법을 적용한 P&ID 그래프 생성 툴. - 전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다. - """ - try: - # 1. 전처리 (Phase 1: Geometric Extraction) - extractor = PidGeometricExtractor(filepath) - geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json" - geo_data_list = extractor.extract_and_save(geo_data_path) - - # geo_data_list는 경로를 반환하므로 다시 로드 - with open(geo_data_path, 'r', encoding='utf-8') as f: - geo_data = json.load(f) - - # 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping) - # 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값) - # 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함 - system_tags = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"Failed to fetch system tags: {e}") - - # 그래프 임시 생성 (Mapper가 위상 정보를 사용하므로 필요) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Mapper 설정 - from openai import AsyncOpenAI - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - # 분류별 노드 분리 - nodes = list(builder.G.nodes()) - transmitter_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FIT', 'FT', 'LT', 'PT', 'TE']] # 단순화된 필터 - valve_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FCV', 'LCV', 'TCV', 'PCV', 'XV']] - equipment_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('type') not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - # 병렬 호출 (vLLM Batching 유도) - tasks = [ - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes) - ] - extracted_results = await asyncio.gather(*tasks) - - # 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - # TopologyBuilder가 기대하는 형식으로 변환 - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": node_data['bbox'].bounds if hasattr(node_data['bbox'], 'bounds') else node_data['bbox'], - "clean_value": mapping.resolved_tag - }) - - # 3. 최종 위상 모델링 (Phase 2) - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - # 4. 저장 - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = f"mcp-server/storage/{graph_id}" - final_builder.save_graph(graph_path) - - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges() - }, ensure_ascii=False) - - except Exception as e: - logging.error(f"build_pid_graph_parallel failed: {e}") - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -@mcp.tool() -def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """ - 구축된 그래프를 기반으로 특정 설비 장애 시 영향도 분석을 수행합니다. - """ - try: - graph_path = f"mcp-server/storage/{graph_id}" - mapping_path = graph_path.replace("_graph.json", "_mapping.json") # 매핑 파일이 따로 저장된다고 가정 - - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - - return json.dumps(result, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False) - -@mcp.tool() -def parse_pid_drawing(filepath: str) -> str: - """확장자 자동 감지하여 P&ID 도면 파싱. - - Args: - filepath: DXF/DWG/PDF 파일 경로 - - Returns: - JSON: { success, text, count, tags, format } - """ - import os - ext = os.path.splitext(filepath)[1].lower() - - if ext == ".dxf": - return parse_pid_dxf(filepath) - elif ext == ".dwg": - # DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다. - # Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다. - return json.dumps({ - "success": False, - "error": "DWG 파일은 현재 직접 파싱할 수 없습니다.\n" + - "사전에 DXF로 변환하여 업로드해 주세요.\n" + - "\n변환 방법:\n" + - "1. Windows에서 AutoCAD 또는 ODA File Converter 사용\n" + - "2. 온라인 DWG → DXF 변환기 사용\n" + - "3. LibreOffice Draw (Windows/macOS 전용) 사용" - }, ensure_ascii=False) - elif ext == ".pdf": - return parse_pid_pdf(filepath) - else: - return json.dumps({ - "success": False, - "error": f"Unsupported format: {ext}. Supported: .dxf, .dwg, .pdf" - }, ensure_ascii=False) - - -# ── 워커 요청 전달 ──────────────────────────────────────────────────────────── - -async def _forward_request(port: int, tool_name: str, params: dict, one_shot: bool = False) -> str: - """HTTP를 통해 워커 프로세스로 요청 전달. - - Args: - port: 워커 포트 - tool_name: 도구 이름 - params: 요청 파라미터 - one_shot: True일 경우 요청 완료 후 워커 종료 - """ - async with httpx.AsyncClient(timeout=300) as client: # 5분 타임아웃 - endpoint = "/execute/one_shot" if one_shot else "/execute" - response = await client.post( - f"http://localhost:{port}{endpoint}", - json={"tool": tool_name, "params": params} - ) - response.raise_for_status() - return response.text - - -# ── 요청 라우팅 (워커 프로세스 사용) ─────────────────────────────────────────── - -@mcp.tool() -async def search_codebase(query: str, top_k: int = 6) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("search_codebase") - return await _forward_request(worker.port, "search_codebase", { - "query": query, - "top_k": top_k - }) - - -@mcp.tool() -async def search_r530_docs(query: str, top_k: int = 5) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("search_r530_docs") - return await _forward_request(worker.port, "search_r530_docs", { - "query": query, - "top_k": top_k - }) - - -@mcp.tool() -async def ask_iiot_llm(question: str, context: str = "") -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("ask_iiot_llm") - return await _forward_request(worker.port, "ask_iiot_llm", { - "question": question, - "context": context - }) - - -@mcp.tool() -async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("rag_query") - return await _forward_request(worker.port, "rag_query", { - "question": question, - "search_code": search_code, - "search_docs": search_docs - }) - - -@mcp.tool() -async def run_sql(sql: str) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("run_sql") - return await _forward_request(worker.port, "run_sql", {"sql": sql}) - - -@mcp.tool() -async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("query_pv_history") - return await _forward_request(worker.port, "query_pv_history", { - "tag_names": tag_names, - "time_from": time_from, - "time_to": time_to, - "limit": limit - }) - - -@mcp.tool() -async def get_tag_metadata(query: str, limit: int = 10) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("get_tag_metadata") - return await _forward_request(worker.port, "get_tag_metadata", { - "query": query, - "limit": limit - }) - - -@mcp.tool() -async def list_drawings(unit_no: str = None) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("list_drawings") - return await _forward_request(worker.port, "list_drawings", { - "unit_no": unit_no - }) - - -@mcp.tool() -async def query_with_nl(question: str) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("query_with_nl") - return await _forward_request(worker.port, "query_with_nl", {"question": question}) - - -@mcp.tool() -async def parse_pid_dxf(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: # P&ID는 1개 동시 실행만 허용 - worker = await process_manager.get_worker("parse_pid_dxf", one_shot=True) - return await _forward_request(worker.port, "parse_pid_dxf", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("parse_pid_pdf", one_shot=True) - return await _forward_request(worker.port, "parse_pid_pdf", { - "filepath": filepath, - "use_ocr": use_ocr - }, one_shot=True) - - -@mcp.tool() -async def parse_pid_drawing(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("parse_pid_drawing", one_shot=True) - return await _forward_request(worker.port, "parse_pid_drawing", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("extract_pid_tags", one_shot=True) - return await _forward_request(worker.port, "extract_pid_tags", { - "text": text, - "source_type": source_type - }, one_shot=True) - - -@mcp.tool() -async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("match_pid_tags", one_shot=True) - return await _forward_request(worker.port, "match_pid_tags", { - "pid_tags": pid_tags, - "experion_tags": experion_tags - }, one_shot=True) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("build_pid_graph_parallel", one_shot=True) - return await _forward_request(worker.port, "build_pid_graph_parallel", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("analyze_pid_impact", one_shot=True) - return await _forward_request(worker.port, "analyze_pid_impact", { - "graph_id": graph_id, - "start_node_id": start_node_id - }, one_shot=True) - - -@mcp.tool() -def get_worker_status() -> str: - """모든 워커 프로세스 상태 조회.""" - status = {} - for name, worker in process_manager.workers.items(): - status[name] = { - "pid": worker.process.pid, - "status": worker.status, - "port": worker.port, - "one_shot": worker.one_shot - } - return json.dumps(status, ensure_ascii=False, indent=2) - - -# ── 엔트리포인트 ────────────────────────────────────────────────────────────── - -def main(): - """HTTP 모드로 실행 — C# McpClient (localhost:5001) 용.""" - mcp.run(transport="streamable-http") - - -if __name__ == "__main__": - # --http 플래그: HTTP 모드 (C# McpClient 용) - # 플래그 없음: stdio 모드 (Claude Code / Roo Code MCP 용) - if "--http" in sys.argv: - mcp.run(transport="streamable-http") - else: - mcp.run(transport="stdio") diff --git a/.rooBackup/2026-05-03-030956/mcp-server/server.py b/.rooBackup/2026-05-03-030956/mcp-server/server.py deleted file mode 100644 index 7d2ad31..0000000 --- a/.rooBackup/2026-05-03-030956/mcp-server/server.py +++ /dev/null @@ -1,1446 +0,0 @@ -#!/usr/bin/env python3 -""" -ExperionCrawler Unified MCP Server -- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen/Qwen3-Coder-Next-FP8 -- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행 -- 사용처: - stdio 모드 (기본): Claude Code MCP / Roo Code MCP - HTTP 모드 (--http): C# McpClient (localhost:5001) -""" - -from __future__ import annotations -import sys -import json -import logging -import httpx -from functools import lru_cache -from mcp.server.fastmcp import FastMCP - -logging.basicConfig(level=logging.WARNING, stream=sys.stderr) - -# ── 설정 ────────────────────────────────────────────────────────────────────── -QDRANT_URL = "http://localhost:6333" -OLLAMA_URL = "http://localhost:11434" -EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" - -# Qdrant 컬렉션 -COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 -COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks) - -# PostgreSQL 연결 -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 # 초 - -# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식 -mcp = FastMCP( - "iiot-rag", - port=5001, - json_response=True, - stateless_http=True, -) - -# Pipeline Imports -from pipeline.extractor import PidGeometricExtractor -from pipeline.topology import PidTopologyBuilder -from pipeline.mapper import IntelligentMapper -from pipeline.analyzer import PidAnalysisEngine -import networkx as nx -import os -import asyncio -import subprocess -import atexit -import signal -from dataclasses import dataclass -from typing import Dict, Optional -from functools import cache - -# ── ProcessManager ───────────────────────────────────────────────────────────── - -@dataclass -class WorkerProcess: - process: subprocess.Popen - port: int - status: str # "running", "stopped", "error" - one_shot: bool = False # 요청 후 프로세스 종료 여부 (P&ID 워커용) - - -class ProcessManager: - """워커 프로세스 관리자.""" - - def __init__(self): - self.workers: Dict[str, WorkerProcess] = {} - self._locks: Dict[str, asyncio.Lock] = {} - self._pid_sem = asyncio.Semaphore(1) # P&ID는 1개 동시 실행만 허용 - self._worker_ports = {"rag": 5002, "nl2sql": 5003, "pid": 5004} - - # 정리 훅 등록 - atexit.register(self._cleanup) - signal.signal(signal.SIGTERM, lambda *_: self._cleanup()) - signal.signal(signal.SIGINT, lambda *_: self._cleanup()) - - def _get_available_port(self, worker_type: str) -> int: - """워커 타입에 대한 포트 반환.""" - return self._worker_ports.get(worker_type, 5002) - - def _classify_tool(self, tool_name: str) -> str: - """도구 이름을 워커 타입으로 분류.""" - rag_tools = {"search_codebase", "search_r530_docs", "ask_iiot_llm", "rag_query"} - nl2sql_tools = {"run_sql", "query_pv_history", "get_tag_metadata", "list_drawings", "query_with_nl"} - pid_tools = { - "extract_pid_tags", "match_pid_tags", "parse_pid_dxf", "parse_pid_pdf", - "parse_pid_drawing", "build_pid_graph_parallel", "analyze_pid_impact" - } - - if tool_name in rag_tools: - return "rag" - elif tool_name in nl2sql_tools: - return "nl2sql" - elif tool_name in pid_tools: - return "pid" - else: - return "default" - - async def start_worker(self, worker_type: str, one_shot: bool = False) -> WorkerProcess: - """서브 프로세스 시작. - - Args: - worker_type: 워커 타입 (rag, nl2sql, pid) - one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용) - """ - port = self._get_available_port(worker_type) - cmd = [ - sys.executable, - f"mcp-server/worker/{worker_type}_worker.py", - str(port) - ] - - # 로그 파일 열기 - log_dir = os.path.join(os.path.dirname(__file__), "logs") - os.makedirs(log_dir, exist_ok=True) - log_file = open(os.path.join(log_dir, f"{worker_type}_worker.log"), "a") - - proc = subprocess.Popen( - cmd, - stdout=log_file, - stderr=log_file, - ) - - # 헬스체크 루프 (최대 15초 대기) - for _ in range(30): # 0.5초 * 30 = 15초 - await asyncio.sleep(0.5) - if proc.poll() is not None: - log_file.close() - raise RuntimeError(f"{worker_type} 워커가 시작 직후 종료됨") - try: - async with httpx.AsyncClient(timeout=1) as client: - await client.get(f"http://localhost:{port}/health") - break # 헬스체크 성공 - except Exception: - continue - else: - proc.kill() - log_file.close() - raise RuntimeError(f"{worker_type} 워커 시작 타임아웃") - - worker = WorkerProcess( - process=proc, - port=port, - status="running", - one_shot=one_shot - ) - self.workers[worker_type] = worker - log_file.close() - return worker - - async def stop_worker(self, worker_type: str): - """서브 프로세스 종료.""" - if worker_type in self.workers: - proc = self.workers[worker_type].process - proc.terminate() - await asyncio.sleep(0.5) - if proc.poll() is None: - proc.kill() - del self.workers[worker_type] - - async def get_worker(self, tool_name: str, one_shot: bool = False) -> WorkerProcess: - """도구 이름에 해당하는 워커 프로세스 반환 (자동 시작). - - Args: - tool_name: 도구 이름 - one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용) - """ - worker_type = self._classify_tool(tool_name) - - if worker_type not in self._locks: - self._locks[worker_type] = asyncio.Lock() - - async with self._locks[worker_type]: - if worker_type not in self.workers: - return await self.start_worker(worker_type, one_shot) - - proc = self.workers[worker_type].process - if proc.poll() is not None: - del self.workers[worker_type] - return await self.start_worker(worker_type, one_shot) - - return self.workers[worker_type] - - def _cleanup(self): - """모든 워커 프로세스 정리.""" - for wtype, worker in list(self.workers.items()): - try: - worker.process.terminate() - except Exception: - pass - self.workers.clear() - - -# 전역 ProcessManager 인스턴스 -process_manager = ProcessManager() - -# ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── - -def _embed(text: str) -> list[float]: - """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - with httpx.Client(timeout=30) as client: - resp = client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] - -# ── LLM (vLLM / Qwen/Qwen3-Coder-Next-FP8) ───────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -# ── PaddleOCR 싱글톤 (PDF fallback용) ────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _ocr(): - """PaddleOCR 인스턴스 (한/영, GPU). 첫 호출 시 ~50MB 모델 다운로드.""" - from paddleocr import PaddleOCR - import os - - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - ocr = PaddleOCR( - use_angle_cls=True, - lang="korean", - use_gpu=use_gpu, - show_log=False, - ) - return ocr - except Exception as e: - # GPU 실패 시 CPU 폴백 - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise e - - -# ── DXF/PDF 텍스트 추출 헬퍼 ─────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - """ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거).""" - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - """PyMuPDF로 PDF 파일에서 텍스트 추출.""" - import fitz # pymupdf - doc = fitz.open(filepath) - texts = [] - for page in doc: - texts.append(page.get_text()) - return "\n".join(texts) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - """PaddleOCR로 PDF에서 이미지 추출 후 OCR (고정밀도).""" - import fitz # pymupdf - from PIL import Image - import numpy as np - - doc = fitz.open(filepath) - all_texts = [] - - for page_idx, page in enumerate(doc): - # 페이지를 이미지로 변환 - mat = fitz.Matrix(300 / 72) # 300 DPI - pix = page.get_pixmap(matrix=mat) - img_data = pix.tobytes("png") - img = Image.open(__import__("io").BytesIO(img_data)) - - # OCR 실행 - result = _ocr().ocr(np.array(img), cls=True) - if result[0]: - for line in result[0]: - all_texts.append(line[1][0]) - - return "\n".join(all_texts) - - -def _convert_dwg_to_dxf_dxflib(filepath: str) -> str: - """libreoffice로 DWG를 DXF로 변환.""" - import subprocess - import os - - dxf_path = filepath.replace(".dwg", ".dxf") - - try: - # LibreOffice로 변환 - result = subprocess.run( - [ - "libreoffice", - "--headless", - "--convert-to", "dxf:AutoCAD DXF", - "--outdir", os.path.dirname(filepath) or ".", - filepath - ], - check=True, - timeout=120, - capture_output=True, - text=True - ) - - if os.path.exists(dxf_path): - return dxf_path - else: - raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.") - - except subprocess.CalledProcessError as e: - raise Exception(f"LibreOffice 변환 실패: {e.stderr}") - - -# ── Qdrant 검색 헬퍼 ────────────────────────────────────────────────────────── - -def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str: - vec = _embed(query) - with httpx.Client(timeout=20) as client: - resp = client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": vec, - "limit": top_k, - "with_payload": True, - "score_threshold": threshold, - }, - ) - resp.raise_for_status() - hits = resp.json().get("result", []) - - if not hits: - return "관련 결과 없음." - - parts = [] - for h in hits: - p = h.get("payload", {}) - file_path = p.get("filePath", p.get("path", "unknown")) - chunk = p.get("codeChunk", p.get("content", p.get("text", ""))) - start_line = p.get("startLine", "") - loc = f"{file_path}:{start_line}" if start_line else file_path - parts.append(f"[score={h['score']:.3f}] {loc}\n```\n{chunk[:700]}\n```") - - return "\n\n---\n\n".join(parts) - -# ── DB 헬퍼 ────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - """PostgreSQL DB 연결 획득.""" - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - - -def _validate_sql(sql: str) -> tuple[bool, str]: - """SQL 안전 검증 — SELECT만 허용, 위험 키워드 차단.""" - if len(sql) > 2000: - return False, "쿼리 길이 2000자를 초과했습니다." - dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'] - sql_upper = sql.upper() - for kw in dangerous: - if kw in sql_upper: - return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다." - if not sql_upper.strip().startswith('SELECT'): - return False, "단순 SELECT 쿼리만 허용됩니다." - if '..' in sql or '~' in sql: - return False, "파일 경로 표현은 허용되지 않습니다." - return True, "" - - -# DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용 -_DB_SCHEMA = """ -PostgreSQL 시계열 데이터베이스 스키마 - -테이블: history_table (시계열 이력) - tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분 - node_id TEXT - OPC UA 노드 ID - value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요 - recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초 - -테이블: realtime_table (실시간 최신값) - tagname TEXT - 태그명 (모두 소문자) - node_id TEXT - OPC UA 노드 ID - livevalue TEXT - 현재값 - timestamp TIMESTAMPTZ - 최종 갱신 시각 - -N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): - 1분 버킷: date_trunc('minute', recorded_at) AS bucket - 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket - 5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket - 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket - N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket - -예시 (2분 간격, 여러 태그): - SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, - tagname, AVG(value::double precision) AS avg_val - FROM history_table - WHERE tagname IN ('tag1', 'tag2') - AND recorded_at >= NOW() - INTERVAL '3 hours' - GROUP BY bucket, tagname ORDER BY bucket, tagname - -규칙: - - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) - - tagname은 모두 소문자로 정확히 입력 - - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수 - - time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용 -""" - -# ── RAG 도구 ───────────────────────────────────────────────────────────────── - -@mcp.tool() -def search_codebase(query: str, top_k: int = 6) -> str: - """ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드). - Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함. - - 사용 시점: ExperionCrawler 코드의 구현 방법, 버그, 구조를 알고 싶을 때. - ⚠️ Experion HS R530 제품 동작/설정/스펙을 알고 싶으면 search_r530_docs 사용. - - Args: - query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스") - top_k: 반환 결과 수 (기본 6) - """ - return _search(COL_CODEBASE, query, top_k) - - -@mcp.tool() -def search_r530_docs(query: str, top_k: int = 5) -> str: - """Honeywell Experion HS R530 공식 제품 문서 검색. - ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함. - - 사용 시점: Experion HS R530의 OPC UA 설정, 인증서, 보안 정책, 포인트 주소 형식, - 채널/컨트롤러 속성, 문제해결 등 제품 스펙과 동작을 알고 싶을 때. - - Args: - query: 검색어 (예: "certificate configuration", "endpoint security policy") - top_k: 반환 결과 수 (기본 5) - """ - return _search(COL_OPC_DOCS, query, top_k) - - -@mcp.tool() -def ask_iiot_llm(question: str, context: str = "") -> str: - """Qwen/Qwen3-Coder-Next-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). - - 사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨 - 종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문. - - Args: - question: 질문 내용 - context: (선택) search_codebase 또는 search_r530_docs 검색 결과 - """ - system = ( - "당신은 IIoT(산업용 IoT), OPC UA, Honeywell Experion PKS/HS R530 전문가입니다.\n" - "컨텍스트가 제공된 경우 컨텍스트를 우선 근거로 삼아 한국어로 답변합니다.\n" - "컨텍스트 출처가 'Experion HS R530 공식 문서'인지 'ExperionCrawler 코드'인지 명확히 구분하여 설명합니다." - ) - user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user_msg}, - ], - max_tokens=2048, - temperature=0.1, - ) - return resp.choices[0].message.content or "(응답 없음)" - - -@mcp.tool() -def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """검색 → Qwen/Qwen3-Coder-Next-FP8 답변 생성 (통합 RAG). - - 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). - ExperionCrawler 코드도 함께 보려면 search_code=True 추가. - - Args: - question: 질문 - search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True) - search_code: ExperionCrawler 소스코드 검색 여부 (기본 False) - """ - context_parts: list[str] = [] - if search_docs: - context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}") - if search_code: - context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}") - return ask_iiot_llm(question, "\n\n".join(context_parts)) - - -# ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── - -@mcp.tool() -def run_sql(sql: str) -> str: - """SQL 쿼리 실행 (SELECT만 허용). - - Args: - sql: 실행할 SELECT SQL 문자열 - - Returns: - JSON: { success, columns, count, data } 또는 { success, error } - """ - valid, err = _validate_sql(sql) - if not valid: - return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) - - conn = None - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute(sql) - rows = cur.fetchall() - columns = [desc[0] for desc in cur.description] - result_data = [dict(zip(columns, row)) for row in rows] - return json.dumps({ - "success": True, - "columns": columns, - "count": len(result_data), - "data": result_data - }, ensure_ascii=False, default=str) - except Exception as e: - return json.dumps({"success": False, "error": f"SQL 실행 실패: {e}"}, ensure_ascii=False) - finally: - if conn: - conn.close() - - -@mcp.tool() -def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """과거 값(PV) 히스토리 조회. - - Args: - tag_names: 태그 이름 목록 (예: ["ficq-6113.pv", "ti-6101.pv"]) - time_from: 시작 시간 (ISO 8601, 예: "2026-04-01T00:00:00") - time_to: 종료 시간 (ISO 8601, 예: "2026-04-02T00:00:00") - limit: 반환 행 수 제한 (기본 100, 최대 5000) - - Returns: - JSON: { success, tag_names, time_range, limit, data } - """ - try: - limit = min(limit, 5000) - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, recorded_at, value - FROM history_table - WHERE tagname = ANY(%s) - AND recorded_at >= %s AND recorded_at <= %s - ORDER BY recorded_at, tagname - LIMIT %s""", - (tag_names, time_from, time_to, limit) - ) - rows = cur.fetchall() - data = [{"tag_name": r[0], "timestamp": r[1].isoformat(), "value": r[2]} for r in rows] - return json.dumps({ - "success": True, - "tag_names": tag_names, - "time_range": f"{time_from} ~ {time_to}", - "count": len(data), - "data": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"히스토리 쿼리 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def get_tag_metadata(query: str, limit: int = 10) -> str: - """태그 메타데이터 검색 (realtime_table 기반). - - Args: - query: 태그명 검색어 (패턴 매칭) - limit: 반환 태그 수 제한 (기본 10) - - Returns: - JSON: { success, query, count, tags } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, livevalue, timestamp, node_id - FROM realtime_table - WHERE tagname ILIKE %s - ORDER BY tagname LIMIT %s""", - (f"%{query}%", limit) - ) - rows = cur.fetchall() - tags = [{"tag_name": r[0], "current_value": r[1], - "last_updated": r[2].isoformat() if r[2] else None, - "node_id": r[3]} for r in rows] - return json.dumps({"success": True, "query": query, "count": len(tags), "tags": tags}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"태그 메타데이터 검색 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def list_drawings(unit_no: str | None = None) -> str: - """단위별 도면 목록 조회 (node_map_master.name 기반). - - Args: - unit_no: 단위 번호 접두사 (예: "A", "B"). None이면 전체 목록 - - Returns: - JSON: { success, unit_no, count, names } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - if unit_no: - cur.execute( - "SELECT DISTINCT name FROM node_map_master WHERE name ILIKE %s ORDER BY name LIMIT 100", - (f"{unit_no}%",) - ) - else: - cur.execute("SELECT DISTINCT name FROM node_map_master ORDER BY name LIMIT 100") - rows = cur.fetchall() - return json.dumps({"success": True, "unit_no": unit_no, - "count": len(rows), "names": [r[0] for r in rows]}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"도면 목록 조회 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_with_nl(question: str) -> str: - """자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다. - - Args: - question: 자연어 질문 (예: "FICQ-6113.PV 최근 1시간 값을 1분 단위로 표시") - - Returns: - JSON: { sql, success, columns, count, data } 또는 { sql, success, error } - """ - system = ( - "You are a PostgreSQL SQL expert.\n" - "Convert the user's question into a SELECT SQL using the schema below.\n" - "IMPORTANT rules:\n" - "- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n" - "- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n" - "- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n" - "- INTERVAL rule:\n" - " * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n" - " use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n" - " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" - " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" - "- Current year is 2026. '4월 27일' means 2026-04-27.\n" - "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" - "- value column is TEXT; cast with ::double precision only when aggregating.\n" - "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" - "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" - "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" - f"{_DB_SCHEMA}" - ) - try: - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": question}, - ], - max_tokens=8192, - temperature=0.1, - ) - sql = (resp.choices[0].message.content or "").strip() - # 마크다운 코드 블록 제거 - if sql.startswith("```"): - lines = sql.splitlines() - sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() - if not sql: - return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) - except Exception as e: - return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False) - - # SQL 실행 - raw = run_sql(sql) - result = json.loads(raw) - result["sql"] = sql - - # long format → pivot 변환 (tagname 컬럼이 있으면 자동 PIVOT) - if result.get("success") and "data" in result: - cols = result.get("columns", []) - data = result["data"] - if "tagname" in cols and data: - time_col = next((c for c in cols if c not in ("tagname", "value", "livevalue", "avg_val")), None) - val_col = next((c for c in ("avg_val", "value") if c in cols), cols[-1]) - if time_col: - tag_names_list = sorted(dict.fromkeys(row["tagname"] for row in data)) - pivoted: dict = {} - for row in data: - key = str(row[time_col]) - if key not in pivoted: - pivoted[key] = {time_col: row[time_col]} - pivoted[key][row["tagname"]] = row.get(val_col) - result["data"] = list(pivoted.values()) - result["columns"] = [time_col] + tag_names_list - result["count"] = len(result["data"]) - - return json.dumps(result, ensure_ascii=False, default=str) - - -# ── P&ID 추출 도구 ────────────────────────────────────────────────────────────── - -@mcp.tool() -def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 도면(DXF/PDF)에서 태그 정보를 추출합니다. - - Args: - text: DXF/PDF에서 추출한 텍스트 - source_type: 'dxf' 또는 'pdf' - - Returns: - JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] } - """ - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV","lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo (e.g. FCV, P, T, VG, BT, DP, PSV)\n" - "- equipmentName: descriptive name if present in text near the tag, else null\n" - "- lineNumber: null unless a line number is explicitly associated\n" - "- pidDrawingNo: null unless a drawing number is explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous ones\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - - import logging - import re - import json as json_module - - try: - truncated_text = text[:100000] if len(text) > 100000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # finish_reason=length 로 잘린 경우: 마지막 완전한 객체까지 살린 뒤 배열 닫기 - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 유효한 JSON 배열 추출 (가장 긴 균형 잡힌 [...] 선택) - def _extract_array(s: str) -> str: - depth = 0; start = -1; best = "" - for i, c in enumerate(s): - if c == '[': - if depth == 0: start = i - depth += 1 - elif c == ']': - depth -= 1 - if depth == 0 and start >= 0: - cand = s[start:i+1] - if len(cand) > len(best): best = cand - return best if best else "[]" - - raw = _extract_array(raw) - - # JSON 파싱 — 실패 시 개별 객체 추출로 폴백 - try: - data = json_module.loads(raw) - except json_module.JSONDecodeError: - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json_module.loads(obj)) - except json_module.JSONDecodeError: - pass - if not data: - return json_module.dumps({"success": False, "count": 0, "tags": []}, ensure_ascii=False) - - logging.info(f"[extract_pid_tags] source={source_type} count={len(data) if isinstance(data, list) else 0}") - - return json_module.dumps({ - "success": True, - "count": len(data), - "tags": data - }, ensure_ascii=False, indent=2) - - except Exception as e: - logging.error(f"P&ID 태그 추출 실패: {e}") - logging.error(f"Raw response: {raw[:1000]}") - return json.dumps({"success": False, "error": f"P&ID 태그 추출 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 태그를 Experion 태그에 매핑합니다. - - Args: - pid_tags: P&ID에서 추출한 태그 목록 (예: ["FT-101", "PT-201"]) - experion_tags: Experion 시스템 태그 목록 (예: ["ficq-6113.pv", "pt-201.pv"]) - - Returns: - JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] } - """ - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "IMPORTANT rules:\n" - "- pidTag: The original P&ID tag from input\n" - "- experionTag: The matched Experion tag (lowercase, with .pv/.sp/.mv suffix)\n" - "- confidence: 0.0 to 1.0 based on match quality\n" - "- If no good match found, set confidence < 0.5 and leave experionTag null\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no matches found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - import re - import json as json_module - - try: - pid_str = "\n".join(pid_tags) - experion_str = "\n".join(experion_tags) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - match = re.search(r'\[.*\]', raw, re.DOTALL) - raw = match.group(0) if match else "[]" - - data = json_module.loads(raw) - return json_module.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - - except Exception as e: - return json.dumps({"success": False, "error": f"P&ID 태그 매핑 실패: {e}"}, ensure_ascii=False) - - -# ── P&ID 파싱 도구 (DXF/PDF/DWG) ─────────────────────────────────────────────── - - -@mcp.tool() -def parse_pid_dxf(filepath: str) -> str: - """ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: DXF 파일 경로 - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], # 제한 - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"DXF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """PyMuPDF 기반 PDF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: PDF 파일 경로 - use_ocr: OCR 사용 여부 (기본 True, 고정밀도) - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - if use_ocr: - text = _extract_text_from_pdf_ocr(filepath) - else: - text = _extract_text_from_pdf(filepath) - - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"PDF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """ - 분산 처리 기법을 적용한 P&ID 그래프 생성 툴. - 전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다. - """ - try: - # 1. 전처리 (Phase 1: Geometric Extraction) - extractor = PidGeometricExtractor(filepath) - geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json" - geo_data_list = extractor.extract_and_save(geo_data_path) - - # geo_data_list는 경로를 반환하므로 다시 로드 - with open(geo_data_path, 'r', encoding='utf-8') as f: - geo_data = json.load(f) - - # 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping) - # 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값) - # 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함 - system_tags = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"Failed to fetch system tags: {e}") - - # 그래프 임시 생성 (Mapper가 위상 정보를 사용하므로 필요) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Mapper 설정 - from openai import AsyncOpenAI - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - # 분류별 노드 분리 - nodes = list(builder.G.nodes()) - transmitter_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FIT', 'FT', 'LT', 'PT', 'TE']] # 단순화된 필터 - valve_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FCV', 'LCV', 'TCV', 'PCV', 'XV']] - equipment_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('type') not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - # 병렬 호출 (vLLM Batching 유도) - tasks = [ - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes) - ] - extracted_results = await asyncio.gather(*tasks) - - # 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - # TopologyBuilder가 기대하는 형식으로 변환 - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": node_data['bbox'].bounds if hasattr(node_data['bbox'], 'bounds') else node_data['bbox'], - "clean_value": mapping.resolved_tag - }) - - # 3. 최종 위상 모델링 (Phase 2) - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - # 4. 저장 - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = f"mcp-server/storage/{graph_id}" - final_builder.save_graph(graph_path) - - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges() - }, ensure_ascii=False) - - except Exception as e: - logging.error(f"build_pid_graph_parallel failed: {e}") - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -@mcp.tool() -def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """ - 구축된 그래프를 기반으로 특정 설비 장애 시 영향도 분석을 수행합니다. - """ - try: - graph_path = f"mcp-server/storage/{graph_id}" - mapping_path = graph_path.replace("_graph.json", "_mapping.json") # 매핑 파일이 따로 저장된다고 가정 - - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - - return json.dumps(result, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False) - -@mcp.tool() -def parse_pid_drawing(filepath: str) -> str: - """확장자 자동 감지하여 P&ID 도면 파싱. - - Args: - filepath: DXF/DWG/PDF 파일 경로 - - Returns: - JSON: { success, text, count, tags, format } - """ - import os - ext = os.path.splitext(filepath)[1].lower() - - if ext == ".dxf": - return parse_pid_dxf(filepath) - elif ext == ".dwg": - # DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다. - # Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다. - return json.dumps({ - "success": False, - "error": "DWG 파일은 현재 직접 파싱할 수 없습니다.\n" + - "사전에 DXF로 변환하여 업로드해 주세요.\n" + - "\n변환 방법:\n" + - "1. Windows에서 AutoCAD 또는 ODA File Converter 사용\n" + - "2. 온라인 DWG → DXF 변환기 사용\n" + - "3. LibreOffice Draw (Windows/macOS 전용) 사용" - }, ensure_ascii=False) - elif ext == ".pdf": - return parse_pid_pdf(filepath) - else: - return json.dumps({ - "success": False, - "error": f"Unsupported format: {ext}. Supported: .dxf, .dwg, .pdf" - }, ensure_ascii=False) - - -# ── 워커 요청 전달 ──────────────────────────────────────────────────────────── - -async def _forward_request(port: int, tool_name: str, params: dict, one_shot: bool = False) -> str: - """HTTP를 통해 워커 프로세스로 요청 전달. - - Args: - port: 워커 포트 - tool_name: 도구 이름 - params: 요청 파라미터 - one_shot: True일 경우 요청 완료 후 워커 종료 - """ - async with httpx.AsyncClient(timeout=300) as client: # 5분 타임아웃 - endpoint = "/execute/one_shot" if one_shot else "/execute" - response = await client.post( - f"http://localhost:{port}{endpoint}", - json={"tool": tool_name, "params": params} - ) - response.raise_for_status() - return response.text - - -# ── 요청 라우팅 (워커 프로세스 사용) ─────────────────────────────────────────── - -@mcp.tool() -async def search_codebase(query: str, top_k: int = 6) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("search_codebase") - return await _forward_request(worker.port, "search_codebase", { - "query": query, - "top_k": top_k - }) - - -@mcp.tool() -async def search_r530_docs(query: str, top_k: int = 5) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("search_r530_docs") - return await _forward_request(worker.port, "search_r530_docs", { - "query": query, - "top_k": top_k - }) - - -@mcp.tool() -async def ask_iiot_llm(question: str, context: str = "") -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("ask_iiot_llm") - return await _forward_request(worker.port, "ask_iiot_llm", { - "question": question, - "context": context - }) - - -@mcp.tool() -async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("rag_query") - return await _forward_request(worker.port, "rag_query", { - "question": question, - "search_code": search_code, - "search_docs": search_docs - }) - - -@mcp.tool() -async def run_sql(sql: str) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("run_sql") - return await _forward_request(worker.port, "run_sql", {"sql": sql}) - - -@mcp.tool() -async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("query_pv_history") - return await _forward_request(worker.port, "query_pv_history", { - "tag_names": tag_names, - "time_from": time_from, - "time_to": time_to, - "limit": limit - }) - - -@mcp.tool() -async def get_tag_metadata(query: str, limit: int = 10) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("get_tag_metadata") - return await _forward_request(worker.port, "get_tag_metadata", { - "query": query, - "limit": limit - }) - - -@mcp.tool() -async def list_drawings(unit_no: str = None) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("list_drawings") - return await _forward_request(worker.port, "list_drawings", { - "unit_no": unit_no - }) - - -@mcp.tool() -async def query_with_nl(question: str) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("query_with_nl") - return await _forward_request(worker.port, "query_with_nl", {"question": question}) - - -@mcp.tool() -async def parse_pid_dxf(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: # P&ID는 1개 동시 실행만 허용 - worker = await process_manager.get_worker("parse_pid_dxf", one_shot=True) - return await _forward_request(worker.port, "parse_pid_dxf", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("parse_pid_pdf", one_shot=True) - return await _forward_request(worker.port, "parse_pid_pdf", { - "filepath": filepath, - "use_ocr": use_ocr - }, one_shot=True) - - -@mcp.tool() -async def parse_pid_drawing(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("parse_pid_drawing", one_shot=True) - return await _forward_request(worker.port, "parse_pid_drawing", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("extract_pid_tags", one_shot=True) - return await _forward_request(worker.port, "extract_pid_tags", { - "text": text, - "source_type": source_type - }, one_shot=True) - - -@mcp.tool() -async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("match_pid_tags", one_shot=True) - return await _forward_request(worker.port, "match_pid_tags", { - "pid_tags": pid_tags, - "experion_tags": experion_tags - }, one_shot=True) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("build_pid_graph_parallel", one_shot=True) - return await _forward_request(worker.port, "build_pid_graph_parallel", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: - worker = await process_manager.get_worker("analyze_pid_impact", one_shot=True) - return await _forward_request(worker.port, "analyze_pid_impact", { - "graph_id": graph_id, - "start_node_id": start_node_id - }, one_shot=True) - - -@mcp.tool() -def get_worker_status() -> str: - """모든 워커 프로세스 상태 조회.""" - status = {} - for name, worker in process_manager.workers.items(): - status[name] = { - "pid": worker.process.pid, - "status": worker.status, - "port": worker.port, - "one_shot": worker.one_shot - } - return json.dumps(status, ensure_ascii=False, indent=2) - - -# ── 엔트리포인트 ────────────────────────────────────────────────────────────── - -def main(): - """HTTP 모드로 실행 — C# McpClient (localhost:5001) 용.""" - mcp.run(transport="streamable-http") - - -if __name__ == "__main__": - # --http 플래그: HTTP 모드 (C# McpClient 용) - # 플래그 없음: stdio 모드 (Claude Code / Roo Code MCP 용) - if "--http" in sys.argv: - mcp.run(transport="streamable-http") - else: - mcp.run(transport="stdio") diff --git a/.rooBackup/2026-05-03-031200/mcp-server/worker/pid_worker.py b/.rooBackup/2026-05-03-031200/mcp-server/worker/pid_worker.py deleted file mode 100644 index 9930c94..0000000 --- a/.rooBackup/2026-05-03-031200/mcp-server/worker/pid_worker.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""P&ID 파싱 전용 워커 프로세스 - -Usage: python pid_worker.py - -담당 도구: - extract_pid_tags, match_pid_tags, - parse_pid_dxf, parse_pid_pdf, parse_pid_drawing, - build_pid_graph_parallel, analyze_pid_impact -""" - -from __future__ import annotations -import sys -import os - -# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근) -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import io -import json -import asyncio -import signal -import logging -import re -from functools import lru_cache - -from fastapi import FastAPI, Request -import uvicorn - -# ── 설정 ───────────────────────────────────────────────────────────────────── - -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 - -_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -STORAGE_DIR = os.path.join(_SERVER_DIR, "storage") - -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [pid_worker] %(levelname)s %(message)s", -) - -app = FastAPI() - -# ── 싱글톤 ─────────────────────────────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -@lru_cache(maxsize=1) -def _ocr(): - from paddleocr import PaddleOCR - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False) - except Exception: - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise - -# ── DB ─────────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - -# ── 텍스트 추출 ────────────────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - import fitz - doc = fitz.open(filepath) - return "\n".join(page.get_text() for page in doc) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - import fitz - from PIL import Image - import numpy as np - doc = fitz.open(filepath) - all_texts = [] - for page in doc: - mat = fitz.Matrix(300 / 72) - pix = page.get_pixmap(matrix=mat) - img = Image.open(io.BytesIO(pix.tobytes("png"))) - result = _ocr().ocr(np.array(img), cls=True) - if result and result[0]: - all_texts.extend(line[1][0] for line in result[0]) - return "\n".join(all_texts) - -# ── JSON 배열 파싱 유틸 ─────────────────────────────────────────────────────── - -def _parse_json_array(raw: str, finish_reason: str = "") -> list: - """LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함.""" - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 가장 긴 균형 잡힌 [...] 추출 - depth = 0; start = -1; best = "" - for i, c in enumerate(raw): - if c == "[": - if depth == 0: - start = i - depth += 1 - elif c == "]": - depth -= 1 - if depth == 0 and start >= 0: - cand = raw[start:i + 1] - if len(cand) > len(best): - best = cand - raw = best if best else "[]" - - try: - return json.loads(raw) - except json.JSONDecodeError: - data = [] - for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL): - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - return data - -# ── 태그 추출/매핑 도구 ─────────────────────────────────────────────────────── - -def _extract_pid_tags(text: str, source_type: str) -> str: - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",' - '"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo\n" - "- equipmentName: descriptive name if present near tag, else null\n" - "- lineNumber/pidDrawingNo: null unless explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - truncated = text[:100000] - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - logging.info(f"extract_pid_tags source={source_type} count={len(data)}") - return json.dumps({"success": True, "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _match_pid_tags(pid_tags: list, experion_tags: list) -> str: - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "- If no good match: confidence < 0.5, experionTag null\n" - "- Output ONLY the JSON array.\n" - ) - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": ( - f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n" - f"Experion Tags:\n{chr(10).join(experion_tags)}" - )}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - return json.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - -# ── 도면 파싱 도구 ──────────────────────────────────────────────────────────── - -_TAG_EXTRACT_SYSTEM = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",' - '"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "Rules:\n" - "- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n" - "- instrumentType: first 2-4 letters of tagNo\n" - "- equipmentName/lineNumber/pidDrawingNo: null if not present\n" - "- confidence: 0.0 to 1.0\n" - "- Output ONLY the JSON array, no markdown.\n" - "- If no tags found, return: []\n" -) - - -def _parse_pid_dxf(filepath: str) -> str: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({"success": True, "text": "", "count": 0, "tags": []}, - ensure_ascii=False, indent=2) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": _TAG_EXTRACT_SYSTEM}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{text[:12000]}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - if not isinstance(data, list): - data = [] - return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath) - if not text.strip(): - return json.dumps({"success": True, "text": "", "count": 0, "tags": []}, - ensure_ascii=False, indent=2) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": _TAG_EXTRACT_SYSTEM}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - if not isinstance(data, list): - data = [] - return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _parse_pid_drawing(filepath: str) -> str: - ext = os.path.splitext(filepath)[1].lower() - if ext == ".dxf": - return _parse_pid_dxf(filepath) - elif ext == ".pdf": - return _parse_pid_pdf(filepath) - elif ext == ".dwg": - return json.dumps({ - "success": False, - "error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.", - }, ensure_ascii=False) - else: - return json.dumps({ - "success": False, - "error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf", - }, ensure_ascii=False) - -# ── 그래프 도구 ─────────────────────────────────────────────────────────────── - -async def _build_pid_graph_parallel(filepath: str) -> str: - from pipeline.extractor import PidGeometricExtractor - from pipeline.topology import PidTopologyBuilder - from pipeline.mapper import IntelligentMapper - from openai import AsyncOpenAI - - os.makedirs(STORAGE_DIR, exist_ok=True) - - # Phase 1: 기하 추출 - extractor = PidGeometricExtractor(filepath) - geo_data_path = os.path.join(STORAGE_DIR, os.path.basename(filepath) + "_geo.json") - extractor.extract_and_save(geo_data_path) - with open(geo_data_path, "r", encoding="utf-8") as f: - geo_data = json.load(f) - - # 시스템 태그 조회 - system_tags: list[str] = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"시스템 태그 조회 실패: {e}") - - # Phase 2: 1차 위상 빌더 (Mapper용 그래프) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Phase 3: 병렬 LLM 매핑 - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - transmitter_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("value", "").upper() in {"FIT", "FT", "LT", "PT", "TE"} - ] - valve_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("value", "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"} - ] - equipment_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"} - ] - - extracted_results = await asyncio.gather( - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes), - ) - - # 매핑 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": ( - node_data["bbox"].bounds - if hasattr(node_data["bbox"], "bounds") - else node_data["bbox"] - ), - "clean_value": mapping.resolved_tag, - }) - - # Phase 4: 최종 위상 모델링 + 저장 - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = os.path.join(STORAGE_DIR, graph_id) - final_builder.save_graph(graph_path) - - logging.info(f"build_pid_graph_parallel graph_id={graph_id} " - f"nodes={final_builder.G.number_of_nodes()} " - f"edges={final_builder.G.number_of_edges()}") - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges(), - }, ensure_ascii=False) - - -def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - from pipeline.analyzer import PidAnalysisEngine - - graph_path = os.path.join(STORAGE_DIR, graph_id) - mapping_path = graph_path.replace("_graph.json", "_mapping.json") - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - return json.dumps(result, ensure_ascii=False, indent=2) - -# ── 요청 디스패처 ───────────────────────────────────────────────────────────── - -async def _dispatch(tool: str, params: dict) -> str: - try: - match tool: - # blocking 함수는 asyncio.to_thread로 스레드풀 오프로드 - case "extract_pid_tags": - return await asyncio.to_thread(_extract_pid_tags, **params) - case "match_pid_tags": - return await asyncio.to_thread(_match_pid_tags, **params) - case "parse_pid_dxf": - return await asyncio.to_thread(_parse_pid_dxf, **params) - case "parse_pid_pdf": - return await asyncio.to_thread(_parse_pid_pdf, **params) - case "parse_pid_drawing": - return await asyncio.to_thread(_parse_pid_drawing, **params) - case "analyze_pid_impact": - return await asyncio.to_thread(_analyze_pid_impact, **params) - # 이미 async — 직접 await - case "build_pid_graph_parallel": - return await _build_pid_graph_parallel(**params) - case _: - return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"}, - ensure_ascii=False) - except Exception as e: - logging.error(f"dispatch error tool={tool}: {e}", exc_info=True) - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -# ── 종료 예약 ───────────────────────────────────────────────────────────────── - -def _schedule_shutdown(): - """응답 전송 완료 후 0.5초 뒤 프로세스 종료 예약.""" - async def _do(): - await asyncio.sleep(0.5) - os.kill(os.getpid(), signal.SIGTERM) - asyncio.create_task(_do()) - -# ── HTTP 엔드포인트 ─────────────────────────────────────────────────────────── - -@app.get("/health") -async def health(): - return {"status": "ok"} - - -@app.post("/execute") -async def execute(request: Request): - body = await request.json() - return await _dispatch(body["tool"], body["params"]) - - -@app.post("/execute/one_shot") -async def execute_one_shot(request: Request): - """요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용).""" - body = await request.json() - result = await _dispatch(body["tool"], body["params"]) - _schedule_shutdown() - return result - -# ── 진입점 ─────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004 - os.makedirs(STORAGE_DIR, exist_ok=True) - uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") - - - diff --git a/.rooBackup/2026-05-03-031200/mcp-server/worker/rag_worker.py b/.rooBackup/2026-05-03-031200/mcp-server/worker/rag_worker.py deleted file mode 100644 index 137f3c1..0000000 --- a/.rooBackup/2026-05-03-031200/mcp-server/worker/rag_worker.py +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env python3 -"""RAG 전용 워커 프로세스 - -Usage: python rag_worker.py - -담당 도구: - search_codebase, search_r530_docs, ask_iiot_llm, rag_query - -특징: - - Ollama Embedding + Qdrant 검색 + vLLM LLM 조합 - - 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시) - - 생명주기: 메인 서버 종료 시까지 유지 -""" - -from __future__ import annotations -import sys -import os - -# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근) -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import logging -import asyncio -from functools import lru_cache - -from fastapi import FastAPI, Request -import uvicorn -import httpx - -# ── 설정 ───────────────────────────────────────────────────────────────────── - -OLLAMA_URL = "http://localhost:11434" -QDRANT_URL = "http://localhost:6333" -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" -EMBED_MODEL = "nomic-embed-text" - -COL_CODEBASE = "ws-65f457145aee80b2" -COL_OPC_DOCS = "experion-opc-docs" - -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [rag_worker] %(levelname)s %(message)s", -) - -app = FastAPI() - -# ── HTTP 클라이언트 싱글톤 ──────────────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _get_http_client(): - return httpx.AsyncClient(timeout=30) - -# ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── - -async def _embed(text: str) -> list[float]: - """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - async with _get_http_client() as client: - resp = await client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] - -# ── Qdrant 검색 ────────────────────────────────────────────────────────────── - -async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]: - """Qdrant에서 벡터 유사도 검색.""" - async with _get_http_client() as client: - resp = await client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": query_vector, - "limit": top_k, - "with_payload": True, - }, - ) - resp.raise_for_status() - return resp.json().get("result", []) - -# ── LLM (vLLM) ─────────────────────────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm_client(): - from openai import AsyncOpenAI - return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - -async def _ask_llm(question: str, context: str = "") -> str: - """vLLM LLM으로 질문 응답.""" - client = _llm_client() - - if context: - prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요. - -컨텍스트: -{context} - -질문: -{question} - -답변:""" - else: - prompt = question - - response = await client.chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": prompt}, - ], - max_tokens=4096, - temperature=0.1, - ) - return response.choices[0].message.content - -# ── RAG 도구 구현 ───────────────────────────────────────────────────────────── - -@app.get("/health") -async def health(): - """워커 헬스체크.""" - return {"status": "ok"} - -@app.post("/execute") -async def execute(request: Request): - """HTTP 요청을 MCP 도구 호출로 변환.""" - body = await request.json() - tool = body["tool"] - params = body["params"] - - try: - if tool == "search_codebase": - result = await _search_codebase(**params) - elif tool == "search_r530_docs": - result = await _search_r530_docs(**params) - elif tool == "ask_iiot_llm": - result = await _ask_iiot_llm(**params) - elif tool == "rag_query": - result = await _rag_query(**params) - else: - return {"success": False, "error": f"Unknown tool: {tool}"} - - return result - except Exception as e: - logging.error(f"Error executing {tool}: {e}") - return {"success": False, "error": str(e)} - -async def _search_codebase(query: str, top_k: int = 6) -> str: - """소스코드 검색.""" - query_vector = await _embed(query) - results = await _qdrant_search(COL_CODEBASE, query_vector, top_k) - - items = [] - for hit in results: - payload = hit.get("payload", {}) - items.append({ - "score": hit.get("score", 0), - "file": payload.get("file", "unknown"), - "content": payload.get("content", "")[:500], - }) - - return { - "success": True, - "count": len(items), - "items": items, - } - -async def _search_r530_docs(query: str, top_k: int = 5) -> str: - """Experion HS R530 공식 문서 검색.""" - query_vector = await _embed(query) - results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k) - - items = [] - for hit in results: - payload = hit.get("payload", {}) - items.append({ - "score": hit.get("score", 0), - "title": payload.get("title", "unknown"), - "content": payload.get("content", "")[:500], - }) - - return { - "success": True, - "count": len(items), - "items": items, - } - -async def _ask_iiot_llm(question: str, context: str = "") -> str: - """IIoT/OPC UA 질문 응답.""" - answer = await _ask_llm(question, context) - return { - "success": True, - "question": question, - "answer": answer, - } - -async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """통합 RAG 검색.""" - contexts = [] - - if search_code: - query_vector = await _embed(question) - code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3) - for hit in code_results: - contexts.append(hit.get("payload", {}).get("content", "")) - - if search_docs: - query_vector = await _embed(question) - doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3) - for hit in doc_results: - contexts.append(hit.get("payload", {}).get("content", "")) - - context = "\n\n".join(contexts[:5]) - answer = await _ask_llm(question, context) - - return { - "success": True, - "question": question, - "context_count": len(contexts), - "answer": answer, - } - -# ── 메인 ───────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002 - logging.info(f"Starting RAG worker on port {port}") - uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/.rooBackup/2026-05-03-141200/mcp-server/worker/pid_worker.py b/.rooBackup/2026-05-03-141200/mcp-server/worker/pid_worker.py deleted file mode 100644 index 32d924d..0000000 --- a/.rooBackup/2026-05-03-141200/mcp-server/worker/pid_worker.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""P&ID 파싱 전용 워커 프로세스 - -Usage: python pid_worker.py - -담당 도구: - extract_pid_tags, match_pid_tags, - parse_pid_dxf, parse_pid_pdf, parse_pid_drawing, - build_pid_graph_parallel, analyze_pid_impact -""" - -from __future__ import annotations -import sys -import os - -# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근) -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import io -import json -import asyncio -import signal -import logging -import re -from functools import lru_cache - -from fastapi import FastAPI, Request -import uvicorn - -# ── 설정 ───────────────────────────────────────────────────────────────────── - -VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1") -VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-Coder-Next-FP8") -DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform") -DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10")) - -_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -STORAGE_DIR = os.path.join(_SERVER_DIR, "storage") - -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [pid_worker] %(levelname)s %(message)s", -) - -app = FastAPI() - -# ── 싱글톤 ─────────────────────────────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -@lru_cache(maxsize=1) -def _ocr(): - from paddleocr import PaddleOCR - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False) - except Exception: - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise - -# ── DB ─────────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - -# ── 텍스트 추출 ────────────────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - import fitz - doc = fitz.open(filepath) - return "\n".join(page.get_text() for page in doc) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - import fitz - from PIL import Image - import numpy as np - doc = fitz.open(filepath) - all_texts = [] - for page in doc: - mat = fitz.Matrix(300 / 72) - pix = page.get_pixmap(matrix=mat) - img = Image.open(io.BytesIO(pix.tobytes("png"))) - result = _ocr().ocr(np.array(img), cls=True) - if result and result[0]: - all_texts.extend(line[1][0] for line in result[0]) - return "\n".join(all_texts) - -# ── JSON 배열 파싱 유틸 ─────────────────────────────────────────────────────── - -def _parse_json_array(raw: str, finish_reason: str = "") -> list: - """LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함.""" - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 가장 긴 균형 잡힌 [...] 추출 - depth = 0; start = -1; best = "" - for i, c in enumerate(raw): - if c == "[": - if depth == 0: - start = i - depth += 1 - elif c == "]": - depth -= 1 - if depth == 0 and start >= 0: - cand = raw[start:i + 1] - if len(cand) > len(best): - best = cand - raw = best if best else "[]" - - try: - return json.loads(raw) - except json.JSONDecodeError: - data = [] - for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL): - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - return data - -# ── 태그 추출/매핑 도구 ─────────────────────────────────────────────────────── - -def _extract_pid_tags(text: str, source_type: str) -> str: - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",' - '"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo\n" - "- equipmentName: descriptive name if present near tag, else null\n" - "- lineNumber/pidDrawingNo: null unless explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - truncated = text[:100000] - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - logging.info(f"extract_pid_tags source={source_type} count={len(data)}") - return json.dumps({"success": True, "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _match_pid_tags(pid_tags: list, experion_tags: list) -> str: - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "- If no good match: confidence < 0.5, experionTag null\n" - "- Output ONLY the JSON array.\n" - ) - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": ( - f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n" - f"Experion Tags:\n{chr(10).join(experion_tags)}" - )}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - return json.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - -# ── 도면 파싱 도구 ──────────────────────────────────────────────────────────── - -_TAG_EXTRACT_SYSTEM = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",' - '"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "Rules:\n" - "- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n" - "- instrumentType: first 2-4 letters of tagNo\n" - "- equipmentName/lineNumber/pidDrawingNo: null if not present\n" - "- confidence: 0.0 to 1.0\n" - "- Output ONLY the JSON array, no markdown.\n" - "- If no tags found, return: []\n" -) - - -def _parse_pid_dxf(filepath: str) -> str: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({"success": True, "text": "", "count": 0, "tags": []}, - ensure_ascii=False, indent=2) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": _TAG_EXTRACT_SYSTEM}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"}, - ], - max_tokens=8192, - temperature=0.1, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - if not isinstance(data, list): - data = [] - return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath) - if not text.strip(): - return json.dumps({"success": True, "text": "", "count": 0, "tags": []}, - ensure_ascii=False, indent=2) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": _TAG_EXTRACT_SYSTEM}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - if not isinstance(data, list): - data = [] - return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _parse_pid_drawing(filepath: str) -> str: - ext = os.path.splitext(filepath)[1].lower() - if ext == ".dxf": - return _parse_pid_dxf(filepath) - elif ext == ".pdf": - return _parse_pid_pdf(filepath) - elif ext == ".dwg": - return json.dumps({ - "success": False, - "error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.", - }, ensure_ascii=False) - else: - return json.dumps({ - "success": False, - "error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf", - }, ensure_ascii=False) - -# ── 그래프 도구 ─────────────────────────────────────────────────────────────── - -async def _build_pid_graph_parallel(filepath: str) -> str: - from pipeline.extractor import PidGeometricExtractor - from pipeline.topology import PidTopologyBuilder - from pipeline.mapper import IntelligentMapper - from openai import AsyncOpenAI - - os.makedirs(STORAGE_DIR, exist_ok=True) - - # Phase 1: 기하 추출 - extractor = PidGeometricExtractor(filepath) - geo_data_path = os.path.join(STORAGE_DIR, os.path.basename(filepath) + "_geo.json") - extractor.extract_and_save(geo_data_path) - with open(geo_data_path, "r", encoding="utf-8") as f: - geo_data = json.load(f) - - # 시스템 태그 조회 - system_tags: list[str] = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"시스템 태그 조회 실패: {e}") - - # Phase 2: 1차 위상 빌더 (Mapper용 그래프) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Phase 3: 병렬 LLM 매핑 - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - transmitter_nodes = [ - n for n, d in builder.G.nodes(data=True) - if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"} - ] - valve_nodes = [ - n for n, d in builder.G.nodes(data=True) - if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"} - ] - equipment_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"} - ] - - extracted_results = await asyncio.gather( - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes), - ) - - # 매핑 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": ( - node_data["bbox"].bounds - if hasattr(node_data["bbox"], "bounds") - else node_data["bbox"] - ), - "clean_value": mapping.resolved_tag, - }) - - # Phase 4: 최종 위상 모델링 + 저장 - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = os.path.join(STORAGE_DIR, graph_id) - final_builder.save_graph(graph_path) - - logging.info(f"build_pid_graph_parallel graph_id={graph_id} " - f"nodes={final_builder.G.number_of_nodes()} " - f"edges={final_builder.G.number_of_edges()}") - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges(), - }, ensure_ascii=False) - - -def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - from pipeline.analyzer import PidAnalysisEngine - - graph_path = os.path.join(STORAGE_DIR, graph_id) - mapping_path = graph_path.replace("_graph.json", "_mapping.json") - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - return json.dumps(result, ensure_ascii=False, indent=2) - -# ── 요청 디스패처 ───────────────────────────────────────────────────────────── - -async def _dispatch(tool: str, params: dict) -> str: - try: - match tool: - # blocking 함수는 asyncio.to_thread로 스레드풀 오프로드 - case "extract_pid_tags": - return await asyncio.to_thread(_extract_pid_tags, **params) - case "match_pid_tags": - return await asyncio.to_thread(_match_pid_tags, **params) - case "parse_pid_dxf": - return await asyncio.to_thread(_parse_pid_dxf, **params) - case "parse_pid_pdf": - return await asyncio.to_thread(_parse_pid_pdf, **params) - case "parse_pid_drawing": - return await asyncio.to_thread(_parse_pid_drawing, **params) - case "analyze_pid_impact": - return await asyncio.to_thread(_analyze_pid_impact, **params) - # 이미 async — 직접 await - case "build_pid_graph_parallel": - return await _build_pid_graph_parallel(**params) - case _: - return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"}, - ensure_ascii=False) - except Exception as e: - logging.error(f"dispatch error tool={tool}: {e}", exc_info=True) - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -# ── 종료 예약 ───────────────────────────────────────────────────────────────── - -def _schedule_shutdown(): - """응답 전송 완료 후 0.5초 뒤 프로세스 종료 예약.""" - async def _do(): - await asyncio.sleep(0.5) - os.kill(os.getpid(), signal.SIGTERM) - asyncio.create_task(_do()) - -# ── HTTP 엔드포인트 ─────────────────────────────────────────────────────────── - -@app.get("/health") -async def health(): - return {"status": "ok"} - - -@app.post("/execute") -async def execute(request: Request): - body = await request.json() - return await _dispatch(body["tool"], body["params"]) - - -@app.post("/execute/one_shot") -async def execute_one_shot(request: Request): - """요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용).""" - body = await request.json() - result = await _dispatch(body["tool"], body["params"]) - _schedule_shutdown() - return result - -# ── 진입점 ─────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004 - os.makedirs(STORAGE_DIR, exist_ok=True) - uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") - - - diff --git a/.rooBackup/2026-05-03_012847/mcp-server/server.py b/.rooBackup/2026-05-03_012847/mcp-server/server.py deleted file mode 100644 index 4f03da5..0000000 --- a/.rooBackup/2026-05-03_012847/mcp-server/server.py +++ /dev/null @@ -1,1101 +0,0 @@ -#!/usr/bin/env python3 -""" -ExperionCrawler Unified MCP Server -- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen/Qwen3-Coder-Next-FP8 -- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행 -- 사용처: - stdio 모드 (기본): Claude Code MCP / Roo Code MCP - HTTP 모드 (--http): C# McpClient (localhost:5001) -""" - -from __future__ import annotations -import sys -import json -import logging -import httpx -from functools import lru_cache -from mcp.server.fastmcp import FastMCP - -logging.basicConfig(level=logging.WARNING, stream=sys.stderr) - -# ── 설정 ────────────────────────────────────────────────────────────────────── -QDRANT_URL = "http://localhost:6333" -OLLAMA_URL = "http://localhost:11434" -EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" - -# Qdrant 컬렉션 -COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 -COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks) - -# PostgreSQL 연결 -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 # 초 - -# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식 -mcp = FastMCP( - "iiot-rag", - port=5001, - json_response=True, - stateless_http=True, -) - -# Pipeline Imports -from pipeline.extractor import PidGeometricExtractor -from pipeline.topology import PidTopologyBuilder -from pipeline.mapper import IntelligentMapper -from pipeline.analyzer import PidAnalysisEngine -import networkx as nx -import os -import asyncio - -# ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── - -def _embed(text: str) -> list[float]: - """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - with httpx.Client(timeout=30) as client: - resp = client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] - -# ── LLM (vLLM / Qwen/Qwen3-Coder-Next-FP8) ───────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -# ── PaddleOCR 싱글톤 (PDF fallback용) ────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _ocr(): - """PaddleOCR 인스턴스 (한/영, GPU). 첫 호출 시 ~50MB 모델 다운로드.""" - from paddleocr import PaddleOCR - import os - - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - ocr = PaddleOCR( - use_angle_cls=True, - lang="korean", - use_gpu=use_gpu, - show_log=False, - ) - return ocr - except Exception as e: - # GPU 실패 시 CPU 폴백 - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise e - - -# ── DXF/PDF 텍스트 추출 헬퍼 ─────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - """ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거).""" - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - """PyMuPDF로 PDF 파일에서 텍스트 추출.""" - import fitz # pymupdf - doc = fitz.open(filepath) - texts = [] - for page in doc: - texts.append(page.get_text()) - return "\n".join(texts) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - """PaddleOCR로 PDF에서 이미지 추출 후 OCR (고정밀도).""" - import fitz # pymupdf - from PIL import Image - import numpy as np - - doc = fitz.open(filepath) - all_texts = [] - - for page_idx, page in enumerate(doc): - # 페이지를 이미지로 변환 - mat = fitz.Matrix(300 / 72) # 300 DPI - pix = page.get_pixmap(matrix=mat) - img_data = pix.tobytes("png") - img = Image.open(__import__("io").BytesIO(img_data)) - - # OCR 실행 - result = _ocr().ocr(np.array(img), cls=True) - if result[0]: - for line in result[0]: - all_texts.append(line[1][0]) - - return "\n".join(all_texts) - - -def _convert_dwg_to_dxf_dxflib(filepath: str) -> str: - """libreoffice로 DWG를 DXF로 변환.""" - import subprocess - import os - - dxf_path = filepath.replace(".dwg", ".dxf") - - try: - # LibreOffice로 변환 - result = subprocess.run( - [ - "libreoffice", - "--headless", - "--convert-to", "dxf:AutoCAD DXF", - "--outdir", os.path.dirname(filepath) or ".", - filepath - ], - check=True, - timeout=120, - capture_output=True, - text=True - ) - - if os.path.exists(dxf_path): - return dxf_path - else: - raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.") - - except subprocess.CalledProcessError as e: - raise Exception(f"LibreOffice 변환 실패: {e.stderr}") - - -# ── Qdrant 검색 헬퍼 ────────────────────────────────────────────────────────── - -def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str: - vec = _embed(query) - with httpx.Client(timeout=20) as client: - resp = client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": vec, - "limit": top_k, - "with_payload": True, - "score_threshold": threshold, - }, - ) - resp.raise_for_status() - hits = resp.json().get("result", []) - - if not hits: - return "관련 결과 없음." - - parts = [] - for h in hits: - p = h.get("payload", {}) - file_path = p.get("filePath", p.get("path", "unknown")) - chunk = p.get("codeChunk", p.get("content", p.get("text", ""))) - start_line = p.get("startLine", "") - loc = f"{file_path}:{start_line}" if start_line else file_path - parts.append(f"[score={h['score']:.3f}] {loc}\n```\n{chunk[:700]}\n```") - - return "\n\n---\n\n".join(parts) - -# ── DB 헬퍼 ────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - """PostgreSQL DB 연결 획득.""" - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - - -def _validate_sql(sql: str) -> tuple[bool, str]: - """SQL 안전 검증 — SELECT만 허용, 위험 키워드 차단.""" - if len(sql) > 2000: - return False, "쿼리 길이 2000자를 초과했습니다." - dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'] - sql_upper = sql.upper() - for kw in dangerous: - if kw in sql_upper: - return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다." - if not sql_upper.strip().startswith('SELECT'): - return False, "단순 SELECT 쿼리만 허용됩니다." - if '..' in sql or '~' in sql: - return False, "파일 경로 표현은 허용되지 않습니다." - return True, "" - - -# DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용 -_DB_SCHEMA = """ -PostgreSQL 시계열 데이터베이스 스키마 - -테이블: history_table (시계열 이력) - tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분 - node_id TEXT - OPC UA 노드 ID - value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요 - recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초 - -테이블: realtime_table (실시간 최신값) - tagname TEXT - 태그명 (모두 소문자) - node_id TEXT - OPC UA 노드 ID - livevalue TEXT - 현재값 - timestamp TIMESTAMPTZ - 최종 갱신 시각 - -N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): - 1분 버킷: date_trunc('minute', recorded_at) AS bucket - 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket - 5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket - 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket - N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket - -예시 (2분 간격, 여러 태그): - SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, - tagname, AVG(value::double precision) AS avg_val - FROM history_table - WHERE tagname IN ('tag1', 'tag2') - AND recorded_at >= NOW() - INTERVAL '3 hours' - GROUP BY bucket, tagname ORDER BY bucket, tagname - -규칙: - - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) - - tagname은 모두 소문자로 정확히 입력 - - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수 - - time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용 -""" - -# ── RAG 도구 ───────────────────────────────────────────────────────────────── - -@mcp.tool() -def search_codebase(query: str, top_k: int = 6) -> str: - """ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드). - Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함. - - 사용 시점: ExperionCrawler 코드의 구현 방법, 버그, 구조를 알고 싶을 때. - ⚠️ Experion HS R530 제품 동작/설정/스펙을 알고 싶으면 search_r530_docs 사용. - - Args: - query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스") - top_k: 반환 결과 수 (기본 6) - """ - return _search(COL_CODEBASE, query, top_k) - - -@mcp.tool() -def search_r530_docs(query: str, top_k: int = 5) -> str: - """Honeywell Experion HS R530 공식 제품 문서 검색. - ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함. - - 사용 시점: Experion HS R530의 OPC UA 설정, 인증서, 보안 정책, 포인트 주소 형식, - 채널/컨트롤러 속성, 문제해결 등 제품 스펙과 동작을 알고 싶을 때. - - Args: - query: 검색어 (예: "certificate configuration", "endpoint security policy") - top_k: 반환 결과 수 (기본 5) - """ - return _search(COL_OPC_DOCS, query, top_k) - - -@mcp.tool() -def ask_iiot_llm(question: str, context: str = "") -> str: - """Qwen/Qwen3-Coder-Next-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). - - 사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨 - 종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문. - - Args: - question: 질문 내용 - context: (선택) search_codebase 또는 search_r530_docs 검색 결과 - """ - system = ( - "당신은 IIoT(산업용 IoT), OPC UA, Honeywell Experion PKS/HS R530 전문가입니다.\n" - "컨텍스트가 제공된 경우 컨텍스트를 우선 근거로 삼아 한국어로 답변합니다.\n" - "컨텍스트 출처가 'Experion HS R530 공식 문서'인지 'ExperionCrawler 코드'인지 명확히 구분하여 설명합니다." - ) - user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user_msg}, - ], - max_tokens=2048, - temperature=0.1, - ) - return resp.choices[0].message.content or "(응답 없음)" - - -@mcp.tool() -def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """검색 → Qwen/Qwen3-Coder-Next-FP8 답변 생성 (통합 RAG). - - 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). - ExperionCrawler 코드도 함께 보려면 search_code=True 추가. - - Args: - question: 질문 - search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True) - search_code: ExperionCrawler 소스코드 검색 여부 (기본 False) - """ - context_parts: list[str] = [] - if search_docs: - context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}") - if search_code: - context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}") - return ask_iiot_llm(question, "\n\n".join(context_parts)) - - -# ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── - -@mcp.tool() -def run_sql(sql: str) -> str: - """SQL 쿼리 실행 (SELECT만 허용). - - Args: - sql: 실행할 SELECT SQL 문자열 - - Returns: - JSON: { success, columns, count, data } 또는 { success, error } - """ - valid, err = _validate_sql(sql) - if not valid: - return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) - - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute(sql) - rows = cur.fetchall() - columns = [desc[0] for desc in cur.description] - result_data = [dict(zip(columns, row)) for row in rows] - return json.dumps({ - "success": True, - "columns": columns, - "count": len(result_data), - "data": result_data - }, ensure_ascii=False, default=str) - except Exception as e: - return json.dumps({"success": False, "error": f"SQL 실행 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """과거 값(PV) 히스토리 조회. - - Args: - tag_names: 태그 이름 목록 (예: ["ficq-6113.pv", "ti-6101.pv"]) - time_from: 시작 시간 (ISO 8601, 예: "2026-04-01T00:00:00") - time_to: 종료 시간 (ISO 8601, 예: "2026-04-02T00:00:00") - limit: 반환 행 수 제한 (기본 100, 최대 5000) - - Returns: - JSON: { success, tag_names, time_range, limit, data } - """ - try: - limit = min(limit, 5000) - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, recorded_at, value - FROM history_table - WHERE tagname = ANY(%s) - AND recorded_at >= %s AND recorded_at <= %s - ORDER BY recorded_at, tagname - LIMIT %s""", - (tag_names, time_from, time_to, limit) - ) - rows = cur.fetchall() - data = [{"tag_name": r[0], "timestamp": r[1].isoformat(), "value": r[2]} for r in rows] - return json.dumps({ - "success": True, - "tag_names": tag_names, - "time_range": f"{time_from} ~ {time_to}", - "count": len(data), - "data": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"히스토리 쿼리 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def get_tag_metadata(query: str, limit: int = 10) -> str: - """태그 메타데이터 검색 (realtime_table 기반). - - Args: - query: 태그명 검색어 (패턴 매칭) - limit: 반환 태그 수 제한 (기본 10) - - Returns: - JSON: { success, query, count, tags } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute( - """SELECT tagname, livevalue, timestamp, node_id - FROM realtime_table - WHERE tagname ILIKE %s - ORDER BY tagname LIMIT %s""", - (f"%{query}%", limit) - ) - rows = cur.fetchall() - tags = [{"tag_name": r[0], "current_value": r[1], - "last_updated": r[2].isoformat() if r[2] else None, - "node_id": r[3]} for r in rows] - return json.dumps({"success": True, "query": query, "count": len(tags), "tags": tags}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"태그 메타데이터 검색 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def list_drawings(unit_no: str | None = None) -> str: - """단위별 도면 목록 조회 (node_map_master.name 기반). - - Args: - unit_no: 단위 번호 접두사 (예: "A", "B"). None이면 전체 목록 - - Returns: - JSON: { success, unit_no, count, names } - """ - try: - conn = _get_db_connection() - with conn.cursor() as cur: - if unit_no: - cur.execute( - "SELECT DISTINCT name FROM node_map_master WHERE name ILIKE %s ORDER BY name LIMIT 100", - (f"{unit_no}%",) - ) - else: - cur.execute("SELECT DISTINCT name FROM node_map_master ORDER BY name LIMIT 100") - rows = cur.fetchall() - return json.dumps({"success": True, "unit_no": unit_no, - "count": len(rows), "names": [r[0] for r in rows]}, - ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"도면 목록 조회 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def query_with_nl(question: str) -> str: - """자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다. - - Args: - question: 자연어 질문 (예: "FICQ-6113.PV 최근 1시간 값을 1분 단위로 표시") - - Returns: - JSON: { sql, success, columns, count, data } 또는 { sql, success, error } - """ - system = ( - "You are a PostgreSQL SQL expert.\n" - "Convert the user's question into a SELECT SQL using the schema below.\n" - "IMPORTANT rules:\n" - "- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n" - "- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n" - "- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n" - "- INTERVAL rule:\n" - " * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n" - " use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n" - " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" - " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" - "- Current year is 2026. '4월 27일' means 2026-04-27.\n" - "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" - "- value column is TEXT; cast with ::double precision only when aggregating.\n" - "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" - "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" - "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" - f"{_DB_SCHEMA}" - ) - try: - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": question}, - ], - max_tokens=8192, - temperature=0.1, - ) - sql = (resp.choices[0].message.content or "").strip() - # 마크다운 코드 블록 제거 - if sql.startswith("```"): - lines = sql.splitlines() - sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() - if not sql: - return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) - except Exception as e: - return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False) - - # SQL 실행 - raw = run_sql(sql) - result = json.loads(raw) - result["sql"] = sql - - # long format → pivot 변환 (tagname 컬럼이 있으면 자동 PIVOT) - if result.get("success") and "data" in result: - cols = result.get("columns", []) - data = result["data"] - if "tagname" in cols and data: - time_col = next((c for c in cols if c not in ("tagname", "value", "livevalue", "avg_val")), None) - val_col = next((c for c in ("avg_val", "value") if c in cols), cols[-1]) - if time_col: - tag_names_list = sorted(dict.fromkeys(row["tagname"] for row in data)) - pivoted: dict = {} - for row in data: - key = str(row[time_col]) - if key not in pivoted: - pivoted[key] = {time_col: row[time_col]} - pivoted[key][row["tagname"]] = row.get(val_col) - result["data"] = list(pivoted.values()) - result["columns"] = [time_col] + tag_names_list - result["count"] = len(result["data"]) - - return json.dumps(result, ensure_ascii=False, default=str) - - -# ── P&ID 추출 도구 ────────────────────────────────────────────────────────────── - -@mcp.tool() -def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 도면(DXF/PDF)에서 태그 정보를 추출합니다. - - Args: - text: DXF/PDF에서 추출한 텍스트 - source_type: 'dxf' 또는 'pdf' - - Returns: - JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] } - """ - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV","lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo (e.g. FCV, P, T, VG, BT, DP, PSV)\n" - "- equipmentName: descriptive name if present in text near the tag, else null\n" - "- lineNumber: null unless a line number is explicitly associated\n" - "- pidDrawingNo: null unless a drawing number is explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous ones\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - - import logging - import re - import json as json_module - - try: - truncated_text = text[:100000] if len(text) > 100000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # finish_reason=length 로 잘린 경우: 마지막 완전한 객체까지 살린 뒤 배열 닫기 - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 유효한 JSON 배열 추출 (가장 긴 균형 잡힌 [...] 선택) - def _extract_array(s: str) -> str: - depth = 0; start = -1; best = "" - for i, c in enumerate(s): - if c == '[': - if depth == 0: start = i - depth += 1 - elif c == ']': - depth -= 1 - if depth == 0 and start >= 0: - cand = s[start:i+1] - if len(cand) > len(best): best = cand - return best if best else "[]" - - raw = _extract_array(raw) - - # JSON 파싱 — 실패 시 개별 객체 추출로 폴백 - try: - data = json_module.loads(raw) - except json_module.JSONDecodeError: - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json_module.loads(obj)) - except json_module.JSONDecodeError: - pass - if not data: - return json_module.dumps({"success": False, "count": 0, "tags": []}, ensure_ascii=False) - - logging.info(f"[extract_pid_tags] source={source_type} count={len(data) if isinstance(data, list) else 0}") - - return json_module.dumps({ - "success": True, - "count": len(data), - "tags": data - }, ensure_ascii=False, indent=2) - - except Exception as e: - logging.error(f"P&ID 태그 추출 실패: {e}") - logging.error(f"Raw response: {raw[:1000]}") - return json.dumps({"success": False, "error": f"P&ID 태그 추출 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 태그를 Experion 태그에 매핑합니다. - - Args: - pid_tags: P&ID에서 추출한 태그 목록 (예: ["FT-101", "PT-201"]) - experion_tags: Experion 시스템 태그 목록 (예: ["ficq-6113.pv", "pt-201.pv"]) - - Returns: - JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] } - """ - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "IMPORTANT rules:\n" - "- pidTag: The original P&ID tag from input\n" - "- experionTag: The matched Experion tag (lowercase, with .pv/.sp/.mv suffix)\n" - "- confidence: 0.0 to 1.0 based on match quality\n" - "- If no good match found, set confidence < 0.5 and leave experionTag null\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no matches found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - import re - import json as json_module - - try: - pid_str = "\n".join(pid_tags) - experion_str = "\n".join(experion_tags) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - - raw = (resp.choices[0].message.content or "").strip() - finish_reason = resp.choices[0].finish_reason - - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - match = re.search(r'\[.*\]', raw, re.DOTALL) - raw = match.group(0) if match else "[]" - - data = json_module.loads(raw) - return json_module.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - - except Exception as e: - return json.dumps({"success": False, "error": f"P&ID 태그 매핑 실패: {e}"}, ensure_ascii=False) - - -# ── P&ID 파싱 도구 (DXF/PDF/DWG) ─────────────────────────────────────────────── - - -@mcp.tool() -def parse_pid_dxf(filepath: str) -> str: - """ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: DXF 파일 경로 - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], # 제한 - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"DXF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """PyMuPDF 기반 PDF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. - - Args: - filepath: PDF 파일 경로 - use_ocr: OCR 사용 여부 (기본 True, 고정밀도) - - Returns: - JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } - """ - try: - if use_ocr: - text = _extract_text_from_pdf_ocr(filepath) - else: - text = _extract_text_from_pdf(filepath) - - if not text.strip(): - return json.dumps({ - "success": True, - "text": "", - "count": 0, - "tags": [] - }, ensure_ascii=False, indent=2) - - # LLM으로 태그 추출 - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array of objects with the following structure:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FT" OR "FIT OR "TIA","lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "IMPORTANT rules:\n" - "- tagNo: Standard tag format with these patterns:\n" - " * Instrument: [Function][Loop]-[Number] (e.g., FT-101, PT-201, LI-301, FICQ-6113)\n" - " * Equipment: [Type]-[Number] (e.g., P-10101, T-10100, C-9111, E-10119)\n" - " * Complex: [Type]-[Number]-[Size]-[Class]-[Material]-[Option] (e.g., VG-6203-15A-F1A-n, CD-10513-40A-S1A-H50)\n" - " * Real examples from DXF: BT-6200, SARF-#6-PID-002, P-6101, DP-10101, CHS-6630-100A-F-C50\n" - "- instrumentType: First 2-4 letters of tagNo (FIT, PT, LI, FICQ, TCV, FCV, PCV, PG, TG, etc.)\n" - "- equipmentName: Descriptive name if available, otherwise null\n" - "- lineNumber: Line number if available, otherwise null\n" - "- pidDrawingNo: Drawing number if available, otherwise null\n" - "- confidence: 0.0 to 1.0 based on how clearly the tag was identified\n" - "- Do NOT include any explanation, only the JSON array.\n" - "- If no tags found, return an empty array: []\n" - "- temperature=0.1 for deterministic output.\n" - ) - - truncated_text = text[:12000] if len(text) > 12000 else text - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - - raw = (resp.choices[0].message.content or "").strip() - - # 마크다운 코드 블록 제거 - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - # JSON 배열 추출 - import re - match = re.search(r'\[.*\]', raw, re.DOTALL) - if match: - raw = match.group(0) - - # JSON 파싱 시도 - try: - data = json.loads(raw) - except json.JSONDecodeError: - # JSON 배열 추출 시도 (더 엄격한 패턴) - match = re.search(r'\[\s*\{.*?\}\s*\]', raw, re.DOTALL) - if match: - raw_clean = match.group(0) - try: - data = json.loads(raw_clean) - except json.JSONDecodeError: - # 마지막으로, JSON 배열을 개별 객체로 분리하여 파싱 시도 - objects = re.findall(r'\{[^{}]*\}', raw, re.DOTALL) - data = [] - for obj in objects: - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - - if not isinstance(data, list): - data = [] - - return json.dumps({ - "success": True, - "text": text[:10000], - "count": len(text), - "tags": data - }, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"PDF 파싱 실패: {e}"}, ensure_ascii=False) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """ - 분산 처리 기법을 적용한 P&ID 그래프 생성 툴. - 전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다. - """ - try: - # 1. 전처리 (Phase 1: Geometric Extraction) - extractor = PidGeometricExtractor(filepath) - geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json" - geo_data_list = extractor.extract_and_save(geo_data_path) - - # geo_data_list는 경로를 반환하므로 다시 로드 - with open(geo_data_path, 'r', encoding='utf-8') as f: - geo_data = json.load(f) - - # 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping) - # 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값) - # 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함 - system_tags = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"Failed to fetch system tags: {e}") - - # 그래프 임시 생성 (Mapper가 위상 정보를 사용하므로 필요) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Mapper 설정 - from openai import AsyncOpenAI - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - # 분류별 노드 분리 - nodes = list(builder.G.nodes()) - transmitter_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FIT', 'FT', 'LT', 'PT', 'TE']] # 단순화된 필터 - valve_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FCV', 'LCV', 'TCV', 'PCV', 'XV']] - equipment_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('type') not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - # 병렬 호출 (vLLM Batching 유도) - tasks = [ - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes) - ] - extracted_results = await asyncio.gather(*tasks) - - # 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - # TopologyBuilder가 기대하는 형식으로 변환 - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": node_data['bbox'].bounds if hasattr(node_data['bbox'], 'bounds') else node_data['bbox'], - "clean_value": mapping.resolved_tag - }) - - # 3. 최종 위상 모델링 (Phase 2) - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - # 4. 저장 - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = f"mcp-server/storage/{graph_id}" - final_builder.save_graph(graph_path) - - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges() - }, ensure_ascii=False) - - except Exception as e: - logging.error(f"build_pid_graph_parallel failed: {e}") - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -@mcp.tool() -def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """ - 구축된 그래프를 기반으로 특정 설비 장애 시 영향도 분석을 수행합니다. - """ - try: - graph_path = f"mcp-server/storage/{graph_id}" - mapping_path = graph_path.replace("_graph.json", "_mapping.json") # 매핑 파일이 따로 저장된다고 가정 - - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - - return json.dumps(result, ensure_ascii=False, indent=2) - except Exception as e: - return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False) - -@mcp.tool() -def parse_pid_drawing(filepath: str) -> str: - """확장자 자동 감지하여 P&ID 도면 파싱. - - Args: - filepath: DXF/DWG/PDF 파일 경로 - - Returns: - JSON: { success, text, count, tags, format } - """ - import os - ext = os.path.splitext(filepath)[1].lower() - - if ext == ".dxf": - return parse_pid_dxf(filepath) - elif ext == ".dwg": - # DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다. - # Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다. - return json.dumps({ - "success": False, - "error": "DWG 파일은 현재 직접 파싱할 수 없습니다.\n" + - "사전에 DXF로 변환하여 업로드해 주세요.\n" + - "\n변환 방법:\n" + - "1. Windows에서 AutoCAD 또는 ODA File Converter 사용\n" + - "2. 온라인 DWG → DXF 변환기 사용\n" + - "3. LibreOffice Draw (Windows/macOS 전용) 사용" - }, ensure_ascii=False) - elif ext == ".pdf": - return parse_pid_pdf(filepath) - else: - return json.dumps({ - "success": False, - "error": f"Unsupported format: {ext}. Supported: .dxf, .dwg, .pdf" - }, ensure_ascii=False) - - -# ── 엔트리포인트 ────────────────────────────────────────────────────────────── - -def main(): - """HTTP 모드로 실행 — C# McpClient (localhost:5001) 용.""" - mcp.run(transport="streamable-http") - - -if __name__ == "__main__": - # --http 플래그: HTTP 모드 (C# McpClient 용) - # 플래그 없음: stdio 모드 (Claude Code / Roo Code MCP 용) - if "--http" in sys.argv: - mcp.run(transport="streamable-http") - else: - mcp.run(transport="stdio") diff --git a/.rooBackup/2026-05-03_013114/mcp-server/worker/pid_worker.py b/.rooBackup/2026-05-03_013114/mcp-server/worker/pid_worker.py deleted file mode 100644 index 49fb144..0000000 --- a/.rooBackup/2026-05-03_013114/mcp-server/worker/pid_worker.py +++ /dev/null @@ -1,609 +0,0 @@ -#!/usr/bin/env python3 -"""P&ID 파싱 전용 워커 프로세스 - -Usage: python pid_worker.py - -담당 도구: - extract_pid_tags, match_pid_tags, - parse_pid_dxf, parse_pid_pdf, parse_pid_drawing, - build_pid_graph_parallel, analyze_pid_impact -""" - -from __future__ import annotations -import sys -import os - -# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근) -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import io -import json -import asyncio -import signal -import logging -import re -from functools import lru_cache - -from fastapi import FastAPI, Request -import uvicorn - -# ── 설정 ───────────────────────────────────────────────────────────────────── - -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 - -_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -STORAGE_DIR = os.path.join(_SERVER_DIR, "storage") - -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [pid_worker] %(levelname)s %(message)s", -) - -app = FastAPI() - -# ── 싱글톤 ─────────────────────────────────────────────────────────────────── - -@lru_cache(maxsize=1) -def _llm(): - from openai import OpenAI - return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - - -@lru_cache(maxsize=1) -def _ocr(): - from paddleocr import PaddleOCR - use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true" - try: - return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False) - except Exception: - if use_gpu: - os.environ["PADDLE_USE_GPU"] = "false" - return _ocr() - raise - -# ── DB ─────────────────────────────────────────────────────────────────────── - -def _get_db_connection(): - import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) - -# ── 텍스트 추출 ────────────────────────────────────────────────────────────── - -def _extract_text_from_dxf(filepath: str) -> str: - import ezdxf - from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) - - -def _extract_text_from_pdf(filepath: str) -> str: - import fitz - doc = fitz.open(filepath) - return "\n".join(page.get_text() for page in doc) - - -def _extract_text_from_pdf_ocr(filepath: str) -> str: - import fitz - from PIL import Image - import numpy as np - doc = fitz.open(filepath) - all_texts = [] - for page in doc: - mat = fitz.Matrix(300 / 72) - pix = page.get_pixmap(matrix=mat) - img = Image.open(io.BytesIO(pix.tobytes("png"))) - result = _ocr().ocr(np.array(img), cls=True) - if result and result[0]: - all_texts.extend(line[1][0] for line in result[0]) - return "\n".join(all_texts) - -# ── JSON 배열 파싱 유틸 ─────────────────────────────────────────────────────── - -def _parse_json_array(raw: str, finish_reason: str = "") -> list: - """LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함.""" - if raw.startswith("```"): - lines = raw.splitlines() - raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() - - if finish_reason == "length": - last_close = raw.rfind("}") - if last_close != -1: - raw = raw[:last_close + 1] + "]" - - # 가장 긴 균형 잡힌 [...] 추출 - depth = 0; start = -1; best = "" - for i, c in enumerate(raw): - if c == "[": - if depth == 0: - start = i - depth += 1 - elif c == "]": - depth -= 1 - if depth == 0 and start >= 0: - cand = raw[start:i + 1] - if len(cand) > len(best): - best = cand - raw = best if best else "[]" - - try: - return json.loads(raw) - except json.JSONDecodeError: - data = [] - for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL): - try: - data.append(json.loads(obj)) - except json.JSONDecodeError: - pass - return data - -# ── 태그 추출/매핑 도구 ─────────────────────────────────────────────────────── - -def _extract_pid_tags(text: str, source_type: str) -> str: - system = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract all instrument and equipment tags from the provided text.\n" - "Return ONLY a valid JSON array. Each element must have exactly these fields:\n" - '{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",' - '"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n' - "Rules:\n" - "- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n" - " Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n" - "- instrumentType: leading letters of tagNo\n" - "- equipmentName: descriptive name if present near tag, else null\n" - "- lineNumber/pidDrawingNo: null unless explicitly associated\n" - "- confidence: 0.95 for clear tags, lower for ambiguous\n" - "- Output ONLY the JSON array, no markdown, no explanation.\n" - "- If no tags found, return: []\n" - ) - truncated = text[:100000] - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - logging.info(f"extract_pid_tags source={source_type} count={len(data)}") - return json.dumps({"success": True, "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _match_pid_tags(pid_tags: list, experion_tags: list) -> str: - system = ( - "You are a P&ID to Experion tag matching expert.\n" - "Match P&ID tags to Experion tags based on similarity.\n" - "Return ONLY a JSON array:\n" - '[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n' - "- If no good match: confidence < 0.5, experionTag null\n" - "- Output ONLY the JSON array.\n" - ) - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": ( - f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n" - f"Experion Tags:\n{chr(10).join(experion_tags)}" - )}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - return json.dumps({"success": True, "count": len(data), "mappings": data}, - ensure_ascii=False, indent=2) - -# ── 도면 파싱 도구 ──────────────────────────────────────────────────────────── - -_TAG_EXTRACT_SYSTEM = ( - "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" - "Extract instrument and equipment tags from the provided text.\n" - "Return ONLY a JSON array:\n" - '[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",' - '"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n' - "Rules:\n" - "- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n" - "- instrumentType: first 2-4 letters of tagNo\n" - "- equipmentName/lineNumber/pidDrawingNo: null if not present\n" - "- confidence: 0.0 to 1.0\n" - "- Output ONLY the JSON array, no markdown.\n" - "- If no tags found, return: []\n" -) - - -def _parse_pid_dxf(filepath: str) -> str: - text = _extract_text_from_dxf(filepath) - if not text.strip(): - return json.dumps({"success": True, "text": "", "count": 0, "tags": []}, - ensure_ascii=False, indent=2) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": _TAG_EXTRACT_SYSTEM}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{text[:12000]}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - if not isinstance(data, list): - data = [] - return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath) - if not text.strip(): - return json.dumps({"success": True, "text": "", "count": 0, "tags": []}, - ensure_ascii=False, indent=2) - - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": _TAG_EXTRACT_SYSTEM}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"}, - ], - max_tokens=4096, - temperature=0.1, - ) - raw = (resp.choices[0].message.content or "").strip() - data = _parse_json_array(raw, resp.choices[0].finish_reason) - if not isinstance(data, list): - data = [] - return json.dumps({"success": True, "text": text[:10000], "count": len(data), "tags": data}, - ensure_ascii=False, indent=2) - - -def _parse_pid_drawing(filepath: str) -> str: - ext = os.path.splitext(filepath)[1].lower() - if ext == ".dxf": - return _parse_pid_dxf(filepath) - elif ext == ".pdf": - return _parse_pid_pdf(filepath) - elif ext == ".dwg": - return json.dumps({ - "success": False, - "error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.", - }, ensure_ascii=False) - else: - return json.dumps({ - "success": False, - "error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf", - }, ensure_ascii=False) - -# ── 그래프 도구 ─────────────────────────────────────────────────────────────── - -async def _build_pid_graph_parallel(filepath: str) -> str: - from pipeline.extractor import PidGeometricExtractor - from pipeline.topology import PidTopologyBuilder - from pipeline.mapper import IntelligentMapper - from openai import AsyncOpenAI - - os.makedirs(STORAGE_DIR, exist_ok=True) - - # Phase 1: 기하 추출 - extractor = PidGeometricExtractor(filepath) - geo_data_path = os.path.join(STORAGE_DIR, os.path.basename(filepath) + "_geo.json") - extractor.extract_and_save(geo_data_path) - with open(geo_data_path, "r", encoding="utf-8") as f: - geo_data = json.load(f) - - # 시스템 태그 조회 - system_tags: list[str] = [] - try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] - except Exception as e: - logging.warning(f"시스템 태그 조회 실패: {e}") - - # Phase 2: 1차 위상 빌더 (Mapper용 그래프) - builder = PidTopologyBuilder(geo_data) - builder.build_graph() - - # Phase 3: 병렬 LLM 매핑 - api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") - mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client) - - transmitter_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("value", "").upper() in {"FIT", "FT", "LT", "PT", "TE"} - ] - valve_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("value", "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"} - ] - equipment_nodes = [ - n for n, d in builder.G.nodes(data=True) - if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"} - ] - - extracted_results = await asyncio.gather( - mapper.extract_transmitters(transmitter_nodes), - mapper.extract_valves(valve_nodes), - mapper.extract_equipment(equipment_nodes), - ) - - # 매핑 결과 통합 - all_mapped_tags = [] - for res_dict in extracted_results: - for node_id, mapping in res_dict.items(): - if mapping.resolved_tag != "UNKNOWN": - node_data = builder.G.nodes[node_id] - all_mapped_tags.append({ - "entity_id": node_id, - "tagName": mapping.resolved_tag, - "bbox": ( - node_data["bbox"].bounds - if hasattr(node_data["bbox"], "bounds") - else node_data["bbox"] - ), - "clean_value": mapping.resolved_tag, - }) - - # Phase 4: 최종 위상 모델링 + 저장 - final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags) - final_builder.build_graph() - - graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json") - graph_path = os.path.join(STORAGE_DIR, graph_id) - final_builder.save_graph(graph_path) - - logging.info(f"build_pid_graph_parallel graph_id={graph_id} " - f"nodes={final_builder.G.number_of_nodes()} " - f"edges={final_builder.G.number_of_edges()}") - return json.dumps({ - "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges(), - }, ensure_ascii=False) - - -def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - from pipeline.analyzer import PidAnalysisEngine - - graph_path = os.path.join(STORAGE_DIR, graph_id) - mapping_path = graph_path.replace("_graph.json", "_mapping.json") - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) - return json.dumps(result, ensure_ascii=False, indent=2) - -# ── 요청 디스패처 ───────────────────────────────────────────────────────────── - -async def _dispatch(tool: str, params: dict) -> str: - try: - match tool: - # blocking 함수는 asyncio.to_thread로 스레드풀 오프로드 - case "extract_pid_tags": - return await asyncio.to_thread(_extract_pid_tags, **params) - case "match_pid_tags": - return await asyncio.to_thread(_match_pid_tags, **params) - case "parse_pid_dxf": - return await asyncio.to_thread(_parse_pid_dxf, **params) - case "parse_pid_pdf": - return await asyncio.to_thread(_parse_pid_pdf, **params) - case "parse_pid_drawing": - return await asyncio.to_thread(_parse_pid_drawing, **params) - case "analyze_pid_impact": - return await asyncio.to_thread(_analyze_pid_impact, **params) - # 이미 async — 직접 await - case "build_pid_graph_parallel": - return await _build_pid_graph_parallel(**params) - case _: - return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"}, - ensure_ascii=False) - except Exception as e: - logging.error(f"dispatch error tool={tool}: {e}", exc_info=True) - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) - -# ── 종료 예약 ───────────────────────────────────────────────────────────────── - -def _schedule_shutdown(): - """응답 전송 완료 후 0.5초 뒤 프로세스 종료 예약.""" - async def _do(): - await asyncio.sleep(0.5) - os.kill(os.getpid(), signal.SIGTERM) - asyncio.create_task(_do()) - -# ── HTTP 엔드포인트 ─────────────────────────────────────────────────────────── - -@app.get("/health") -async def health(): - return {"status": "ok"} - - -@app.post("/execute") -async def execute(request: Request): - body = await request.json() - return await _dispatch(body["tool"], body["params"]) - - -@app.post("/execute/one_shot") -async def execute_one_shot(request: Request): - """요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용).""" - body = await request.json() - result = await _dispatch(body["tool"], body["params"]) - _schedule_shutdown() - return result - -# ── 진입점 ─────────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004 - os.makedirs(STORAGE_DIR, exist_ok=True) - uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") - - - -❯ 이거 패러렐 프로세싱으로 만든거 맞지? - -● 솔직히 말하면 절반만 맞습니다. - - 현재 상태 - - 병렬인 것 - - build_pid_graph_parallel 내부 — asyncio.gather로 LLM 3개 동시 호출: - extracted_results = await asyncio.gather( - mapper.extract_transmitters(transmitter_nodes), # LLM 호출 1 - mapper.extract_valves(valve_nodes), # LLM 호출 2 - mapper.extract_equipment(equipment_nodes), # LLM 호출 3 - ) - 이건 server.py에서 그대로 가져온 것이고, 진짜 병렬입니다. - - 병렬이 아닌 것 - - 나머지 도구들 — _parse_pid_dxf, _parse_pid_pdf, _extract_pid_tags 등: - # 전부 동기 함수 → uvicorn 이벤트루프를 블로킹함 - def _parse_pid_dxf(filepath: str) -> str: - text = _extract_text_from_dxf(filepath) # ezdxf: blocking I/O - resp = _llm().chat.completions.create(...) # vLLM HTTP: blocking - ... - async def execute(request)에서 이 함수를 직접 호출하면 이벤트루프 전체가 멈춥니다. - - 수정 방향 - - asyncio.to_thread()로 동기 함수를 스레드풀에 오프로드: - - async def _dispatch(tool: str, params: dict) -> str: - match tool: - case "parse_pid_dxf": - # blocking → 스레드풀로 분리 - return await asyncio.to_thread(_parse_pid_dxf, **params) - case "parse_pid_pdf": - return await asyncio.to_thread(_parse_pid_pdf, **params) - case "extract_pid_tags": - return await asyncio.to_thread(_extract_pid_tags, **params) - case "match_pid_tags": - return await asyncio.to_thread(_match_pid_tags, **params) - case "analyze_pid_impact": - return await asyncio.to_thread(_analyze_pid_impact, **params) - case "build_pid_graph_parallel": - return await _build_pid_graph_parallel(**params) # 이미 async - ... - - 단, 이 워커는 계획서에서 세마포어로 동시 요청 1개만 허용하기로 했으므로 실질적 영향은 작습니다. 그래도 이벤트루프 - 블로킹은 uvicorn의 헬스체크·종료 신호 처리까지 막기 때문에 수정하는 게 맞습니다. - - 수정해드릴까요? - -✻ Cooked for 31s - -❯ 그래 - - Read 1 file (ctrl+o to expand) - -● _dispatch 함수만 수정하면 됩니다. - -● Update(worker/pid_worker.py) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Edit file - worker/pid_worker.py -╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - 402 async def _dispatch(tool: str, params: dict) -> str: - 403 try: - 404 match tool: - 405 + # blocking 함수는 asyncio.to_thread로 스레드풀 오프로드 - 406 case "extract_pid_tags": - 406 - return _extract_pid_tags(**params) - 407 + return await asyncio.to_thread(_extract_pid_tags, **params) - return await asyncio.to_thread(_match_pid_tags, **params) - case "analyze_pid_impact": - return await asyncio.to_thread(_analyze_pid_impact, **params) - case "build_pid_graph_parallel": - return await _build_pid_graph_parallel(**params) # 이미 async - ... - - 단, 이 워커는 계획서에서 세마포어로 동시 요청 1개만 허용하기로 했으므로 실질적 영향은 작습니다. 그래도 이벤트루프 - 블로킹은 uvicorn의 헬스체크·종료 신호 처리까지 막기 때문에 수정하는 게 맞습니다. - - 수정해드릴까요? - -✻ Cooked for 31s - -❯ 그래 - - Read 1 file (ctrl+o to expand) - -● _dispatch 함수만 수정하면 됩니다. - -● Update(worker/pid_worker.py) - ⎿  Added 9 lines, removed 7 lines - 402 async def _dispatch(tool: str, params: dict) -> str: - 403 try: - 404 match tool: - 405 + # blocking 함수는 asyncio.to_thread로 스레드풀 오프로드 - 406 case "extract_pid_tags": - 406 - return _extract_pid_tags(**params) - 407 + return await asyncio.to_thread(_extract_pid_tags, **params) - 408 case "match_pid_tags": - 408 - return _match_pid_tags(**params) - 409 + return await asyncio.to_thread(_match_pid_tags, **params) - 410 case "parse_pid_dxf": - 410 - return _parse_pid_dxf(**params) - 411 + return await asyncio.to_thread(_parse_pid_dxf, **params) - 412 case "parse_pid_pdf": - 412 - return _parse_pid_pdf(**params) - 413 + return await asyncio.to_thread(_parse_pid_pdf, **params) - 414 case "parse_pid_drawing": - 414 - return _parse_pid_drawing(**params) - 415 + return await asyncio.to_thread(_parse_pid_drawing, **params) - 416 + case "analyze_pid_impact": - 417 + return await asyncio.to_thread(_analyze_pid_impact, **params) - 418 + # 이미 async — 직접 await - 419 case "build_pid_graph_parallel": - 420 return await _build_pid_graph_parallel(**params) - 417 - case "analyze_pid_impact": - 418 - return _analyze_pid_impact(**params) - 421 case _: - 422 return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"}, - 423 ensure_ascii=False) - -● Bash(.venv/bin/python -m py_compile worker/pid_worker.py && echo "OK") - ⎿  OK - -● 완료. 변경된 _dispatch: - - # blocking 함수는 asyncio.to_thread로 스레드풀 오프로드 - case "extract_pid_tags": - return await asyncio.to_thread(_extract_pid_tags, **params) - case "parse_pid_dxf": - return await asyncio.to_thread(_parse_pid_dxf, **params) - ... - - # 이미 async — 직접 await - case "build_pid_graph_parallel": - return await _build_pid_graph_parallel(**params) - - 이제 ezdxf 파싱, PaddleOCR, vLLM HTTP 호출 같은 blocking 작업이 스레드풀에서 실행되어 uvicorn 이벤트루프를 막지 - 않습니다. 헬스체크(/health)와 종료 신호(SIGTERM) 처리가 요청 진행 중에도 정상 작동합니다. \ No newline at end of file diff --git a/.rooBackup/2026-05-03_08-40/mcp-server/pipeline/topology.py b/.rooBackup/2026-05-03_08-40/mcp-server/pipeline/topology.py deleted file mode 100644 index 3dc7899..0000000 --- a/.rooBackup/2026-05-03_08-40/mcp-server/pipeline/topology.py +++ /dev/null @@ -1,123 +0,0 @@ -import networkx as nx -from shapely.geometry import box, Point, LineString -import json -from typing import List, Dict, Any, Optional, Tuple - -class PidTopologyBuilder: - def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None): - """ - - geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts) - - all_extracted_tags: 통합된 태그 리스트 - - config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값 - """ - self.data = geometric_data - self.all_tags = all_extracted_tags if all_extracted_tags else [] - self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0} - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - bbox_vals = item['bbox'] - # BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y) - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - - self.G.add_node(item['entity_id'], - type=item['entity_type'], - bbox=bbox_geom, - value=item.get('clean_value'), - layer=item.get('layer')) - - # 2. 분산 추출된 태그 통합 및 노드 추가 - for tag in self.all_tags: - bbox_vals = tag['bbox'] - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - self.G.add_node(tag['entity_id'], - type='TEXT', - bbox=bbox_geom, - value=tag.get('clean_value') or tag.get('tagName')) - - # 3. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반] - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']] - for line_id in lines: - original_item = next((item for item in self.data if item['entity_id'] == line_id), None) - if not original_item or not original_item.get('coordinates'): - continue - - coords = original_item['coordinates'] - line_geom = LineString(coords) - endpoints = [line_geom.coords[0], line_geom.coords[-1]] - - connected_nodes = [] - for pt in endpoints: - p = Point(pt) - for eq_id in equipments: - if self.G.nodes[eq_id]['bbox'].distance(p) < self.config['dist_threshold']: - connected_nodes.append(eq_id) - - # 중복 제거 - connected_nodes = list(set(connected_nodes)) - - if len(connected_nodes) >= 2: - # 방향성 추론 로직 (단순화: 첫 번째 발견된 설비 -> 두 번째 발견된 설비) - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe') - elif len(connected_nodes) == 1: - pass - - def _find_nearest_equipment(self, tag_id, equipment_ids): - tag_bbox = self.G.nodes[tag_id]['bbox'] - min_dist = float('inf') - nearest = None - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - if dist < min_dist: - min_dist = dist - nearest = eq_id - return nearest if min_dist < self.config['tag_threshold'] else None - - def validate_topology(self): - """위상 무결성 검증""" - isolated = list(nx.isolates(self.G)) - return { - "isolated_nodes": isolated, - "node_count": self.G.number_of_nodes(), - "edge_count": self.G.number_of_edges() - } - - def save_graph(self, output_path: str): - """그래프 구조를 JSON 형태로 저장""" - from networkx.readwrite import json_graph - data = json_graph.node_link_data(self.G) - - # shapely geometry 객체는 JSON 직렬화가 안 되므로 변환 - for node in data['nodes']: - if 'bbox' in node: - bbox = node['bbox'] - node['bbox'] = { - 'min_x': bbox.bounds[0], - 'min_y': bbox.bounds[1], - 'max_x': bbox.bounds[2], - 'max_y': bbox.bounds[3] - } - - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - return output_path - -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - if start_node not in graph: - return [] - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) diff --git a/.rooBackup/2026-05-03_08-42/mcp-server/pipeline/topology.py b/.rooBackup/2026-05-03_08-42/mcp-server/pipeline/topology.py deleted file mode 100644 index 7c80546..0000000 --- a/.rooBackup/2026-05-03_08-42/mcp-server/pipeline/topology.py +++ /dev/null @@ -1,125 +0,0 @@ -import networkx as nx -from shapely.geometry import box, Point, LineString -import json -from typing import List, Dict, Any, Optional, Tuple - -class PidTopologyBuilder: - def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None): - """ - - geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts) - - all_extracted_tags: 통합된 태그 리스트 - - config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값 - """ - self.data = geometric_data - self.all_tags = all_extracted_tags if all_extracted_tags else [] - self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0} - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - bbox_vals = item['bbox'] - # BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y) - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - - self.G.add_node(item['entity_id'], - type=item['entity_type'], - bbox=bbox_geom, - value=item.get('clean_value'), - layer=item.get('layer')) - - # 2. 분산 추출된 태그 통합 및 노드 추가 - for tag in self.all_tags: - bbox_vals = tag['bbox'] - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - self.G.add_node(tag['entity_id'], - type='TEXT', - bbox=bbox_geom, - value=tag.get('clean_value') or tag.get('tagName')) - - # 3. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반] - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']] - for line_id in lines: - original_item = next((item for item in self.data if item['entity_id'] == line_id), None) - if not original_item or not original_item.get('coordinates'): - continue - - coords = original_item['coordinates'] - line_geom = LineString(coords) - # 개선: 끝점뿐만 아니라 라인 전체가 설비 BBox와 교차하거나 매우 가까운지 확인 - connected_nodes = [] - for eq_id in equipments: - eq_bbox = self.G.nodes[eq_id]['bbox'] - # 1. 라인이 BBox와 교차하는지 확인 (관통 포함) - if line_geom.intersects(eq_bbox): - connected_nodes.append(eq_id) - # 2. 교차하지 않더라도 임계값 이내에 있는지 확인 (근접 연결) - elif line_geom.distance(eq_bbox) < self.config['dist_threshold']: - connected_nodes.append(eq_id) - - # 중복 제거 - connected_nodes = list(set(connected_nodes)) - - if len(connected_nodes) >= 2: - # 방향성 추론 로직 (단순화: 첫 번째 발견된 설비 -> 두 번째 발견된 설비) - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe') - elif len(connected_nodes) == 1: - pass - - def _find_nearest_equipment(self, tag_id, equipment_ids): - tag_bbox = self.G.nodes[tag_id]['bbox'] - min_dist = float('inf') - nearest = None - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - if dist < min_dist: - min_dist = dist - nearest = eq_id - return nearest if min_dist < self.config['tag_threshold'] else None - - def validate_topology(self): - """위상 무결성 검증""" - isolated = list(nx.isolates(self.G)) - return { - "isolated_nodes": isolated, - "node_count": self.G.number_of_nodes(), - "edge_count": self.G.number_of_edges() - } - - def save_graph(self, output_path: str): - """그래프 구조를 JSON 형태로 저장""" - from networkx.readwrite import json_graph - data = json_graph.node_link_data(self.G) - - # shapely geometry 객체는 JSON 직렬화가 안 되므로 변환 - for node in data['nodes']: - if 'bbox' in node: - bbox = node['bbox'] - node['bbox'] = { - 'min_x': bbox.bounds[0], - 'min_y': bbox.bounds[1], - 'max_x': bbox.bounds[2], - 'max_y': bbox.bounds[3] - } - - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - return output_path - -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - if start_node not in graph: - return [] - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) diff --git a/.rooBackup/2026-05-03_08-46/mcp-server/pipeline/topology.py b/.rooBackup/2026-05-03_08-46/mcp-server/pipeline/topology.py deleted file mode 100644 index 479e906..0000000 --- a/.rooBackup/2026-05-03_08-46/mcp-server/pipeline/topology.py +++ /dev/null @@ -1,147 +0,0 @@ -import networkx as nx -from shapely.geometry import box, Point, LineString -import json -from typing import List, Dict, Any, Optional, Tuple - -class PidTopologyBuilder: - def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None): - """ - - geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts) - - all_extracted_tags: 통합된 태그 리스트 - - config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값 - """ - self.data = geometric_data - self.all_tags = all_extracted_tags if all_extracted_tags else [] - self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0} - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - bbox_vals = item['bbox'] - # BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y) - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - - self.G.add_node(item['entity_id'], - type=item['entity_type'], - bbox=bbox_geom, - value=item.get('clean_value'), - layer=item.get('layer')) - - # 2. 분산 추출된 태그 통합 및 노드 추가 - for tag in self.all_tags: - bbox_vals = tag['bbox'] - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - self.G.add_node(tag['entity_id'], - type='TEXT', - bbox=bbox_geom, - value=tag.get('clean_value') or tag.get('tagName')) - - # 3. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반] - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']] - for line_id in lines: - original_item = next((item for item in self.data if item['entity_id'] == line_id), None) - if not original_item or not original_item.get('coordinates'): - continue - - coords = original_item['coordinates'] - line_geom = LineString(coords) - # 개선: 끝점뿐만 아니라 라인 전체가 설비 BBox와 교차하거나 매우 가까운지 확인 - connected_nodes = [] - for eq_id in equipments: - eq_bbox = self.G.nodes[eq_id]['bbox'] - # 1. 라인이 BBox와 교차하는지 확인 (관통 포함) - if line_geom.intersects(eq_bbox): - connected_nodes.append(eq_id) - # 2. 교차하지 않더라도 임계값 이내에 있는지 확인 (근접 연결) - elif line_geom.distance(eq_bbox) < self.config['dist_threshold']: - connected_nodes.append(eq_id) - - # 중복 제거 - connected_nodes = list(set(connected_nodes)) - - if len(connected_nodes) >= 2: - # 방향성 추론 로직 (단순화: 첫 번째 발견된 설비 -> 두 번째 발견된 설비) - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe') - elif len(connected_nodes) == 1: - pass - - def _find_nearest_equipment(self, tag_id, equipment_ids): - """ - 단순 거리 기반 매핑에서 위상 기반 가중치 매핑으로 개선. - 가중치 = 거리 점수 + 연결성 점수 - """ - tag_bbox = self.G.nodes[tag_id]['bbox'] - best_score = float('inf') - nearest = None - - # 태그 노드와 연결된 배관(LINE/LWPOLYLINE) 확인 - connected_pipes = [n for n in self.G.neighbors(tag_id) if self.G.nodes[n]['type'] in ['LINE', 'LWPOLYLINE']] - - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - - if dist > self.config['tag_threshold']: - continue - - # 1. 거리 점수 (낮을수록 좋음) - score = dist - - # 2. 연결성 가중치 (태그와 설비가 동일한 배관에 연결되어 있다면 점수 대폭 감점 = 우선순위 상승) - # 태그가 직접 배관에 연결되어 있지는 않지만, 태그 근처의 배관이 설비에 연결되어 있는지 확인 - for pipe_id in connected_pipes: - if self.G.has_edge(pipe_id, eq_id) or self.G.has_edge(eq_id, pipe_id): - score -= self.config['tag_threshold'] * 0.5 # 연결성 보너스 - - if score < best_score: - best_score = score - nearest = eq_id - - return nearest - - def validate_topology(self): - """위상 무결성 검증""" - isolated = list(nx.isolates(self.G)) - return { - "isolated_nodes": isolated, - "node_count": self.G.number_of_nodes(), - "edge_count": self.G.number_of_edges() - } - - def save_graph(self, output_path: str): - """그래프 구조를 JSON 형태로 저장""" - from networkx.readwrite import json_graph - data = json_graph.node_link_data(self.G) - - # shapely geometry 객체는 JSON 직렬화가 안 되므로 변환 - for node in data['nodes']: - if 'bbox' in node: - bbox = node['bbox'] - node['bbox'] = { - 'min_x': bbox.bounds[0], - 'min_y': bbox.bounds[1], - 'max_x': bbox.bounds[2], - 'max_y': bbox.bounds[3] - } - - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - return output_path - -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - if start_node not in graph: - return [] - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) diff --git a/.rooBackup/2026-05-03_08-49/mcp-server/pipeline/mapper.py b/.rooBackup/2026-05-03_08-49/mcp-server/pipeline/mapper.py deleted file mode 100644 index ac9085c..0000000 --- a/.rooBackup/2026-05-03_08-49/mcp-server/pipeline/mapper.py +++ /dev/null @@ -1,122 +0,0 @@ -import networkx as nx -import asyncio -import json -from typing import List, Optional, Dict, Any, Tuple -from pydantic import BaseModel, Field -from rapidfuzz import process, fuzz -from openai import AsyncOpenAI - -# --- 응답 구조화를 위한 Pydantic 모델 --- -class MappingResult(BaseModel): - resolved_tag: str = Field(..., description="The final mapped system tag") - reason: str = Field(..., description="Reason for this mapping based on context") - confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1") - -class IntelligentMapper: - def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None): - self.graph = graph # Phase 2에서 생성된 NetworkX 그래프 - self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트 - self.client = api_client - - def get_node_context(self, node_id: str) -> str: - """노드의 주변 위상 정보를 텍스트로 변환""" - if not self.graph.has_node(node_id): - return "Node not found in graph" - - neighbors = list(self.graph.neighbors(node_id)) - context = [] - for n in neighbors: - attr = self.graph.nodes[n] - val = attr.get('value', n) - typ = attr.get('type', 'Unknown') - context.append(f"Connected to {val} (Type: {typ})") - - return ", ".join(context) if context else "No connected neighbors" - - async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult: - """공통 매핑 로직 (비동기 + 구조화 응답)""" - if not self.client: - return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0) - - # Phase 2에서 'value'에 clean_value가 저장됨 - node_data = self.graph.nodes.get(node_id, {}) - tag_text = node_data.get('value', '') - - # 1차 후보 추출 (RapidFuzz) - candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5) - context = self.get_node_context(node_id) - - prompt = f""" - {category_prompt} - P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다. - 위상 맥락: {context} - 후보 리스트: {candidates} - - 반드시 다음 JSON 형식으로만 응답하세요: - {{ - "resolved_tag": "태그명 또는 UNKNOWN", - "reason": "매핑 이유", - "confidence": 0.0~1.0 - }} - """ - - try: - response = await self.client.chat.completions.create( - model="Qwen/Qwen3-Coder-Next-FP8", # MCP 서버 설정 모델 사용 - messages=[{"role": "user", "content": prompt}], - response_format={ "type": "json_object" } - ) - raw_content = response.choices[0].message.content - return MappingResult.model_validate_json(raw_content) - except Exception as e: - print(f"Error resolving node {node_id}: {e}") - return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0) - - # --- 전문화된 Worker 함수들 --- - async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]: - prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다." - tasks = [self._resolve_generic(nid, prompt) for nid in node_ids] - results = await asyncio.gather(*tasks) - return dict(zip(node_ids, results)) - - async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]: - prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다." - tasks = [self._resolve_generic(nid, prompt) for nid in node_ids] - results = await asyncio.gather(*tasks) - return dict(zip(node_ids, results)) - - async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]: - prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다." - tasks = [self._resolve_generic(nid, prompt) for nid in node_ids] - results = await asyncio.gather(*tasks) - return dict(zip(node_ids, results)) - -def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]: - """심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증""" - if resolved_tag == "UNKNOWN": - return False, "Tag not resolved" - - unit_map = { - "Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"], - "Flow Meter": ["m3/h", "lpm", "kg/h"], - "Temperature Sensor": ["°C", "C", "K", "°F"] - } - - actual_unit = tag_metadata.get('unit', '').strip() - allowed_units = unit_map.get(symbol_type, []) - - if actual_unit and actual_unit in allowed_units: - return True, "Unit Match" - - actual_desc = tag_metadata.get('description', '').lower() - expected_keywords = { - "Pressure Transmitter": ["pressure", "press"], - "Flow Meter": ["flow", "flowrate"], - "Temperature Sensor": ["temp", "temperature"] - } - - keywords = expected_keywords.get(symbol_type, []) - if any(kw in actual_desc for kw in keywords): - return True, "Description Match (Unit Missing)" - - return False, "Mismatch: Symbol type and Tag metadata do not align" diff --git a/.rooBackup/2026-05-03_08-52/mcp-server/pipeline/topology.py b/.rooBackup/2026-05-03_08-52/mcp-server/pipeline/topology.py deleted file mode 100644 index df04f58..0000000 --- a/.rooBackup/2026-05-03_08-52/mcp-server/pipeline/topology.py +++ /dev/null @@ -1,168 +0,0 @@ -import networkx as nx -from shapely.geometry import box, Point, LineString -import json -from typing import List, Dict, Any, Optional, Tuple - -class PidTopologyBuilder: - def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None): - """ - - geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts) - - all_extracted_tags: 통합된 태그 리스트 - - config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값 - """ - self.data = geometric_data - self.all_tags = all_extracted_tags if all_extracted_tags else [] - self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0} - self.G = nx.DiGraph() # 방향성 그래프 생성 - - def build_graph(self): - # 1. 모든 객체를 노드로 추가 - for item in self.data: - bbox_vals = item['bbox'] - # BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y) - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - - self.G.add_node(item['entity_id'], - type=item['entity_type'], - bbox=bbox_geom, - value=item.get('clean_value'), - layer=item.get('layer')) - - # 2. 분산 추출된 태그 통합 및 노드 추가 - for tag in self.all_tags: - bbox_vals = tag['bbox'] - bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y']) - self.G.add_node(tag['entity_id'], - type='TEXT', - bbox=bbox_geom, - value=tag.get('clean_value') or tag.get('tagName')) - - # 3. 태그-설비 논리적 연결 (Association) - tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT'] - equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']] - - for tag in tags: - best_match = self._find_nearest_equipment(tag, equipments) - if best_match: - self.G.add_edge(tag, best_match, relation='associated_with') - - # 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반] - lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']] - for line_id in lines: - original_item = next((item for item in self.data if item['entity_id'] == line_id), None) - if not original_item or not original_item.get('coordinates'): - continue - - coords = original_item['coordinates'] - line_geom = LineString(coords) - # 개선: 끝점뿐만 아니라 라인 전체가 설비 BBox와 교차하거나 매우 가까운지 확인 - connected_nodes = [] - for eq_id in equipments: - eq_bbox = self.G.nodes[eq_id]['bbox'] - # 1. 라인이 BBox와 교차하는지 확인 (관통 포함) - if line_geom.intersects(eq_bbox): - connected_nodes.append(eq_id) - # 2. 교차하지 않더라도 임계값 이내에 있는지 확인 (근접 연결) - elif line_geom.distance(eq_bbox) < self.config['dist_threshold']: - connected_nodes.append(eq_id) - - # 중복 제거 - connected_nodes = list(set(connected_nodes)) - - if len(connected_nodes) >= 2: - # 개선: 단순 순서가 아닌, 기하학적 좌표 기반의 흐름 방향 추론 (왼쪽 -> 오른쪽, 위 -> 아래 우선) - # 실제 공정 도면의 일반적인 흐름 방향을 반영 - node0_bbox = self.G.nodes[connected_nodes[0]]['bbox'] - node1_bbox = self.G.nodes[connected_nodes[1]]['bbox'] - - center0 = ((node0_bbox.bounds[0] + node0_bbox.bounds[2])/2, (node0_bbox.bounds[1] + node0_bbox.bounds[3])/2) - center1 = ((node1_bbox.bounds[0] + node1_bbox.bounds[2])/2, (node1_bbox.bounds[1] + node1_bbox.bounds[3])/2) - - # X축 차이가 Y축 차이보다 크면 X축 기준, 아니면 Y축 기준으로 방향 결정 - if abs(center1[0] - center0[0]) > abs(center1[1] - center0[1]): - # X축 기준: 왼쪽 -> 오른쪽 - if center0[0] < center1[0]: - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe', flow_direction='forward') - else: - self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward') - else: - # Y축 기준: 위 -> 아래 (도면 좌표계에 따라 다를 수 있으나 일반적인 관례 적용) - if center0[1] > center1[1]: - self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe', flow_direction='forward') - else: - self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward') - elif len(connected_nodes) == 1: - # 단일 연결의 경우, 일단 엣지를 생성하되 방향은 미정(undirected-like)으로 처리하거나 - # 추후 전파 로직에서 결정하도록 함 - pass - - def _find_nearest_equipment(self, tag_id, equipment_ids): - """ - 단순 거리 기반 매핑에서 위상 기반 가중치 매핑으로 개선. - 가중치 = 거리 점수 + 연결성 점수 - """ - tag_bbox = self.G.nodes[tag_id]['bbox'] - best_score = float('inf') - nearest = None - - # 태그 노드와 연결된 배관(LINE/LWPOLYLINE) 확인 - connected_pipes = [n for n in self.G.neighbors(tag_id) if self.G.nodes[n]['type'] in ['LINE', 'LWPOLYLINE']] - - for eq_id in equipment_ids: - eq_bbox = self.G.nodes[eq_id]['bbox'] - dist = tag_bbox.distance(eq_bbox) - - if dist > self.config['tag_threshold']: - continue - - # 1. 거리 점수 (낮을수록 좋음) - score = dist - - # 2. 연결성 가중치 (태그와 설비가 동일한 배관에 연결되어 있다면 점수 대폭 감점 = 우선순위 상승) - # 태그가 직접 배관에 연결되어 있지는 않지만, 태그 근처의 배관이 설비에 연결되어 있는지 확인 - for pipe_id in connected_pipes: - if self.G.has_edge(pipe_id, eq_id) or self.G.has_edge(eq_id, pipe_id): - score -= self.config['tag_threshold'] * 0.5 # 연결성 보너스 - - if score < best_score: - best_score = score - nearest = eq_id - - return nearest - - def validate_topology(self): - """위상 무결성 검증""" - isolated = list(nx.isolates(self.G)) - return { - "isolated_nodes": isolated, - "node_count": self.G.number_of_nodes(), - "edge_count": self.G.number_of_edges() - } - - def save_graph(self, output_path: str): - """그래프 구조를 JSON 형태로 저장""" - from networkx.readwrite import json_graph - data = json_graph.node_link_data(self.G) - - # shapely geometry 객체는 JSON 직렬화가 안 되므로 변환 - for node in data['nodes']: - if 'bbox' in node: - bbox = node['bbox'] - node['bbox'] = { - 'min_x': bbox.bounds[0], - 'min_y': bbox.bounds[1], - 'max_x': bbox.bounds[2], - 'max_y': bbox.bounds[3] - } - - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - return output_path - -def analyze_impact(graph, start_node): - """특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출""" - if start_node not in graph: - return [] - # BFS를 통해 도달 가능한 모든 노드 탐색 - impacted_nodes = nx.descendants(graph, start_node) - return list(impacted_nodes) diff --git a/.rooBackup/2026-05-03_08-56/mcp-server/pipeline/extractor.py b/.rooBackup/2026-05-03_08-56/mcp-server/pipeline/extractor.py deleted file mode 100644 index 0394c2d..0000000 --- a/.rooBackup/2026-05-03_08-56/mcp-server/pipeline/extractor.py +++ /dev/null @@ -1,173 +0,0 @@ -import ezdxf -import re -import json -from typing import List, Optional, Tuple, Union -from pydantic import BaseModel, Field -from shapely.geometry import box, Point - -# --- Data Models --- - -class BoundingBox(BaseModel): - min_x: float - min_y: float - max_x: float - max_y: float - center: Tuple[float, float] - -class GeometricEntity(BaseModel): - entity_id: str - entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC - layer: str - bbox: BoundingBox - raw_value: Optional[str] = None - clean_value: Optional[str] = None - coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list) - properties: dict = Field(default_factory=dict) - -# --- Extractor Implementation --- - -class PidGeometricExtractor: - def __init__(self, file_path: str): - try: - self.doc = ezdxf.readfile(file_path) - self.msp = self.doc.modelspace() - except Exception as e: - raise IOError(f"Failed to load DXF file: {e}") - - def clean_text(self, text: str) -> str: - """ - DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환. - """ - if not text: - return "" - - # 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등) - text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text) - - # 2. 중괄호 { } 제거 - text = re.sub(r'[\{\}]', ' ', text) - - # 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered) - text = re.sub(r'%%[U|O|S|R]', ' ', text) - - # 4. 불필요한 특수 기호 및 반복되는 공백 정제 - text = re.sub(r'\s+', ' ', text).strip() - - return text - - def get_bbox(self, entity) -> Optional[BoundingBox]: - """ - 엔티티 타입별로 동적인 Bounding Box를 계산하여 반환. - """ - try: - if entity.dxftype() == 'TEXT': - p = entity.dxf.insert - h = entity.dxf.height - # 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6) - width = len(entity.dxf.text) * h * 0.6 - return self._create_bbox(p.x, p.y, p.x + width, p.y + h) - - elif entity.dxftype() == 'MTEXT': - p = entity.dxf.insert - h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5 - w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6 - return self._create_bbox(p.x, p.y, p.x + w, p.y + h) - - elif entity.dxftype() == 'LINE': - start = entity.dxf.start - end = entity.dxf.end - return self._create_bbox( - min(start.x, end.x), min(start.y, end.y), - max(start.x, end.x), max(start.y, end.y) - ) - - elif entity.dxftype() == 'LWPOLYLINE': - points = entity.get_points() - if not points: return None - xs = [p[0] for p in points] - ys = [p[1] for p in points] - return self._create_bbox(min(xs), min(ys), max(xs), max(ys)) - - elif entity.dxftype() in ('CIRCLE', 'ARC'): - center = entity.dxf.center - radius = entity.dxf.radius - return self._create_bbox( - center.x - radius, center.y - radius, - center.x + radius, center.y + radius - ) - - except Exception as e: - print(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}") - return None - - def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox: - return BoundingBox( - min_x=min_x, - min_y=min_y, - max_x=max_x, - max_y=max_y, - center=((min_x + max_x) / 2, (min_y + max_y) / 2) - ) - - def extract_and_save(self, output_path: str): - """ - 기하학적 데이터를 추출하여 JSON 파일로 저장. - """ - results = [] - for entity in self.msp: - bbox_obj = self.get_bbox(entity) - if not bbox_obj: - continue - - raw_text = "" - if entity.dxftype() == 'TEXT': - raw_text = entity.dxf.text - elif entity.dxftype() == 'MTEXT': - raw_text = entity.text - - # 좌표 추출 (3D 좌표를 2D로 변환) - coords = [] - if hasattr(entity, 'get_points'): - # ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함 - coords = [(p[0], p[1]) for p in entity.get_points()] - elif entity.dxftype() == 'LINE': - coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)] - elif entity.dxftype() in ('CIRCLE', 'ARC'): - coords = [(entity.dxf.center.x, entity.dxf.center.y)] - - entity_data = GeometricEntity( - entity_id=entity.dxf.handle, - entity_type=entity.dxftype(), - layer=entity.dxf.layer, - bbox=bbox_obj, - raw_value=raw_text if raw_text else None, - clean_value=self.clean_text(raw_text) if raw_text else None, - coordinates=coords, - properties={ - "color": entity.dxf.color, - "lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None, - } - ) - results.append(entity_data.model_dump()) - - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(results, f, ensure_ascii=False, indent=4) - - return output_path - -# --- Proximity Utilities --- - -def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool: - """ - 두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인. - shapely box를 사용하여 거리 계산. - """ - box_a = box(bbox_a.min_x, bbox_a.min_y, bbox_a.max_x, bbox_a.max_y) - box_b = box(bbox_b.min_x, bbox_b.min_y, bbox_b.max_x, bbox_b.max_y) - return box_a.distance(box_b) <= threshold - -def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool: - """ - 특정 점이 Bounding Box 내부에 있는지 확인. - """ - return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y) diff --git a/.rooBackup/2026-05-03-031119/mcp-server/server.py b/.rooBackup/enum-opt-202605081758/mcp-server/server.py similarity index 78% rename from .rooBackup/2026-05-03-031119/mcp-server/server.py rename to .rooBackup/enum-opt-202605081758/mcp-server/server.py index 299b467..2bf643d 100644 --- a/.rooBackup/2026-05-03-031119/mcp-server/server.py +++ b/.rooBackup/enum-opt-202605081758/mcp-server/server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ ExperionCrawler Unified MCP Server -- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen/Qwen3-Coder-Next-FP8 +- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen3.6-27B-FP8 - NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행 - 사용처: stdio 모드 (기본): Claude Code MCP / Roo Code MCP @@ -23,7 +23,7 @@ QDRANT_URL = "http://localhost:6333" OLLAMA_URL = "http://localhost:11434" EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" +VLLM_MODEL = "Qwen3.6-27B-FP8" # Qdrant 컬렉션 COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 @@ -72,7 +72,7 @@ class ProcessManager: def __init__(self): self.workers: Dict[str, WorkerProcess] = {} self._locks: Dict[str, asyncio.Lock] = {} - self._pid_sem = asyncio.Semaphore(1) # P&ID는 1개 동시 실행만 허용 + self._pid_locks: Dict[str, asyncio.Lock] = {} # 파일/ID별 세부 Lock self._worker_ports = {"rag": 5002, "nl2sql": 5003, "pid": 5004} # 정리 훅 등록 @@ -112,7 +112,7 @@ class ProcessManager: port = self._get_available_port(worker_type) cmd = [ sys.executable, - f"mcp-server/worker/{worker_type}_worker.py", + f"worker/{worker_type}_worker.py", str(port) ] @@ -202,17 +202,22 @@ process_manager = ProcessManager() # ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── -def _embed(text: str) -> list[float]: +async def _embed(text: str) -> list[float]: """Ollama nomic-embed-text로 768-dim 벡터 생성.""" - with httpx.Client(timeout=30) as client: - resp = client.post( - f"{OLLAMA_URL}/api/embeddings", - json={"model": EMBED_MODEL, "prompt": text}, - ) - resp.raise_for_status() - return resp.json()["embedding"] + import asyncio + + def _call_embed(): + with httpx.Client(timeout=30) as client: + resp = client.post( + f"{OLLAMA_URL}/api/embeddings", + json={"model": EMBED_MODEL, "prompt": text}, + ) + resp.raise_for_status() + return resp.json()["embedding"] + + return await asyncio.to_thread(_call_embed) -# ── LLM (vLLM / Qwen/Qwen3-Coder-Next-FP8) ───────────────────────────────────── +# ── LLM (vLLM / Qwen3.6-27B-FP8) ───────────────────────────────────── @lru_cache(maxsize=1) def _llm(): @@ -247,109 +252,136 @@ def _ocr(): # ── DXF/PDF 텍스트 추출 헬퍼 ─────────────────────────────────────────────────── -def _extract_text_from_dxf(filepath: str) -> str: +async def _extract_text_from_dxf(filepath: str) -> str: """ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거).""" + import asyncio import ezdxf from ezdxf.tools.text import plain_mtext - doc = ezdxf.readfile(filepath) - msp = doc.modelspace() - texts = [] - for entity in msp: - if entity.dxftype() == "TEXT": - texts.append(entity.dxf.text) - elif entity.dxftype() == "MTEXT": - try: - plain = plain_mtext(entity.dxf.text) - if plain.strip(): - texts.append(plain) - except Exception: - pass - return "\n".join(texts) + + def _extract(): + doc = ezdxf.readfile(filepath) + msp = doc.modelspace() + texts = [] + for entity in msp: + if entity.dxftype() == "TEXT": + texts.append(entity.dxf.text) + elif entity.dxftype() == "MTEXT": + try: + plain = plain_mtext(entity.dxf.text) + if plain.strip(): + texts.append(plain) + except Exception: + pass + return "\n".join(texts) + + return await asyncio.to_thread(_extract) -def _extract_text_from_pdf(filepath: str) -> str: +async def _extract_text_from_pdf(filepath: str) -> str: """PyMuPDF로 PDF 파일에서 텍스트 추출.""" + import asyncio import fitz # pymupdf - doc = fitz.open(filepath) - texts = [] - for page in doc: - texts.append(page.get_text()) - return "\n".join(texts) + + def _extract(): + doc = fitz.open(filepath) + texts = [] + for page in doc: + texts.append(page.get_text()) + return "\n".join(texts) + + return await asyncio.to_thread(_extract) -def _extract_text_from_pdf_ocr(filepath: str) -> str: +async def _extract_text_from_pdf_ocr(filepath: str) -> str: """PaddleOCR로 PDF에서 이미지 추출 후 OCR (고정밀도).""" + import asyncio import fitz # pymupdf from PIL import Image import numpy as np - doc = fitz.open(filepath) - all_texts = [] + def _extract(): + doc = fitz.open(filepath) + all_texts = [] - for page_idx, page in enumerate(doc): - # 페이지를 이미지로 변환 - mat = fitz.Matrix(300 / 72) # 300 DPI - pix = page.get_pixmap(matrix=mat) - img_data = pix.tobytes("png") - img = Image.open(__import__("io").BytesIO(img_data)) + for page_idx, page in enumerate(doc): + # 페이지를 이미지로 변환 + mat = fitz.Matrix(300 / 72) # 300 DPI + pix = page.get_pixmap(matrix=mat) + img_data = pix.tobytes("png") + img = Image.open(__import__("io").BytesIO(img_data)) - # OCR 실행 - result = _ocr().ocr(np.array(img), cls=True) - if result[0]: - for line in result[0]: - all_texts.append(line[1][0]) + # OCR 실행 + result = _ocr().ocr(np.array(img), cls=True) + if result[0]: + for line in result[0]: + all_texts.append(line[1][0]) - return "\n".join(all_texts) + return "\n".join(all_texts) + + return await asyncio.to_thread(_extract) -def _convert_dwg_to_dxf_dxflib(filepath: str) -> str: +async def _convert_dwg_to_dxf_dxflib(filepath: str) -> str: """libreoffice로 DWG를 DXF로 변환.""" + import asyncio import subprocess import os dxf_path = filepath.replace(".dwg", ".dxf") - try: - # LibreOffice로 변환 - result = subprocess.run( - [ - "libreoffice", - "--headless", - "--convert-to", "dxf:AutoCAD DXF", - "--outdir", os.path.dirname(filepath) or ".", - filepath - ], - check=True, - timeout=120, - capture_output=True, - text=True - ) - - if os.path.exists(dxf_path): - return dxf_path - else: - raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.") + def _convert(): + try: + # LibreOffice로 변환 + result = subprocess.run( + [ + "libreoffice", + "--headless", + "--convert-to", "dxf:AutoCAD DXF", + "--outdir", os.path.dirname(filepath) or ".", + filepath + ], + check=True, + timeout=120, + capture_output=True, + text=True + ) - except subprocess.CalledProcessError as e: - raise Exception(f"LibreOffice 변환 실패: {e.stderr}") + if os.path.exists(dxf_path): + return dxf_path + else: + raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.") + + except subprocess.CalledProcessError as e: + raise Exception(f"LibreOffice 변환 실패: {e.stderr}") + + return await asyncio.to_thread(_convert) # ── Qdrant 검색 헬퍼 ────────────────────────────────────────────────────────── -def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str: - vec = _embed(query) - with httpx.Client(timeout=20) as client: - resp = client.post( - f"{QDRANT_URL}/collections/{collection}/points/search", - json={ - "vector": vec, - "limit": top_k, - "with_payload": True, - "score_threshold": threshold, - }, - ) - resp.raise_for_status() - hits = resp.json().get("result", []) +async def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str: + import asyncio + + def _call_embed(): + return _embed(query) + + vec = await _call_embed() + + def _call_search(): + with httpx.Client(timeout=20) as client: + resp = client.post( + f"{QDRANT_URL}/collections/{collection}/points/search", + json={ + "vector": vec, + "limit": top_k, + "with_payload": True, + "score_threshold": threshold, + }, + ) + resp.raise_for_status() + return resp.json().get("result", []) + + hits = await asyncio.to_thread(_call_search) if not hits: return "관련 결과 없음." @@ -367,10 +399,15 @@ def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> # ── DB 헬퍼 ────────────────────────────────────────────────────────────────── -def _get_db_connection(): +async def _get_db_connection(): """PostgreSQL DB 연결 획득.""" + import asyncio import psycopg - return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) + + def _connect(): + return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) + + return await asyncio.to_thread(_connect) def _validate_sql(sql: str) -> tuple[bool, str]: @@ -405,6 +442,39 @@ PostgreSQL 시계열 데이터베이스 스키마 livevalue TEXT - 현재값 timestamp TIMESTAMPTZ - 최종 갱신 시각 +테이블: tag_metadata (태그 메타데이터 - 변경 드묾) + base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124') + attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...) + value TEXT - 메타데이터 값 + node_id TEXT - OPC UA 노드 ID + loaded_at TIMESTAMPTZ - 마지막 로드 시각 + +뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰) + base_tag TEXT - 기본 태그명 + pv TEXT - 현재 프로세스 값 + sp TEXT - 설정값 + op TEXT - 출력값 + instate0 TEXT - 상태 비트 0 (true/false) + instate1 TEXT - 상태 비트 1 (true/false) + instate2 TEXT - 상태 비트 2 (true/false) + description TEXT - 장비 설명 (tag_metadata.desc) + area TEXT - 소속 플랜트 (tag_metadata.area) + state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop") + state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local") + state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal") + +새로운 태그 타입: + - 아날로그: ficq-6101.pv/sp/op (Double) + - 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean) + - Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean) + - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String) + +BCD 상태 조회 팁: + - instate0~7은 Boolean (true/false) + - state0descriptor~7은 해당 비트의 의미 설명 + - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태 + - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능 + N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): 1분 버킷: date_trunc('minute', recorded_at) AS bucket 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket @@ -461,7 +531,7 @@ def search_r530_docs(query: str, top_k: int = 5) -> str: @mcp.tool() def ask_iiot_llm(question: str, context: str = "") -> str: - """Qwen/Qwen3-Coder-Next-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). + """Qwen3.6-27B-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). 사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨 종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문. @@ -477,7 +547,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str: ) user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question resp = _llm().chat.completions.create( - model=VLLM_MODEL, + model="Qwen3.6-27B-FP8", messages=[ {"role": "system", "content": system}, {"role": "user", "content": user_msg}, @@ -490,7 +560,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str: @mcp.tool() def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """검색 → Qwen/Qwen3-Coder-Next-FP8 답변 생성 (통합 RAG). + """검색 → Qwen3.6-27B-FP8 답변 생성 (통합 RAG). 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). ExperionCrawler 코드도 함께 보려면 search_code=True 추가. @@ -510,23 +580,15 @@ def rag_query(question: str, search_code: bool = False, search_docs: bool = True # ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── -@mcp.tool() -def run_sql(sql: str) -> str: - """SQL 쿼리 실행 (SELECT만 허용). - - Args: - sql: 실행할 SELECT SQL 문자열 - - Returns: - JSON: { success, columns, count, data } 또는 { success, error } - """ +async def _execute_sql_internal(sql: str) -> str: + """SQL 실행 내부 함수 (run_sql과 query_with_nl에서 공유).""" valid, err = _validate_sql(sql) if not valid: return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) conn = None try: - conn = _get_db_connection() + conn = await _get_db_connection() with conn.cursor() as cur: cur.execute(sql) rows = cur.fetchall() @@ -544,6 +606,18 @@ def run_sql(sql: str) -> str: if conn: conn.close() +@mcp.tool() +async def run_sql(sql: str) -> str: + """SQL 쿼리 실행 (SELECT만 허용). + + Args: + sql: 실행할 SELECT SQL 문자열 + + Returns: + JSON: { success, columns, count, data } 또는 { success, error } + """ + return await _execute_sql_internal(sql) + @mcp.tool() def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: @@ -656,7 +730,7 @@ def list_drawings(unit_no: str | None = None) -> str: @mcp.tool() -def query_with_nl(question: str) -> str: +async def query_with_nl(question: str) -> str: """자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다. Args: @@ -665,6 +739,9 @@ def query_with_nl(question: str) -> str: Returns: JSON: { sql, success, columns, count, data } 또는 { sql, success, error } """ + import asyncio + import json as json_module + system = ( "You are a PostgreSQL SQL expert.\n" "Convert the user's question into a SELECT SQL using the schema below.\n" @@ -685,16 +762,20 @@ def query_with_nl(question: str) -> str: "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" f"{_DB_SCHEMA}" ) + try: - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": question}, - ], - max_tokens=8192, - temperature=0.1, - ) + def _call_llm(): + return _llm().chat.completions.create( + model="Qwen3.6-27B-FP8", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": question}, + ], + max_tokens=8192, + temperature=0.1, + ) + + resp = await asyncio.to_thread(_call_llm) sql = (resp.choices[0].message.content or "").strip() # 마크다운 코드 블록 제거 if sql.startswith("```"): @@ -706,7 +787,7 @@ def query_with_nl(question: str) -> str: return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False) # SQL 실행 - raw = run_sql(sql) + raw = await _execute_sql_internal(sql) result = json.loads(raw) result["sql"] = sql @@ -735,7 +816,7 @@ def query_with_nl(question: str) -> str: # ── P&ID 추출 도구 ────────────────────────────────────────────────────────────── @mcp.tool() -def extract_pid_tags(text: str, source_type: str) -> str: +async def extract_pid_tags(text: str, source_type: str) -> str: """P&ID 도면(DXF/PDF)에서 태그 정보를 추출합니다. Args: @@ -745,6 +826,11 @@ def extract_pid_tags(text: str, source_type: str) -> str: Returns: JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] } """ + import asyncio + import logging + import re + import json as json_module + system = ( "You are a P&ID (Piping and Instrumentation Diagram) expert.\n" "Extract all instrument and equipment tags from the provided text.\n" @@ -761,24 +847,23 @@ def extract_pid_tags(text: str, source_type: str) -> str: "- Output ONLY the JSON array, no markdown, no explanation.\n" "- If no tags found, return: []\n" ) - - import logging - import re - import json as json_module try: truncated_text = text[:100000] if len(text) > 100000 else text - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"}, - ], - max_tokens=32768, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) + def _call_llm(): + return _llm().chat.completions.create( + model="Qwen3.6-27B-FP8", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"}, + ], + max_tokens=32768, + temperature=0.1, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, + ) + + resp = await asyncio.to_thread(_call_llm) raw = (resp.choices[0].message.content or "").strip() finish_reason = resp.choices[0].finish_reason @@ -839,7 +924,7 @@ def extract_pid_tags(text: str, source_type: str) -> str: @mcp.tool() -def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: +async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: """P&ID 태그를 Experion 태그에 매핑합니다. Args: @@ -849,6 +934,10 @@ def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: Returns: JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] } """ + import asyncio + import re + import json as json_module + system = ( "You are a P&ID to Experion tag matching expert.\n" "Match P&ID tags to Experion tags based on similarity.\n" @@ -863,24 +952,24 @@ def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: "- If no matches found, return an empty array: []\n" "- temperature=0.1 for deterministic output.\n" ) - - import re - import json as json_module try: pid_str = "\n".join(pid_tags) experion_str = "\n".join(experion_tags) - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"}, - ], - max_tokens=16384, - temperature=0.1, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) + def _call_llm(): + return _llm().chat.completions.create( + model="Qwen3.6-27B-FP8", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"}, + ], + max_tokens=16384, + temperature=0.1, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, + ) + + resp = await asyncio.to_thread(_call_llm) raw = (resp.choices[0].message.content or "").strip() finish_reason = resp.choices[0].finish_reason @@ -909,7 +998,7 @@ def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: @mcp.tool() -def parse_pid_dxf(filepath: str) -> str: +async def parse_pid_dxf(filepath: str) -> str: """ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. Args: @@ -918,8 +1007,15 @@ def parse_pid_dxf(filepath: str) -> str: Returns: JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } """ + import asyncio + import json + import re + try: - text = _extract_text_from_dxf(filepath) + def _extract_text(): + return _extract_text_from_dxf(filepath) + text = await asyncio.to_thread(_extract_text) + if not text.strip(): return json.dumps({ "success": True, @@ -952,15 +1048,18 @@ def parse_pid_dxf(filepath: str) -> str: truncated_text = text[:12000] if len(text) > 12000 else text - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) + def _call_llm(): + return _llm().chat.completions.create( + model="Qwen3.6-27B-FP8", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"}, + ], + max_tokens=4096, + temperature=0.1, + ) + + resp = await asyncio.to_thread(_call_llm) raw = (resp.choices[0].message.content or "").strip() @@ -970,7 +1069,6 @@ def parse_pid_dxf(filepath: str) -> str: raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() # JSON 배열 추출 - import re match = re.search(r'\[.*\]', raw, re.DOTALL) if match: raw = match.group(0) @@ -1009,7 +1107,7 @@ def parse_pid_dxf(filepath: str) -> str: @mcp.tool() -def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: +async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: """PyMuPDF 기반 PDF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출. Args: @@ -1019,11 +1117,17 @@ def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: Returns: JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] } """ + import asyncio + import json + import re + try: - if use_ocr: - text = _extract_text_from_pdf_ocr(filepath) - else: - text = _extract_text_from_pdf(filepath) + def _extract_text(): + if use_ocr: + return _extract_text_from_pdf_ocr(filepath) + else: + return _extract_text_from_pdf(filepath) + text = await asyncio.to_thread(_extract_text) if not text.strip(): return json.dumps({ @@ -1057,15 +1161,18 @@ def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: truncated_text = text[:12000] if len(text) > 12000 else text - resp = _llm().chat.completions.create( - model=VLLM_MODEL, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"}, - ], - max_tokens=4096, - temperature=0.1, - ) + def _call_llm(): + return _llm().chat.completions.create( + model="Qwen3.6-27B-FP8", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"}, + ], + max_tokens=4096, + temperature=0.1, + ) + + resp = await asyncio.to_thread(_call_llm) raw = (resp.choices[0].message.content or "").strip() @@ -1075,7 +1182,6 @@ def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip() # JSON 배열 추출 - import re match = re.search(r'\[.*\]', raw, re.DOTALL) if match: raw = match.group(0) @@ -1119,25 +1225,38 @@ async def build_pid_graph_parallel(filepath: str) -> str: 분산 처리 기법을 적용한 P&ID 그래프 생성 툴. 전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다. """ + import asyncio + import json + try: # 1. 전처리 (Phase 1: Geometric Extraction) - extractor = PidGeometricExtractor(filepath) - geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json" - geo_data_list = extractor.extract_and_save(geo_data_path) + def _extract_and_save(): + extractor = PidGeometricExtractor(filepath) + geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json" + geo_data_list = extractor.extract_and_save(geo_data_path) + return geo_data_path + geo_data_path = await asyncio.to_thread(_extract_and_save) # geo_data_list는 경로를 반환하므로 다시 로드 - with open(geo_data_path, 'r', encoding='utf-8') as f: - geo_data = json.load(f) + def _load_geo_data(): + with open(geo_data_path, 'r', encoding='utf-8') as f: + return json.load(f) + geo_data = await asyncio.to_thread(_load_geo_data) # 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping) # 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값) # 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함 system_tags = [] try: - conn = _get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - system_tags = [r[0] for r in cur.fetchall()] + def _fetch_system_tags(): + conn = _get_db_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT tagname FROM realtime_table") + return [r[0] for r in cur.fetchall()] + finally: + conn.close() + system_tags = await asyncio.to_thread(_fetch_system_tags) except Exception as e: logging.warning(f"Failed to fetch system tags: {e}") @@ -1189,34 +1308,41 @@ async def build_pid_graph_parallel(filepath: str) -> str: return json.dumps({ "success": True, - "graph_id": graph_id, - "graph_path": graph_path, - "nodes": final_builder.G.number_of_nodes(), - "edges": final_builder.G.number_of_edges() + "data": { + "graph_id": graph_id, + "graph_path": graph_path, + "nodes": final_builder.G.number_of_nodes(), + "edges": final_builder.G.number_of_edges() + }, + "message": "그래프 생성 완료" }, ensure_ascii=False) except Exception as e: logging.error(f"build_pid_graph_parallel failed: {e}") - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) + return json.dumps({"success": False, "data": None, "error": str(e), "message": "그래프 생성 실패"}, ensure_ascii=False) @mcp.tool() -def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: +async def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: """ 구축된 그래프를 기반으로 특정 설비 장애 시 영향도 분석을 수행합니다. """ + import asyncio + try: graph_path = f"mcp-server/storage/{graph_id}" - mapping_path = graph_path.replace("_graph.json", "_mapping.json") # 매핑 파일이 따로 저장된다고 가정 + mapping_path = graph_path.replace("_graph.json", "_mapping.json") - analyzer = PidAnalysisEngine(graph_path, mapping_path) - result = analyzer.analyze_impact(start_node_id) + def _analyze(): + analyzer = PidAnalysisEngine(graph_path, mapping_path) + return analyzer.analyze_impact(start_node_id) + result = await asyncio.to_thread(_analyze) return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False) @mcp.tool() -def parse_pid_drawing(filepath: str) -> str: +async def parse_pid_drawing(filepath: str) -> str: """확장자 자동 감지하여 P&ID 도면 파싱. Args: @@ -1226,10 +1352,11 @@ def parse_pid_drawing(filepath: str) -> str: JSON: { success, text, count, tags, format } """ import os + ext = os.path.splitext(filepath)[1].lower() if ext == ".dxf": - return parse_pid_dxf(filepath) + return await parse_pid_dxf(filepath) elif ext == ".dwg": # DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다. # Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다. @@ -1243,7 +1370,7 @@ def parse_pid_drawing(filepath: str) -> str: "3. LibreOffice Draw (Windows/macOS 전용) 사용" }, ensure_ascii=False) elif ext == ".pdf": - return parse_pid_pdf(filepath) + return await parse_pid_pdf(filepath) else: return json.dumps({ "success": False, @@ -1262,7 +1389,7 @@ async def _forward_request(port: int, tool_name: str, params: dict, one_shot: bo params: 요청 파라미터 one_shot: True일 경우 요청 완료 후 워커 종료 """ - async with httpx.AsyncClient(timeout=300) as client: # 5분 타임아웃 + async with httpx.AsyncClient(timeout=600) as client: # 5분 타임아웃 (대용량 DXF 처리용) endpoint = "/execute/one_shot" if one_shot else "/execute" response = await client.post( f"http://localhost:{port}{endpoint}", @@ -1353,17 +1480,15 @@ async def list_drawings(unit_no: str = None) -> str: }) -@mcp.tool() -async def query_with_nl(question: str) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("query_with_nl") - return await _forward_request(worker.port, "query_with_nl", {"question": question}) - - @mcp.tool() async def parse_pid_dxf(filepath: str) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: # P&ID는 1개 동시 실행만 허용 + # 파일 경로 기반으로 Lock 획득하여 동일 파일 중복 처리 방지 및 다른 파일 병렬 처리 허용 + lock_key = os.path.basename(filepath) + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("parse_pid_dxf", one_shot=True) return await _forward_request(worker.port, "parse_pid_dxf", {"filepath": filepath}, one_shot=True) @@ -1371,7 +1496,11 @@ async def parse_pid_dxf(filepath: str) -> str: @mcp.tool() async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: + lock_key = os.path.basename(filepath) + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("parse_pid_pdf", one_shot=True) return await _forward_request(worker.port, "parse_pid_pdf", { "filepath": filepath, @@ -1382,7 +1511,11 @@ async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: @mcp.tool() async def parse_pid_drawing(filepath: str) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: + lock_key = os.path.basename(filepath) + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("parse_pid_drawing", one_shot=True) return await _forward_request(worker.port, "parse_pid_drawing", {"filepath": filepath}, one_shot=True) @@ -1390,7 +1523,15 @@ async def parse_pid_drawing(filepath: str) -> str: @mcp.tool() async def extract_pid_tags(text: str, source_type: str) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: + # 텍스트 추출/매핑은 특정 파일에 종속되지 않으므로 전역 Lock 사용 (또는 세마포어 유지) + # 여기서는 단순화를 위해 전역 Lock 하나를 사용하거나, + # 텍스트 기반 작업은 병렬 처리가 가능하므로 Lock을 제거할 수도 있으나, + # 워커 리소스 보호를 위해 'global_text' 키로 Lock 관리 + lock_key = "global_text_processing" + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("extract_pid_tags", one_shot=True) return await _forward_request(worker.port, "extract_pid_tags", { "text": text, @@ -1401,7 +1542,11 @@ async def extract_pid_tags(text: str, source_type: str) -> str: @mcp.tool() async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: + lock_key = "global_matching" + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("match_pid_tags", one_shot=True) return await _forward_request(worker.port, "match_pid_tags", { "pid_tags": pid_tags, @@ -1412,7 +1557,11 @@ async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: @mcp.tool() async def build_pid_graph_parallel(filepath: str) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: + lock_key = os.path.basename(filepath) + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("build_pid_graph_parallel", one_shot=True) return await _forward_request(worker.port, "build_pid_graph_parallel", {"filepath": filepath}, one_shot=True) @@ -1420,7 +1569,12 @@ async def build_pid_graph_parallel(filepath: str) -> str: @mcp.tool() async def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - async with process_manager._pid_sem: + # graph_id 기반으로 Lock 관리 + lock_key = graph_id + if lock_key not in process_manager._pid_locks: + process_manager._pid_locks[lock_key] = asyncio.Lock() + + async with process_manager._pid_locks[lock_key]: worker = await process_manager.get_worker("analyze_pid_impact", one_shot=True) return await _forward_request(worker.port, "analyze_pid_impact", { "graph_id": graph_id, diff --git a/.rooBackup/2026-05-03-031200/mcp-server/worker/nl2sql_worker.py b/.rooBackup/enum-opt-202605081758/mcp-server/worker/nl2sql_worker.py similarity index 59% rename from .rooBackup/2026-05-03-031200/mcp-server/worker/nl2sql_worker.py rename to .rooBackup/enum-opt-202605081758/mcp-server/worker/nl2sql_worker.py index 55a9e7a..8925a17 100644 --- a/.rooBackup/2026-05-03-031200/mcp-server/worker/nl2sql_worker.py +++ b/.rooBackup/enum-opt-202605081758/mcp-server/worker/nl2sql_worker.py @@ -30,11 +30,11 @@ import httpx # ── 설정 ───────────────────────────────────────────────────────────────────── -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 +DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform") +DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10")) -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" +VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1") +VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8") logging.basicConfig( level=logging.INFO, @@ -57,28 +57,117 @@ def _llm_client(): from openai import AsyncOpenAI return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy") +# DB 스키마 — server.py::_DB_SCHEMA와 동일 +DB_SCHEMA = """ +PostgreSQL 시계열 데이터베이스 스키마 + +테이블: history_table (시계열 이력) + tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분 + node_id TEXT - OPC UA 노드 ID + value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요 + recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초 + +테이블: realtime_table (실시간 최신값) + tagname TEXT - 태그명 (모두 소문자) + node_id TEXT - OPC UA 노드 ID + livevalue TEXT - 현재값 + timestamp TIMESTAMPTZ - 최종 갱신 시각 + +테이블: tag_metadata (태그 메타데이터 - 변경 드묾) + base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124') + attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...) + value TEXT - 메타데이터 값 + node_id TEXT - OPC UA 노드 ID + loaded_at TIMESTAMPTZ - 마지막 로드 시각 + +뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰) + base_tag TEXT - 기본 태그명 + pv TEXT - 현재 프로세스 값 + sp TEXT - 설정값 + op TEXT - 출력값 + instate0 TEXT - 상태 비트 0 (true/false) + instate1 TEXT - 상태 비트 1 (true/false) + instate2 TEXT - 상태 비트 2 (true/false) + description TEXT - 장비 설명 (tag_metadata.desc) + area TEXT - 소속 플랜트 (tag_metadata.area) + state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop") + state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local") + state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal") + +새로운 태그 타입: + - 아날로그: ficq-6101.pv/sp/op (Double) + - 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean) + - Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean) + - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String) + +BCD 상태 조회 팁: + - instate0~7은 Boolean (true/false) + - state0descriptor~7은 해당 비트의 의미 설명 + - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태 + - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능 + +N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): + 1분 버킷: date_trunc('minute', recorded_at) AS bucket + 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket + 5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket + 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket + N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket + +예시 (2분 간격, 여러 태그): + SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, + tagname, AVG(value::double precision) AS avg_val + FROM history_table + WHERE tagname IN ('tag1', 'tag2') + AND recorded_at >= NOW() - INTERVAL '3 hours' + GROUP BY bucket, tagname ORDER BY bucket, tagname + +규칙: + - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) + - tagname은 모두 소문자로 정확히 입력 + - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수 + - time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용 +""" + async def _generate_sql(natural_language: str) -> str: """자연어를 SQL로 변환.""" client = _llm_client() - prompt = f"""다음 자연어 질문을 PostgreSQL SQL 쿼리로 변환하세요. -데이터베이스는 TimescaleDB 기반의 IIoT 플랫폼입니다. - -질문: -{natural_language} - -SQL 쿼리 (SELECT 문만, 설명 없이):""" + system = ( + "You are a PostgreSQL SQL expert.\n" + "Convert the user's question into a SELECT SQL using the schema below.\n" + "IMPORTANT rules:\n" + "- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n" + "- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n" + "- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n" + "- INTERVAL rule:\n" + " * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n" + " use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n" + " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" + " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" + "- Current year is 2026. '4월 27일' means 2026-04-27.\n" + "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" + "- value column is TEXT; cast with ::double precision only when aggregating.\n" + "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" + "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" + "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" + f"{DB_SCHEMA}" + ) response = await client.chat.completions.create( - model=VLLM_MODEL, + model="Qwen3.6-27B-FP8", messages=[ - {"role": "system", "content": "You are a PostgreSQL SQL expert. Generate only valid SQL queries."}, - {"role": "user", "content": prompt}, + {"role": "system", "content": system}, + {"role": "user", "content": natural_language}, ], - max_tokens=1024, + max_tokens=8192, temperature=0.1, ) - return response.choices[0].message.content.strip() + sql = response.choices[0].message.content.strip() + # 마크다운 코드 블록 제거 + if sql.startswith("```"): + lines = sql.splitlines() + sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + return sql # ── NL2SQL 도구 구현 ───────────────────────────────────────────────────────── @@ -237,8 +326,13 @@ async def _list_drawings(unit_no: str = None) -> str: async def _query_with_nl(question: str) -> str: """자연어로 SQL 쿼리 실행.""" + import json sql = await _generate_sql(question) + # SQL이 비어있으면 오류 반환 + if not sql: + return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) + conn = _get_db_connection() try: with conn.cursor() as cur: diff --git a/.rooBackup/2026-04-30-15-03-15/src/Infrastructure/Database/ExperionDbContext.cs b/.rooBackup/enum-opt-202605081758/src/Infrastructure/Database/ExperionDbContext.cs similarity index 89% rename from .rooBackup/2026-04-30-15-03-15/src/Infrastructure/Database/ExperionDbContext.cs rename to .rooBackup/enum-opt-202605081758/src/Infrastructure/Database/ExperionDbContext.cs index eba2106..0ea7a02 100644 --- a/.rooBackup/2026-04-30-15-03-15/src/Infrastructure/Database/ExperionDbContext.cs +++ b/.rooBackup/enum-opt-202605081758/src/Infrastructure/Database/ExperionDbContext.cs @@ -21,6 +21,12 @@ public class ExperionDbContext : DbContext public DbSet HistoryRecords => Set(); public DbSet FastSessions => Set(); public DbSet FastRecords => Set(); + public DbSet TagMetadata => Set(); + + // P&ID 데이터베이스용 DbSet + public DbSet PidEquipment => Set(); + public DbSet PidAuditLog => Set(); + public DbSet PidGraphStatuses => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -72,6 +78,93 @@ public class ExperionDbContext : DbContext e.HasKey(x => new { x.SessionId, x.RecordedAt, x.TagName }); e.HasIndex(x => x.SessionId); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique(); + e.HasIndex(x => x.BaseTag); + }); + + // P&ID 엔티티 설정 + modelBuilder.Entity(entity => + { + entity.ToTable("pid_equipment"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.TagNo) + .IsRequired() + .HasMaxLength(50); + + entity.Property(e => e.EquipmentName) + .HasMaxLength(200); + + entity.Property(e => e.InstrumentType) + .HasMaxLength(10); + + entity.Property(e => e.LineNumber) + .HasMaxLength(100); + + entity.Property(e => e.PidDrawingNo) + .HasMaxLength(50); + + entity.Property(e => e.Confidence) + .HasPrecision(4, 3); + + entity.Property(e => e.IsActive) + .HasDefaultValue(true); + + entity.Property(e => e.ExtractedAt) + .HasDefaultValueSql("NOW()"); + + entity.Property(e => e.UpdatedAt) + .ValueGeneratedOnAddOrUpdate() + .HasDefaultValueSql("NOW()"); + + entity.HasIndex(e => e.TagNo); + entity.HasIndex(e => e.InstrumentType); + entity.HasIndex(e => e.ExtractedAt); + + entity.HasOne(e => e.ExperionTag) + .WithMany() + .HasForeignKey(e => e.ExperionTagId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("pid_audit_log"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Source) + .HasMaxLength(50) + .HasDefaultValue("WebUI"); + + entity.Property(e => e.Action) + .HasMaxLength(50); + + entity.Property(e => e.TargetTagNo) + .HasMaxLength(50); + + entity.Property(e => e.LoggedAt) + .HasDefaultValueSql("NOW()"); + + entity.HasIndex(e => e.LoggedAt); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("pid_graph_status"); + entity.HasKey(e => e.TaskId); + + entity.Property(e => e.Status) + .HasMaxLength(20); + + entity.Property(e => e.Message) + .HasMaxLength(500); + + entity.HasIndex(e => e.UpdatedAt); + }); } } @@ -189,6 +282,53 @@ public class ExperionDbService : IExperionDbService // realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음 + // tag_metadata 테이블 생성 (메타데이터 - 변경 드묾) + await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS tag_metadata ( + id SERIAL PRIMARY KEY, + base_tag TEXT NOT NULL, + attribute TEXT NOT NULL, + value TEXT, + node_id TEXT, + loaded_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(base_tag, attribute) + ) + """); + + await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag) + """); + + // v_tag_summary 뷰 생성 (실시간 + 메타데이터 통합) + await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE OR REPLACE VIEW v_tag_summary AS + SELECT + rt_base.base_tag, + pv_rt.livevalue AS pv, + sp_rt.livevalue AS sp, + op_rt.livevalue AS op, + instate0_rt.livevalue AS instate0, + instate1_rt.livevalue AS instate1, + instate2_rt.livevalue AS instate2, + desc_md.value AS description, + area_md.value AS area, + s0d_md.value AS state0_descriptor, + s1d_md.value AS state1_descriptor, + s2d_md.value AS state2_descriptor + FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base + LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv' + LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp' + LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op' + LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0' + LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1' + LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2' + LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc' + LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area' + LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor' + LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor' + LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor' + """); + // history 테이블은 수동으로 하이퍼테이블 생성 필요 // CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능 // 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은 diff --git a/.rooBackup/enum-opt-202605081758/src/Infrastructure/OpcUa/MetadataLoaderService.cs b/.rooBackup/enum-opt-202605081758/src/Infrastructure/OpcUa/MetadataLoaderService.cs new file mode 100644 index 0000000..66f72d6 --- /dev/null +++ b/.rooBackup/enum-opt-202605081758/src/Infrastructure/OpcUa/MetadataLoaderService.cs @@ -0,0 +1,131 @@ +using ExperionCrawler.Core.Application.Interfaces; +using ExperionCrawler.Core.Domain.Entities; +using ExperionCrawler.Infrastructure.Database; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +/// +/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신 +/// +public class MetadataLoaderService : IMetadataLoaderService +{ + private readonly IExperionOpcClient _opcClient; + private readonly ExperionDbContext _ctx; + private readonly ILogger _logger; + + // 로드할 메타데이터 속성 목록 + private static readonly string[] MetaAttributes = + { + "desc", "area", + "state0descriptor", "state1descriptor", "state2descriptor", + "state3descriptor", "state4descriptor", "state5descriptor", + "state6descriptor", "state7descriptor" + }; + + public MetadataLoaderService( + IExperionOpcClient opcClient, + ExperionDbContext ctx, + ILogger logger) + { + _opcClient = opcClient; + _ctx = ctx; + _logger = logger; + } + + public async Task LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable baseTags) + { + var baseTagSet = baseTags.ToHashSet(StringComparer.OrdinalIgnoreCase); + if (baseTagSet.Count == 0) return 0; + + // ── Step 1: node_map_master에서 실제 node_id 조회 ────────────────── + // hostname을 직접 조성하지 않고 DB에 저장된 원본 node_id를 사용 + var masterNodes = await _ctx.NodeMapMasters + .Where(n => MetaAttributes.Contains(n.Name)) + .Select(n => new { n.NodeId, n.Name }) + .ToListAsync(); + + var nodeMap = new Dictionary(); + foreach (var node in masterNodes) + { + var baseTag = ExtractBaseTag(node.NodeId); + if (baseTagSet.Contains(baseTag)) + nodeMap[node.NodeId] = (baseTag, node.Name); + } + + // ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ──────────────────────── + var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys); + var entries = new List<(string baseTag, string attr, string? value, string nodeId)>(); + + foreach (var result in results) + { + if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta)) + { + entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId)); + } + } + + // ── Step 3: 단일 배치 UPSERT ────────────────────────────────────── + if (entries.Count > 0) + { + // VALUES 절을 동적으로 생성하여 한 번에 INSERT + // CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at + var valuesSql = string.Join(", ", entries.Select((e, i) => + $"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())")); + + await _ctx.Database.ExecuteSqlRawAsync(@" + WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS ( + VALUES " + valuesSql + @" + ) + INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at) + SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data + ON CONFLICT (base_tag, attribute) + DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()", + entries.SelectMany((e, i) => new object[] { + new NpgsqlParameter($"@bt{i}", e.baseTag), + new NpgsqlParameter($"@attr{i}", e.attr), + new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value), + new NpgsqlParameter($"@nid{i}", e.nodeId) + }).ToArray()); + } + + // v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN) + _logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagSet.Count); + return entries.Count; + } + + // "ns=1;s=sinamserver:FIC101.desc" → "FIC101" + private static string ExtractBaseTag(string nodeId) + { + var colon = nodeId.LastIndexOf(':'); + if (colon < 0) return nodeId; + var afterColon = nodeId[(colon + 1)..]; + var dot = afterColon.IndexOf('.'); + return dot > 0 ? afterColon[..dot] : afterColon; + } + + public async Task ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable? baseTags = null) + { + List tags; + if (baseTags != null) + { + tags = baseTags.ToList(); + } + else + { + // realtime_table에 등록된 포인트의 tagname에서 base_tag 추출 + // tagname 형식: "FIC101.pv" → base_tag: "FIC101" (첫 번째 '.' 앞) + var tagnames = await _ctx.RealtimePoints.Select(p => p.TagName).Distinct().ToListAsync(); + tags = tagnames + .Select(tn => { var d = tn.IndexOf('.'); return d > 0 ? tn[..d] : tn; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (tags.Count == 0) + _logger.LogWarning("[Metadata] realtime_table이 비어있습니다. 메타데이터 로드 대상 없음."); + } + return await LoadMetadataAsync(cfg, tags); + } +} diff --git a/.rooBackup/2026-05-02_11-30/src/Web/wwwroot/js/app.js b/.rooBackup/enum-opt-202605081758/src/Web/wwwroot/js/app.js similarity index 94% rename from .rooBackup/2026-05-02_11-30/src/Web/wwwroot/js/app.js rename to .rooBackup/enum-opt-202605081758/src/Web/wwwroot/js/app.js index dbf59f2..e483b5b 100644 --- a/.rooBackup/2026-05-02_11-30/src/Web/wwwroot/js/app.js +++ b/.rooBackup/enum-opt-202605081758/src/Web/wwwroot/js/app.js @@ -572,7 +572,7 @@ function nmReset() { /* ───────────────────────────────────────────────────────────── 06 포인트빌더 ───────────────────────────────────────────────────────────── */ -const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8']; +const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10']; const PB_DT_IDS = ['pb-dt1','pb-dt2']; async function pbLoad() { @@ -604,8 +604,8 @@ async function pbLoad() { async function pbRefresh() { try { const d = await api('GET', '/api/pointbuilder/points'); - document.getElementById('pb-count').textContent = `(${d.count}개)`; - pbRender(d.points || []); + document.getElementById('pb-count').textContent = `(${d.total}개)`; + pbRender(d.items || []); rtStatus(); } catch (e) { console.error('pbRefresh:', e); } } @@ -722,6 +722,58 @@ async function rtStatus() { } catch (e) { /* 무시 */ } } +/* ── 메타데이터 관리 ─────────────────────────────────────────── */ + +async function metaReload() { + const body = { + serverHostName: document.getElementById('pb-rt-ip').value.trim(), + port: parseInt(document.getElementById('pb-rt-port').value) || 4840, + clientHostName: document.getElementById('pb-rt-client').value.trim(), + userName: document.getElementById('pb-rt-user').value.trim(), + password: document.getElementById('pb-rt-pw').value + }; + const logEl = document.getElementById('meta-log'); + logEl.classList.remove('hidden'); + logEl.innerHTML = '
⏳ 메타데이터 재로드 중...
'; + try { + const d = await api('POST', '/api/tags/metadata/reload', body); + logEl.innerHTML = `
${d.success ? '✅' : '❌'} ${esc(d.message)}
`; + } catch (e) { + logEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} + +async function metaView() { + const viewEl = document.getElementById('meta-view'); + viewEl.classList.remove('hidden'); + viewEl.innerHTML = '
⏳ 조회 중...
'; + try { + const d = await api('GET', '/api/tags/metadata'); + const items = d.items || []; + if (items.length === 0) { + viewEl.innerHTML = '
메타데이터가 없습니다.
'; + return; + } + viewEl.innerHTML = ` + + + + ${items.map(m => ` + + + + + + + `).join('')} + +
BaseTagAttributeValueLoadedAt
${esc(m.baseTag)}${esc(m.attribute)}${esc(m.value || '-')}${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}
+ `; + } catch (e) { + viewEl.innerHTML = `
❌ ${esc(e.message)}
`; + } +} + /* ───────────────────────────────────────────────────────────── 07 이력 조회 ───────────────────────────────────────────────────────────── */ @@ -2086,6 +2138,7 @@ let fastCurrentSessionId = null; let fastChart = null; let fastLivePollTimer = null; let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용) +let fastDbConnected = false; // DB 연결 상태 function fastModalClose() { document.getElementById('modal-fast-new').style.display = 'none'; @@ -2095,11 +2148,53 @@ function fastModalClose() { // fastRecord — API 함수 // ═══════════════════════════════════════════════════════════════ +/** DB 연결 테스트 후 세션 목록 로드 */ +async function fastDbConnect() { + const statusEl = document.getElementById('fast-db-status'); + const btnEl = document.getElementById('btn-fast-db-connect'); + statusEl.textContent = 'DB 접속 중...'; + statusEl.style.color = 'var(--t2)'; + btnEl.disabled = true; + + try { + const res = await fetch('/api/fast/sessions'); + if (!res.ok) throw new Error('DB 응답 없음'); + const data = await res.json(); + + fastDbConnected = true; + statusEl.textContent = 'DB 연결됨'; + statusEl.style.color = '#4caf50'; + btnEl.style.display = 'none'; + + await fastSessionsLoad(); + } catch (e) { + console.error('[fast] DB 접속 실패:', e); + statusEl.textContent = 'DB 미연결'; + statusEl.style.color = 'var(--red,#e55)'; + btnEl.disabled = false; + alert('DB 연결에 실패했습니다. 다시 시도해주세요.'); + } +} + async function fastSessionsLoad() { const res = await fetch('/api/fast/sessions'); - if (!res.ok) return; + if (!res.ok) { + // DB 연결 안 된 상태 — 세션 목록 비우기 + const list = document.getElementById('fast-session-list'); + list.innerHTML = 'DB 연결이 필요합니다. "DB 접속" 버튼을 눌러주세요.'; + return; + } const data = await res.json(); + fastDbConnected = true; + const statusEl = document.getElementById('fast-db-status'); + if (statusEl) { + statusEl.textContent = 'DB 연결됨'; + statusEl.style.color = '#4caf50'; + } + const btnEl = document.getElementById('btn-fast-db-connect'); + if (btnEl) btnEl.style.display = 'none'; + const list = document.getElementById('fast-session-list'); list.innerHTML = ''; @@ -2399,7 +2494,18 @@ function fastTagColor(tag) { // fastRecord — 이벤트 리스너 // ═══════════════════════════════════════════════════════════════ +// DB 접속 버튼 +document.getElementById('btn-fast-db-connect')?.addEventListener('click', () => { + fastDbConnect(); +}); + +// + 신규 버튼 — DB 연결 확인 후 모달 열기 document.getElementById('btn-fast-new')?.addEventListener('click', async () => { + if (!fastDbConnected) { + alert('데이터베이스 접속을 먼저 완료해주세요. "DB 접속" 버튼을 눌러주세요.'); + return; + } + const select = document.getElementById('fast-tag-select'); select.innerHTML = ''; document.getElementById('fast-session-name').value = ''; @@ -2534,6 +2640,18 @@ async function pidLoadServerFiles(selectFileName) { try { const res = await fetch('/api/pid/server-files'); if (!res.ok) throw new Error(`HTTP ${res.status}`); + + // 응답이 JSON인지 확인 + const contentType = res.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await res.text(); + // HTML이 반환되면 P&ID 컨트롤러가 비활성화된 것 + if (text.startsWith(' { + const seconds = Math.floor((Date.now() - startTime) / 1000); + const m = String(Math.floor(seconds / 60)).padStart(2, '0'); + const s = String(seconds % 60).padStart(2, '0'); + elapsedEl.textContent = `${m}:${s}`; + }, 1000); + } + try { const res = await fetch('/api/pid/extract', { method: 'POST', @@ -2579,6 +2715,7 @@ async function pidExtract() { } const data = await res.json(); + pidExtracting = false; if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}건`; log('pid-log', [ { c: 'ok', t: `✅ 추출 완료: ${data.totalCount}건` }, @@ -2590,8 +2727,16 @@ async function pidExtract() { await pidLoadTable(); pidUpdateStats(); } catch (e) { + pidExtracting = false; if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`; log('pid-log', [{ c: 'err', t: `❌ ${e.message}` }]); + } finally { + // 경과 시간 시계 종료 + if (pidElapsedInterval) { + clearInterval(pidElapsedInterval); + pidElapsedInterval = null; + } + if (elapsedEl) elapsedEl.style.display = 'none'; } } @@ -2729,15 +2874,26 @@ document.getElementById('btn-pid-export-excel')?.addEventListener('click', async // 탭 진입 시 초기화 document.querySelectorAll('[data-tab="pid"]').forEach(item => { - item.addEventListener('click', () => { + item.addEventListener('click', async () => { pidCurrentPage = 1; pidLastResult = null; document.getElementById('pid-file-input').value = ''; - document.getElementById('pid-status').textContent = '대기 중...'; - document.getElementById('pid-table-body').innerHTML = ''; - document.getElementById('pid-pagination').innerHTML = ''; - document.getElementById('pid-stat-total').textContent = '0'; - document.getElementById('pid-stat-high').textContent = '0'; - document.getElementById('pid-stat-mapped').textContent = '0'; + if (!pidExtracting) { + document.getElementById('pid-status').textContent = '대기 중...'; + const elapsedEl = document.getElementById('pid-elapsed'); + if (elapsedEl) elapsedEl.style.display = 'none'; + if (pidElapsedInterval) { + clearInterval(pidElapsedInterval); + pidElapsedInterval = null; + } + // 추출 중이 아니면 테이블/통계 초기화 및 갱신 + document.getElementById('pid-table-body').innerHTML = ''; + document.getElementById('pid-pagination').innerHTML = ''; + document.getElementById('pid-stat-total').textContent = '0'; + document.getElementById('pid-stat-high').textContent = '0'; + document.getElementById('pid-stat-mapped').textContent = '0'; + await pidLoadTable(1); + } + // 추출 중이면 시계 유지, 테이블/통계 갱신 생략 }); }); diff --git a/mcp-server-진단-문제점-개선방안.md b/mcp-server-진단-문제점-개선방안.md new file mode 100644 index 0000000..fa82f8b --- /dev/null +++ b/mcp-server-진단-문제점-개선방안.md @@ -0,0 +1,407 @@ +# MCP Server 진단 및 개선 방안 + +## 분석 개요 +- 분석 대상: `mcp-server/server.py` (1608줄), `worker/` 하위 워커 스크립트 +- 진단 일자: 2026-05-08 +- 수정 완료: 2026-05-08 +- 분석 범위: FastMCP tool 등록 동작 검증, 워커 아키텍처 동작 확인, 병목 지점 식별 + +--- + +## 1. 진단 결과 (검증 완료) + +### 1.1 [HIGH] 워커 아키텍처 전체 무효화 ✅ 수정 완료 + +**문제**: `server.py`의 1398-1592줄(워커 포워딩 래퍼)이 FastMCP의 중복 등록 방지 정책으로 인해 **전혀 동작하지 않음** + +**원인 (실험으로 확인)**: 동일 이름의 `@mcp.tool()` 데코레이터가 두 번 등록될 때 FastMCP 1.27.0은 첫 번째 등록을 유지하고 두 번째는 WARNING만 출력한 후 무시함 + +``` +WARNING Tool already exists: foo ← FastMCP 1.27.0 실제 출력 +registered tools: ['foo'] +foo is async? False ← 첫 번째(직접 구현) 유지 확인 +``` + +**수정 전 코드 예시**: +```python +# 498줄 — 첫 등록 (직접 구현) → 이것이 실제 동작 +@mcp.tool() +def search_codebase(query: str, top_k: int = 6) -> str: + return _search(COL_CODEBASE, query, top_k) + +# 1401줄 — 두 번째 등록 (워커 포워딩) → 절대 호출 안 됨 +@mcp.tool() +async def search_codebase(query: str, top_k: int = 6) -> str: + worker = await process_manager.get_worker("search_codebase") + return await _forward_request(worker.port, ...) +``` + +**영향**: 모든 요청이 server.py 단일 프로세스에서 직접 실행됨. RAG/NL2SQL/P&ID를 별도 워커로 분리한다는 설계 의도가 완전히 무의미함. + +**중복 등록된 tool 목록 (15개, WARNING 15회)**: + +> 최초 진단은 "14개"로 기록했으나 실제 카운트는 15개. `query_with_nl`은 두 번째 섹션에 없어 중복 아님. `get_worker_status`는 두 번째 섹션에만 있어 중복 아님. + +| Tool 이름 | 첫 등록(실제 동작) | 두 번째 등록(dead) | +|-----------|---------------------|---------------------| +| `search_codebase` | 498-510줄 (직접) | 1400-1407줄 (워커) | +| `search_r530_docs` | 513-525줄 (직접) | 1410-1417줄 (워커) | +| `ask_iiot_llm` | 528-555줄 (직접) | 1420-1427줄 (워커) | +| `rag_query` | 557-574줄 (직접) | 1430-1438줄 (워커) | +| `run_sql` | 605-615줄 (직접) | 1441-1445줄 (워커) | +| `query_pv_history` | 618-658줄 (직접) | 1448-1457줄 (워커) | +| `get_tag_metadata` | 661-693줄 (직접) | 1460-1468줄 (워커) | +| `list_drawings` | 696-726줄 (직접) | 1471-1477줄 (워커) | +| `extract_pid_tags` | 814-919줄 (직접) | 1519-1535줄 (워커) | +| `match_pid_tags` | 922-990줄 (직접) | 1538-1550줄 (워커) | +| `parse_pid_dxf` | 996-1102줄 (직접) | 1480-1489줄 (워커) | +| `parse_pid_pdf` | 1105-1215줄 (직접) | 1492-1504줄 (워커) | +| `parse_pid_drawing` | 1340-1374줄 (직접) | 1507-1516줄 (워커) | +| `build_pid_graph_parallel` | 1218-1318줄 (직접) | 1553-1562줄 (워커) | +| `analyze_pid_impact` | 1320-1338줄 (직접) | 1565-1578줄 (워커) | + +단독 등록 (중복 없음): +- `query_with_nl`: 728-809줄만 존재 +- `get_worker_status`: 1581-1592줄만 존재 (항상 `{}` 반환 — workers가 비어있으므로) + +--- + +### 1.2 [HIGH] async 함수 await 누락 — 7개 함수 ✅ 수정 완료 + +> 최초 진단은 MEDIUM으로 분류하고 3개 함수("코드 구조가 혼란스러움")만 언급했으나, +> 실제로는 **런타임 오류**이며 RAG 3개 + DB 4개 = 총 7개. + +**공통 원인**: `async def` 함수를 sync `def` 내에서 `await` 없이 호출하면 코루틴 객체가 반환됨. +- `_get_db_connection()` 누락: `.cursor()` 호출 시 `AttributeError` +- `_search()` 누락: FastMCP가 코루틴 객체를 `str()`로 직렬화 → `""` 반환 + +**케이스 A — RAG 도구 3개 (코루틴 repr 반환)** + +`search_codebase`, `search_r530_docs`, `rag_query`: `def`(sync)에서 `async def _search()`를 await 없이 호출. + +```python +# 수정 전 — MCP 응답이 "" +@mcp.tool() +def search_codebase(query: str, top_k: int = 6) -> str: + return _search(COL_CODEBASE, query, top_k) # 코루틴 객체 반환 + +# rag_query: f-string 안에서 호출 → 코루틴 repr이 문자열로 삽입 +context_parts.append(f"...\n{_search(COL_OPC_DOCS, question, 4)}") +``` + +`ask_iiot_llm`은 sync `_llm()` 클라이언트 사용이므로 해당 없음. + +**케이스 B — `_get_db_connection()` await 누락: 즉시 크래시 (3개 함수)** + +`query_pv_history`, `get_tag_metadata`, `list_drawings`: `def`(sync)로 정의되어 있어 호출 즉시 크래시. + +```python +@mcp.tool() +def query_pv_history(...) -> str: # sync def + conn = _get_db_connection() # await 없음 → 코루틴 객체 + with conn.cursor() as cur: # AttributeError: 'coroutine' has no 'cursor' +``` + +**케이스 C — `build_pid_graph_parallel` (무음 실패)** + +`async def`이지만 내부 중첩 sync 함수 `_fetch_system_tags`에서 `_get_db_connection()` 호출. +`try/except`가 AttributeError를 삼켜 함수 자체는 크래시하지 않지만, `system_tags`가 항상 `[]`로 폴백되어 **P&ID 매핑이 태그 정보 없이 실행됨**. + +**영향 요약**: + +| 함수 | 결과 | +|------|------| +| `search_codebase` | `""` 반환 | +| `search_r530_docs` | `""` 반환 | +| `rag_query` | context에 코루틴 repr 삽입 → LLM이 쓰레기 컨텍스트로 답변 | +| `query_pv_history` | 호출 즉시 AttributeError 크래시 | +| `get_tag_metadata` | 호출 즉시 AttributeError 크래시 | +| `list_drawings` | 호출 즉시 AttributeError 크래시 | +| `build_pid_graph_parallel` | 크래시 없지만 system_tags 항상 `[]` → 매핑 품질 저하 | + +`run_sql`은 `async def`로 올바르게 정의되어 있어 이 문제 없음. + +```python +# 252줄 +async def _get_db_connection(): # ← async def + return await asyncio.to_thread(_connect) +``` + +**케이스 A — 즉시 크래시 (3개 함수)** + +`query_pv_history`, `get_tag_metadata`, `list_drawings`: `def`(sync)로 정의되어 있어 호출 즉시 크래시. + +```python +@mcp.tool() +def query_pv_history(...) -> str: # sync def + conn = _get_db_connection() # await 없음 → 코루틴 객체 + with conn.cursor() as cur: # AttributeError: 'coroutine' has no 'cursor' +``` + +**케이스 B — 무음 실패 (1개 함수)** + +`build_pid_graph_parallel`: `async def`이지만 내부 중첩 sync 함수 `_fetch_system_tags`에서 호출. + +```python +@mcp.tool() +async def build_pid_graph_parallel(filepath: str) -> str: + system_tags = [] + try: + def _fetch_system_tags(): # sync 함수 (to_thread 용) + conn = _get_db_connection() # await 없음 → 코루틴 객체 + with conn.cursor() as cur: # AttributeError + ... + system_tags = await asyncio.to_thread(_fetch_system_tags) + except Exception as e: + logging.warning(f"Failed to fetch system tags: {e}") # 예외를 삼킴 +``` + +`try/except`가 AttributeError를 삼켜 함수 자체는 크래시하지 않지만, `system_tags`가 항상 `[]`로 폴백되어 **P&ID 매핑이 태그 정보 없이 실행됨**. + +**영향 요약**: + +| 함수 | 결과 | +|------|------| +| `query_pv_history` | 호출 즉시 AttributeError 크래시 | +| `get_tag_metadata` | 호출 즉시 AttributeError 크래시 | +| `list_drawings` | 호출 즉시 AttributeError 크래시 | +| `build_pid_graph_parallel` | 크래시 없지만 system_tags 항상 `[]` → 매핑 품질 저하 | + +`run_sql`은 `async def`로 올바르게 정의되어 있어 이 문제 없음. + +--- + +### 1.3 [MEDIUM] Dead Code 부피 ✅ 수정 완료 + +워커 아키텍처 관련 dead code 총 **약 367줄**: + +| 영역 | 라인 | 줄 수 | 내용 | +|------|------|-------|------| +| `WorkerProcess` dataclass | 62-67 | ~6줄 | 워커 프로세스 데이터 구조 | +| `ProcessManager` 클래스 | 69-197 | ~129줄 | 워커 프로세스 관리 (시작/종료/헬스체크) | +| `process_manager` 전역 인스턴스 | 201 | 1줄 | `ProcessManager()` | +| `_forward_request` | 1377-1395 | ~19줄 | 워커 HTTP 포워딩 | +| 워커 포워딩 래퍼 15개 | 1398-1578 | ~180줄 | `@mcp.tool()` 중복 등록 | +| `get_worker_status` | 1581-1592 | ~12줄 | 워커 상태 조회 (항상 `{}`) | +| 불필요한 top-level import | 52-57 | ~6줄 | `subprocess`, `atexit`, `signal`, `dataclass`, `Dict`, `Optional`, `cache` | + +--- + +### 1.4 [LOW] worker/ 디렉토리 유지보수 문제 (미수정 — 호출 경로 없음) + +`worker/rag_worker.py`, `worker/nl2sql_worker.py`, `worker/pid_worker.py`는 현재 어떤 경로로도 호출되지 않음. +server.py 정리 후 완전한 dead code가 됨. 내부 버그 목록은 참고용으로만 보존: + +| 파일 | 버그 | +|------|------| +| `rag_worker.py` | 응답 형식이 server.py와 다름 (`{"success": true, "items": [...]}` vs JSON string) | +| `nl2sql_worker.py` | `query_pv_history`에서 `time_bucket('1 min', ts)` 사용 — `time_bucket` 금지, `ts` 컬럼 없음 (실제: `recorded_at`) | +| `nl2sql_worker.py` | `get_tag_metadata`에서 `tag_name`, `unit`, `description` 조회 — `unit`/`description` 컬럼 실제 테이블에 없음 | +| `nl2sql_worker.py` | `list_drawings`에서 `dict(zip(columns, row[0]))` — `row[0]`은 문자열이므로 첫 글자만 매핑됨 | +| `pid_worker.py` | `build_pid_graph_parallel`가 server.py 직접 구현과 완전히 다른 독립 병렬 아키텍처로 구현됨 | + +--- + +## 2. 수정 내용 + +### 2.1 server.py 수정 (1608줄 → 1241줄, -367줄) + +#### (A) 불필요한 import 제거 및 `import os` 상단 이동 + +```python +# 수정 전 +import os # 44-57줄 내 위치 (설정 섹션 아래) +import subprocess +import atexit +import signal +from dataclasses import dataclass +from typing import Dict, Optional +from functools import cache + +# 수정 후 — 상단 imports 블록으로 이동, 불필요한 것 제거 +import os # 상단으로 이동 (설정 섹션 env var 사용 위해) +import asyncio +# subprocess/atexit/signal/dataclass/Dict/Optional/cache 삭제 +``` + +`subprocess`는 `_convert_dwg_to_dxf_dxflib` 내부에서 로컬 import로 사용하므로 top-level 제거만 필요. + +#### (B) 설정값 환경변수화 + +```python +# 수정 전 — 하드코딩 +QDRANT_URL = "http://localhost:6333" +OLLAMA_URL = "http://localhost:11434" +EMBED_MODEL = "nomic-embed-text" +VLLM_BASE_URL = "http://localhost:8000/v1" +VLLM_MODEL = "Qwen3.6-27B-FP8" +DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" +DB_TIMEOUT = 10 + +# 수정 후 — 환경변수 (worker/*.py와 동일한 방식) +QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333") +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") +EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text") +VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1") +VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8") +DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform") +DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10")) +``` + +#### (C) ProcessManager 전체 삭제 + +`WorkerProcess` dataclass, `ProcessManager` 클래스, `process_manager = ProcessManager()` 전체 제거. + +#### (D) `_get_db_connection()` await 누락 수정 (4개) + +**케이스 A — RAG 도구 3개**: `def` → `async def` + `await` 추가 + +```python +@mcp.tool() +async def search_codebase(query: str, top_k: int = 6) -> str: + return await _search(COL_CODEBASE, query, top_k) + +@mcp.tool() +async def rag_query(question: str, ...) -> str: + context_parts.append(f"...\n{await _search(COL_OPC_DOCS, question, 4)}") + return ask_iiot_llm(question, ...) # ask_iiot_llm은 sync이므로 await 불필요 +``` + +**케이스 B — NL2SQL 함수 3개**: `def` → `async def` 변경 + `await` 추가 + +```python +@mcp.tool() +async def query_pv_history(...) -> str: + conn = await _get_db_connection() +``` + +`get_tag_metadata`, `list_drawings`도 동일하게 적용. + +**케이스 C — `build_pid_graph_parallel`**: `asyncio.to_thread` 내 중첩 sync 함수 제거, async 컨텍스트에서 직접 `await` + +```python +# 수정 전 — system_tags 항상 [] (무음 실패) +system_tags = [] +try: + def _fetch_system_tags(): + conn = _get_db_connection() # await 없음 → AttributeError → except에서 삼킴 + ... + system_tags = await asyncio.to_thread(_fetch_system_tags) +except Exception as e: + logging.warning(...) + +# 수정 후 — async 컨텍스트에서 직접 await +system_tags = [] +try: + conn = await _get_db_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT tagname FROM realtime_table") + system_tags = [r[0] for r in cur.fetchall()] + finally: + conn.close() +except Exception as e: + logging.warning(f"Failed to fetch system tags: {e}") +``` + +#### (E) 워커 포워딩 섹션 전체 삭제 + +`_forward_request`, 워커 포워딩 래퍼 15개, `get_worker_status` 삭제. + +#### 수정 결과 + +| 항목 | 수정 전 | 수정 후 | +|------|---------|---------| +| 총 줄 수 | 1608줄 | 1241줄 | +| `@mcp.tool()` 등록 수 | 32개 | 16개 | +| FastMCP 시작 시 WARNING | 15회 | 0회 | +| `search_codebase` 호출 결과 | 코루틴 repr 문자열 반환 | 정상 검색 결과 반환 | +| `search_r530_docs` 호출 결과 | 코루틴 repr 문자열 반환 | 정상 검색 결과 반환 | +| `rag_query` 호출 결과 | 쓰레기 컨텍스트로 LLM 답변 | 정상 RAG 답변 | +| `query_pv_history` 호출 결과 | AttributeError 크래시 | 정상 동작 | +| `get_tag_metadata` 호출 결과 | AttributeError 크래시 | 정상 동작 | +| `list_drawings` 호출 결과 | AttributeError 크래시 | 정상 동작 | +| `build_pid_graph_parallel` system_tags | 항상 `[]` (무음 실패) | DB에서 실제 태그 로드 | + +--- + +### 2.2 Claude Code 연결 — `.mcp.json` 신규 생성 + +> 최초 개선 방안의 "방안 B: server_stdio.py 신규 생성"은 불필요. server.py가 이미 `--http` 플래그 없이 실행하면 stdio 모드로 동작함 (파일 주석에도 명시되어 있었음). + +`server.py` 기존 엔트리포인트: +```python +if __name__ == "__main__": + if "--http" in sys.argv: + mcp.run(transport="streamable-http") # C# McpClient용 + else: + mcp.run(transport="stdio") # Claude Code / Roo Code MCP용 ← 이미 있음 +``` + +신규 파일: `/home/windpacer/projects/ExperionCrawler/.mcp.json` +```json +{ + "mcpServers": { + "iiot-rag": { + "command": "uv", + "args": ["run", "--directory", "mcp-server", "python", "server.py"], + "type": "stdio" + } + } +} +``` + +--- + +### 2.3 opencode 연결 — `~/.config/opencode/opencode.json` mcp 섹션 추가 + +> 최초 개선 방안의 "방안 A: Remote HTTP (권장)"은 현재 server.py가 `json_response=True, stateless_http=True`로 설정되어 있어 C# HttpClient 전용 비표준 모드이므로 opencode MCP 클라이언트와의 호환성이 불확실함. stdio local 방식이 더 확실함. + +`~/.config/opencode/opencode.json`에 `mcp` 섹션 추가: +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { ... }, + "model": "vllm_node/Qwen3.6-27B-FP8", + "mcp": { + "iiot-rag": { + "type": "local", + "command": ["uv", "run", "--directory", "/home/windpacer/projects/ExperionCrawler/mcp-server", "python", "server.py"], + "enabled": true + } + } +} +``` + +**경로 참고**: `~/.config/opencode/opencode.json`은 전역 설정 파일이므로 `--directory`에 절대 경로를 사용했음. 프로젝트를 다른 위치로 이동하면 이 경로를 수동으로 수정해야 함. `.mcp.json`(Claude Code용)은 프로젝트 루트에 위치하므로 상대 경로(`--directory mcp-server`)를 사용해 이동에 영향 없음. + +| 파일 | 위치 | 경로 방식 | +|------|------|-----------| +| `.mcp.json` | 프로젝트 루트 | 상대 경로 (`--directory mcp-server`) — 이동에 강건 | +| `opencode.json` | `~/.config/opencode/` (전역) | 절대 경로 필수 — 이동 시 수정 필요 | + +--- + +## 3. 최초 진단 오류 정리 + +| 항목 | 최초 진단 | 실제 | +|------|-----------|------| +| 중복 tool 수 | 14개 | **15개** (`query_with_nl` 제외한 나머지 15개) | +| 1.2 대상 함수 | 3개 (`query_pv_history`, `get_tag_metadata`, `list_drawings`) | **7개** (RAG 3개 + DB 4개 전부 누락) | +| 1.2 심각도 | MEDIUM ("구조 혼란") | **HIGH (크래시 또는 무음 실패)** | +| 1.2 설명 | "psycopg.connect()를 직접 호출하는 별도 코드로 동작" | **틀림 — 별도 코드 없음, 무조건 AttributeError** | +| Phase 2 권장 방안 | 방안 A (Remote HTTP) | **불확실 — json_response=True 호환성 미검증** | +| 방안 B | server_stdio.py 신규 생성 | **불필요 — server.py가 이미 stdio 지원** | +| ProcessManager dead code | 59-197줄 (~140줄) | **62-201줄 (~143줄, process_manager 인스턴스 포함)** | +| opencode.json 경로 | 미언급 | **절대 경로 사용 — 프로젝트 이동 시 수동 수정 필요** | + +--- + +## 4. worker/ 디렉토리 향후 처리 + +현재 `worker/` 디렉토리는 어떤 경로로도 호출되지 않는 완전한 dead code. + +**권장**: 삭제. 재구현이 필요한 경우 아래 제약 사항을 반드시 준수: +- FastMCP 동일 이름 tool 중복 등록 불가 → 워커 포워딩 래퍼에 다른 이름 사용하거나 server.py에서 조건부 분기 +- nl2sql_worker.py 재작성 시 실제 스키마 기준: `tagname` (not `tag_name`), `recorded_at` (not `ts`), `time_bucket` 사용 금지 +- 외부 서비스(vLLM, Qdrant, PG)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음 diff --git a/mcp-server/server.py b/mcp-server/server.py index 2bf643d..406be5f 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -10,6 +10,7 @@ ExperionCrawler Unified MCP Server from __future__ import annotations import sys +import os import json import logging import httpx @@ -19,19 +20,19 @@ from mcp.server.fastmcp import FastMCP logging.basicConfig(level=logging.WARNING, stream=sys.stderr) # ── 설정 ────────────────────────────────────────────────────────────────────── -QDRANT_URL = "http://localhost:6333" -OLLAMA_URL = "http://localhost:11434" -EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 -VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "Qwen3.6-27B-FP8" +QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333") +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") +EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text") +VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1") +VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8") # Qdrant 컬렉션 COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks) # PostgreSQL 연결 -DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" -DB_TIMEOUT = 10 # 초 +DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform") +DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10")) # C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식 mcp = FastMCP( @@ -47,158 +48,7 @@ from pipeline.topology import PidTopologyBuilder from pipeline.mapper import IntelligentMapper from pipeline.analyzer import PidAnalysisEngine import networkx as nx -import os import asyncio -import subprocess -import atexit -import signal -from dataclasses import dataclass -from typing import Dict, Optional -from functools import cache - -# ── ProcessManager ───────────────────────────────────────────────────────────── - -@dataclass -class WorkerProcess: - process: subprocess.Popen - port: int - status: str # "running", "stopped", "error" - one_shot: bool = False # 요청 후 프로세스 종료 여부 (P&ID 워커용) - - -class ProcessManager: - """워커 프로세스 관리자.""" - - def __init__(self): - self.workers: Dict[str, WorkerProcess] = {} - self._locks: Dict[str, asyncio.Lock] = {} - self._pid_locks: Dict[str, asyncio.Lock] = {} # 파일/ID별 세부 Lock - self._worker_ports = {"rag": 5002, "nl2sql": 5003, "pid": 5004} - - # 정리 훅 등록 - atexit.register(self._cleanup) - signal.signal(signal.SIGTERM, lambda *_: self._cleanup()) - signal.signal(signal.SIGINT, lambda *_: self._cleanup()) - - def _get_available_port(self, worker_type: str) -> int: - """워커 타입에 대한 포트 반환.""" - return self._worker_ports.get(worker_type, 5002) - - def _classify_tool(self, tool_name: str) -> str: - """도구 이름을 워커 타입으로 분류.""" - rag_tools = {"search_codebase", "search_r530_docs", "ask_iiot_llm", "rag_query"} - nl2sql_tools = {"run_sql", "query_pv_history", "get_tag_metadata", "list_drawings", "query_with_nl"} - pid_tools = { - "extract_pid_tags", "match_pid_tags", "parse_pid_dxf", "parse_pid_pdf", - "parse_pid_drawing", "build_pid_graph_parallel", "analyze_pid_impact" - } - - if tool_name in rag_tools: - return "rag" - elif tool_name in nl2sql_tools: - return "nl2sql" - elif tool_name in pid_tools: - return "pid" - else: - return "default" - - async def start_worker(self, worker_type: str, one_shot: bool = False) -> WorkerProcess: - """서브 프로세스 시작. - - Args: - worker_type: 워커 타입 (rag, nl2sql, pid) - one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용) - """ - port = self._get_available_port(worker_type) - cmd = [ - sys.executable, - f"worker/{worker_type}_worker.py", - str(port) - ] - - # 로그 파일 열기 - log_dir = os.path.join(os.path.dirname(__file__), "logs") - os.makedirs(log_dir, exist_ok=True) - log_file = open(os.path.join(log_dir, f"{worker_type}_worker.log"), "a") - - proc = subprocess.Popen( - cmd, - stdout=log_file, - stderr=log_file, - ) - - # 헬스체크 루프 (최대 15초 대기) - for _ in range(30): # 0.5초 * 30 = 15초 - await asyncio.sleep(0.5) - if proc.poll() is not None: - log_file.close() - raise RuntimeError(f"{worker_type} 워커가 시작 직후 종료됨") - try: - async with httpx.AsyncClient(timeout=1) as client: - await client.get(f"http://localhost:{port}/health") - break # 헬스체크 성공 - except Exception: - continue - else: - proc.kill() - log_file.close() - raise RuntimeError(f"{worker_type} 워커 시작 타임아웃") - - worker = WorkerProcess( - process=proc, - port=port, - status="running", - one_shot=one_shot - ) - self.workers[worker_type] = worker - log_file.close() - return worker - - async def stop_worker(self, worker_type: str): - """서브 프로세스 종료.""" - if worker_type in self.workers: - proc = self.workers[worker_type].process - proc.terminate() - await asyncio.sleep(0.5) - if proc.poll() is None: - proc.kill() - del self.workers[worker_type] - - async def get_worker(self, tool_name: str, one_shot: bool = False) -> WorkerProcess: - """도구 이름에 해당하는 워커 프로세스 반환 (자동 시작). - - Args: - tool_name: 도구 이름 - one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용) - """ - worker_type = self._classify_tool(tool_name) - - if worker_type not in self._locks: - self._locks[worker_type] = asyncio.Lock() - - async with self._locks[worker_type]: - if worker_type not in self.workers: - return await self.start_worker(worker_type, one_shot) - - proc = self.workers[worker_type].process - if proc.poll() is not None: - del self.workers[worker_type] - return await self.start_worker(worker_type, one_shot) - - return self.workers[worker_type] - - def _cleanup(self): - """모든 워커 프로세스 정리.""" - for wtype, worker in list(self.workers.items()): - try: - worker.process.terminate() - except Exception: - pass - self.workers.clear() - - -# 전역 ProcessManager 인스턴스 -process_manager = ProcessManager() # ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── @@ -444,7 +294,7 @@ PostgreSQL 시계열 데이터베이스 스키마 테이블: tag_metadata (태그 메타데이터 - 변경 드묾) base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124') - attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...) + attribute TEXT - 속성명 ('desc', 'area') value TEXT - 메타데이터 값 node_id TEXT - OPC UA 노드 ID loaded_at TIMESTAMPTZ - 마지막 로드 시각 @@ -459,20 +309,16 @@ PostgreSQL 시계열 데이터베이스 스키마 instate2 TEXT - 상태 비트 2 (true/false) description TEXT - 장비 설명 (tag_metadata.desc) area TEXT - 소속 플랜트 (tag_metadata.area) - state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop") - state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local") - state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal") 새로운 태그 타입: - 아날로그: ficq-6101.pv/sp/op (Double) - 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean) - Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean) - - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String) + - 메타데이터: desc (String), area (Enum) BCD 상태 조회 팁: - instate0~7은 Boolean (true/false) - - state0descriptor~7은 해당 비트의 의미 설명 - - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태 + - pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능 - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능 N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): @@ -500,7 +346,7 @@ N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): # ── RAG 도구 ───────────────────────────────────────────────────────────────── @mcp.tool() -def search_codebase(query: str, top_k: int = 6) -> str: +async def search_codebase(query: str, top_k: int = 6) -> str: """ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드). Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함. @@ -511,11 +357,11 @@ def search_codebase(query: str, top_k: int = 6) -> str: query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스") top_k: 반환 결과 수 (기본 6) """ - return _search(COL_CODEBASE, query, top_k) + return await _search(COL_CODEBASE, query, top_k) @mcp.tool() -def search_r530_docs(query: str, top_k: int = 5) -> str: +async def search_r530_docs(query: str, top_k: int = 5) -> str: """Honeywell Experion HS R530 공식 제품 문서 검색. ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함. @@ -526,7 +372,7 @@ def search_r530_docs(query: str, top_k: int = 5) -> str: query: 검색어 (예: "certificate configuration", "endpoint security policy") top_k: 반환 결과 수 (기본 5) """ - return _search(COL_OPC_DOCS, query, top_k) + return await _search(COL_OPC_DOCS, query, top_k) @mcp.tool() @@ -559,7 +405,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str: @mcp.tool() -def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: +async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: """검색 → Qwen3.6-27B-FP8 답변 생성 (통합 RAG). 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). @@ -572,9 +418,9 @@ def rag_query(question: str, search_code: bool = False, search_docs: bool = True """ context_parts: list[str] = [] if search_docs: - context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}") + context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{await _search(COL_OPC_DOCS, question, 4)}") if search_code: - context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}") + context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{await _search(COL_CODEBASE, question, 3)}") return ask_iiot_llm(question, "\n\n".join(context_parts)) @@ -620,7 +466,7 @@ async def run_sql(sql: str) -> str: @mcp.tool() -def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: +async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: """과거 값(PV) 히스토리 조회. Args: @@ -635,7 +481,7 @@ def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: conn = None try: limit = min(limit, 5000) - conn = _get_db_connection() + conn = await _get_db_connection() with conn.cursor() as cur: cur.execute( """SELECT tagname, recorded_at, value @@ -663,7 +509,7 @@ def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: @mcp.tool() -def get_tag_metadata(query: str, limit: int = 10) -> str: +async def get_tag_metadata(query: str, limit: int = 10) -> str: """태그 메타데이터 검색 (realtime_table 기반). Args: @@ -675,7 +521,7 @@ def get_tag_metadata(query: str, limit: int = 10) -> str: """ conn = None try: - conn = _get_db_connection() + conn = await _get_db_connection() with conn.cursor() as cur: cur.execute( """SELECT tagname, livevalue, timestamp, node_id @@ -698,7 +544,7 @@ def get_tag_metadata(query: str, limit: int = 10) -> str: @mcp.tool() -def list_drawings(unit_no: str | None = None) -> str: +async def list_drawings(unit_no: str | None = None) -> str: """단위별 도면 목록 조회 (node_map_master.name 기반). Args: @@ -709,7 +555,7 @@ def list_drawings(unit_no: str | None = None) -> str: """ conn = None try: - conn = _get_db_connection() + conn = await _get_db_connection() with conn.cursor() as cur: if unit_no: cur.execute( @@ -1248,15 +1094,13 @@ async def build_pid_graph_parallel(filepath: str) -> str: # 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함 system_tags = [] try: - def _fetch_system_tags(): - conn = _get_db_connection() - try: - with conn.cursor() as cur: - cur.execute("SELECT tagname FROM realtime_table") - return [r[0] for r in cur.fetchall()] - finally: - conn.close() - system_tags = await asyncio.to_thread(_fetch_system_tags) + conn = await _get_db_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT tagname FROM realtime_table") + system_tags = [r[0] for r in cur.fetchall()] + finally: + conn.close() except Exception as e: logging.warning(f"Failed to fetch system tags: {e}") @@ -1378,223 +1222,6 @@ async def parse_pid_drawing(filepath: str) -> str: }, ensure_ascii=False) -# ── 워커 요청 전달 ──────────────────────────────────────────────────────────── - -async def _forward_request(port: int, tool_name: str, params: dict, one_shot: bool = False) -> str: - """HTTP를 통해 워커 프로세스로 요청 전달. - - Args: - port: 워커 포트 - tool_name: 도구 이름 - params: 요청 파라미터 - one_shot: True일 경우 요청 완료 후 워커 종료 - """ - async with httpx.AsyncClient(timeout=600) as client: # 5분 타임아웃 (대용량 DXF 처리용) - endpoint = "/execute/one_shot" if one_shot else "/execute" - response = await client.post( - f"http://localhost:{port}{endpoint}", - json={"tool": tool_name, "params": params} - ) - response.raise_for_status() - return response.text - - -# ── 요청 라우팅 (워커 프로세스 사용) ─────────────────────────────────────────── - -@mcp.tool() -async def search_codebase(query: str, top_k: int = 6) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("search_codebase") - return await _forward_request(worker.port, "search_codebase", { - "query": query, - "top_k": top_k - }) - - -@mcp.tool() -async def search_r530_docs(query: str, top_k: int = 5) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("search_r530_docs") - return await _forward_request(worker.port, "search_r530_docs", { - "query": query, - "top_k": top_k - }) - - -@mcp.tool() -async def ask_iiot_llm(question: str, context: str = "") -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("ask_iiot_llm") - return await _forward_request(worker.port, "ask_iiot_llm", { - "question": question, - "context": context - }) - - -@mcp.tool() -async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """RAG 워커로 요청 전달.""" - worker = await process_manager.get_worker("rag_query") - return await _forward_request(worker.port, "rag_query", { - "question": question, - "search_code": search_code, - "search_docs": search_docs - }) - - -@mcp.tool() -async def run_sql(sql: str) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("run_sql") - return await _forward_request(worker.port, "run_sql", {"sql": sql}) - - -@mcp.tool() -async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("query_pv_history") - return await _forward_request(worker.port, "query_pv_history", { - "tag_names": tag_names, - "time_from": time_from, - "time_to": time_to, - "limit": limit - }) - - -@mcp.tool() -async def get_tag_metadata(query: str, limit: int = 10) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("get_tag_metadata") - return await _forward_request(worker.port, "get_tag_metadata", { - "query": query, - "limit": limit - }) - - -@mcp.tool() -async def list_drawings(unit_no: str = None) -> str: - """NL2SQL 워커로 요청 전달.""" - worker = await process_manager.get_worker("list_drawings") - return await _forward_request(worker.port, "list_drawings", { - "unit_no": unit_no - }) - - -@mcp.tool() -async def parse_pid_dxf(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - # 파일 경로 기반으로 Lock 획득하여 동일 파일 중복 처리 방지 및 다른 파일 병렬 처리 허용 - lock_key = os.path.basename(filepath) - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("parse_pid_dxf", one_shot=True) - return await _forward_request(worker.port, "parse_pid_dxf", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - lock_key = os.path.basename(filepath) - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("parse_pid_pdf", one_shot=True) - return await _forward_request(worker.port, "parse_pid_pdf", { - "filepath": filepath, - "use_ocr": use_ocr - }, one_shot=True) - - -@mcp.tool() -async def parse_pid_drawing(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - lock_key = os.path.basename(filepath) - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("parse_pid_drawing", one_shot=True) - return await _forward_request(worker.port, "parse_pid_drawing", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def extract_pid_tags(text: str, source_type: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - # 텍스트 추출/매핑은 특정 파일에 종속되지 않으므로 전역 Lock 사용 (또는 세마포어 유지) - # 여기서는 단순화를 위해 전역 Lock 하나를 사용하거나, - # 텍스트 기반 작업은 병렬 처리가 가능하므로 Lock을 제거할 수도 있으나, - # 워커 리소스 보호를 위해 'global_text' 키로 Lock 관리 - lock_key = "global_text_processing" - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("extract_pid_tags", one_shot=True) - return await _forward_request(worker.port, "extract_pid_tags", { - "text": text, - "source_type": source_type - }, one_shot=True) - - -@mcp.tool() -async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - lock_key = "global_matching" - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("match_pid_tags", one_shot=True) - return await _forward_request(worker.port, "match_pid_tags", { - "pid_tags": pid_tags, - "experion_tags": experion_tags - }, one_shot=True) - - -@mcp.tool() -async def build_pid_graph_parallel(filepath: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - lock_key = os.path.basename(filepath) - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("build_pid_graph_parallel", one_shot=True) - return await _forward_request(worker.port, "build_pid_graph_parallel", {"filepath": filepath}, one_shot=True) - - -@mcp.tool() -async def analyze_pid_impact(graph_id: str, start_node_id: str) -> str: - """P&ID 워커로 요청 전달 (one_shot: 요청 후 종료).""" - # graph_id 기반으로 Lock 관리 - lock_key = graph_id - if lock_key not in process_manager._pid_locks: - process_manager._pid_locks[lock_key] = asyncio.Lock() - - async with process_manager._pid_locks[lock_key]: - worker = await process_manager.get_worker("analyze_pid_impact", one_shot=True) - return await _forward_request(worker.port, "analyze_pid_impact", { - "graph_id": graph_id, - "start_node_id": start_node_id - }, one_shot=True) - - -@mcp.tool() -def get_worker_status() -> str: - """모든 워커 프로세스 상태 조회.""" - status = {} - for name, worker in process_manager.workers.items(): - status[name] = { - "pid": worker.process.pid, - "status": worker.status, - "port": worker.port, - "one_shot": worker.one_shot - } - return json.dumps(status, ensure_ascii=False, indent=2) - # ── 엔트리포인트 ────────────────────────────────────────────────────────────── diff --git a/mcp-server/worker/nl2sql_worker.py b/mcp-server/worker/nl2sql_worker.py index 8925a17..289f958 100644 --- a/mcp-server/worker/nl2sql_worker.py +++ b/mcp-server/worker/nl2sql_worker.py @@ -75,7 +75,7 @@ PostgreSQL 시계열 데이터베이스 스키마 테이블: tag_metadata (태그 메타데이터 - 변경 드묾) base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124') - attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...) + attribute TEXT - 속성명 ('desc', 'area') value TEXT - 메타데이터 값 node_id TEXT - OPC UA 노드 ID loaded_at TIMESTAMPTZ - 마지막 로드 시각 @@ -90,20 +90,16 @@ PostgreSQL 시계열 데이터베이스 스키마 instate2 TEXT - 상태 비트 2 (true/false) description TEXT - 장비 설명 (tag_metadata.desc) area TEXT - 소속 플랜트 (tag_metadata.area) - state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop") - state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local") - state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal") 새로운 태그 타입: - 아날로그: ficq-6101.pv/sp/op (Double) - 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean) - Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean) - - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String) + - 메타데이터: desc (String), area (Enum) BCD 상태 조회 팁: - instate0~7은 Boolean (true/false) - - state0descriptor~7은 해당 비트의 의미 설명 - - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태 + - pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능 - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능 N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): diff --git a/plans/enum-metadata-optimize-coding-plan.md b/plans/enum-metadata-optimize-coding-plan.md index 6150a78..4b26f7b 100644 --- a/plans/enum-metadata-optimize-coding-plan.md +++ b/plans/enum-metadata-optimize-coding-plan.md @@ -13,10 +13,13 @@ - [ ] STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업 - [ ] STEP 2 — `MetadataLoaderService.cs`: `MetaAttributes` 배열에서 state0~7descriptor 제거 -- [ ] STEP 3 — `MetadataLoaderService.cs`: 빌드 검증 +- [ ] STEP 2.5 — `MetadataLoaderService.cs`: 클래스 주석 업데이트 +- [ ] STEP 3 — `MetadataLoaderService.cs` + `ExperionDbContext.cs`: 빌드 검증 - [ ] STEP 4 — `ExperionDbContext.cs`: `v_tag_summary` 뷰에서 state descriptor JOIN 제거 -- [ ] STEP 5 — `ExperionDbContext.cs`: 빌드 검증 +- [ ] STEP 4.5 — `tag_metadata` 고아 데이터 삭제 (선택적) +- [ ] STEP 5 — `ExperionDbContext.cs` 변경 후 빌드 검증 - [ ] STEP 6 — `app.js`: pv 값 파싱 헬퍼 함수 `parseEnumPv()` 추가 +- [ ] STEP 6.5 — NL2SQL DB_SCHEMA 동기화 (`server.py` + `nl2sql_worker.py`) - [ ] STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용 - [ ] STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트 - [ ] STEP 9 — git 커밋 및 정리 @@ -27,9 +30,11 @@ | # | 파일 | 변경 내용 | 영향 범위 | |---|------|-----------|-----------| -| 1 | `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | `MetaAttributes` 배열 수정 | 메타데이터 로딩 | -| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰 단순화 | DB 뷰 | +| 1 | `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | `MetaAttributes` 배열 수정 + 주석 업데이트 | 메타데이터 로딩 | +| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰 단순화 + 고아 데이터 삭제 | DB 뷰 | | 3 | `src/Web/wwwroot/js/app.js` | pv 파싱 헬퍼 + 표시 로직 변경 | 프론트엔드 UI | +| 4 | `mcp-server/server.py` | DB_SCHEMA에서 state0~2_descriptor 제거 | NL2SQL | +| 5 | `mcp-server/worker/nl2sql_worker.py` | DB_SCHEMA에서 state0~2_descriptor 제거 | NL2SQL | --- @@ -47,14 +52,17 @@ TIMESTAMP=$(date +%Y%m%d%H%M) mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/OpcUa mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Web/wwwroot/js +mkdir -p .rooBackup/enum-opt-$TIMESTAMP/mcp-server/worker cp src/Infrastructure/OpcUa/MetadataLoaderService.cs .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/OpcUa/ cp src/Infrastructure/Database/ExperionDbContext.cs .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database/ cp src/Web/wwwroot/js/app.js .rooBackup/enum-opt-$TIMESTAMP/src/Web/wwwroot/js/ +cp mcp-server/server.py .rooBackup/enum-opt-$TIMESTAMP/mcp-server/ +cp mcp-server/worker/nl2sql_worker.py .rooBackup/enum-opt-$TIMESTAMP/mcp-server/worker/ ``` **검증 기준**: -- [ ] `.rooBackup/enum-opt-YYYYMMDDHHMM/` 폴더가 생성되고 3개 파일이 복사됨 +- [ ] `.rooBackup/enum-opt-YYYYMMDDHHMM/` 폴더가 생성되고 5개 파일이 복사됨 - [ ] 원본 파일과 백업 파일의 체크섬이 일치함 (`md5sum` 비교) **enum-metadata-optimization.md 규칙 매핑**: @@ -111,6 +119,7 @@ private static readonly string[] MetaAttributes = - `LoadMetadataAsync()` 메서드: `MetaAttributes.Contains(n.Name)`으로 `node_map_master`에서 필터링하므로 state descriptor 노드는 더 이상 조회되지 않음 - `ReadTagsAsync()`: 8개 menos의 nodeId로 배치 읽기 → 네트워크 트래픽 감소 - UPSERT 쿼리: 8개 menos의 행 삽입 → DB 부하 감소 +- 클래스 주석 (11줄): `state0~7descriptor` 언급도 함께 제거 필요 **검증 기준**: - [ ] `MetaAttributes` 배열이 `["desc", "area"]` 두 개만 포함 @@ -124,6 +133,37 @@ private static readonly string[] MetaAttributes = --- +### STEP 2.5 — `MetadataLoaderService.cs`: 클래스 주석 업데이트 + +**파일**: [`MetadataLoaderService.cs`](src/Infrastructure/OpcUa/MetadataLoaderService.cs:11) +**변경 위치**: 11줄 (클래스 XML 주석) + +**변경 전 코드** (11줄): +```csharp +/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신 +``` + +**변경 후 코드**: +```csharp +/// 메타데이터(desc, area)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신 +``` + +**diff**: +```diff +-/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신 ++/// 메타데이터(desc, area)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신 +``` + +**변경 이유**: +- STEP 2에서 `state0~7descriptor`를 제거했는데 주석에는 여전히 남아 있음 +- 주석과 코드 불일치로 인한 혼란 방지 + +**검증 기준**: +- [ ] 주석이 `desc, area`만 언급함 +- [ ] 컴파일 오류 없음 (주석 변경이므로 영향 없음) + +--- + ### STEP 3 — `MetadataLoaderService.cs` 변경 후 빌드 검증 **목적**: STEP 2 변경이 컴파일 오류 없이 통과하는지 확인 @@ -226,6 +266,40 @@ dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q --- +### STEP 4.5 — `tag_metadata` 고아 데이터 삭제 (선택적) + +**파일**: [`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:185) +**변경 위치**: `InitializeAsync()` 메서드 내 `v_tag_summary` 뷰 생성 직후 (330줄 이후) + +**추가할 코드**: +```csharp +// state descriptor 고아 데이터 정리 (state0~7descriptor는 더 이상 로딩하지 않음) +await _ctx.Database.ExecuteSqlRawAsync(""" + DELETE FROM tag_metadata WHERE attribute IN ( + 'state0descriptor', 'state1descriptor', 'state2descriptor', + 'state3descriptor', 'state4descriptor', 'state5descriptor', + 'state6descriptor', 'state7descriptor' + ) + """); +``` + +**삽입 위치 상세**: +- 330줄 (`""");` — v_tag_summary 뷰 생성 종료) 바로 다음에 삽입 +- `CREATE EXTENSION IF NOT EXISTS timescaledb` 이전 + +**변경 이유**: +- `MetaAttributes`에서 state descriptor가 제거되면 더 이상 갱신되지 않으나, 기존 데이터는 영구히 남음 +- 테이블 크기와 불필요한 JOIN 결과 방지 + +**검증 기준**: +- [ ] 실행 시 기존 state descriptor 행이 삭제됨 +- [ ] `SELECT COUNT(*) FROM tag_metadata WHERE attribute LIKE 'state%descriptor'` → 0 반환 +- [ ] desc/area 행은 영향 없음 + +**참고**: 기존 데이터를 보존해야 한다면 이 스텝을 스킵 가능. 하지만 `v_tag_summary` 뷰에서 해당 컬럼이 제거되었으므로 조회 자체가 불가능해짐. + +--- + ### STEP 5 — `ExperionDbContext.cs` 변경 후 빌드 검증 **목적**: STEP 4 변경이 컴파일 오류 없이 통과하는지 확인 @@ -297,6 +371,84 @@ function parseEnumPv(v) { --- +### STEP 6.5 — NL2SQL DB_SCHEMA 동기화 (`server.py` + `nl2sql_worker.py`) + +**파일**: [`mcp-server/server.py`](mcp-server/server.py:447) / [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:78) +**변경 위치**: 두 파일의 `DB_SCHEMA` 문자열 내 `tag_metadata` / `v_tag_summary` 설명 부분 + +**변경 전 코드** (`server.py:447` + `462-464` + `470` + `474-475`): +``` + attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...) + ... + state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop") + state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local") + state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal") + ... + - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String) + ... + - state0descriptor~7은 해당 비트의 의미 설명 + - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태 +``` + +**변경 후 코드**: +``` + attribute TEXT - 속성명 ('desc', 'area') + ... + description TEXT - 장비 설명 (tag_metadata.desc) + area TEXT - 소속 플랜트 (tag_metadata.area) + ... + - 메타데이터: desc (String), area (Enum) + ... + - pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능 + - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능 +``` + +**diff** (`server.py` 기준, `nl2sql_worker.py`는 동일): +```diff +- attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...) ++ attribute TEXT - 속성명 ('desc', 'area') + value TEXT - 메타데이터 값 + node_id TEXT - OPC UA 노드 ID + loaded_at TIMESTAMPTZ - 마지막 로드 시각 + + 뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰) + ... + description TEXT - 장비 설명 (tag_metadata.desc) + area TEXT - 소속 플랜트 (tag_metadata.area) +- state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop") +- state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local") +- state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal") + + 새로운 태그 타입: + - 아날로그: ficq-6101.pv/sp/op (Double) + - 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean) + - Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean) +- - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String) ++ - 메타데이터: desc (String), area (Enum) + + BCD 상태 조회 팁: + - instate0~7은 Boolean (true/false) +- - state0descriptor~7은 해당 비트의 의미 설명 +- - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태 ++ - pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능 + - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능 +``` + +**변경 이유**: +- `v_tag_summary` 뷰에서 `state0~2_descriptor` 컬럼이 제거되면 LLM이 해당 컬럼을 SELECT하는 SQL을 생성하면 실패함 +- DB_SCHEMA는 LLM의 시스템 프롬프트로 사용되므로 실제 DB 스키마와 반드시 일치해야 함 + +**검증 기준**: +- [ ] `server.py` DB_SCHEMA에서 `state0_descriptor` / `state1_descriptor` / `state2_descriptor` 언급 없음 +- [ ] `nl2sql_worker.py` DB_SCHEMA에서 동일하게 제거됨 +- [ ] `attribute` 설명이 `'desc', 'area'`만 포함 +- [ ] MCP 서버 재시작 후 NL2SQL 쿼리가 정상 동작 (state descriptor 없이) + +**enum-metadata-optimization.md 규칙 매핑**: +- 섹션 3.1: pv 값이 EnumValueType 형식인 경우 DisplayName 파싱으로 상태 확인 + +--- + ### STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용 **파일**: [`app.js`](src/Web/wwwroot/js/app.js:633) @@ -361,9 +513,15 @@ dotnet run --project src/Web/ExperionCrawler.csproj #### 백엔드 검증 - [ ] 애플리케이션 시작 시 DB 초기화 성공 - [ ] `v_tag_summary` 뷰 생성 성공 (state descriptor JOIN 없음) +- [ ] `tag_metadata` 고아 데이터 삭제 성공 (STEP 4.5) - [ ] 메타데이터 로드 시 `desc`, `area`만 조회됨 (로그 확인) - [ ] `tag_metadata` 테이블에 state0~7descriptor 행 없음 +#### NL2SQL 검증 +- [ ] MCP 서버 재시작 성공 +- [ ] "xv-6124 상태 알려줘" 쿼리가 state descriptor 없이 정상 동작 +- [ ] 생성된 SQL에서 `state0_descriptor` 컬럼 없음 + #### 프론트엔드 검증 - [ ] 브라우저 콘솔 JS 오류 없음 - [ ] 포인트빌더 테이블에서 digital 태그 pv 값이 DisplayName만 표시됨 @@ -391,17 +549,21 @@ dotnet run --project src/Web/ExperionCrawler.csproj ```bash # 1. MetadataLoaderService.cs 커밋 git add src/Infrastructure/OpcUa/MetadataLoaderService.cs -git commit -m "feat: MetaAttributes에서 state0~7descriptor 제거 (pv 값 파싱으로 대체)" +git commit -m "feat: MetaAttributes에서 state0~7descriptor 제거, 주석 동시 업데이트" # 2. ExperionDbContext.cs 커밋 git add src/Infrastructure/Database/ExperionDbContext.cs -git commit -m "feat: v_tag_summary 뷰에서 state descriptor JOIN 제거" +git commit -m "feat: v_tag_summary 뷰에서 state descriptor JOIN 제거, 고아 데이터 DELETE 추가" # 3. app.js 커밋 git add src/Web/wwwroot/js/app.js git commit -m "feat: pv 값 파싱 헬퍼 parseEnumPv() 추가, 포인트빌더 테이블 적용" -# 4. 계획 문서 커밋 +# 4. NL2SQL DB_SCHEMA 커밋 +git add mcp-server/server.py mcp-server/worker/nl2sql_worker.py +git commit -m "feat: NL2SQL DB_SCHEMA에서 state0~2_descriptor 제거 (v_tag_summary 변경 반영)" + +# 5. 계획 문서 커밋 git add plans/enum-metadata-optimize-coding-plan.md git commit -m "docs: enum metadata 최적화 코딩 계획 작성" ``` @@ -422,6 +584,8 @@ TIMESTAMP=$(ls -d .rooBackup/enum-opt-* | tail -1 | xargs basename) cp .rooBackup/$TIMESTAMP/src/Infrastructure/OpcUa/MetadataLoaderService.cs src/Infrastructure/OpcUa/ cp .rooBackup/$TIMESTAMP/src/Infrastructure/Database/ExperionDbContext.cs src/Infrastructure/Database/ cp .rooBackup/$TIMESTAMP/src/Web/wwwroot/js/app.js src/Web/wwwroot/js/ +cp .rooBackup/$TIMESTAMP/mcp-server/server.py mcp-server/ +cp .rooBackup/$TIMESTAMP/mcp-server/worker/nl2sql_worker.py mcp-server/worker/ ``` --- @@ -432,12 +596,15 @@ cp .rooBackup/$TIMESTAMP/src/Web/wwwroot/js/app.js src/Web/wwwroot/js/ | STEP | 파일 | 핵심 검증 항목 | |------|------|----------------| -| 1 | — | 백업 파일 3개 생성됨 | +| 1 | — | 백업 파일 5개 생성됨 | | 2 | MetadataLoaderService.cs | `MetaAttributes` = `["desc", "area"]` | +| 2.5 | MetadataLoaderService.cs | 클래스 주석 업데이트 | | 3 | — | 빌드 성공 | | 4 | ExperionDbContext.cs | state descriptor JOIN 3개 제거됨 | +| 4.5 | ExperionDbContext.cs | 고아 데이터 DELETE 쿼리 추가 | | 5 | — | 빌드 성공 | | 6 | app.js | `parseEnumPv()` 함수 추가됨 | +| 6.5 | server.py + nl2sql_worker.py | DB_SCHEMA에서 state descriptor 제거 | | 7 | app.js | `pbRender()`에서 `parseEnumPv()` 적용됨 | -| 8 | 전체 | End-to-End 테스트 통과 | +| 8 | 전체 | End-to-End + NL2SQL 테스트 통과 | | 9 | — | git 커밋 완료 | diff --git a/src/Core/Application/Services/PidExtractorService.cs b/src/Core/Application/Services/PidExtractorService.cs index 88936f8..248aea2 100644 --- a/src/Core/Application/Services/PidExtractorService.cs +++ b/src/Core/Application/Services/PidExtractorService.cs @@ -281,13 +281,13 @@ public class PidExtractorService : IPidExtractorService }; } - public async Task ExportToCsvAsync(IEnumerable items) + public 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(); + return Task.FromResult(sb.ToString()); } private static string Csv(string? v) @@ -297,7 +297,7 @@ public class PidExtractorService : IPidExtractorService ? $"\"{v.Replace("\"", "\"\"")}\"" : v; } - public async Task ExportToExcelAsync(IEnumerable items) + public Task ExportToExcelAsync(IEnumerable items) { using var package = new OfficeOpenXml.ExcelPackage(); var worksheet = package.Workbook.Worksheets.Add("P&ID Equipment"); @@ -328,7 +328,7 @@ public class PidExtractorService : IPidExtractorService row++; } - return package.GetAsByteArray(); + return Task.FromResult(package.GetAsByteArray()); } } diff --git a/src/Infrastructure/OpcUa/ExperionOpcClient.cs b/src/Infrastructure/OpcUa/ExperionOpcClient.cs index d142a1b..0cdb886 100644 --- a/src/Infrastructure/OpcUa/ExperionOpcClient.cs +++ b/src/Infrastructure/OpcUa/ExperionOpcClient.cs @@ -114,6 +114,8 @@ public class ExperionOpcClient : IExperionOpcClient // 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password)) var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password)); +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type return await new DefaultSessionFactory(null).CreateAsync( appConfig, endpoint, @@ -123,6 +125,7 @@ public class ExperionOpcClient : IExperionOpcClient identity, null, CancellationToken.None); +#pragma warning restore CS8625 } // ── 접속 테스트 ─────────────────────────────────────────────────────────── @@ -437,7 +440,7 @@ public class ExperionOpcClient : IExperionOpcClient // DisplayName 우선, 그 다음 BrowseName, 마지막으로 NodeId 사용 // DisplayName이 이스케이프된 계층 경로 일 때 BrowseName도 함께 결합 // ───────────────────────────────────────── - string? displayName = null; + string displayName = $"Node:{r.NodeId!}"; string? browseName = null; if (r.NodeClass == NodeClass.Variable || r.NodeClass == NodeClass.Object) @@ -468,12 +471,11 @@ public class ExperionOpcClient : IExperionOpcClient } else { - displayName = $"Node:{r.NodeId.ToString()}"; // NodeId만 사용 noNameCount++; } return new ExperionNodeInfo( - r.NodeId.ToString(), + r.NodeId!.ToString()!, displayName, r.NodeClass.ToString(), r.NodeClass == NodeClass.Object @@ -497,7 +499,7 @@ public class ExperionOpcClient : IExperionOpcClient /// 비정상적인 DisplayName을 정상적인 이름으로 변환. /// 예: "ns=1;s=FICQ-6102.hzset.fieldvalue" -> "FICQ-6102.hzset.fieldvalue" /// - private static string? SanitizeDisplayName(string original) + private static string SanitizeDisplayName(string original) { // 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환 if (original.StartsWith("ns=") || original.Contains('.')) diff --git a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs index e43f54e..e562ee2 100644 --- a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs +++ b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs @@ -557,6 +557,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, ExperionServerConfig cfg) { var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password)); +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type return await new DefaultSessionFactory(null).CreateAsync( appConfig, endpoint, @@ -566,6 +567,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, identity, null, CancellationToken.None); +#pragma warning restore CS8625 } private volatile bool _disposed = false; diff --git a/src/Web/Controllers/PidGraphController.cs b/src/Web/Controllers/PidGraphController.cs index 53a50ef..babe63a 100644 --- a/src/Web/Controllers/PidGraphController.cs +++ b/src/Web/Controllers/PidGraphController.cs @@ -36,7 +36,7 @@ public class PidGraphController : ControllerBase if (!result.Success) { - return NotFound(PidResponse.Fail(result.Error)); + return NotFound(PidResponse.Fail(result.Error ?? "Unknown error")); } // 프론트엔드 camelCase 규칙 준수 및 PidResponse 래핑 @@ -84,8 +84,8 @@ public class PidGraphController : ControllerBase public async Task GetAnalysisStatusStream(string taskId, CancellationToken ct) { Response.ContentType = "text/event-stream"; - Response.Headers.Add("Cache-Control", "no-cache"); - Response.Headers.Add("Connection", "keep-alive"); + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("Connection", "keep-alive"); _logger.LogInformation("SSE stream started for taskId: {TaskId}", taskId); @@ -154,7 +154,7 @@ public class PidGraphController : ControllerBase } else { - var status = new AnalysisStatus(taskId, 0, "Failed", result.Error); + var status = new AnalysisStatus(taskId, 0, "Failed", result.Error ?? "Unknown error"); await _statusStore.UpdateStatusAsync(status); await _eventBroadcaster.NotifyAsync(taskId, status); } diff --git a/src/Web/Controllers/TextToSqlController.cs b/src/Web/Controllers/TextToSqlController.cs index 07bd76b..645d335 100644 --- a/src/Web/Controllers/TextToSqlController.cs +++ b/src/Web/Controllers/TextToSqlController.cs @@ -74,7 +74,7 @@ public class TextToSqlController : ControllerBase { _logger.LogInformation("[TextToSql] data 필드가 문자열임: {DataString}", dataString); var parsedData = System.Text.Json.JsonSerializer.Deserialize?>(dataString); - jsonData["data"] = parsedData; + jsonData["data"] = parsedData!; } return Ok(new { success = true, data = jsonData }); @@ -139,7 +139,7 @@ public class TextToSqlController : ControllerBase // JSON 결과 반환 (쿼리 결과) try { - var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data!); return Ok(new { success = true, data = jsonData }); } catch @@ -185,7 +185,7 @@ public class TextToSqlController : ControllerBase try { - var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data!); return Ok(new { success = true, @@ -230,7 +230,7 @@ public class TextToSqlController : ControllerBase try { - var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data!); return Ok(new { success = true, @@ -274,7 +274,7 @@ public class TextToSqlController : ControllerBase try { - var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data!); return Ok(new { success = true,