mcp-server warning clear

This commit is contained in:
windpacer
2026-05-09 04:28:10 +09:00
parent 9b87ad13a0
commit 05e2156843
55 changed files with 1555 additions and 13280 deletions

9
.mcp.json Normal file
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"iiot-rag": {
"command": "uv",
"args": ["run", "--directory", "mcp-server", "python", "server.py"],
"type": "stdio"
}
}
}

View File

@@ -1,103 +0,0 @@
using System.Diagnostics;
namespace ExperionCrawler.Infrastructure.Mcp;
public class McpServerHostedService : IHostedService
{
private readonly McpClient _mcpClient;
private readonly ILogger<McpServerHostedService> _logger;
private readonly string _workingDirectory;
private Process? _process;
public McpServerHostedService(
McpClient mcpClient,
ILogger<McpServerHostedService> 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;
}
}

View File

@@ -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을 통해 텍스트의 좌표값이 추출되는가?

View File

@@ -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로 전달 가능한가?

View File

@@ -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, 시스템태그, 신뢰도, 검증결과)` 형태로 저장되는가?

View File

@@ -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<IActionResult> GetImpactAnalysis(string nodeId)
{
// Python 분석 마이크로서비스에 요청
var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}");
var result = await response.Content.ReadFromJsonAsync<ImpactResult>();
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$ 시각화`)이 통합되어 동작하는가?

View File

@@ -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로 전달 가능한가?

View File

@@ -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, 시스템태그, 신뢰도, 검증결과)` 형태로 저장되는가?

View File

@@ -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<IActionResult> 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<IActionResult> GetImpactAnalysis(string nodeId)
{
// Python 분석 마이크로서비스에 요청
var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}");
var result = await response.Content.ReadFromJsonAsync<ImpactResult>();
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$ 시각화`)이 통합되어 동작하는가?

File diff suppressed because it is too large Load Diff

View File

@@ -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<PidGraphBuildResult> BuildPidGraphAsync(string filepath);
Task<PidImpactResult> AnalyzeImpactAsync(string graphId, string nodeId);
}
public class PidGraphService : IPidGraphService
{
private readonly McpClient _mcpClient;
private readonly ILogger<PidGraphService> _logger;
public PidGraphService(McpClient mcpClient, ILogger<PidGraphService> logger)
{
_mcpClient = mcpClient;
_logger = logger;
}
public async Task<PidGraphBuildResult> BuildPidGraphAsync(string filepath)
{
try
{
var args = new Dictionary<string, object>
{
["filepath"] = filepath
};
var jsonResponse = await _mcpClient.CallToolAsync("build_pid_graph_parallel", args);
var result = JsonSerializer.Deserialize<PidGraphBuildResult>(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<PidImpactResult> AnalyzeImpactAsync(string graphId, string nodeId)
{
try
{
var args = new Dictionary<string, object>
{
["graph_id"] = graphId,
["start_node_id"] = nodeId
};
var jsonResponse = await _mcpClient.CallToolAsync("analyze_pid_impact", args);
var result = JsonSerializer.Deserialize<PidImpactResult>(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<string, int>? ImpactedNodes { get; set; }
public List<List<string>>? Paths { get; set; }
public string? Error { get; set; }
}

View File

@@ -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<PidGraphController> _logger;
// 간단한 인메모리 상태 저장소 (실제 운영 환경에서는 Redis/DistributedCache 권장)
private static readonly ConcurrentDictionary<string, AnalysisStatus> _statusStore = new();
public PidGraphController(IPidGraphService pidGraphService, ILogger<PidGraphController> logger)
{
_pidGraphService = pidGraphService;
_logger = logger;
}
[HttpGet("impact/{graphId}/{nodeId}")]
public async Task<IActionResult> 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<IActionResult> 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);
}

View File

@@ -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 = `<strong>${found.label || found.id}</strong><br>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 = `
<strong>노드 ID:</strong> ${clickedNode}<br>
<strong>라벨:</strong> ${node.label || '-'}<br>
<strong>좌표:</strong> (${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 = `<span>${id}</span><span class="mut">Depth: ${depth}</span>`;
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();
}

View File

@@ -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 = `<strong>${found.label || found.id}</strong><br>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 = `
<strong>노드 ID:</strong> ${clickedNode}<br>
<strong>라벨:</strong> ${node.label || '-'}<br>
<strong>좌표:</strong> (${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 = `<span>${id}</span><span class="mut">Depth: ${depth}</span>`;
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();
}

View File

@@ -1,93 +0,0 @@
using System.Diagnostics;
namespace ExperionCrawler.Infrastructure.Mcp;
public class McpServerHostedService : IHostedService
{
private readonly McpClient _mcpClient;
private readonly ILogger<McpServerHostedService> _logger;
private readonly string _workingDirectory;
private Process? _process;
public McpServerHostedService(
McpClient mcpClient,
ILogger<McpServerHostedService> 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;
}
}

View File

@@ -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 서브클래스 ─────────────────────────────────────────────────
/// <summary>커스텀 NodeManager를 주입한 StandardServer 파생 클래스.</summary>
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 서버 서비스 ────────────────────────────────────────────────────────
/// <summary>
/// ExperionCrawler OPC UA 서버 서비스.
/// IHostedService 와 IExperionOpcServerService 를 모두 구현한다.
/// - IHostedService.StartAsync : 자동 시작 플래그 파일이 있으면 서버 시작 (앱 재기동용)
/// - IHostedService.StopAsync : 앱 종료 — 플래그 파일 유지 (재기동 시 자동 재시작)
/// - StartServerAsync : UI 시작 버튼 — 서버 시작 + 플래그 파일 저장
/// - StopServerAsync : UI 중지 버튼 — 서버 중지 + 플래그 파일 삭제
/// </summary>
public class ExperionOpcServerService : IExperionOpcServerService, IHostedService, IDisposable
{
private readonly ILogger<ExperionOpcServerService> _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<ExperionOpcServerService> 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<RealtimePoint> 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<int>("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<IExperionDbService>();
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<int>("OpcUaServer:Port", 4841);
var enableSec = _configuration.GetValue<bool>("OpcUaServer:EnableSecurity", false);
var allowAnon = _configuration.GetValue<bool>("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;
}
}
}

View File

@@ -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;
/// <summary>
/// fastRecord 데이터 수집 서비스.
/// realtime_table에서 지정한 샘플링 간격마다 태그 값을 복사하여 fast_records 테이블에 저장.
/// OPC UA 직접 연결 없이 기존 실시간 구독 결과(realtime_table)를 재활용.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly ConcurrentDictionary<int, FastSessionContext> _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<ExperionFastService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<FastSessionInfo> 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<IExperionDbService>();
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<IExperionDbService>();
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<IExperionDbService>();
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<IExperionDbService>();
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
}
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var session = await db.GetFastSessionAsync(sessionId);
return session == null ? null : MapToInfo(session);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return (await db.GetFastSessionsAsync()).Select(MapToInfo);
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<IExperionDbService>();
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<IExperionDbService>();
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<IExperionDbService>();
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<string[]>(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; }
}
}
/// <summary>
/// 만료된 FastSession을 정리하는 BackgroundService.
/// 매일 03:00 UTC에 실행. pinned = true 세션과 retention_days = null 세션은 제외.
/// </summary>
public class ExperionFastCleanupService : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<ExperionFastCleanupService> _logger;
public ExperionFastCleanupService(
IServiceProvider sp,
ILogger<ExperionFastCleanupService> 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<IExperionDbService>();
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] 정리 작업 오류");
}
}
}
}

View File

@@ -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;
/// <summary>
/// OPC UA Subscription 기반 실시간 livevalue 업데이트 서비스.
/// 값이 변경될 때만 콜백을 받아 realtime_table 을 갱신합니다.
/// </summary>
public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionRealtimeService> _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<string, (string? value, DateTime timestamp)>
_pendingUpdates = new();
// nodeId → RealtimePoint 매핑 (FlushLoop에서 tagname을 찾기 위해 사용)
private Dictionary<string, Core.Domain.Entities.RealtimePoint> _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<ExperionRealtimeService> 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<ExperionServerConfig>(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<Task>();
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<RealtimePoint> points;
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<IExperionDbService>();
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<IExperionOpcServerService>();
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<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
{
return await _configProvider.GetConfigAsync(cfg);
}
private static async Task<ConfiguredEndpoint> 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<ISession> 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();
}
}

View File

@@ -1,217 +0,0 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ExperionCrawler.Infrastructure.Mcp;
/// <summary>
/// Python FastMCP 서버 (localhost:5001)와 JSON-RPC over HTTP로 통신하는 저수준 클라이언트.
/// 모델 클래스(McpResponse 등)도 여기서 단일 관리한다.
/// </summary>
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<bool> 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<List<McpTool>> 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<string> CallToolAsync(string toolName, Dictionary<string, object> 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<string> RunSqlAsync(string sql) =>
CallToolAsync("run_sql", new Dictionary<string, object> { ["sql"] = sql });
public Task<string> QueryPvHistoryAsync(
List<string> tagNames, string timeFrom, string timeTo, int limit = 100) =>
CallToolAsync("query_pv_history", new Dictionary<string, object>
{
["tag_names"] = tagNames,
["time_from"] = timeFrom,
["time_to"] = timeTo,
["limit"] = limit
});
public Task<string> GetTagMetadataAsync(string query, int limit = 10) =>
CallToolAsync("get_tag_metadata", new Dictionary<string, object>
{
["query"] = query,
["limit"] = limit
});
public Task<string> ListDrawingsAsync(string? unitNo = null)
{
var args = new Dictionary<string, object>();
if (!string.IsNullOrEmpty(unitNo))
args["unit_no"] = unitNo;
return CallToolAsync("list_drawings", args);
}
public Task<string> QueryWithNlAsync(string question) =>
CallToolAsync("query_with_nl", new Dictionary<string, object> { ["question"] = question });
public Task<string> ExtractPidTagsAsync(string text, string sourceType) =>
CallToolAsync("extract_pid_tags", new Dictionary<string, object>
{
["text"] = text,
["source_type"] = sourceType
});
public Task<string> MatchPidTagsAsync(IEnumerable<string> pidTags, IEnumerable<string> experionTags) =>
CallToolAsync("match_pid_tags", new Dictionary<string, object>
{
["pid_tags"] = pidTags.ToList(),
["experion_tags"] = experionTags.ToList()
});
public Task<string> ParsePidDxfAsync(string filepath) =>
CallToolAsync("parse_pid_dxf", new Dictionary<string, object> { ["filepath"] = filepath });
public Task<string> ParsePidPdfAsync(string filepath, bool useOcr = true) =>
CallToolAsync("parse_pid_pdf", new Dictionary<string, object>
{
["filepath"] = filepath,
["use_ocr"] = useOcr
});
public Task<string> ParsePidDrawingAsync(string filepath) =>
CallToolAsync("parse_pid_drawing", new Dictionary<string, object> { ["filepath"] = filepath });
private async Task<McpResponse?> 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<McpResponse>(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

View File

@@ -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<PidGraphBuildResult> BuildPidGraphAsync(string filepath, Action<double, string>? progressHandler = null);
Task<PidImpactResult> AnalyzeImpactAsync(string graphId, string nodeId);
}
public class PidGraphService : IPidGraphService
{
private readonly McpClient _mcpClient;
private readonly ILogger<PidGraphService> _logger;
public PidGraphService(McpClient mcpClient, ILogger<PidGraphService> logger)
{
_mcpClient = mcpClient;
_logger = logger;
}
public async Task<PidGraphBuildResult> BuildPidGraphAsync(string filepath, Action<double, string>? progressHandler = null)
{
try
{
progressHandler?.Invoke(10, "MCP 서버에 추출 요청 전송 중...");
var args = new Dictionary<string, object>
{
["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<PidGraphBuildResult>(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<PidImpactResult> AnalyzeImpactAsync(string graphId, string nodeId)
{
try
{
var args = new Dictionary<string, object>
{
["graph_id"] = graphId,
["start_node_id"] = nodeId
};
var jsonResponse = await _mcpClient.CallToolAsync("analyze_pid_impact", args);
var result = JsonSerializer.Deserialize<PidImpactResult>(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<string, int>? ImpactedNodes { get; set; }
public List<List<string>>? Paths { get; set; }
public string? Error { get; set; }
}

View File

@@ -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<PidGraphController> _logger;
// 간단한 인메모리 상태 저장소 (실제 운영 환경에서는 Redis/DistributedCache 권장)
private static readonly ConcurrentDictionary<string, AnalysisStatus> _statusStore = new();
public PidGraphController(IPidGraphService pidGraphService, ILogger<PidGraphController> logger)
{
_pidGraphService = pidGraphService;
_logger = logger;
}
[HttpGet("impact/{graphId}/{nodeId}")]
public async Task<IActionResult> 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<IActionResult> 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);
}

View File

@@ -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` 등의 특수 문자를 제거하는 정제 단계를 추가하여 데이터 품질을 높일 것.

View File

@@ -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로 전달 가능한가?

View File

@@ -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, 시스템태그, 신뢰도, 검증결과, 매핑근거)` 형태로 저장되는가?

View File

@@ -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<IActionResult> 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<IActionResult> 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<ImpactResult>();
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$ 시각화`)이 통합되어 동작하는가?

View File

@@ -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<ImpactResult> 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<ImpactResult>(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` 설정 하에 안정적으로 동작하는가?

View File

@@ -1,228 +0,0 @@
#!/usr/bin/env python3
"""RAG 전용 워커 프로세스
Usage: python rag_worker.py <port>
담당 도구:
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)

View File

@@ -1,277 +0,0 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,466 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
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")

View File

@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""RAG 전용 워커 프로세스
Usage: python rag_worker.py <port>
담당 도구:
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)

View File

@@ -1,466 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
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")

File diff suppressed because it is too large Load Diff

View File

@@ -1,609 +0,0 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
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) 처리가 요청 진행 중에도 정상 작동합니다.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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:

View File

@@ -21,6 +21,12 @@ public class ExperionDbContext : DbContext
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>();
// P&ID 데이터베이스용 DbSet
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
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<TagMetadata>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique();
e.HasIndex(x => x.BaseTag);
});
// P&ID 엔티티 설정
modelBuilder.Entity<PidEquipment>(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<PidAuditLog>(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<PidGraphStatus>(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() 메서드를 사용하여 수동 생성 가능
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은

View File

@@ -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;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> 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<string, (string baseTag, string attr)>();
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<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
List<string> 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);
}
}

View File

@@ -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 = '<div class="ll inf">⏳ 메타데이터 재로드 중...</div>';
try {
const d = await api('POST', '/api/tags/metadata/reload', body);
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function metaView() {
const viewEl = document.getElementById('meta-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', '/api/tags/metadata');
const items = d.items || [];
if (items.length === 0) {
viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
return;
}
viewEl.innerHTML = `
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
<tbody>
${items.map(m => `
<tr>
<td style="font-weight:600">${esc(m.baseTag)}</td>
<td>${esc(m.attribute)}</td>
<td>${esc(m.value || '-')}</td>
<td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
/*
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 = '<span style="color:var(--t3);font-size:12px;padding:4px 0">DB 연결이 필요합니다. "DB 접속" 버튼을 눌러주세요.</span>';
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 = '<option disabled>로딩 중...</option>';
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('<!DOCTYPE') || text.startsWith('<html')) {
throw new Error('P&ID 기능이 비활성화되어 있습니다. 관리자에게 문의하세요.');
}
throw new Error('예상치 못한 응답 형식');
}
const data = await res.json();
if (!data.files || data.files.length === 0) {
@@ -2550,22 +2668,40 @@ async function pidLoadServerFiles(selectFileName) {
}
}
// P&ID 추출 진행 상태 — 탭 전환 시 상태 초기화 방지
let pidExtracting = false;
let pidElapsedInterval = null;
async function pidExtract() {
const fileName = document.getElementById('pid-server-file').value;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
const elapsedEl = document.getElementById('pid-elapsed');
if (!fileName) {
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
return;
}
pidExtracting = true;
if (statusEl) statusEl.textContent = '추출 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '';
}
// 경과 시간 시계 시작
const startTime = Date.now();
if (elapsedEl) {
elapsedEl.style.display = 'inline';
pidElapsedInterval = setInterval(() => {
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);
}
// 추출 중이면 시계 유지, 테이블/통계 갱신 생략
});
});

View File

@@ -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()`로 직렬화 → `"<coroutine object _search at 0x...>"` 반환
**케이스 A — RAG 도구 3개 (코루틴 repr 반환)**
`search_codebase`, `search_r530_docs`, `rag_query`: `def`(sync)에서 `async def _search()`를 await 없이 호출.
```python
# 수정 전 — MCP 응답이 "<coroutine object _search at 0x...>"
@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` | `"<coroutine object _search at 0x...>"` 반환 |
| `search_r530_docs` | `"<coroutine object _search at 0x...>"` 반환 |
| `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)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음

View File

@@ -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)
# ── 엔트리포인트 ──────────────────────────────────────────────────────────────

View File

@@ -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 사용):

View File

@@ -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 커밋 완료 |

View File

@@ -281,13 +281,13 @@ public class PidExtractorService : IPidExtractorService
};
}
public async Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
public Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> 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<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
public Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> 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());
}
}

View File

@@ -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"
/// </summary>
private static string? SanitizeDisplayName(string original)
private static string SanitizeDisplayName(string original)
{
// 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환
if (original.StartsWith("ns=") || original.Contains('.'))

View File

@@ -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;

View File

@@ -36,7 +36,7 @@ public class PidGraphController : ControllerBase
if (!result.Success)
{
return NotFound(PidResponse<object>.Fail(result.Error));
return NotFound(PidResponse<object>.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);
}

View File

@@ -74,7 +74,7 @@ public class TextToSqlController : ControllerBase
{
_logger.LogInformation("[TextToSql] data 필드가 문자열임: {DataString}", dataString);
var parsedData = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>?>(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<object>(result.Data);
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(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<object>(result.Data);
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
return Ok(new
{
success = true,
@@ -230,7 +230,7 @@ public class TextToSqlController : ControllerBase
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
return Ok(new
{
success = true,
@@ -274,7 +274,7 @@ public class TextToSqlController : ControllerBase
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
return Ok(new
{
success = true,