mcp-server warning clear
This commit is contained in:
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"iiot-rag": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "--directory", "mcp-server", "python", "server.py"],
|
||||||
|
"type": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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을 통해 텍스트의 좌표값이 추출되는가?
|
|
||||||
@@ -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로 전달 가능한가?
|
|
||||||
@@ -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, 시스템태그, 신뢰도, 검증결과)` 형태로 저장되는가?
|
|
||||||
@@ -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$ 시각화`)이 통합되어 동작하는가?
|
|
||||||
@@ -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로 전달 가능한가?
|
|
||||||
@@ -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, 시스템태그, 신뢰도, 검증결과)` 형태로 저장되는가?
|
|
||||||
@@ -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
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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] 정리 작업 오류");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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` 등의 특수 문자를 제거하는 정제 단계를 추가하여 데이터 품질을 높일 것.
|
|
||||||
@@ -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로 전달 가능한가?
|
|
||||||
@@ -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, 시스템태그, 신뢰도, 검증결과, 매핑근거)` 형태로 저장되는가?
|
|
||||||
@@ -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$ 시각화`)이 통합되어 동작하는가?
|
|
||||||
@@ -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` 설정 하에 안정적으로 동작하는가?
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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) 처리가 요청 진행 중에도 정상 작동합니다.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ExperionCrawler Unified MCP Server
|
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 실행
|
- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행
|
||||||
- 사용처:
|
- 사용처:
|
||||||
stdio 모드 (기본): Claude Code MCP / Roo Code MCP
|
stdio 모드 (기본): Claude Code MCP / Roo Code MCP
|
||||||
@@ -23,7 +23,7 @@ QDRANT_URL = "http://localhost:6333"
|
|||||||
OLLAMA_URL = "http://localhost:11434"
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일
|
EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일
|
||||||
VLLM_BASE_URL = "http://localhost:8000/v1"
|
VLLM_BASE_URL = "http://localhost:8000/v1"
|
||||||
VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8"
|
VLLM_MODEL = "Qwen3.6-27B-FP8"
|
||||||
|
|
||||||
# Qdrant 컬렉션
|
# Qdrant 컬렉션
|
||||||
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
||||||
@@ -72,7 +72,7 @@ class ProcessManager:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.workers: Dict[str, WorkerProcess] = {}
|
self.workers: Dict[str, WorkerProcess] = {}
|
||||||
self._locks: Dict[str, asyncio.Lock] = {}
|
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}
|
self._worker_ports = {"rag": 5002, "nl2sql": 5003, "pid": 5004}
|
||||||
|
|
||||||
# 정리 훅 등록
|
# 정리 훅 등록
|
||||||
@@ -112,7 +112,7 @@ class ProcessManager:
|
|||||||
port = self._get_available_port(worker_type)
|
port = self._get_available_port(worker_type)
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
f"mcp-server/worker/{worker_type}_worker.py",
|
f"worker/{worker_type}_worker.py",
|
||||||
str(port)
|
str(port)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -202,17 +202,22 @@ process_manager = ProcessManager()
|
|||||||
|
|
||||||
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
|
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _embed(text: str) -> list[float]:
|
async def _embed(text: str) -> list[float]:
|
||||||
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
|
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
|
||||||
with httpx.Client(timeout=30) as client:
|
import asyncio
|
||||||
resp = client.post(
|
|
||||||
f"{OLLAMA_URL}/api/embeddings",
|
def _call_embed():
|
||||||
json={"model": EMBED_MODEL, "prompt": text},
|
with httpx.Client(timeout=30) as client:
|
||||||
)
|
resp = client.post(
|
||||||
resp.raise_for_status()
|
f"{OLLAMA_URL}/api/embeddings",
|
||||||
return resp.json()["embedding"]
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def _llm():
|
def _llm():
|
||||||
@@ -247,109 +252,136 @@ def _ocr():
|
|||||||
|
|
||||||
# ── DXF/PDF 텍스트 추출 헬퍼 ───────────────────────────────────────────────────
|
# ── DXF/PDF 텍스트 추출 헬퍼 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _extract_text_from_dxf(filepath: str) -> str:
|
async def _extract_text_from_dxf(filepath: str) -> str:
|
||||||
"""ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거)."""
|
"""ezdxf로 DXF 파일에서 텍스트 추출 (MTEXT 포맷 코드 제거)."""
|
||||||
|
import asyncio
|
||||||
import ezdxf
|
import ezdxf
|
||||||
from ezdxf.tools.text import plain_mtext
|
from ezdxf.tools.text import plain_mtext
|
||||||
doc = ezdxf.readfile(filepath)
|
|
||||||
msp = doc.modelspace()
|
def _extract():
|
||||||
texts = []
|
doc = ezdxf.readfile(filepath)
|
||||||
for entity in msp:
|
msp = doc.modelspace()
|
||||||
if entity.dxftype() == "TEXT":
|
texts = []
|
||||||
texts.append(entity.dxf.text)
|
for entity in msp:
|
||||||
elif entity.dxftype() == "MTEXT":
|
if entity.dxftype() == "TEXT":
|
||||||
try:
|
texts.append(entity.dxf.text)
|
||||||
plain = plain_mtext(entity.dxf.text)
|
elif entity.dxftype() == "MTEXT":
|
||||||
if plain.strip():
|
try:
|
||||||
texts.append(plain)
|
plain = plain_mtext(entity.dxf.text)
|
||||||
except Exception:
|
if plain.strip():
|
||||||
pass
|
texts.append(plain)
|
||||||
return "\n".join(texts)
|
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 파일에서 텍스트 추출."""
|
"""PyMuPDF로 PDF 파일에서 텍스트 추출."""
|
||||||
|
import asyncio
|
||||||
import fitz # pymupdf
|
import fitz # pymupdf
|
||||||
doc = fitz.open(filepath)
|
|
||||||
texts = []
|
def _extract():
|
||||||
for page in doc:
|
doc = fitz.open(filepath)
|
||||||
texts.append(page.get_text())
|
texts = []
|
||||||
return "\n".join(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 (고정밀도)."""
|
"""PaddleOCR로 PDF에서 이미지 추출 후 OCR (고정밀도)."""
|
||||||
|
import asyncio
|
||||||
import fitz # pymupdf
|
import fitz # pymupdf
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
doc = fitz.open(filepath)
|
def _extract():
|
||||||
all_texts = []
|
doc = fitz.open(filepath)
|
||||||
|
all_texts = []
|
||||||
|
|
||||||
for page_idx, page in enumerate(doc):
|
for page_idx, page in enumerate(doc):
|
||||||
# 페이지를 이미지로 변환
|
# 페이지를 이미지로 변환
|
||||||
mat = fitz.Matrix(300 / 72) # 300 DPI
|
mat = fitz.Matrix(300 / 72) # 300 DPI
|
||||||
pix = page.get_pixmap(matrix=mat)
|
pix = page.get_pixmap(matrix=mat)
|
||||||
img_data = pix.tobytes("png")
|
img_data = pix.tobytes("png")
|
||||||
img = Image.open(__import__("io").BytesIO(img_data))
|
img = Image.open(__import__("io").BytesIO(img_data))
|
||||||
|
|
||||||
# OCR 실행
|
# OCR 실행
|
||||||
result = _ocr().ocr(np.array(img), cls=True)
|
result = _ocr().ocr(np.array(img), cls=True)
|
||||||
if result[0]:
|
if result[0]:
|
||||||
for line in result[0]:
|
for line in result[0]:
|
||||||
all_texts.append(line[1][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로 변환."""
|
"""libreoffice로 DWG를 DXF로 변환."""
|
||||||
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
|
|
||||||
dxf_path = filepath.replace(".dwg", ".dxf")
|
dxf_path = filepath.replace(".dwg", ".dxf")
|
||||||
|
|
||||||
try:
|
def _convert():
|
||||||
# LibreOffice로 변환
|
try:
|
||||||
result = subprocess.run(
|
# LibreOffice로 변환
|
||||||
[
|
result = subprocess.run(
|
||||||
"libreoffice",
|
[
|
||||||
"--headless",
|
"libreoffice",
|
||||||
"--convert-to", "dxf:AutoCAD DXF",
|
"--headless",
|
||||||
"--outdir", os.path.dirname(filepath) or ".",
|
"--convert-to", "dxf:AutoCAD DXF",
|
||||||
filepath
|
"--outdir", os.path.dirname(filepath) or ".",
|
||||||
],
|
filepath
|
||||||
check=True,
|
],
|
||||||
timeout=120,
|
check=True,
|
||||||
capture_output=True,
|
timeout=120,
|
||||||
text=True
|
capture_output=True,
|
||||||
)
|
text=True
|
||||||
|
)
|
||||||
if os.path.exists(dxf_path):
|
|
||||||
return dxf_path
|
|
||||||
else:
|
|
||||||
raise FileNotFoundError("DXF 변환 파일이 생성되지 않았습니다.")
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
if os.path.exists(dxf_path):
|
||||||
raise Exception(f"LibreOffice 변환 실패: {e.stderr}")
|
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 검색 헬퍼 ──────────────────────────────────────────────────────────
|
# ── Qdrant 검색 헬퍼 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str:
|
async def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> str:
|
||||||
vec = _embed(query)
|
import asyncio
|
||||||
with httpx.Client(timeout=20) as client:
|
|
||||||
resp = client.post(
|
def _call_embed():
|
||||||
f"{QDRANT_URL}/collections/{collection}/points/search",
|
return _embed(query)
|
||||||
json={
|
|
||||||
"vector": vec,
|
vec = await _call_embed()
|
||||||
"limit": top_k,
|
|
||||||
"with_payload": True,
|
def _call_search():
|
||||||
"score_threshold": threshold,
|
with httpx.Client(timeout=20) as client:
|
||||||
},
|
resp = client.post(
|
||||||
)
|
f"{QDRANT_URL}/collections/{collection}/points/search",
|
||||||
resp.raise_for_status()
|
json={
|
||||||
hits = resp.json().get("result", [])
|
"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:
|
if not hits:
|
||||||
return "관련 결과 없음."
|
return "관련 결과 없음."
|
||||||
@@ -367,10 +399,15 @@ def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) ->
|
|||||||
|
|
||||||
# ── DB 헬퍼 ──────────────────────────────────────────────────────────────────
|
# ── DB 헬퍼 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_db_connection():
|
async def _get_db_connection():
|
||||||
"""PostgreSQL DB 연결 획득."""
|
"""PostgreSQL DB 연결 획득."""
|
||||||
|
import asyncio
|
||||||
import psycopg
|
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]:
|
def _validate_sql(sql: str) -> tuple[bool, str]:
|
||||||
@@ -405,6 +442,39 @@ PostgreSQL 시계열 데이터베이스 스키마
|
|||||||
livevalue TEXT - 현재값
|
livevalue TEXT - 현재값
|
||||||
timestamp TIMESTAMPTZ - 최종 갱신 시각
|
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 사용):
|
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||||
1분 버킷: date_trunc('minute', recorded_at) AS bucket
|
1분 버킷: date_trunc('minute', recorded_at) AS bucket
|
||||||
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) 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()
|
@mcp.tool()
|
||||||
def ask_iiot_llm(question: str, context: str = "") -> str:
|
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로 넘겨
|
사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨
|
||||||
종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문.
|
종합 분석·답변이 필요할 때. 또는 일반 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
|
user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question
|
||||||
resp = _llm().chat.completions.create(
|
resp = _llm().chat.completions.create(
|
||||||
model=VLLM_MODEL,
|
model="Qwen3.6-27B-FP8",
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": user_msg},
|
{"role": "user", "content": user_msg},
|
||||||
@@ -490,7 +560,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
|
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).
|
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
|
||||||
ExperionCrawler 코드도 함께 보려면 search_code=True 추가.
|
ExperionCrawler 코드도 함께 보려면 search_code=True 추가.
|
||||||
@@ -510,23 +580,15 @@ def rag_query(question: str, search_code: bool = False, search_docs: bool = True
|
|||||||
|
|
||||||
# ── NL2SQL 도구 ───────────────────────────────────────────────────────────────
|
# ── NL2SQL 도구 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@mcp.tool()
|
async def _execute_sql_internal(sql: str) -> str:
|
||||||
def run_sql(sql: str) -> str:
|
"""SQL 실행 내부 함수 (run_sql과 query_with_nl에서 공유)."""
|
||||||
"""SQL 쿼리 실행 (SELECT만 허용).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sql: 실행할 SELECT SQL 문자열
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON: { success, columns, count, data } 또는 { success, error }
|
|
||||||
"""
|
|
||||||
valid, err = _validate_sql(sql)
|
valid, err = _validate_sql(sql)
|
||||||
if not valid:
|
if not valid:
|
||||||
return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False)
|
return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False)
|
||||||
|
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = _get_db_connection()
|
conn = await _get_db_connection()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(sql)
|
cur.execute(sql)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
@@ -544,6 +606,18 @@ def run_sql(sql: str) -> str:
|
|||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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()
|
@mcp.tool()
|
||||||
def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
|
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()
|
@mcp.tool()
|
||||||
def query_with_nl(question: str) -> str:
|
async def query_with_nl(question: str) -> str:
|
||||||
"""자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다.
|
"""자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -665,6 +739,9 @@ def query_with_nl(question: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON: { sql, success, columns, count, data } 또는 { sql, success, error }
|
JSON: { sql, success, columns, count, data } 또는 { sql, success, error }
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
system = (
|
system = (
|
||||||
"You are a PostgreSQL SQL expert.\n"
|
"You are a PostgreSQL SQL expert.\n"
|
||||||
"Convert the user's question into a SELECT SQL using the schema below.\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"
|
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
|
||||||
f"{_DB_SCHEMA}"
|
f"{_DB_SCHEMA}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = _llm().chat.completions.create(
|
def _call_llm():
|
||||||
model=VLLM_MODEL,
|
return _llm().chat.completions.create(
|
||||||
messages=[
|
model="Qwen3.6-27B-FP8",
|
||||||
{"role": "system", "content": system},
|
messages=[
|
||||||
{"role": "user", "content": question},
|
{"role": "system", "content": system},
|
||||||
],
|
{"role": "user", "content": question},
|
||||||
max_tokens=8192,
|
],
|
||||||
temperature=0.1,
|
max_tokens=8192,
|
||||||
)
|
temperature=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await asyncio.to_thread(_call_llm)
|
||||||
sql = (resp.choices[0].message.content or "").strip()
|
sql = (resp.choices[0].message.content or "").strip()
|
||||||
# 마크다운 코드 블록 제거
|
# 마크다운 코드 블록 제거
|
||||||
if sql.startswith("```"):
|
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)
|
return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False)
|
||||||
|
|
||||||
# SQL 실행
|
# SQL 실행
|
||||||
raw = run_sql(sql)
|
raw = await _execute_sql_internal(sql)
|
||||||
result = json.loads(raw)
|
result = json.loads(raw)
|
||||||
result["sql"] = sql
|
result["sql"] = sql
|
||||||
|
|
||||||
@@ -735,7 +816,7 @@ def query_with_nl(question: str) -> str:
|
|||||||
# ── P&ID 추출 도구 ──────────────────────────────────────────────────────────────
|
# ── P&ID 추출 도구 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@mcp.tool()
|
@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)에서 태그 정보를 추출합니다.
|
"""P&ID 도면(DXF/PDF)에서 태그 정보를 추출합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -745,6 +826,11 @@ def extract_pid_tags(text: str, source_type: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] }
|
JSON: { success, count, tags: [{tagNo, equipmentName, instrumentType, lineNumber, pidDrawingNo, confidence}] }
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
system = (
|
system = (
|
||||||
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
|
||||||
"Extract all instrument and equipment tags from the provided text.\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"
|
"- Output ONLY the JSON array, no markdown, no explanation.\n"
|
||||||
"- If no tags found, return: []\n"
|
"- If no tags found, return: []\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import json as json_module
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
truncated_text = text[:100000] if len(text) > 100000 else text
|
truncated_text = text[:100000] if len(text) > 100000 else text
|
||||||
|
|
||||||
resp = _llm().chat.completions.create(
|
def _call_llm():
|
||||||
model=VLLM_MODEL,
|
return _llm().chat.completions.create(
|
||||||
messages=[
|
model="Qwen3.6-27B-FP8",
|
||||||
{"role": "system", "content": system},
|
messages=[
|
||||||
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"},
|
{"role": "system", "content": system},
|
||||||
],
|
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"},
|
||||||
max_tokens=32768,
|
],
|
||||||
temperature=0.1,
|
max_tokens=32768,
|
||||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
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()
|
raw = (resp.choices[0].message.content or "").strip()
|
||||||
finish_reason = resp.choices[0].finish_reason
|
finish_reason = resp.choices[0].finish_reason
|
||||||
@@ -839,7 +924,7 @@ def extract_pid_tags(text: str, source_type: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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 태그에 매핑합니다.
|
"""P&ID 태그를 Experion 태그에 매핑합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -849,6 +934,10 @@ def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] }
|
JSON: { success, count, mappings: [{pidTag, experionTag, confidence}] }
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
system = (
|
system = (
|
||||||
"You are a P&ID to Experion tag matching expert.\n"
|
"You are a P&ID to Experion tag matching expert.\n"
|
||||||
"Match P&ID tags to Experion tags based on similarity.\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"
|
"- If no matches found, return an empty array: []\n"
|
||||||
"- temperature=0.1 for deterministic output.\n"
|
"- temperature=0.1 for deterministic output.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
import re
|
|
||||||
import json as json_module
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pid_str = "\n".join(pid_tags)
|
pid_str = "\n".join(pid_tags)
|
||||||
experion_str = "\n".join(experion_tags)
|
experion_str = "\n".join(experion_tags)
|
||||||
|
|
||||||
resp = _llm().chat.completions.create(
|
def _call_llm():
|
||||||
model=VLLM_MODEL,
|
return _llm().chat.completions.create(
|
||||||
messages=[
|
model="Qwen3.6-27B-FP8",
|
||||||
{"role": "system", "content": system},
|
messages=[
|
||||||
{"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"},
|
{"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,
|
max_tokens=16384,
|
||||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
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()
|
raw = (resp.choices[0].message.content or "").strip()
|
||||||
finish_reason = resp.choices[0].finish_reason
|
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()
|
@mcp.tool()
|
||||||
def parse_pid_dxf(filepath: str) -> str:
|
async def parse_pid_dxf(filepath: str) -> str:
|
||||||
"""ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출.
|
"""ezdxf 기반 DXF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -918,8 +1007,15 @@ def parse_pid_dxf(filepath: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] }
|
JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] }
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
try:
|
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():
|
if not text.strip():
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -952,15 +1048,18 @@ def parse_pid_dxf(filepath: str) -> str:
|
|||||||
|
|
||||||
truncated_text = text[:12000] if len(text) > 12000 else text
|
truncated_text = text[:12000] if len(text) > 12000 else text
|
||||||
|
|
||||||
resp = _llm().chat.completions.create(
|
def _call_llm():
|
||||||
model=VLLM_MODEL,
|
return _llm().chat.completions.create(
|
||||||
messages=[
|
model="Qwen3.6-27B-FP8",
|
||||||
{"role": "system", "content": system},
|
messages=[
|
||||||
{"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"},
|
{"role": "system", "content": system},
|
||||||
],
|
{"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"},
|
||||||
max_tokens=4096,
|
],
|
||||||
temperature=0.1,
|
max_tokens=4096,
|
||||||
)
|
temperature=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await asyncio.to_thread(_call_llm)
|
||||||
|
|
||||||
raw = (resp.choices[0].message.content or "").strip()
|
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()
|
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
|
||||||
|
|
||||||
# JSON 배열 추출
|
# JSON 배열 추출
|
||||||
import re
|
|
||||||
match = re.search(r'\[.*\]', raw, re.DOTALL)
|
match = re.search(r'\[.*\]', raw, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
raw = match.group(0)
|
raw = match.group(0)
|
||||||
@@ -1009,7 +1107,7 @@ def parse_pid_dxf(filepath: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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으로 태그 자동 추출.
|
"""PyMuPDF 기반 PDF 파일 파싱. 텍스트 추출 후 LLM으로 태그 자동 추출.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1019,11 +1117,17 @@ def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] }
|
JSON: { success, text, count, tags: [{tagNo, equipmentName, ...}] }
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if use_ocr:
|
def _extract_text():
|
||||||
text = _extract_text_from_pdf_ocr(filepath)
|
if use_ocr:
|
||||||
else:
|
return _extract_text_from_pdf_ocr(filepath)
|
||||||
text = _extract_text_from_pdf(filepath)
|
else:
|
||||||
|
return _extract_text_from_pdf(filepath)
|
||||||
|
text = await asyncio.to_thread(_extract_text)
|
||||||
|
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
return json.dumps({
|
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
|
truncated_text = text[:12000] if len(text) > 12000 else text
|
||||||
|
|
||||||
resp = _llm().chat.completions.create(
|
def _call_llm():
|
||||||
model=VLLM_MODEL,
|
return _llm().chat.completions.create(
|
||||||
messages=[
|
model="Qwen3.6-27B-FP8",
|
||||||
{"role": "system", "content": system},
|
messages=[
|
||||||
{"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"},
|
{"role": "system", "content": system},
|
||||||
],
|
{"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"},
|
||||||
max_tokens=4096,
|
],
|
||||||
temperature=0.1,
|
max_tokens=4096,
|
||||||
)
|
temperature=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await asyncio.to_thread(_call_llm)
|
||||||
|
|
||||||
raw = (resp.choices[0].message.content or "").strip()
|
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()
|
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
|
||||||
|
|
||||||
# JSON 배열 추출
|
# JSON 배열 추출
|
||||||
import re
|
|
||||||
match = re.search(r'\[.*\]', raw, re.DOTALL)
|
match = re.search(r'\[.*\]', raw, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
raw = match.group(0)
|
raw = match.group(0)
|
||||||
@@ -1119,25 +1225,38 @@ async def build_pid_graph_parallel(filepath: str) -> str:
|
|||||||
분산 처리 기법을 적용한 P&ID 그래프 생성 툴.
|
분산 처리 기법을 적용한 P&ID 그래프 생성 툴.
|
||||||
전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다.
|
전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. 전처리 (Phase 1: Geometric Extraction)
|
# 1. 전처리 (Phase 1: Geometric Extraction)
|
||||||
extractor = PidGeometricExtractor(filepath)
|
def _extract_and_save():
|
||||||
geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json"
|
extractor = PidGeometricExtractor(filepath)
|
||||||
geo_data_list = extractor.extract_and_save(geo_data_path)
|
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는 경로를 반환하므로 다시 로드
|
# geo_data_list는 경로를 반환하므로 다시 로드
|
||||||
with open(geo_data_path, 'r', encoding='utf-8') as f:
|
def _load_geo_data():
|
||||||
geo_data = json.load(f)
|
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)
|
# 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping)
|
||||||
# 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값)
|
# 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값)
|
||||||
# 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함
|
# 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함
|
||||||
system_tags = []
|
system_tags = []
|
||||||
try:
|
try:
|
||||||
conn = _get_db_connection()
|
def _fetch_system_tags():
|
||||||
with conn.cursor() as cur:
|
conn = _get_db_connection()
|
||||||
cur.execute("SELECT tagname FROM realtime_table")
|
try:
|
||||||
system_tags = [r[0] for r in cur.fetchall()]
|
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:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to fetch system tags: {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({
|
return json.dumps({
|
||||||
"success": True,
|
"success": True,
|
||||||
"graph_id": graph_id,
|
"data": {
|
||||||
"graph_path": graph_path,
|
"graph_id": graph_id,
|
||||||
"nodes": final_builder.G.number_of_nodes(),
|
"graph_path": graph_path,
|
||||||
"edges": final_builder.G.number_of_edges()
|
"nodes": final_builder.G.number_of_nodes(),
|
||||||
|
"edges": final_builder.G.number_of_edges()
|
||||||
|
},
|
||||||
|
"message": "그래프 생성 완료"
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"build_pid_graph_parallel failed: {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()
|
@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:
|
try:
|
||||||
graph_path = f"mcp-server/storage/{graph_id}"
|
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)
|
def _analyze():
|
||||||
result = analyzer.analyze_impact(start_node_id)
|
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)
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False)
|
return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def parse_pid_drawing(filepath: str) -> str:
|
async def parse_pid_drawing(filepath: str) -> str:
|
||||||
"""확장자 자동 감지하여 P&ID 도면 파싱.
|
"""확장자 자동 감지하여 P&ID 도면 파싱.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1226,10 +1352,11 @@ def parse_pid_drawing(filepath: str) -> str:
|
|||||||
JSON: { success, text, count, tags, format }
|
JSON: { success, text, count, tags, format }
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
ext = os.path.splitext(filepath)[1].lower()
|
ext = os.path.splitext(filepath)[1].lower()
|
||||||
|
|
||||||
if ext == ".dxf":
|
if ext == ".dxf":
|
||||||
return parse_pid_dxf(filepath)
|
return await parse_pid_dxf(filepath)
|
||||||
elif ext == ".dwg":
|
elif ext == ".dwg":
|
||||||
# DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다.
|
# DWG 파일은 사전에 DXF로 변환하여 업로드해야 합니다.
|
||||||
# Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다.
|
# Linux에서 DWG를 DXF로 변환하는 도구는 제한되어 있습니다.
|
||||||
@@ -1243,7 +1370,7 @@ def parse_pid_drawing(filepath: str) -> str:
|
|||||||
"3. LibreOffice Draw (Windows/macOS 전용) 사용"
|
"3. LibreOffice Draw (Windows/macOS 전용) 사용"
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
elif ext == ".pdf":
|
elif ext == ".pdf":
|
||||||
return parse_pid_pdf(filepath)
|
return await parse_pid_pdf(filepath)
|
||||||
else:
|
else:
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -1262,7 +1389,7 @@ async def _forward_request(port: int, tool_name: str, params: dict, one_shot: bo
|
|||||||
params: 요청 파라미터
|
params: 요청 파라미터
|
||||||
one_shot: True일 경우 요청 완료 후 워커 종료
|
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"
|
endpoint = "/execute/one_shot" if one_shot else "/execute"
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"http://localhost:{port}{endpoint}",
|
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()
|
@mcp.tool()
|
||||||
async def parse_pid_dxf(filepath: str) -> str:
|
async def parse_pid_dxf(filepath: str) -> str:
|
||||||
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
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)
|
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()
|
@mcp.tool()
|
||||||
async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||||
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
worker = await process_manager.get_worker("parse_pid_pdf", one_shot=True)
|
||||||
return await _forward_request(worker.port, "parse_pid_pdf", {
|
return await _forward_request(worker.port, "parse_pid_pdf", {
|
||||||
"filepath": filepath,
|
"filepath": filepath,
|
||||||
@@ -1382,7 +1511,11 @@ async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def parse_pid_drawing(filepath: str) -> str:
|
async def parse_pid_drawing(filepath: str) -> str:
|
||||||
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
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)
|
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()
|
@mcp.tool()
|
||||||
async def extract_pid_tags(text: str, source_type: str) -> str:
|
async def extract_pid_tags(text: str, source_type: str) -> str:
|
||||||
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
worker = await process_manager.get_worker("extract_pid_tags", one_shot=True)
|
||||||
return await _forward_request(worker.port, "extract_pid_tags", {
|
return await _forward_request(worker.port, "extract_pid_tags", {
|
||||||
"text": text,
|
"text": text,
|
||||||
@@ -1401,7 +1542,11 @@ async def extract_pid_tags(text: str, source_type: str) -> str:
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async 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 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
worker = await process_manager.get_worker("match_pid_tags", one_shot=True)
|
||||||
return await _forward_request(worker.port, "match_pid_tags", {
|
return await _forward_request(worker.port, "match_pid_tags", {
|
||||||
"pid_tags": 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()
|
@mcp.tool()
|
||||||
async def build_pid_graph_parallel(filepath: str) -> str:
|
async def build_pid_graph_parallel(filepath: str) -> str:
|
||||||
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
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)
|
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()
|
@mcp.tool()
|
||||||
async 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:
|
||||||
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
|
"""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)
|
worker = await process_manager.get_worker("analyze_pid_impact", one_shot=True)
|
||||||
return await _forward_request(worker.port, "analyze_pid_impact", {
|
return await _forward_request(worker.port, "analyze_pid_impact", {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
@@ -30,11 +30,11 @@ import httpx
|
|||||||
|
|
||||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform"
|
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||||
DB_TIMEOUT = 10
|
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||||
|
|
||||||
VLLM_BASE_URL = "http://localhost:8000/v1"
|
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||||
VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8"
|
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -57,28 +57,117 @@ def _llm_client():
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
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:
|
async def _generate_sql(natural_language: str) -> str:
|
||||||
"""자연어를 SQL로 변환."""
|
"""자연어를 SQL로 변환."""
|
||||||
client = _llm_client()
|
client = _llm_client()
|
||||||
|
|
||||||
prompt = f"""다음 자연어 질문을 PostgreSQL SQL 쿼리로 변환하세요.
|
system = (
|
||||||
데이터베이스는 TimescaleDB 기반의 IIoT 플랫폼입니다.
|
"You are a PostgreSQL SQL expert.\n"
|
||||||
|
"Convert the user's question into a SELECT SQL using the schema below.\n"
|
||||||
질문:
|
"IMPORTANT rules:\n"
|
||||||
{natural_language}
|
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
|
||||||
|
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
|
||||||
SQL 쿼리 (SELECT 문만, 설명 없이):"""
|
"- 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(
|
response = await client.chat.completions.create(
|
||||||
model=VLLM_MODEL,
|
model="Qwen3.6-27B-FP8",
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": "You are a PostgreSQL SQL expert. Generate only valid SQL queries."},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": natural_language},
|
||||||
],
|
],
|
||||||
max_tokens=1024,
|
max_tokens=8192,
|
||||||
temperature=0.1,
|
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 도구 구현 ─────────────────────────────────────────────────────────
|
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -237,8 +326,13 @@ async def _list_drawings(unit_no: str = None) -> str:
|
|||||||
|
|
||||||
async def _query_with_nl(question: str) -> str:
|
async def _query_with_nl(question: str) -> str:
|
||||||
"""자연어로 SQL 쿼리 실행."""
|
"""자연어로 SQL 쿼리 실행."""
|
||||||
|
import json
|
||||||
sql = await _generate_sql(question)
|
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()
|
conn = _get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
@@ -21,6 +21,12 @@ public class ExperionDbContext : DbContext
|
|||||||
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
|
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
|
||||||
public DbSet<FastSession> FastSessions => Set<FastSession>();
|
public DbSet<FastSession> FastSessions => Set<FastSession>();
|
||||||
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
|
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)
|
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.HasKey(x => new { x.SessionId, x.RecordedAt, x.TagName });
|
||||||
e.HasIndex(x => x.SessionId);
|
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은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음
|
// 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 테이블은 수동으로 하이퍼테이블 생성 필요
|
// history 테이블은 수동으로 하이퍼테이블 생성 필요
|
||||||
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
|
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
|
||||||
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -572,7 +572,7 @@ function nmReset() {
|
|||||||
/* ─────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────
|
||||||
06 포인트빌더
|
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'];
|
const PB_DT_IDS = ['pb-dt1','pb-dt2'];
|
||||||
|
|
||||||
async function pbLoad() {
|
async function pbLoad() {
|
||||||
@@ -604,8 +604,8 @@ async function pbLoad() {
|
|||||||
async function pbRefresh() {
|
async function pbRefresh() {
|
||||||
try {
|
try {
|
||||||
const d = await api('GET', '/api/pointbuilder/points');
|
const d = await api('GET', '/api/pointbuilder/points');
|
||||||
document.getElementById('pb-count').textContent = `(${d.count}개)`;
|
document.getElementById('pb-count').textContent = `(${d.total}개)`;
|
||||||
pbRender(d.points || []);
|
pbRender(d.items || []);
|
||||||
rtStatus();
|
rtStatus();
|
||||||
} catch (e) { console.error('pbRefresh:', e); }
|
} catch (e) { console.error('pbRefresh:', e); }
|
||||||
}
|
}
|
||||||
@@ -722,6 +722,58 @@ async function rtStatus() {
|
|||||||
} catch (e) { /* 무시 */ }
|
} 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 이력 조회
|
07 이력 조회
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
@@ -2086,6 +2138,7 @@ let fastCurrentSessionId = null;
|
|||||||
let fastChart = null;
|
let fastChart = null;
|
||||||
let fastLivePollTimer = null;
|
let fastLivePollTimer = null;
|
||||||
let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용)
|
let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용)
|
||||||
|
let fastDbConnected = false; // DB 연결 상태
|
||||||
|
|
||||||
function fastModalClose() {
|
function fastModalClose() {
|
||||||
document.getElementById('modal-fast-new').style.display = 'none';
|
document.getElementById('modal-fast-new').style.display = 'none';
|
||||||
@@ -2095,11 +2148,53 @@ function fastModalClose() {
|
|||||||
// fastRecord — API 함수
|
// 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() {
|
async function fastSessionsLoad() {
|
||||||
const res = await fetch('/api/fast/sessions');
|
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();
|
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');
|
const list = document.getElementById('fast-session-list');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
@@ -2399,7 +2494,18 @@ function fastTagColor(tag) {
|
|||||||
// fastRecord — 이벤트 리스너
|
// fastRecord — 이벤트 리스너
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// DB 접속 버튼
|
||||||
|
document.getElementById('btn-fast-db-connect')?.addEventListener('click', () => {
|
||||||
|
fastDbConnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// + 신규 버튼 — DB 연결 확인 후 모달 열기
|
||||||
document.getElementById('btn-fast-new')?.addEventListener('click', async () => {
|
document.getElementById('btn-fast-new')?.addEventListener('click', async () => {
|
||||||
|
if (!fastDbConnected) {
|
||||||
|
alert('데이터베이스 접속을 먼저 완료해주세요. "DB 접속" 버튼을 눌러주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const select = document.getElementById('fast-tag-select');
|
const select = document.getElementById('fast-tag-select');
|
||||||
select.innerHTML = '<option disabled>로딩 중...</option>';
|
select.innerHTML = '<option disabled>로딩 중...</option>';
|
||||||
document.getElementById('fast-session-name').value = '';
|
document.getElementById('fast-session-name').value = '';
|
||||||
@@ -2534,6 +2640,18 @@ async function pidLoadServerFiles(selectFileName) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/pid/server-files');
|
const res = await fetch('/api/pid/server-files');
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!data.files || data.files.length === 0) {
|
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() {
|
async function pidExtract() {
|
||||||
const fileName = document.getElementById('pid-server-file').value;
|
const fileName = document.getElementById('pid-server-file').value;
|
||||||
const statusEl = document.getElementById('pid-status');
|
const statusEl = document.getElementById('pid-status');
|
||||||
const logEl = document.getElementById('pid-log');
|
const logEl = document.getElementById('pid-log');
|
||||||
|
const elapsedEl = document.getElementById('pid-elapsed');
|
||||||
|
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
|
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pidExtracting = true;
|
||||||
if (statusEl) statusEl.textContent = '추출 중...';
|
if (statusEl) statusEl.textContent = '추출 중...';
|
||||||
if (logEl) {
|
if (logEl) {
|
||||||
logEl.style.display = 'block';
|
logEl.style.display = 'block';
|
||||||
logEl.innerHTML = '';
|
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 {
|
try {
|
||||||
const res = await fetch('/api/pid/extract', {
|
const res = await fetch('/api/pid/extract', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2579,6 +2715,7 @@ async function pidExtract() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
pidExtracting = false;
|
||||||
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}건`;
|
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}건`;
|
||||||
log('pid-log', [
|
log('pid-log', [
|
||||||
{ c: 'ok', t: `✅ 추출 완료: ${data.totalCount}건` },
|
{ c: 'ok', t: `✅ 추출 완료: ${data.totalCount}건` },
|
||||||
@@ -2590,8 +2727,16 @@ async function pidExtract() {
|
|||||||
await pidLoadTable();
|
await pidLoadTable();
|
||||||
pidUpdateStats();
|
pidUpdateStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
pidExtracting = false;
|
||||||
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
|
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
|
||||||
log('pid-log', [{ c: 'err', t: `❌ ${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 => {
|
document.querySelectorAll('[data-tab="pid"]').forEach(item => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', async () => {
|
||||||
pidCurrentPage = 1;
|
pidCurrentPage = 1;
|
||||||
pidLastResult = null;
|
pidLastResult = null;
|
||||||
document.getElementById('pid-file-input').value = '';
|
document.getElementById('pid-file-input').value = '';
|
||||||
document.getElementById('pid-status').textContent = '대기 중...';
|
if (!pidExtracting) {
|
||||||
document.getElementById('pid-table-body').innerHTML = '';
|
document.getElementById('pid-status').textContent = '대기 중...';
|
||||||
document.getElementById('pid-pagination').innerHTML = '';
|
const elapsedEl = document.getElementById('pid-elapsed');
|
||||||
document.getElementById('pid-stat-total').textContent = '0';
|
if (elapsedEl) elapsedEl.style.display = 'none';
|
||||||
document.getElementById('pid-stat-high').textContent = '0';
|
if (pidElapsedInterval) {
|
||||||
document.getElementById('pid-stat-mapped').textContent = '0';
|
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);
|
||||||
|
}
|
||||||
|
// 추출 중이면 시계 유지, 테이블/통계 갱신 생략
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
407
mcp-server-진단-문제점-개선방안.md
Normal file
407
mcp-server-진단-문제점-개선방안.md
Normal 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)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음
|
||||||
@@ -10,6 +10,7 @@ ExperionCrawler Unified MCP Server
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
@@ -19,19 +20,19 @@ from mcp.server.fastmcp import FastMCP
|
|||||||
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
|
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
|
||||||
|
|
||||||
# ── 설정 ──────────────────────────────────────────────────────────────────────
|
# ── 설정 ──────────────────────────────────────────────────────────────────────
|
||||||
QDRANT_URL = "http://localhost:6333"
|
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
|
||||||
OLLAMA_URL = "http://localhost:11434"
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||||
EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일
|
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
|
||||||
VLLM_BASE_URL = "http://localhost:8000/v1"
|
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||||
VLLM_MODEL = "Qwen3.6-27B-FP8"
|
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||||
|
|
||||||
# Qdrant 컬렉션
|
# Qdrant 컬렉션
|
||||||
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
||||||
COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks)
|
COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks)
|
||||||
|
|
||||||
# PostgreSQL 연결
|
# PostgreSQL 연결
|
||||||
DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform"
|
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||||
DB_TIMEOUT = 10 # 초
|
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||||
|
|
||||||
# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식
|
# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
@@ -47,158 +48,7 @@ from pipeline.topology import PidTopologyBuilder
|
|||||||
from pipeline.mapper import IntelligentMapper
|
from pipeline.mapper import IntelligentMapper
|
||||||
from pipeline.analyzer import PidAnalysisEngine
|
from pipeline.analyzer import PidAnalysisEngine
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
import os
|
|
||||||
import asyncio
|
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) ───────────────────────────────────────────────────────────
|
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -444,7 +294,7 @@ PostgreSQL 시계열 데이터베이스 스키마
|
|||||||
|
|
||||||
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
||||||
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
||||||
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
|
attribute TEXT - 속성명 ('desc', 'area')
|
||||||
value TEXT - 메타데이터 값
|
value TEXT - 메타데이터 값
|
||||||
node_id TEXT - OPC UA 노드 ID
|
node_id TEXT - OPC UA 노드 ID
|
||||||
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
||||||
@@ -459,20 +309,16 @@ PostgreSQL 시계열 데이터베이스 스키마
|
|||||||
instate2 TEXT - 상태 비트 2 (true/false)
|
instate2 TEXT - 상태 비트 2 (true/false)
|
||||||
description TEXT - 장비 설명 (tag_metadata.desc)
|
description TEXT - 장비 설명 (tag_metadata.desc)
|
||||||
area TEXT - 소속 플랜트 (tag_metadata.area)
|
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)
|
- 아날로그: ficq-6101.pv/sp/op (Double)
|
||||||
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
||||||
- Pump: p-6102.pv/op (Int32), p-6102.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 상태 조회 팁:
|
BCD 상태 조회 팁:
|
||||||
- instate0~7은 Boolean (true/false)
|
- instate0~7은 Boolean (true/false)
|
||||||
- state0descriptor~7은 해당 비트의 의미 설명
|
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
|
||||||
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
|
|
||||||
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
||||||
|
|
||||||
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||||
@@ -500,7 +346,7 @@ N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
|||||||
# ── RAG 도구 ─────────────────────────────────────────────────────────────────
|
# ── RAG 도구 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@mcp.tool()
|
@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# 코드).
|
"""ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드).
|
||||||
Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함.
|
Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함.
|
||||||
|
|
||||||
@@ -511,11 +357,11 @@ def search_codebase(query: str, top_k: int = 6) -> str:
|
|||||||
query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스")
|
query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스")
|
||||||
top_k: 반환 결과 수 (기본 6)
|
top_k: 반환 결과 수 (기본 6)
|
||||||
"""
|
"""
|
||||||
return _search(COL_CODEBASE, query, top_k)
|
return await _search(COL_CODEBASE, query, top_k)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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 공식 제품 문서 검색.
|
"""Honeywell Experion HS R530 공식 제품 문서 검색.
|
||||||
ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함.
|
ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함.
|
||||||
|
|
||||||
@@ -526,7 +372,7 @@ def search_r530_docs(query: str, top_k: int = 5) -> str:
|
|||||||
query: 검색어 (예: "certificate configuration", "endpoint security policy")
|
query: 검색어 (예: "certificate configuration", "endpoint security policy")
|
||||||
top_k: 반환 결과 수 (기본 5)
|
top_k: 반환 결과 수 (기본 5)
|
||||||
"""
|
"""
|
||||||
return _search(COL_OPC_DOCS, query, top_k)
|
return await _search(COL_OPC_DOCS, query, top_k)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -559,7 +405,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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).
|
"""검색 → Qwen3.6-27B-FP8 답변 생성 (통합 RAG).
|
||||||
|
|
||||||
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
|
기본값: 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] = []
|
context_parts: list[str] = []
|
||||||
if search_docs:
|
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:
|
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))
|
return ask_iiot_llm(question, "\n\n".join(context_parts))
|
||||||
|
|
||||||
|
|
||||||
@@ -620,7 +466,7 @@ async def run_sql(sql: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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) 히스토리 조회.
|
"""과거 값(PV) 히스토리 조회.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -635,7 +481,7 @@ def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit:
|
|||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
limit = min(limit, 5000)
|
limit = min(limit, 5000)
|
||||||
conn = _get_db_connection()
|
conn = await _get_db_connection()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT tagname, recorded_at, value
|
"""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()
|
@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 기반).
|
"""태그 메타데이터 검색 (realtime_table 기반).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -675,7 +521,7 @@ def get_tag_metadata(query: str, limit: int = 10) -> str:
|
|||||||
"""
|
"""
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = _get_db_connection()
|
conn = await _get_db_connection()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT tagname, livevalue, timestamp, node_id
|
"""SELECT tagname, livevalue, timestamp, node_id
|
||||||
@@ -698,7 +544,7 @@ def get_tag_metadata(query: str, limit: int = 10) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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 기반).
|
"""단위별 도면 목록 조회 (node_map_master.name 기반).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -709,7 +555,7 @@ def list_drawings(unit_no: str | None = None) -> str:
|
|||||||
"""
|
"""
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = _get_db_connection()
|
conn = await _get_db_connection()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if unit_no:
|
if unit_no:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -1248,15 +1094,13 @@ async def build_pid_graph_parallel(filepath: str) -> str:
|
|||||||
# 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함
|
# 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함
|
||||||
system_tags = []
|
system_tags = []
|
||||||
try:
|
try:
|
||||||
def _fetch_system_tags():
|
conn = await _get_db_connection()
|
||||||
conn = _get_db_connection()
|
try:
|
||||||
try:
|
with conn.cursor() as cur:
|
||||||
with conn.cursor() as cur:
|
cur.execute("SELECT tagname FROM realtime_table")
|
||||||
cur.execute("SELECT tagname FROM realtime_table")
|
system_tags = [r[0] for r in cur.fetchall()]
|
||||||
return [r[0] for r in cur.fetchall()]
|
finally:
|
||||||
finally:
|
conn.close()
|
||||||
conn.close()
|
|
||||||
system_tags = await asyncio.to_thread(_fetch_system_tags)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to fetch system tags: {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)
|
}, 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)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 엔트리포인트 ──────────────────────────────────────────────────────────────
|
# ── 엔트리포인트 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ PostgreSQL 시계열 데이터베이스 스키마
|
|||||||
|
|
||||||
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
||||||
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
||||||
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
|
attribute TEXT - 속성명 ('desc', 'area')
|
||||||
value TEXT - 메타데이터 값
|
value TEXT - 메타데이터 값
|
||||||
node_id TEXT - OPC UA 노드 ID
|
node_id TEXT - OPC UA 노드 ID
|
||||||
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
||||||
@@ -90,20 +90,16 @@ PostgreSQL 시계열 데이터베이스 스키마
|
|||||||
instate2 TEXT - 상태 비트 2 (true/false)
|
instate2 TEXT - 상태 비트 2 (true/false)
|
||||||
description TEXT - 장비 설명 (tag_metadata.desc)
|
description TEXT - 장비 설명 (tag_metadata.desc)
|
||||||
area TEXT - 소속 플랜트 (tag_metadata.area)
|
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)
|
- 아날로그: ficq-6101.pv/sp/op (Double)
|
||||||
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
||||||
- Pump: p-6102.pv/op (Int32), p-6102.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 상태 조회 팁:
|
BCD 상태 조회 팁:
|
||||||
- instate0~7은 Boolean (true/false)
|
- instate0~7은 Boolean (true/false)
|
||||||
- state0descriptor~7은 해당 비트의 의미 설명
|
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
|
||||||
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
|
|
||||||
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
||||||
|
|
||||||
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||||
|
|||||||
@@ -13,10 +13,13 @@
|
|||||||
|
|
||||||
- [ ] STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업
|
- [ ] STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업
|
||||||
- [ ] STEP 2 — `MetadataLoaderService.cs`: `MetaAttributes` 배열에서 state0~7descriptor 제거
|
- [ ] 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 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 — `app.js`: pv 값 파싱 헬퍼 함수 `parseEnumPv()` 추가
|
||||||
|
- [ ] STEP 6.5 — NL2SQL DB_SCHEMA 동기화 (`server.py` + `nl2sql_worker.py`)
|
||||||
- [ ] STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
|
- [ ] STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
|
||||||
- [ ] STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트
|
- [ ] STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트
|
||||||
- [ ] STEP 9 — git 커밋 및 정리
|
- [ ] STEP 9 — git 커밋 및 정리
|
||||||
@@ -27,9 +30,11 @@
|
|||||||
|
|
||||||
| # | 파일 | 변경 내용 | 영향 범위 |
|
| # | 파일 | 변경 내용 | 영향 범위 |
|
||||||
|---|------|-----------|-----------|
|
|---|------|-----------|-----------|
|
||||||
| 1 | `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | `MetaAttributes` 배열 수정 | 메타데이터 로딩 |
|
| 1 | `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | `MetaAttributes` 배열 수정 + 주석 업데이트 | 메타데이터 로딩 |
|
||||||
| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰 단순화 | DB 뷰 |
|
| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰 단순화 + 고아 데이터 삭제 | DB 뷰 |
|
||||||
| 3 | `src/Web/wwwroot/js/app.js` | pv 파싱 헬퍼 + 표시 로직 변경 | 프론트엔드 UI |
|
| 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/OpcUa
|
||||||
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database
|
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/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/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/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 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` 비교)
|
- [ ] 원본 파일과 백업 파일의 체크섬이 일치함 (`md5sum` 비교)
|
||||||
|
|
||||||
**enum-metadata-optimization.md 규칙 매핑**:
|
**enum-metadata-optimization.md 규칙 매핑**:
|
||||||
@@ -111,6 +119,7 @@ private static readonly string[] MetaAttributes =
|
|||||||
- `LoadMetadataAsync()` 메서드: `MetaAttributes.Contains(n.Name)`으로 `node_map_master`에서 필터링하므로 state descriptor 노드는 더 이상 조회되지 않음
|
- `LoadMetadataAsync()` 메서드: `MetaAttributes.Contains(n.Name)`으로 `node_map_master`에서 필터링하므로 state descriptor 노드는 더 이상 조회되지 않음
|
||||||
- `ReadTagsAsync()`: 8개 menos의 nodeId로 배치 읽기 → 네트워크 트래픽 감소
|
- `ReadTagsAsync()`: 8개 menos의 nodeId로 배치 읽기 → 네트워크 트래픽 감소
|
||||||
- UPSERT 쿼리: 8개 menos의 행 삽입 → DB 부하 감소
|
- UPSERT 쿼리: 8개 menos의 행 삽입 → DB 부하 감소
|
||||||
|
- 클래스 주석 (11줄): `state0~7descriptor` 언급도 함께 제거 필요
|
||||||
|
|
||||||
**검증 기준**:
|
**검증 기준**:
|
||||||
- [ ] `MetaAttributes` 배열이 `["desc", "area"]` 두 개만 포함
|
- [ ] `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 3 — `MetadataLoaderService.cs` 변경 후 빌드 검증
|
||||||
|
|
||||||
**목적**: STEP 2 변경이 컴파일 오류 없이 통과하는지 확인
|
**목적**: 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 5 — `ExperionDbContext.cs` 변경 후 빌드 검증
|
||||||
|
|
||||||
**목적**: STEP 4 변경이 컴파일 오류 없이 통과하는지 확인
|
**목적**: 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()` 적용
|
### STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
|
||||||
|
|
||||||
**파일**: [`app.js`](src/Web/wwwroot/js/app.js:633)
|
**파일**: [`app.js`](src/Web/wwwroot/js/app.js:633)
|
||||||
@@ -361,9 +513,15 @@ dotnet run --project src/Web/ExperionCrawler.csproj
|
|||||||
#### 백엔드 검증
|
#### 백엔드 검증
|
||||||
- [ ] 애플리케이션 시작 시 DB 초기화 성공
|
- [ ] 애플리케이션 시작 시 DB 초기화 성공
|
||||||
- [ ] `v_tag_summary` 뷰 생성 성공 (state descriptor JOIN 없음)
|
- [ ] `v_tag_summary` 뷰 생성 성공 (state descriptor JOIN 없음)
|
||||||
|
- [ ] `tag_metadata` 고아 데이터 삭제 성공 (STEP 4.5)
|
||||||
- [ ] 메타데이터 로드 시 `desc`, `area`만 조회됨 (로그 확인)
|
- [ ] 메타데이터 로드 시 `desc`, `area`만 조회됨 (로그 확인)
|
||||||
- [ ] `tag_metadata` 테이블에 state0~7descriptor 행 없음
|
- [ ] `tag_metadata` 테이블에 state0~7descriptor 행 없음
|
||||||
|
|
||||||
|
#### NL2SQL 검증
|
||||||
|
- [ ] MCP 서버 재시작 성공
|
||||||
|
- [ ] "xv-6124 상태 알려줘" 쿼리가 state descriptor 없이 정상 동작
|
||||||
|
- [ ] 생성된 SQL에서 `state0_descriptor` 컬럼 없음
|
||||||
|
|
||||||
#### 프론트엔드 검증
|
#### 프론트엔드 검증
|
||||||
- [ ] 브라우저 콘솔 JS 오류 없음
|
- [ ] 브라우저 콘솔 JS 오류 없음
|
||||||
- [ ] 포인트빌더 테이블에서 digital 태그 pv 값이 DisplayName만 표시됨
|
- [ ] 포인트빌더 테이블에서 digital 태그 pv 값이 DisplayName만 표시됨
|
||||||
@@ -391,17 +549,21 @@ dotnet run --project src/Web/ExperionCrawler.csproj
|
|||||||
```bash
|
```bash
|
||||||
# 1. MetadataLoaderService.cs 커밋
|
# 1. MetadataLoaderService.cs 커밋
|
||||||
git add src/Infrastructure/OpcUa/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 커밋
|
# 2. ExperionDbContext.cs 커밋
|
||||||
git add src/Infrastructure/Database/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 커밋
|
# 3. app.js 커밋
|
||||||
git add src/Web/wwwroot/js/app.js
|
git add src/Web/wwwroot/js/app.js
|
||||||
git commit -m "feat: pv 값 파싱 헬퍼 parseEnumPv() 추가, 포인트빌더 테이블 적용"
|
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 add plans/enum-metadata-optimize-coding-plan.md
|
||||||
git commit -m "docs: enum metadata 최적화 코딩 계획 작성"
|
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/OpcUa/MetadataLoaderService.cs src/Infrastructure/OpcUa/
|
||||||
cp .rooBackup/$TIMESTAMP/src/Infrastructure/Database/ExperionDbContext.cs src/Infrastructure/Database/
|
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/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 | 파일 | 핵심 검증 항목 |
|
| STEP | 파일 | 핵심 검증 항목 |
|
||||||
|------|------|----------------|
|
|------|------|----------------|
|
||||||
| 1 | — | 백업 파일 3개 생성됨 |
|
| 1 | — | 백업 파일 5개 생성됨 |
|
||||||
| 2 | MetadataLoaderService.cs | `MetaAttributes` = `["desc", "area"]` |
|
| 2 | MetadataLoaderService.cs | `MetaAttributes` = `["desc", "area"]` |
|
||||||
|
| 2.5 | MetadataLoaderService.cs | 클래스 주석 업데이트 |
|
||||||
| 3 | — | 빌드 성공 |
|
| 3 | — | 빌드 성공 |
|
||||||
| 4 | ExperionDbContext.cs | state descriptor JOIN 3개 제거됨 |
|
| 4 | ExperionDbContext.cs | state descriptor JOIN 3개 제거됨 |
|
||||||
|
| 4.5 | ExperionDbContext.cs | 고아 데이터 DELETE 쿼리 추가 |
|
||||||
| 5 | — | 빌드 성공 |
|
| 5 | — | 빌드 성공 |
|
||||||
| 6 | app.js | `parseEnumPv()` 함수 추가됨 |
|
| 6 | app.js | `parseEnumPv()` 함수 추가됨 |
|
||||||
|
| 6.5 | server.py + nl2sql_worker.py | DB_SCHEMA에서 state descriptor 제거 |
|
||||||
| 7 | app.js | `pbRender()`에서 `parseEnumPv()` 적용됨 |
|
| 7 | app.js | `pbRender()`에서 `parseEnumPv()` 적용됨 |
|
||||||
| 8 | 전체 | End-to-End 테스트 통과 |
|
| 8 | 전체 | End-to-End + NL2SQL 테스트 통과 |
|
||||||
| 9 | — | git 커밋 완료 |
|
| 9 | — | git 커밋 완료 |
|
||||||
|
|||||||
@@ -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();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId");
|
sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId");
|
||||||
foreach (var i in items)
|
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}");
|
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)
|
private static string Csv(string? v)
|
||||||
@@ -297,7 +297,7 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
|
public Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
|
||||||
{
|
{
|
||||||
using var package = new OfficeOpenXml.ExcelPackage();
|
using var package = new OfficeOpenXml.ExcelPackage();
|
||||||
var worksheet = package.Workbook.Worksheets.Add("P&ID Equipment");
|
var worksheet = package.Workbook.Worksheets.Add("P&ID Equipment");
|
||||||
@@ -328,7 +328,7 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
row++;
|
row++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return package.GetAsByteArray();
|
return Task.FromResult(package.GetAsByteArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
|
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
|
||||||
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.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(
|
return await new DefaultSessionFactory(null).CreateAsync(
|
||||||
appConfig,
|
appConfig,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -123,6 +125,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
identity,
|
identity,
|
||||||
null,
|
null,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
#pragma warning restore CS8625
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 접속 테스트 ───────────────────────────────────────────────────────────
|
// ── 접속 테스트 ───────────────────────────────────────────────────────────
|
||||||
@@ -437,7 +440,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
// DisplayName 우선, 그 다음 BrowseName, 마지막으로 NodeId 사용
|
// DisplayName 우선, 그 다음 BrowseName, 마지막으로 NodeId 사용
|
||||||
// DisplayName이 이스케이프된 계층 경로 일 때 BrowseName도 함께 결합
|
// DisplayName이 이스케이프된 계층 경로 일 때 BrowseName도 함께 결합
|
||||||
// ─────────────────────────────────────────
|
// ─────────────────────────────────────────
|
||||||
string? displayName = null;
|
string displayName = $"Node:{r.NodeId!}";
|
||||||
string? browseName = null;
|
string? browseName = null;
|
||||||
|
|
||||||
if (r.NodeClass == NodeClass.Variable || r.NodeClass == NodeClass.Object)
|
if (r.NodeClass == NodeClass.Variable || r.NodeClass == NodeClass.Object)
|
||||||
@@ -468,12 +471,11 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
displayName = $"Node:{r.NodeId.ToString()}"; // NodeId만 사용
|
|
||||||
noNameCount++;
|
noNameCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ExperionNodeInfo(
|
return new ExperionNodeInfo(
|
||||||
r.NodeId.ToString(),
|
r.NodeId!.ToString()!,
|
||||||
displayName,
|
displayName,
|
||||||
r.NodeClass.ToString(),
|
r.NodeClass.ToString(),
|
||||||
r.NodeClass == NodeClass.Object
|
r.NodeClass == NodeClass.Object
|
||||||
@@ -497,7 +499,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
/// 비정상적인 DisplayName을 정상적인 이름으로 변환.
|
/// 비정상적인 DisplayName을 정상적인 이름으로 변환.
|
||||||
/// 예: "ns=1;s=FICQ-6102.hzset.fieldvalue" -> "FICQ-6102.hzset.fieldvalue"
|
/// 예: "ns=1;s=FICQ-6102.hzset.fieldvalue" -> "FICQ-6102.hzset.fieldvalue"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? SanitizeDisplayName(string original)
|
private static string SanitizeDisplayName(string original)
|
||||||
{
|
{
|
||||||
// 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환
|
// 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환
|
||||||
if (original.StartsWith("ns=") || original.Contains('.'))
|
if (original.StartsWith("ns=") || original.Contains('.'))
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
|
|||||||
ExperionServerConfig cfg)
|
ExperionServerConfig cfg)
|
||||||
{
|
{
|
||||||
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.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
|
||||||
return await new DefaultSessionFactory(null).CreateAsync(
|
return await new DefaultSessionFactory(null).CreateAsync(
|
||||||
appConfig,
|
appConfig,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -566,6 +567,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
|
|||||||
identity,
|
identity,
|
||||||
null,
|
null,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
#pragma warning restore CS8625
|
||||||
}
|
}
|
||||||
|
|
||||||
private volatile bool _disposed = false;
|
private volatile bool _disposed = false;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class PidGraphController : ControllerBase
|
|||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
return NotFound(PidResponse<object>.Fail(result.Error));
|
return NotFound(PidResponse<object>.Fail(result.Error ?? "Unknown error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프론트엔드 camelCase 규칙 준수 및 PidResponse 래핑
|
// 프론트엔드 camelCase 규칙 준수 및 PidResponse 래핑
|
||||||
@@ -84,8 +84,8 @@ public class PidGraphController : ControllerBase
|
|||||||
public async Task GetAnalysisStatusStream(string taskId, CancellationToken ct)
|
public async Task GetAnalysisStatusStream(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Response.ContentType = "text/event-stream";
|
Response.ContentType = "text/event-stream";
|
||||||
Response.Headers.Add("Cache-Control", "no-cache");
|
Response.Headers.Append("Cache-Control", "no-cache");
|
||||||
Response.Headers.Add("Connection", "keep-alive");
|
Response.Headers.Append("Connection", "keep-alive");
|
||||||
|
|
||||||
_logger.LogInformation("SSE stream started for taskId: {TaskId}", taskId);
|
_logger.LogInformation("SSE stream started for taskId: {TaskId}", taskId);
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ public class PidGraphController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
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 _statusStore.UpdateStatusAsync(status);
|
||||||
await _eventBroadcaster.NotifyAsync(taskId, status);
|
await _eventBroadcaster.NotifyAsync(taskId, status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class TextToSqlController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("[TextToSql] data 필드가 문자열임: {DataString}", dataString);
|
_logger.LogInformation("[TextToSql] data 필드가 문자열임: {DataString}", dataString);
|
||||||
var parsedData = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>?>(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 });
|
return Ok(new { success = true, data = jsonData });
|
||||||
@@ -139,7 +139,7 @@ public class TextToSqlController : ControllerBase
|
|||||||
// JSON 결과 반환 (쿼리 결과)
|
// JSON 결과 반환 (쿼리 결과)
|
||||||
try
|
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 });
|
return Ok(new { success = true, data = jsonData });
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -185,7 +185,7 @@ public class TextToSqlController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
|
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -230,7 +230,7 @@ public class TextToSqlController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
|
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -274,7 +274,7 @@ public class TextToSqlController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
|
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
|
|||||||
Reference in New Issue
Block a user