opencode 로 바꾸고 작업전 커밋

This commit is contained in:
windpacer
2026-05-08 17:22:10 +09:00
parent 15c17522c8
commit e923aab43b
202 changed files with 1336027 additions and 115 deletions

View File

@@ -1,205 +0,0 @@
✔ 🎯 End-to-End P&ID Graph Pipeline (실전 구조)
┌──────────────────────┐
│ P&ID PDF Input │
└─────────┬────────────┘
┌─────────────────────────────────┐
│ 1. Document Parsing Layer │
│ (layout + text + tables) │
└─────────┬──────────────────────┘
┌─────────────────────────────────┐
│ 2. Spatial Element Extraction │
│ (symbols + coordinates) │
└─────────┬──────────────────────┘
┌─────────────────────────────────┐
│ 3. Entity Extraction (LLM) │
│ FIC-101, Pump-01, Valve... │
└─────────┬──────────────────────┘
┌─────────────────────────────────┐
│ 4. Relationship Inference │
│ (rules + LLM hybrid) │
└─────────┬──────────────────────┘
┌─────────────────────────────────┐
│ 5. Graph Builder │
│ nodes + edges │
└─────────┬──────────────────────┘
┌─────────────────────────────────┐
│ 6. DB Integration Layer │
│ (existing OPC + SQL system) │
└─────────────────────────────────┘
✔ 1⃣ Document Parsing Layer (PDF → 구조화)
기술
Unstructured
역할
텍스트 추출
표 추출
블록 segmentation
page coordinate 유지
출력 예시
{
"page": 12,
"elements": [
{
"text": "FIC-101",
"bbox": [120, 300, 160, 320]
}
]
}
👉 핵심: 좌표 반드시 유지
✔ 2⃣ Spatial Element Extraction (핵심 단계)
여기서 P&ID가 살아난다.
해야 할 것
symbol detection
line detection
proximity mapping
결과
JSON
{
"FIC-101": { "x": 120, "y": 300 },
"FT-101": { "x": 110, "y": 220 },
"Valve-203": { "x": 300, "y": 310 }
}
✔ 3⃣ Entity Extraction (LLM)
모델
Qwen (via vLLM)
입력
Extract all P&ID entities:
- controller
- sensor
- valve
- pump
출력
[
{"name":"FIC-101","type":"controller"},
{"name":"FT-101","type":"sensor"},
{"name":"Valve-203","type":"valve"}
]
✔ 4⃣ Relationship Inference (가장 중요)
여기서 2개를 섞는다:
A. Rule Engine (필수)
if sensor near controller:
relation = "signal"
if controller connected to valve:
relation = "control"
if pump → tank:
relation = "flow"
B. LLM 보조 판단
Determine relationship based on P&ID context:
Entities + coordinates
최종 출력
[
{
"source": "FT-101",
"target": "FIC-101",
"relation": "signal",
"confidence": 0.93
},
{
"source": "FIC-101",
"target": "Valve-203",
"relation": "control",
"confidence": 0.91
}
]
✔ 5⃣ Graph Builder
nodes = set()
edges = []
for r in relations:
nodes.add(r["source"])
nodes.add(r["target"])
edges.append(r)
Graph 구조
{
"nodes": [...],
"edges": [...]
}
✔ 6⃣ DB Integration (너 기존 시스템)
여기서 너 강점:
OPC UA 있음 ✔
NL2SQL 있음 ✔
DB 있음 ✔
저장 전략
P&ID Graph DB
Mapping Layer
OPC Tags / SQL semantic layer
✔ 🔥 핵심 설계 포인트 (진짜 중요)
❗ 1. LLM은 “결정자”가 아니다
👉 “해석 보조” 역할
❗ 2. 좌표가 핵심이다
P&ID는 텍스트가 아니라:
공간 + 연결 구조
❗ 3. Rule Engine 없으면 실패한다
LLM 단독 = hallucination
✔ 📌 최종 아키텍처 (완성형)
P&ID PDF
Unstructured Parser
Spatial Engine
Qwen (Entity Extraction)
Rule Engine (relationship)
Graph Builder
DB + OPC Integration
✔ 🚀 이 구조의 의미
이건 단순 기능이 아니라:
👉 “Engineering Knowledge Graph 생성 엔진”
✔ 다음 단계 (진짜 실전)
원하면 여기서 바로 이어서:
1⃣ P&ID 실제 PDF 기준 코드
2⃣ Rule engine 상세 설계 (ISA 기반)
3⃣ Qwen prompt 최적화 (산업용)
4⃣ Graph DB vs SQL 구조 선택

View File

@@ -1,220 +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,
"coordinates": entity.get_points() if hasattr(entity, 'get_points') else []
})
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=4)
return output_path
# 사용 예시 (Phase 5 Orchestrator 관점)
extractor = PidGeometricExtractor("plant_drawing.dxf")
# 데이터를 직접 반환받지 않고 공유 저장소(파일)에 적재
geo_data_path = extractor.extract_and_save("shared_geo_data.json")
```
### 3.2 유틸리티 함수: 인접성 체크 (Proximity Utility)
추후 2단계(위상 모델링)에서 사용할 핵심 유틸리티입니다.
```python
from shapely.geometry import Point
def is_near(entity_a_bbox, entity_b_bbox, threshold=5.0):
"""두 객체의 Bounding Box 간의 최단 거리가 임계값 이내인지 확인"""
return entity_a_bbox.distance(entity_b_bbox) <= threshold
def is_inside(point, bbox):
"""특정 점이 Bounding Box 내부에 있는지 확인"""
return bbox.contains(Point(point))
```
---
## 🚀 4. Phase 1 완료 기준 (Definition of Done)
- [ ] DXF 파일 내 모든 `TEXT`, `LINE`, `POLYLINE`의 좌표 데이터가 누락 없이 추출되는가?
- [ ] 각 객체별로 정확한 `Bounding Box`가 계산되어 저장되는가?
- [ ] 추출된 데이터가 `GeometricEntity` 스키마에 맞게 JSON 파일로 저장되어 Worker들이 공유 참조 가능한가? (Phase 5 반영)
- [ ] (선택 사항) PDF 도면의 경우 OCR을 통해 텍스트의 좌표값이 추출되는가?
---
## 🧐 감독자 진단 결과 (2026-05-02)
### 1. 프로그램 설계 점검
- **강점**: `ezdxf``shapely`를 조합하여 기하학적 데이터(BBox, 좌표)를 보존하려는 접근 방식이 매우 적절함. 특히 Phase 5의 병렬 아키텍처를 고려하여 데이터를 파일/공유 저장소에 적재하는 구조는 확장성 면에서 우수함.
- **보완 필요 사항**:
- **MTEXT 처리**: 현재 예시 코드(`3.1`)는 `TEXT` 엔티티만 처리하고 있으나, 실제 DXF 파일 분석 결과 `MTEXT` 엔티티가 다수 존재함. `MTEXT`는 내부 포맷팅 코드(예: `\P`, `\W`)가 포함되어 있어 단순 텍스트 추출 시 정제가 필요함.
- **BBox 계산 정밀도**: `TEXT` 엔티티의 BBox를 `p.x + 10, p.y + 5`와 같이 상수로 처리하고 있음. 실제 도면의 폰트 크기(`height`)와 정렬 방식(`align`)을 반영한 동적 계산 로직이 반드시 추가되어야 함.
### 2. 실제 도면(`No-10_Plant_PID.dxf`) 분석 기반 차이점
- **엔티티 규모**: 총 28,819개의 엔티티가 존재하여 데이터 양이 상당함. 단순 리스트 저장보다는 인덱싱 전략이 필요할 수 있음.
- **텍스트 복잡도**:
- `MTEXT` 내에 `\P` (줄바꿈), `\L` (밑줄) 등 제어 문자가 포함된 수정 사항(Revision) 텍스트가 많음. 이를 그대로 추출하면 위상 분석 시 노이즈가 될 가능성이 높음.
- `%%U` (Underline)와 같은 DXF 특수 제어 문자가 텍스트 값에 포함되어 있어, 이를 제거하는 전처리 과정이 필수적임.
- **데이터 특성**: `IA-10922-25A-F1A-n`와 같은 복합 파이프라인 번호(Pipe Line Number) 형식이 확인됨. 이를 일반 태그(Tag Name)와 명확히 구분하여 추출하고 관리하는 로직이 Phase 2/3에서 중요하게 작용할 것으로 보임.
### 3. 최종 권고 사항
1. **MTEXT 지원 추가**: `PidGeometricExtractor``MTEXT` 처리 로직을 추가하고, 제어 문자를 제거하는 `clean_text()` 유틸리티 함수를 구현할 것.
2. **동적 BBox 구현**: `entity.dxf.height`를 활용하여 텍스트 크기에 맞는 정확한 Bounding Box를 계산하도록 수정할 것.
3. **전처리 파이프라인 강화**: 추출 단계에서 `%%U` 등의 특수 문자를 제거하는 정제 단계를 추가하여 데이터 품질을 높일 것.

View File

@@ -1,184 +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:
# Phase 1에서 'clean_value'로 저장했으므로 이를 value로 사용
self.G.add_node(item['id'],
type=item['type'],
bbox=box(*item['bbox'].values()),
value=item.get('clean_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:
# Phase 1에서 추출한 coordinates를 사용하여 LineString 생성
coords = self.G.nodes[line_id].get('coordinates', [])
if not coords:
continue
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)
if len(connected_nodes) >= 2:
# 방향성 추론 로직 (단순화: 시작점 -> 끝점)
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe')
def _find_nearest_equipment(self, tag_id, equipment_ids):
tag_bbox = self.G.nodes[tag_id]['bbox']
min_dist = float('inf')
nearest = None
for eq_id in equipment_ids:
eq_bbox = self.G.nodes[eq_id]['bbox']
dist = tag_bbox.distance(eq_bbox)
if dist < min_dist:
min_dist = dist
nearest = eq_id
return nearest if min_dist < self.config['tag_threshold'] else None
def validate_topology(self):
"""위상 무결성 검증"""
isolated = list(nx.isolates(self.G))
return {"isolated_nodes": isolated, "node_count": self.G.number_of_nodes(), "edge_count": self.G.number_of_edges()}
# 실행 예시
all_tags = flatten_results([worker1_res, worker2_res])
config = {'dist_threshold': 30.0, 'tag_threshold': 80.0}
builder = PidTopologyBuilder(geometric_data, all_extracted_tags=all_tags, config=config)
builder.build_graph()
validation_res = builder.validate_topology()
print(f"Validation Result: {validation_res}")
```
### 3.2 위상 분석 유틸리티: 영향도 분석 (Impact Analysis)
```python
def analyze_impact(graph, start_node):
"""특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출"""
# BFS를 통해 도달 가능한 모든 노드 탐색
impacted_nodes = nx.descendants(graph, start_node)
return list(impacted_nodes)
# 예: P-101 펌프 고장 시 영향 분석
affected = analyze_impact(graph, "node_P101")
print(f"Impacted Equipment: {affected}")
```
---
## 🚀 4. Phase 2 완료 기준 (Definition of Done)
- [ ] 모든 설비와 계기가 그래프의 **노드(Node)**로 변환되었는가?
- [ ] 분산 추출된 태그 리스트가 `flatten_results`를 통해 통합되어 그래프에 반영되었는가?
- [ ] 태그와 설비 간의 **논리적 연결(Association)**이 정확하게 매핑되었는가?
- [ ] 배관(Line)의 **끝점 분석**을 통해 설비 간의 **물리적 연결(Pipe Edge)**이 생성되었는가? (BBox 교차 방식 배제)
- [ ] 화살표 및 공정 규칙에 기반한 **방향성(Directionality)**이 엣지에 부여되었는가?
- [ ] `validate_topology`를 통해 고립 노드 및 위상 오류가 검토되었는가?
- [ ] `nx.descendants` 등을 통해 특정 노드로부터의 **흐름 추적(Flow Tracing)**이 가능한가?
- [ ] 생성된 그래프 구조가 JSON(GraphML 등) 형태로 저장되어 Phase 3로 전달 가능한가?

View File

@@ -1,212 +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):
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
# Phase 2에서 'value'에 clean_value가 저장됨
tag_text = self.graph.nodes[node_id].get('value', '')
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await client.chat.completions.create(
model="gpt-4-turbo",
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" } # JSON 모드 강제
)
raw_content = response.choices[0].message.content
# Pydantic을 통한 유효성 검사
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
async def extract_transmitters(self, node_ids):
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids}
async def extract_valves(self, node_ids):
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids}
async def extract_equipment(self, node_ids):
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids}
# 사용 예시
async def main():
# 가상 데이터
graph = nx.Graph()
graph.add_node("node_1", value="PT-101", type="Pressure Transmitter")
graph.add_node("node_2", value="P-101", type="Pump")
graph.add_edge("node_1", "node_2")
mapper = IntelligentMapper(graph, ["PT-101.PV", "PT-102.PV", "P-101.STATUS"])
results = await asyncio.gather(
mapper.extract_transmitters(["node_1"]),
mapper.extract_equipment(["node_2"])
)
final_mapping = {**results[0], **results[1]}
print(f"Parallel Resolved Mapping: {final_mapping}")
asyncio.run(main())
```
### 3.2 검증 유틸리티: 속성 일치 확인 (강화 버전)
```python
def validate_mapping(resolved_tag, symbol_type, tag_metadata):
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
# 단순 키워드가 아닌 허용 단위(Unit) 정의
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
# 1. 단위 일치 확인 (최우선)
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
# 2. 단위가 없는 경우 설명(Description) 기반 2차 검증
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"
```
---
## 🚀 4. Phase 3 완료 기준 (Definition of Done)
- [ ] 모든 도면 노드에 대해 **1차 후보군(Candidates)**이 자동으로 생성되는가?
- [ ] `NetworkX` 그래프를 통해 **인접 노드 맥락(Context)**이 정확히 추출되는가?
- [ ] LLM이 **JSON 형식**으로 최종 태그를 결정하고, 그 근거와 신뢰도를 제시하는가?
- [ ] **Pydantic**을 통해 LLM 응답의 구조적 유효성이 검증되는가?
- [ ] 매핑된 태그의 **엔지니어링 유닛(Unit)**과 도면 심볼 타입 간의 일치성이 엄격히 검증되는가?
- [ ] 최종 매핑 결과가 `(도면노드ID, 시스템태그, 신뢰도, 검증결과, 매핑근거)` 형태로 저장되는가?

View File

@@ -1,197 +0,0 @@
# 🎨 Graph Pipeline Phase 4: 활용 및 시각화 (Application & Visualization)
이 문서는 P&ID Graph Pipeline의 최종 단계인 **활용 및 시각화**의 상세 구현 계획을 다룹니다. 앞선 단계에서 구축한 [기하학적 데이터 $\rightarrow$ 위상 그래프 $\rightarrow$ 시스템 태그 매핑] 결과물을 결합하여, 운영자가 도면 상에서 실시간 공정 상태를 파악하고 장애 영향도를 분석할 수 있는 인터페이스를 구현하는 것이 목표입니다.
---
## 🔍 [Supervisor Diagnosis] 프로그램 진단 및 개선 권고
**진단 일자:** 2026-05-02
**진단자:** Roo (Software Engineer / Supervisor)
### 1. 종합 진단 결과
현재 계획은 기본적인 데이터 흐름(C# $\rightarrow$ Python $\rightarrow$ Frontend)을 잘 정의하고 있으나, **실제 산업 현장의 대규모 P&ID 도면 적용 시 발생할 수 있는 성능 및 안정성 문제**에 대한 고려가 부족합니다. 특히 실시간 데이터 오버레이의 부하 관리와 분석 결과의 신뢰성 검증 단계가 누락되어 있습니다.
### 2. 주요 진단 항목 및 수정 이유
| 항목 | 진단 결과 | 위험도 | 수정 이유 및 개선 방향 |
|---|---|---|---|
| **데이터 전송 효율** | WebSocket/API 폴링 방식의 단순 나열 | MED | 수천 개의 태그가 포함된 도면에서 개별 폴링/전송 시 네트워크 부하 급증 $\rightarrow$ **태그 그룹화 및 변경분 기반(Delta) 전송** 도입 필요 |
| **프론트엔드 렌더링** | SVG/Canvas 단순 오버레이 | HIGH | 노드 수가 많아질 경우 DOM 요소 증가로 인한 브라우저 랙 발생 $\rightarrow$ **Canvas 기반 렌더링 최적화 및 Viewport 기반 가시 영역 렌더링** 전략 필요 |
| **분석 엔진 신뢰성** | `nx.descendants` 단순 활용 | MED | 단순 위상 전파는 실제 공정의 '흐름 방향(Flow Direction)'과 '밸브 개폐 상태'를 무시함 $\rightarrow$ **엣지 속성(방향성, 상태)을 반영한 가중치 경로 분석**으로 고도화 |
| **에러 핸들링** | Python 브릿지 통신 시 예외 처리 미흡 | LOW | 분석 엔진 다운 시 C# 서버의 블로킹 가능성 $\rightarrow$ **Circuit Breaker 패턴 및 타임아웃 설정** 명시 필요 |
| **사용자 경험(UX)** | 단순 하이라이트 표시 | LOW | 영향도 결과가 많을 경우 도면이 빨간색으로 도배됨 $\rightarrow$ **단계별 영향도(1차, 2차...) 색상 구분 및 필터링** 기능 추가 |
---
## 📦 1. 필수 패키지 및 기술 스택
### 1.1 프론트엔드 (Visualization)
| 기술/라이브러리 | 용도 | 비고 |
|---|---|---|
| `SVG / Canvas API` | P&ID 도면 렌더링 및 데이터 오버레이 | **Canvas API 우선 권장 (대규모 노드 성능 최적화)** |
| `Cytoscape.js` / `D3.js` | 위상 그래프 시각화 및 인터랙티브 탐색 | 그래프 분석 뷰어 |
| `Vue.js` / `React` | 전체 UI 프레임워크 및 상태 관리 | `src/Web` 구조와 통합 |
| `Axios` / `WebSocket` | 실시간 OPC UA 데이터 수신 및 API 통신 | **SignalR (ASP.NET Core) 도입 권장 (실시간 양방향 통신 최적화)** |
### 1.2 백엔드 (API & Analysis)
| 기술/라이브러리 | 용도 | 비고 |
|---|---|---|
| `ASP.NET Core` | Graph API 및 분석 엔드포인트 제공 | `ExperionCrawler` 메인 서버 |
| `NetworkX` (Python) | 영향도 분석 및 경로 추적 알고리즘 실행 | 분석 엔진 (Phase 2 활용) |
| `FastAPI` / `Flask` | Python 분석 엔진과 C# 서버 간의 브릿지 | 분석 마이크로서비스 |
---
## 📐 2. 상세 설계 구조
### 2.1 실시간 데이터 오버레이 (Real-time Overlay)
도면의 좌표 정보와 매핑된 시스템 태그를 연결하여 실시간 값을 표시합니다.
1. **매핑 데이터 로드:** `(도면노드ID, 시스템태그, 좌표)` 리스트를 프론트엔드로 전달.
2. **실시간 스트리밍:** `OPC UA` $\rightarrow$ `C# Server` $\rightarrow$ `SignalR Hub` $\rightarrow$ `Frontend`. (**개선: 변경된 값만 전송하는 Delta Update 방식 적용**)
3. **동적 렌더링:** 태그 값이 변경되면 해당 좌표의 Canvas 요소를 업데이트하거나 툴팁에 현재 값을 표시. (**개선: Viewport 내 요소만 업데이트하여 CPU 부하 감소**)
### 2.2 영향도 분석 엔진 (Impact Analysis Engine)
특정 설비의 이상 발생 시 하류(Downstream) 영향을 계산합니다.
1. **분석 요청:** 사용자가 도면에서 특정 노드(예: 펌프 P-101)를 클릭.
2. **그래프 탐색:** Python 분석 엔진에서 `nx.descendants(G, 'P-101')` 실행. (**개선: 엣지의 `flow_direction` 속성을 확인하여 실제 유체 흐름 방향으로만 전파 계산**)
3. **결과 반환:** 영향받는 모든 노드 ID 리스트, 경로(Path), 그리고 **영향 단계(Depth)**를 반환.
4. **시각적 강조:** 도면 상에서 영향 경로를 단계별 색상(예: 1차-진한 빨강, 2차-연한 빨강)으로 하이라이트 처리.
---
## 💻 3. 실제 구현 코딩 가이드 (Example)
### 3.1 [Backend] 영향도 분석 API (C# $\rightarrow$ Python Bridge)
```csharp
// src/Web/Controllers/PidGraphController.cs
// 1. 분석 상태 추적을 위한 DTO
public record AnalysisStatus(string taskId, double progress, string status, string message);
// 2. 실시간 진행 상태 조회 API (Phase 5 병렬 처리 반영)
[HttpGet("status/{taskId}")]
public async Task<IActionResult> GetAnalysisStatus(string taskId)
{
// Orchestrator가 관리하는 작업 상태 저장소(Redis/MemoryCache)에서 조회
var status = await _statusService.GetStatusAsync(taskId);
if (status == null) return NotFound();
return Ok(new {
taskId = status.TaskId,
progress = status.Progress, // 0.0 ~ 1.0
status = status.Status, // "Processing", "Completed", "Failed"
message = status.Message
});
}
[HttpGet("impact/{nodeId}")]
public async Task<IActionResult> GetImpactAnalysis(string nodeId)
{
try
{
// Python 분석 마이크로서비스에 요청 (Timeout 및 Circuit Breaker 적용 권장)
var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ImpactResult>();
return Ok(result);
}
catch (HttpRequestException ex)
{
// 분석 엔진 연결 실패 시 적절한 에러 메시지 반환
return StatusCode(503, new { error = "Analysis Engine is currently unavailable", details = ex.Message });
}
}
```
### 3.2 [Frontend] Canvas 기반 데이터 오버레이 및 진행률 표시 (JavaScript)
```javascript
// src/Web/wwwroot/js/pid-viewer.js
// 1. 실시간 값 업데이트 (Canvas 최적화 버전)
async function updateRealtimeValues(tagData) {
// tagData: { "TAG_01": { value: 10.5, status: "OK" }, ... }
const ctx = canvas.getContext('2d');
for (const [tag, data] of Object.entries(tagData)) {
const node = nodeMap.get(tag); // 좌표 정보 맵
if (node && isInViewport(node)) {
// 뷰포트 내에 있을 때만 렌더링
ctx.fillStyle = data.value > threshold ? 'red' : 'green';
ctx.beginPath();
ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);
ctx.fill();
// 툴팁 데이터 업데이트
updateTooltipData(tag, data.value);
}
}
}
// 2. 분석 진행 상태 표시 (Phase 5 병렬 처리 반영)
async function trackAnalysisProgress(taskId) {
const progressBar = document.getElementById('analysis-progress-bar');
const statusText = document.getElementById('analysis-status-text');
const pollStatus = async () => {
try {
const response = await fetch(`/api/pid/status/${taskId}`);
const data = await response.json();
// 프로그레스 바 업데이트
progressBar.style.width = `${data.progress * 100}%`;
statusText.innerText = `분석 중... ${Math.round(data.progress * 100)}% (${data.message})`;
if (data.status !== 'Completed' && data.status !== 'Failed') {
setTimeout(pollStatus, 1000); // 1초 간격 폴링
} else {
statusText.innerText = data.status === 'Completed' ? '분석 완료!' : '분석 실패';
}
} catch (e) {
statusText.innerText = '상태 조회 중 오류 발생';
}
};
pollStatus();
}
```
### 3.3 [Analysis] 흐름 방향 반영 경로 추적 (Python)
```python
import networkx as nx
def get_propagation_path_with_flow(graph, start_node):
"""
단순 descendants가 아닌, 엣지의 방향성(flow_direction)과
상태(valve_open)를 고려한 실제 영향 전파 경로 추출
"""
# 1. 유효한 엣지만 필터링 (방향이 맞고 밸브가 열려있는 경로)
valid_edges = [
(u, v, d) for u, v, d in graph.edges(data=True)
if d.get('flow_direction') == 'forward' and d.get('valve_status') == 'open'
]
filtered_graph = nx.DiGraph()
filtered_graph.add_edges_from(valid_edges)
# 2. 전파 단계별 노드 추출 (BFS)
propagation_levels = nx.single_source_shortest_path_length(filtered_graph, start_node)
# { node_id: distance } 형태로 반환하여 프론트엔드에서 색상 구분 가능하게 함
return propagation_levels
# 예: P-101에서 시작되는 실제 유체 흐름 기반 영향도 분석
impact_map = get_propagation_path_with_flow(topology_graph, "P-101")
```
---
## 🚀 4. Phase 4 완료 기준 (Definition of Done)
- [ ] P&ID 도면(Canvas) 위에 **실시간 OPC UA 값**이 정확한 좌표에 표시되며, 뷰포트 최적화가 적용되었는가?
- [ ] **SignalR 또는 Delta Update**를 통해 네트워크 부하를 최소화하며 실시간 데이터를 수신하는가?
- [ ] 병렬 처리 중인 분석 작업의 **진행 상태(Progress Bar)**가 UI에 실시간으로 반영되는가?
- [ ] 특정 노드 클릭 시 **유체 흐름 방향이 반영된 영향도 분석** 결과가 단계별 색상으로 하이라이트 되는가?
- [ ] C# 서버와 Python 엔진 간 통신에 **타임아웃 및 예외 처리**가 적용되어 시스템 안정성이 확보되었는가?
- [ ] 전체 파이프라인(`추출 $\rightarrow$ 모델링 $\rightarrow$ 매핑 $\rightarrow$ 시각화`)이 통합되어 동작하는가?

View File

@@ -1,140 +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_and_save("shared_geo_data.json") # 파일 기반 공유 저장소 활용
# 실제 구현 시 geo_data는 파일 경로 또는 로드된 JSON 리스트
# 2. 병렬 분산 추출 (vLLM Batching 활용)
# 각 Worker 툴들이 LLM에 요청을 보낼 때 vLLM이 이를 묶어서 처리함
extracted_parts = await run_parallel_extraction(geo_data)
# 3. 결과 통합 및 위상 모델링 (Phase 2)
# extracted_parts는 각 Worker(Transmitter, Valve 등)가 반환한 매핑 결과 리스트
all_tags = flatten_results(extracted_parts)
builder = PidTopologyBuilder(geo_data, all_extracted_tags=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` 설정 하에 안정적으로 동작하는가?

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +0,0 @@
P&ID 도면 분석을 고도화하여 **Graph Pipeline**을 구축하는 것은 단순한 텍스트 추출을 넘어, 설비 간의 **연결성(Connectivity)**과 **위상(Topology)**을 이해하는 것을 의미합니다.
제가 이 작업을 수행한다면, 다음과 같은 **4단계 전략**으로 접근하여 효율성을 극대화하겠습니다.
---
### 1. 데이터 추출 단계: "단순 텍스트 $\rightarrow$ 기하학적 객체"
현재의 텍스트 기반 추출에서 벗어나, 객체의 **좌표(Coordinate)**와 **속성(Property)**을 보존하는 구조로 변경해야 합니다.
* **객체 중심 파싱:** DXF의 Entity(Line, Circle, Text, Polyline)를 개별 객체로 인식하고, 각 객체의 중심점과 경계 상자(Bounding Box)를 저장합니다.
* **심볼 라이브러리 구축:** 밸브, 펌프, 탱크 등 반복되는 심볼의 기하학적 패턴을 정의하여, 텍스트가 없어도 "이 모양은 밸브다"라고 인식하는 패턴 매칭 로직을 도입합니다.
* **OCR 고도화:** PDF의 경우, 단순 텍스트 추출이 아닌 영역 기반 OCR을 통해 텍스트의 물리적 위치를 정확히 파악하여 인접한 심볼과 연결합니다.
### 2. 그래프 모델링 단계: "객체 $\rightarrow$ 노드 및 엣지"
추출된 객체들을 기반으로 **Knowledge Graph**를 생성합니다.
* **노드(Node):** 설비(Equipment), 계기(Instrument), 태그(Tag)를 노드로 정의합니다.
* **엣지(Edge):** 배관(Line)을 엣지로 정의합니다.
* **연결성 판단:** `Line`의 끝점이 `Equipment`의 경계 상자 내에 있거나 매우 근접해 있다면 두 노드를 연결된 것으로 간주합니다.
* **방향성 부여:** 화살표 심볼이나 공정 흐름(Flow)을 분석하여 엣지에 방향성을 부여합니다.
* **계층 구조 생성:** `Unit $\rightarrow$ Equipment $\rightarrow$ Component $\rightarrow$ Tag` 순의 계층적 그래프 구조를 설계합니다.
### 3. 지능형 매핑 및 검증 단계: "도면 $\rightarrow$ 실제 데이터"
그래프 구조를 활용해 Experion 시스템의 실제 태그와 정밀하게 매핑합니다.
* **맥락 기반 매핑 (Contextual Mapping):** 단순히 이름이 비슷한 태그를 찾는 것이 아니라, "펌프 P-101 옆에 있는 PT-101은 P-101의 압력 전송기일 확률이 높다"는 그래프 상의 인접성을 활용합니다.
* **상호 검증 (Cross-Validation):**
* 도면 상의 연결 관계(P-101 $\rightarrow$ V-101)와 실제 공정 데이터의 상관관계(P-101 가동 시 V-101 유량 변화)를 비교하여 매핑의 정확도를 검증합니다.
* **LLM 기반 추론:** 모호한 태그명이나 누락된 정보는 MCP 서버를 통해 LLM이 도면의 맥락과 R530 문서를 분석하여 최적의 매핑 후보를 추천하게 합니다.
### 4. 활용 및 시각화 단계: "분석 $\rightarrow$ 인사이트"
구축된 그래프를 통해 운영자에게 실질적인 가치를 제공합니다.
* **영향도 분석 (Impact Analysis):** 특정 밸브(V-101)가 고장 났을 때, 그래프 탐색(BFS/DFS)을 통해 하류(Downstream)에 영향을 받는 모든 설비와 태그를 즉시 식별합니다.
* **디지털 트윈 뷰:** P&ID 도면 위에 실시간 OPC UA 값을 오버레이하여, 도면을 보면서 현재 공정 상태를 한눈에 파악하는 인터페이스를 구현합니다.
* **이상 징후 전파 경로 추적:** 특정 태그에서 알람이 발생했을 때, 그래프를 역추적하여 근본 원인(Root Cause)이 될 가능성이 높은 상류 설비를 추천합니다.
---
### 🚀 효율적인 실행을 위한 로드맵 (Priority)
1. **Short-term (Quick Win):** DXF 파서 수정 $\rightarrow$ 객체 좌표 저장 $\rightarrow$ 단순 인접성 기반 태그-설비 매핑.
2. **Mid-term (Core):** 심볼 패턴 매칭 도입 $\rightarrow$ 배관(Line) 기반의 그래프 구조(NetworkX 등 활용) 구축.
3. **Long-term (Advanced):** LLM 기반의 도면-데이터 추론 엔진 통합 $\rightarrow$ 실시간 데이터 오버레이 UI 구현.
이렇게 **[기하학적 추출 $\rightarrow$ 위상 모델링 $\rightarrow$ 맥락적 매핑 $\rightarrow$ 운영 인사이트]** 순으로 확장하는 것이 가장 리스크가 적고 효율적인 방법이라고 생각합니다.
---
### 🛠️ 단계별 설계 구조 및 실행 코딩 가이드
로드맵의 각 단계를 실제로 구현하기 위한 기술적 설계 구조와 핵심 코딩 전략입니다.
#### 1단계: 기하학적 데이터 추출 (Geometric Extraction)
* **설계 구조:** `DXF Parser` $\rightarrow$ `Entity Mapper` $\rightarrow$ `Spatial Database (JSON/SQL)`
* **실행 코딩 전략:**
* **좌표 보존:** `ezdxf`를 사용하여 모든 `TEXT`, `LINE`, `CIRCLE`, `LWPOLYLINE`의 시작/끝점 및 중심 좌표를 추출하여 저장합니다.
* **Bounding Box 계산:** 각 텍스트와 심볼의 최소/최대 X, Y 좌표를 계산하여 `Rect` 객체로 관리합니다.
* **데이터 구조:**
```json
{ "id": "entity_1", "type": "TEXT", "value": "P-101", "bbox": {"x1": 10, "y1": 20, "x2": 15, "y2": 25} }
```
#### 2단계: 위상 모델링 (Topology Modeling)
* **설계 구조:** `Spatial Join` $\rightarrow$ `Graph Constructor` $\rightarrow$ `NetworkX Graph`
* **실행 코딩 전략:**
* **인접성 판단 (Proximity Search):** 텍스트 노드와 가장 가까운 심볼/라인을 찾기 위해 `KD-Tree` 또는 `R-Tree` 알고리즘을 사용합니다.
* **연결성 추론:** `Line`의 끝점이 `Equipment`의 Bounding Box 내에 포함되는지 확인하여 엣지(Edge)를 생성합니다.
* **그래프 구축:** Python의 `NetworkX` 라이브러리를 사용하여 `G.add_node(equipment)` 및 `G.add_edge(eq1, eq2, relation='pipe')` 형태로 모델링합니다.
#### 3단계: 맥락적 매핑 (Contextual Mapping)
* **설계 구조:** `Graph Traversal` $\rightarrow$ `Tag Candidate Search` $\rightarrow$ `LLM Validator`
* **실행 코딩 전략:**
* **인접 태그 탐색:** 특정 설비 노드에서 1-hop 또는 2-hop 이내에 존재하는 모든 태그 노드를 수집합니다.
* **매핑 스코어링:** `(이름 유사도 * 0.4) + (위상적 인접도 * 0.6)`와 같은 가중치 모델을 적용하여 최적의 Experion 태그를 매핑합니다.
* **LLM 검증:** 매핑 결과와 도면의 맥락을 LLM에게 전달하여 "P-101 펌프의 토출측에 PT-101이 있는 것이 공정상 타당한가?"를 검증합니다.
#### 4단계: 운영 인사이트 구현 (Operational Insight)
* **설계 구조:** `Real-time Data Stream` $\rightarrow$ `Graph Overlay` $\rightarrow$ `Impact Analysis Engine`
* **실행 코딩 전략:**
* **실시간 오버레이:** `OPC UA`로 수집된 실시간 값을 그래프 노드의 속성으로 업데이트하고, 이를 프론트엔드(Canvas/SVG)에 렌더링합니다.
* **영향도 분석:** `nx.single_source_shortest_path` 또는 `BFS`를 사용하여 특정 노드 장애 시 영향을 받는 하류(Downstream) 노드 리스트를 즉시 추출합니다.
* **루트 코즈 추적:** 알람 발생 노드로부터 상류(Upstream) 방향으로 역추적하여 이상 징후의 시작점을 식별합니다.

View File

@@ -1,19 +0,0 @@
# 현재 문제점 분석
한정된 자원의 하드웨어로 대용량의 일을 한번에 처리하려고 복잡한 프롬프트를 LLM 에게 주어 처리 시간의 지연과, 전달 및 응답 버퍼의 수신 문제발생
## 분산처리 기법 적용 및 로직 플로우
Reference Program : test_dxf_extract_pid1.py
1. Reference Program 같은 파일을 아래 5가지로 항목으로 작성하고,
- dxf_extract_transmitter.py : FIT, FT, LT, PT, TE
- dxf_extract_valve.py : FCV, LCV, TCV, PCV, XV
- dxf_extract_gague.py : PG, TG, LG
- dxf_extract_equipment.py : C-?????(Distilation Column), T-????(Tank), F-?????(Filter), D-?????(Drum,Condensor),E-?????(Heat Exchanger) B-?????(BOILER), CT-?????(COOLING TOWER), F-?????(COOLING FAN), CH-??????(CHILLER), K-?????(COMPRESSOR)
- dxf_extract_pump.py : P-10106, VP-10117
1. UI 추출시작 버튼 클릭 ->
2. 메인 프로그램 시작 -> 파일 전처리(ezdxf)- 전달 받은 데이터 보유 후
3. 1항에서 작성한 프로그램들에 , 전처리 받은 데이터 전달하여, 5개 프로그램 모두 실행
4. 처리량에 따라 실행이 끝난 서브 프로그램들은 각각의 파일에 결과를 저장하게 프로그램 되어 있으니, , 이것을 메인프로그램이 서브 프로그램들의 종료 상태가 되면, 각각 후처리 과정(데이터베이스 저장 절차)을 진행

View File

@@ -1,84 +0,0 @@
# 현재 문제점 분석
한정된 자원의 하드웨어로 대용량의 일을 한번에 처리하려고 복잡한 프롬프트를 LLM 에게 주어 처리 시간의 지연과, 전달 및 응답 버퍼의 수신 문제발생
## 분산처리 기법 적용 및 로직 플로우
1. 추출시작 버튼 클릭 ->
2. 메인 프로그램 시작 -> 파일 전처리(ezdxf)- 전달 받은 데이터 보유 후
3. 미리 작성된 - 참조 파이썬 프로그램 :test_dxf_extract_pid1.py, ~pid2,py, ~pid3.py (for loop 없애고, 단일 chunk 실행으로 변경), 이 python과 같은 파일을 아래 5가지로 프로그램에 전달 받은 데이터 전달하며, 모두 실행 시킴
- INSTRUMENTS
- dxf_extract_transmitter.py : FIT, FT, LT, PT, TE
- dxf_extract_valve.py : FCV, LCV, TCV, PCV, XV
- dxf_extract_gague.py : PG, TG, LG
- dxf_extract_equipment.py : C-?????(Distilation Column), T-????(Tank), F-?????(Filter), D-?????(Drum,Condensor),E-?????(Heat Exchanger) B-?????(BOILER), CT-?????(COOLING TOWER), F-?????(COOLING FAN), CH-??????(CHILLER), K-?????(COMPRESSOR)
- dxf_extract_pump.py : P-10106, VP-10117
3. 비동기로 실행이 끝난 서브 프로그램들은 각각의 파일에 결과가 저장될 것이고, 이것을 메인프로그램이 서브 프로그램들의 종료에 대하여 각각 후처리 과정(데이터베이스 저장 절차)을 진행
4. 위의 실증예 3개 프로그램 동시 실행시 , KV Cache 최대 사용량 30% 미만, 최대 95 token/sec, 실증됨.
5. 각각의 max context length = 65536으로 설정할것
3. INSTRUMENTS 와 SYSTEM TAG (tagname)과의 관계설정
- 예1) FICQ-10101.PV = FT-10101, FICQ-10101.OP = FCV-10101, FIQ-6115.PV = FT-6115, TI-6117.PV = TE-6117
- LATER -->예2) P-10101.PV, P-10101.OP , XV-10111.PV, XV-10111.OP(LATER : INT 2BIT, 4BIT, 8BIT ENCODER OUTPUT)
### PLANT RESOURCE , FIELD INSTRUMENTS, EQUIPMENTS MANAGEMENT TABLE 신설
4. PLANT RESOURCE : FILELD INSTRUMENTS용 데이터베이스 테이블 신설
- Instruments Table Column:
TagName,
Type:
- Flow Transmitter
- (Type_sub) : Coriollis Mass Flowmeter
- (Type_sub) : Variable Area Flowmeter
- (Type_sub) : Rotameter
- (Type_sub) : Magnetic Flowmeter
- (Type_sub) :
- (Type_sub) :
- (Type_sub) :
- (Type_sub) :
- Pressure Transmitter
- (Type_sub) : Absolute Pressure Transmitter (Vacuum)
- (Type_sub) : Gauge Pressure Transmitter
- (Type_sub) : DP Transmitter
- Level Transmitter
- (Type_sub) : Remote Sealed DP Transmitter
- (Type_sub) : Float Level Transmitter
- (Type_sub) :
- Temperature Sensor
- (Type_sub) : R.T.D (pt100)
- (Type_sub) : Thermocople type k
- (Type_sub) :
- (Type_sub) :
- Control Valve
- (Type_sub) : Globe 2 Way
- (Size) : 25A
- (Action) : Fail Close (Air to Open) / Fail Open (Air to Close)
- (Type_sub) :
- (Type_sub) :
- On-Off Valve
- (Type_sub) :
- (Type_sub) :
- Pressure Safety Vavle
- (Type_sub) :
- Pressure Relief Valve,
- (Type_sub) :
Type_sub,
Range_Max,
Range_Calibrated,
Model_No,
Installed_at,
Repaired_at,
Repair_history,
Last_Calibrated_at,
Recommended_Spare_parts
Doc_No,
TagName,
DataSheet,
Drawings,
NamePlate, : (photo),
Manual_No : (pdf file, numbering rule needed)

View File

@@ -1,104 +0,0 @@
import networkx as nx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Dict, List, Optional
import uvicorn
import json
import os
app = FastAPI(title="P&ID Analysis Engine")
# 전역 변수로 그래프 및 매핑 데이터 로드
TOPOLOGY_FILE = "futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json"
MAPPING_FILE = "futurePlan/End-to-End P&ID Graph Pipeline/pid_final_mapping.json"
topology_graph = nx.DiGraph()
tag_mapping = {}
def load_data():
global topology_graph, tag_mapping
try:
if os.path.exists(TOPOLOGY_FILE):
with open(TOPOLOGY_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
# NetworkX 그래프 생성
for node in data.get('nodes', []):
topology_graph.add_node(node['id'], **node)
for edge in data.get('edges', []):
topology_graph.add_edge(edge['source'], edge['target'], **edge)
print(f"Successfully loaded topology from {TOPOLOGY_FILE}")
if os.path.exists(MAPPING_FILE):
with open(MAPPING_FILE, 'r', encoding='utf-8') as f:
tag_mapping = json.load(f)
print(f"Successfully loaded mapping from {MAPPING_FILE}")
except Exception as e:
print(f"Error loading data: {e}")
@app.on_event("startup")
async def startup_event():
load_data()
class ImpactRequest(BaseModel):
nodeId: str
class ImpactResult(BaseModel):
startNode: str
impactedNodes: Dict[str, int] # { nodeId: depth }
path: List[List[str]]
def get_propagation_path_with_flow(graph, start_node):
"""
엣지의 방향성(flow_direction)과 상태(valve_status)를 고려한 실제 영향 전파 경로 추출
"""
if start_node not in graph:
return {}
# 1. 유효한 엣지만 필터링 (방향이 forward이고 밸브가 open인 경로)
# 실제 데이터에 flow_direction이나 valve_status가 없을 경우를 대비해 기본값 설정
valid_edges = [
(u, v) for u, v, d in graph.edges(data=True)
if d.get('flow_direction', 'forward') == 'forward'
and d.get('valve_status', 'open') == 'open'
]
filtered_graph = nx.DiGraph()
filtered_graph.add_edges_from(valid_edges)
# 2. 전파 단계별 노드 추출 (BFS)
try:
propagation_levels = nx.single_source_shortest_path_length(filtered_graph, start_node)
return propagation_levels
except Exception:
return {}
@app.get("/impact/{nodeId}")
async def analyze_impact(nodeId: str):
if nodeId not in topology_graph:
raise HTTPException(status_code=404, detail=f"Node {nodeId} not found in topology")
impact_map = get_propagation_path_with_flow(topology_graph, nodeId)
# 경로 추출 (시각화를 위해 간단하게 모든 영향 노드로의 최단 경로 포함)
paths = []
for target in impact_map.keys():
if target != nodeId:
try:
path = nx.shortest_path(topology_graph, source=nodeId, target=target)
paths.append(path)
except nx.NetworkXNoPath:
continue
return {
"startNode": nodeId,
"impactedNodes": impact_map,
"paths": paths
}
@app.get("/health")
async def health_check():
return {"status": "healthy", "nodes": topology_graph.number_of_nodes(), "edges": topology_graph.number_of_edges()}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -1,188 +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)
# --- Execution Block ---
if __name__ == "__main__":
# 테스트 파일 경로 (환경에 맞게 수정)
input_dxf = "futurePlan/End-to-End P&ID Graph Pipeline/No-10_Plant_PID.dxf"
output_json = "futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json"
print(f"Starting extraction from {input_dxf}...")
try:
extractor = PidGeometricExtractor(input_dxf)
saved_path = extractor.extract_and_save(output_json)
print(f"Successfully saved geometric data to {saved_path}")
except Exception as e:
print(f"Extraction failed: {e}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +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_key: str = None):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = AsyncOpenAI(api_key=api_key) if api_key else None
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 Key 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="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: 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) 정의
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"

View File

@@ -1,190 +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 []
if config:
self.config = config
else:
try:
with open('futurePlan/End-to-End P&ID Graph Pipeline/topology_config.json', 'r') as f:
self.config = json.load(f)
except Exception:
self.config = {'dist_threshold': 50.0, 'tag_threshold': 100.0, 'merge_threshold': 2.0}
self.G = nx.DiGraph() # 방향성 그래프 생성
def build_graph(self):
# 1. 노드 병합 및 추가 (Merging)
self.merged_data = self._merge_nodes()
for item in self.merged_data:
bbox_vals = item['bbox']
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) [개선: Proximity 기반]
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
for line_id in lines:
# 저장된 merged_data에서 coordinates 찾기
original_item = next((item for item in self.merged_data if item['entity_id'] == line_id), None)
if not original_item:
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)
connected_nodes = []
for eq_id in equipments:
eq_bbox = self.G.nodes[eq_id]['bbox']
# End-point뿐만 아니라 Line 전체와 BBox 간의 최단 거리 측정
if 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 _merge_nodes(self) -> List[Dict[str, Any]]:
"""기하학적으로 거의 동일한 노드들을 병합하여 그래프 단순화"""
if not self.data:
return []
merge_threshold = self.config.get('merge_threshold', 2.0)
merged = []
visited = set()
for i in range(len(self.data)):
if i in visited:
continue
current = self.data[i]
current_bbox = box(*(current['bbox']['min_x'], current['bbox']['min_y'], current['bbox']['max_x'], current['bbox']['max_y']))
# 동일 타입이면서 BBox 거리가 매우 가까운 노드들 탐색
cluster = [current]
visited.add(i)
for j in range(i + 1, len(self.data)):
if j in visited:
continue
target = self.data[j]
if target['entity_type'] != current['entity_type']:
continue
target_bbox = box(*(target['bbox']['min_x'], target['bbox']['min_y'], target['bbox']['max_x'], target['bbox']['max_y']))
if current_bbox.distance(target_bbox) < merge_threshold:
cluster.append(target)
visited.add(j)
# 클러스터 대표값 설정 (첫 번째 노드 기준, BBox는 합집합으로 확장)
if len(cluster) > 1:
# BBox 합집합 계산
min_x = min(c['bbox']['min_x'] for c in cluster)
min_y = min(c['bbox']['min_y'] for c in cluster)
max_x = max(c['bbox']['max_x'] for c in cluster)
max_y = max(c['bbox']['max_y'] for c in cluster)
representative = cluster[0].copy()
representative['bbox'] = {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y}
# 병합된 원본 ID 리스트 저장
representative['merged_ids'] = [c['entity_id'] for c in cluster]
merged.append(representative)
else:
merged.append(current)
return merged
def save_graph(self, output_path: str):
"""그래프 구조를 JSON 형태로 저장 (NetworkX의 node_link_data 활용) {
"nodes": [...],
"links": [...]
}"""
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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +0,0 @@
import json
import sys
import os
# 경로 설정을 위해 현재 파일의 디렉토리를 sys.path에 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from pid_geometric_extractor import PidGeometricExtractor
from pid_topology_builder import PidTopologyBuilder, analyze_impact
def run_pipeline():
# 1. 경로 설정 (현재 디렉토리 기준 상대 경로)
input_dxf = os.path.join(current_dir, "No-10_Plant_PID.dxf")
geo_json_path = os.path.join(current_dir, "shared_geo_data.json")
graph_json_path = os.path.join(current_dir, "pid_graph_topology.json")
print("--- Phase 1: Geometric Extraction ---")
try:
extractor = PidGeometricExtractor(input_dxf)
extractor.extract_and_save(geo_json_path)
print(f"Geometric data saved to {geo_json_path}")
except Exception as e:
print(f"Phase 1 failed: {e}")
return
print("\n--- Phase 2: Topology Modeling ---")
try:
with open(geo_json_path, 'r', encoding='utf-8') as f:
geometric_data = json.load(f)
# 테스트를 위해 all_extracted_tags는 빈 리스트로 전달
# config를 None으로 전달하여 topology_config.json 설정을 사용하도록 함
builder = PidTopologyBuilder(
geometric_data=geometric_data,
all_extracted_tags=[],
config=None
)
builder.build_graph()
# 위상 검증
validation = builder.validate_topology()
print(f"Topology Validation: {validation}")
# 그래프 저장
builder.save_graph(graph_json_path)
print(f"Graph topology saved to {graph_json_path}")
# 영향도 분석 테스트 (노드가 존재하는 경우)
if validation['node_count'] > 0:
sample_node = list(builder.G.nodes())[0]
impacted = analyze_impact(builder.G, sample_node)
print(f"Impact analysis for node {sample_node}: {impacted}")
except Exception as e:
print(f"Phase 2 failed: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
run_pipeline()

View File

@@ -1,134 +0,0 @@
import json
import sys
import os
import asyncio
import networkx as nx
# 경로 설정을 위해 현재 파일의 디렉토리를 sys.path에 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from pid_geometric_extractor import PidGeometricExtractor
from pid_topology_builder import PidTopologyBuilder
from pid_intelligent_mapper import IntelligentMapper, validate_mapping
async def run_full_pipeline():
# 1. 경로 설정
input_dxf = os.path.join(current_dir, "No-10_Plant_PID.dxf")
geo_json_path = os.path.join(current_dir, "shared_geo_data.json")
graph_json_path = os.path.join(current_dir, "pid_graph_topology.json")
mapping_result_path = os.path.join(current_dir, "pid_final_mapping.json")
# --- Phase 1: Geometric Extraction ---
print("\n--- Phase 1: Geometric Extraction ---")
try:
extractor = PidGeometricExtractor(input_dxf)
extractor.extract_and_save(geo_json_path)
print(f"Geometric data saved to {geo_json_path}")
except Exception as e:
print(f"Phase 1 failed: {e}")
return
# --- Phase 2: Topology Modeling ---
print("\n--- Phase 2: Topology Modeling ---")
try:
with open(geo_json_path, 'r', encoding='utf-8') as f:
geometric_data = json.load(f)
builder = PidTopologyBuilder(
geometric_data=geometric_data,
all_extracted_tags=[],
config={'dist_threshold': 50.0, 'tag_threshold': 100.0}
)
builder.build_graph()
builder.save_graph(graph_json_path)
print(f"Graph topology saved to {graph_json_path}")
except Exception as e:
print(f"Phase 2 failed: {e}")
return
# --- Phase 3: Intelligent Mapping ---
print("\n--- Phase 3: Intelligent Mapping ---")
try:
# 1. 그래프 로드
with open(graph_json_path, 'r', encoding='utf-8') as f:
graph_data = json.load(f)
# NetworkX 그래프 복원 (node_link_data 형식 대응)
from networkx.readwrite import json_graph
G = json_graph.node_link_graph(graph_data)
# 2. 시스템 태그 리스트 (실제로는 API나 DB에서 가져와야 함)
# 테스트를 위한 가상 태그 리스트
system_tags = [
"PT-101.PV", "PT-102.PV", "FT-201.PV", "LT-301.PV",
"P-101.STATUS", "P-101.SPEED", "V-101.OPEN", "V-101.CLOSE",
"T-101.TEMP", "TK-101.LEVEL"
]
# 3. 매퍼 초기화 (API Key는 환경변수나 설정파일에서 가져오는 것을 권장)
api_key = os.getenv("OPENAI_API_KEY", "your-api-key-here")
mapper = IntelligentMapper(G, system_tags, api_key=api_key)
# 4. 노드 분류 및 매핑 실행
nodes = list(G.nodes())
transmitter_nodes = [n for n in nodes if "Transmitter" in G.nodes[n].get('type', '')]
valve_nodes = [n for n in nodes if "Valve" in G.nodes[n].get('type', '')]
equipment_nodes = [n for n in nodes if "Equipment" in G.nodes[n].get('type', '') or "Pump" in G.nodes[n].get('type', '')]
print(f"Mapping {len(transmitter_nodes)} transmitters, {len(valve_nodes)} valves, {len(equipment_nodes)} equipment...")
# 비동기 실행
results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes)
)
# 결과 통합
final_mapping_raw = {}
for res in results:
final_mapping_raw.update(res)
# 5. 검증 및 최종 결과 정리
# 가상 메타데이터 (실제로는 시스템에서 조회)
mock_metadata = {
"PT-101.PV": {"unit": "bar", "description": "Pressure Transmitter 101"},
"P-101.STATUS": {"unit": "", "description": "Pump 101 Status"},
}
final_results = []
for node_id, mapping in final_mapping_raw.items():
symbol_type = G.nodes[node_id].get('type', 'Unknown')
tag = mapping.resolved_tag
meta = mock_metadata.get(tag, {"unit": "", "description": ""})
is_valid, val_msg = validate_mapping(tag, symbol_type, meta)
final_results.append({
"node_id": node_id,
"symbol_type": symbol_type,
"original_text": G.nodes[node_id].get('value', ''),
"resolved_tag": tag,
"confidence": mapping.confidence,
"reason": mapping.reason,
"validation": {
"is_valid": is_valid,
"message": val_msg
}
})
# 6. 결과 저장
with open(mapping_result_path, 'w', encoding='utf-8') as f:
json.dump(final_results, f, indent=4, ensure_ascii=False)
print(f"Final mapping results saved to {mapping_result_path}")
print(f"Successfully mapped {len(final_results)} nodes.")
except Exception as e:
print(f"Phase 3 failed: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(run_full_pipeline())

View File

@@ -1,5 +0,0 @@
{
"dist_threshold": 20.0,
"tag_threshold": 60.0,
"merge_threshold": 2.0
}

Binary file not shown.

View File

@@ -1,3 +0,0 @@
1. 리모트 (웹브라우저 실행한 PC)에서 파일을 선택하면, 서버로 전달되지 않는다 ---> 추출시작시 에러남
2. 파일선택 버튼을 누르면 리모트 PC의 파일을 읽는다. 원격 서버의 파일은 읽히지 않는다.
3. 그러면 어쩌란 말인가 ????

View File

@@ -1,533 +0,0 @@
# P&ID 데이터베이스화 기능 통합 설계
## 📋 개요
DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.
---
## 🎯 목표
1. P&ID 도면에서 장비 정보를 추출
2. 추출된 정보를 PostgreSQL 로 저장
3. 기존 Experion 데이터와 연동
4. 웹에서 시각화 및 관리
---
## 🏗️ 아키텍처 설계
```
┌─────────────────────────────────────────────────────────────────────┐
│ ExperionCrawler │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Frontend UI │◄────►│ Web API │◄────►│ DB │ │
│ │ (app.js, .html)│ │ (Controllers) │ │ (Experion │ │
│ └─────────────────┘ └─────────────────┘ │ DbContext)│ │
│ │ │ └─────────────┘ │
│ └──────────────────────────┼────────────────────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ │ P&ID Extraction Service │ │
│ │ (AI 기반 추출) │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ ┌───────────────▼───────────────┐ │
│ │ Image/Text Preprocessing │ │
│ │ (PDF → PNG → OCR) │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ ┌───────────────▼───────────────┐ │
│ │ Claude Vision API │ │
│ │ (필드 추출) │ │
│ └───────────────┬───────────────┘ │
└────────────────────────────────────┼────────────────────────────────┘
┌─────────────────────┐
│ PostgreSQL DB │
│ ┌───────────────┐ │
│ │ pid_equipment │ │
│ │ Active │ │
│ │ Audit Log │ │
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ experion_tags │ │
│ │ Active │ │
│ └───────────────┘ │
└─────────────────────┘
```
---
## 📁 폴더 구조
```
ExperionCrawler/
├── src/
│ ├── Web/
│ │ └── Controllers/
│ │ ├── ExperionControllers.cs (기존)
│ │ └── PidController.cs (추가)
│ ├── Core/
│ │ ├── Application/
│ │ │ ├── Interfaces/
│ │ │ │ ├── IExperionServices.cs (기존)
│ │ │ │ ├── IPidExtractorService.cs (추가)
│ │ │ │ └── ITagMappingService.cs (추가)
│ │ │ ├── Services/
│ │ │ │ ├── TextToSqlService.cs (기존)
│ │ │ │ ├── PidExtractorService.cs (추가)
│ │ │ │ ├── AxImportGenerator.cs (추가)
│ │ │ │ └── TagMappingService.cs (추가)
│ │ │ └── Dtos/
│ │ │ ├── PidEquipmentDto.cs (추가)
│ │ │ └── TagCountDto.cs (추가)
│ │ └── Domain/
│ │ ├── Entities/
│ │ │ ├── PidEquipment.cs (추가)
│ │ │ └── PidAuditLog.cs (추가)
│ │ └── ValueObjects/
│ │ ├── ConfidenceScore.cs (추가)
│ │ └── MeasurementUnit.cs (추가)
│ └── Infrastructure/
│ ├── Database/
│ │ ├── ExperionDbContext.cs (기존 - 확장)
│ │ └── PidDbContext.cs (추가)
│ └── OpcUa/
│ └── (기존)
├── futurePlan/
│ ├── temp/
│ │ ├── pid_extractor.py (AI 추출기)
│ │ ├── schema.sql (추구용 DB 스키마)
│ │ └── requirements.txt (Python 의존성)
│ └── P&ID_데이터베이스화_통합_설계.md
├── src/Web/wwwroot/
│ └── js/
│ └── app.js (기존 - 확장)
```
---
## 🔌 데이터베이스 스키마 확장
### PidDbContext.cs (새 파일)
```csharp
using Microsoft.EntityFrameworkCore;
namespace ExperionCrawler.Infrastructure.Database;
public class PidDbContext : DbContext
{
public DbSet<PidEquipment> PidEquipment { get; set; }
public DbSet<PidAuditLog> PidAuditLog { get; set; }
// 기존 ExperionDbContext와 통합
public DbSet<TagInfo> TagInfo { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// PidEquipment 설정
modelBuilder.Entity<PidEquipment>(entity =>
{
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 => x => x.Confidence).HasPrecision(3, 2);
entity.Property(e => x => x.IsActive).HasDefaultValue(true);
// 태그 번호로 Experion과 연동
entity.HasOne(e => e.ExperionTag)
.WithMany(t => t.PidEquipments)
.HasForeignKey(e => e.ExperionTagId)
.OnDelete(DeleteBehavior.SetNull);
});
// PidAuditLog 설정
modelBuilder.Entity<PidAuditLog>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.UserId).HasMaxLength(100);
});
}
}
```
### 기존 ExperionDbContext.cs 확장
```csharp
public class ExperionDbContext : DbContext
{
// 기존 DbSet
// P&ID 데이터베이스용 DbSet 추가
public DbSet<PidEquipment> PidEquipment { get; set; }
public DbSet<PidAuditLog> PidAuditLog { get; set; }
// Expose PidDbContext connection string if needed
public string PidConnectionString => Configuration.GetConnectionString("PidDb");
}
```
---
## 🎯 필드 매핑
### P&ID 추출 필드 ↔ DB 필드
| 추출 필드 (AI) | DB 필드 (PidEquipment) | 설명 |
|---------------------|--------------------------|----------------------------|
| Tag No. | TagNo | 태그번호 (FT-1001, PT-2003) |
| Equipment Name | EquipmentName | 장비명 (Flow Transmitter) |
| Instrument Type | InstrumentType | 계기타입 (FT, PT, LT) |
| Line Number | LineNumber | 라인번호 (6"-P-1001-A1A) |
| P&ID Drawing No. | PidDrawingNo | 도면번호 (P&ID-100-001) |
| Confidence | Confidence | 신뢰도 (0.0~1.0) |
---
## 💻 PidExtractorService.cs (핵심 서비스)
```csharp
using Azure.AI.Vision.ImageAnalysis;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
namespace ExperionCrawler.Core.Application.Services;
public class PidExtractorService : IPidExtractorService
{
private readonly string _anthropicApiKey;
private readonly BinaryData _systemPrompt;
private readonly PidDbContext _pidDbContext;
public PidExtractorService(
IConfiguration configuration,
PidDbContext pidDbContext)
{
_anthropicApiKey = configuration["Anthropic:ApiKey"]!;
_pidDbContext = pidDbContext;
_systemPrompt = BinaryData.FromString(GetPrompt());
}
public async Task<PidExtractionResult> ExtractFromFile(string filePath, bool useImageMode = false)
{
// 1. 파일 텍스트/이미지 변환
var imageData = await PreprocessFile(filePath, useImageMode);
// 2. Claude Vision API 분석
using var client = new ImageAnalysisClient(new Uri("https://vision.api.anthropic.com"),
new System.ClientModel.ApiKeyCredential(_anthropicApiKey));
var result = await client.AnalyzeAsync(ImageAnalyzerOptions.Create(
BinaryData.FromBytes(imageData),
ImageAnalysisFeature.RecognizedText | ImageAnalysisFeature.DenseCaption
));
// 3. JSON 파싱 및 검증
var extractedItems = ParseExtractedData(result.Value.Text);
// 4. DB 저장
var dbItems = new List<PidEquipment>();
foreach (var item in extractedItems)
{
// 기존 태그와 매핑 확인
var existingTag = await FindMatchingExperionTag(item.TagNo);
var pidEquipment = new PidEquipment
{
TagNo = item.TagNo,
EquipmentName = item.EquipmentName,
InstrumentType = item.InstrumentType,
LineNumber = item.LineNumber,
PidDrawingNo = item.PidDrawingNo,
Confidence = item.Confidence,
ExperionTagId = existingTag?.Id,
ExtractedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
dbItems.Add(pidEquipment);
}
await _pidDbContext.PidEquipment.AddRangeAsync(dbItems);
await _pidDbContext.SaveChangesAsync();
return new PidExtractionResult
{
TotalCount = dbItems.Count,
ConfidenceItems = dbItems.Count(i => i.Confidence >= 0.7),
LowConfidenceItems = dbItems.Count(i => i.Confidence < 0.5),
CsvPath = $"output/pid_extracted_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv",
ExcelPath = $"output/pid_AX_import_{DateTime.UtcNow:yyyyMMdd_HHmmss}.xlsx"
};
}
private string GetPrompt()
{
return @"
Analyze the P&ID (Piping and Instrumentation Diagram) drawing and extract the following information.
Return ONLY pure JSON (no markdown, no explanations):
{
""items"": [
{
""tagNo"": ""Tag number (e.g., FT-1001, PT-2003, E-101, CV-123)"",
""equipmentName"": ""Full equipment name (e.g., ""Flow Transmitter"")"",
""instrumentType"": ""Short type code (FT, PT, LT, CV, E, V, P, etc.)"",
""lineNumber"": ""Line reference (e.g., ""6\""-P-1001-A1A"")"",
""pidDrawingNo"": ""P&ID drawing number (if identifiable)""
}
],
""note"": ""Any items that cannot be clearly identified"" // optional
}";
}
}
```
---
## 🌐 PidController.cs (Web API)
```csharp
using Microsoft.AspNetCore.Mvc;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Dtos;
namespace ExperionCrawler.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PidController : ControllerBase
{
private readonly IPidExtractorService _pidExtractor;
private readonly IExperionServices _experionServices;
public PidController(IPidExtractorService pidExtractor,
IExperionServices experionServices)
{
_pidExtractor = pidExtractor;
_experionServices = experionServices;
}
[HttpPost("extract")]
public async Task<IActionResult> ExtractFromFile(IFormFile file, bool useImageMode = false)
{
if (file == null || file.Length == 0)
return BadRequest("파일이 없습니다.");
using var stream = file.OpenReadStream();
var result = await _pidExtractor.ExtractFromStream(stream, file.FileName, useImageMode);
return Ok(new
{
totalCount = result.TotalCount,
confidenceItems = result.ConfidenceItems,
lowConfidenceItems = result.LowConfidenceItems,
csvPath = result.CsvPath,
excelPath = result.ExcelPath
});
}
[HttpGet("equipment")]
public async Task<IActionResult> GetEquipment(string tagNo = null, int page = 1, int pageSize = 50)
{
var query = _pidExtractor.GetQueryable();
if (!string.IsNullOrEmpty(tagNo))
query = query.Where(e => e.TagNo.Contains(tagNo));
var total = await query.CountAsync();
var items = await query
.OrderByDescending(e => e.ExtractedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return Ok(new
{
total,
page,
pageSize,
confidenceRate = items.Sum(e => e.Confidence) / (items.Count > 0 ? items.Count : 1),
items = items.Select(e => new
{
id = e.Id,
tagNo = e.TagNo,
equipmentName = e.EquipmentName,
instrumentType = e.InstrumentType,
lineNumber = e.LineNumber,
pidDrawingNo = e.PidDrawingNo,
confidence = e.Confidence,
isActive = e.IsActive
})
});
}
[HttpGet("statistics")]
public async Task<IActionResult> GetStatistics()
{
var typeCount = await _pidExtractor.GetInstrumentTypeCount();
var confidenceRange = await _pidExtractor.GetConfidenceDistribution();
var drawingCount = await _pidExtractor.GetDrawingCount();
return Ok(new
{
typeCount,
confidenceRange,
drawingCount
});
}
[HttpPut("{id}/confidence")]
public async Task<IActionResult> UpdateConfidence(long id, decimal confidence)
{
if (confidence < 0 || confidence > 1)
return BadRequest("신뢰도는 0~1 사이어야 합니다.");
await _pidExtractor.UpdateConfidence(id, confidence);
return Ok(new { message = "신뢰도가 업데이트되었습니다." });
}
}
```
---
## 🎨 Frontend UI 확장 (app.js)
```javascript
// P&ID 추출 및 관리 기능
class PidManager {
constructor() {
this.extractorFileInput = document.getElementById('pid-file-input');
this.extractActionBtn = document.getElementById('extract-pid-btn');
this.useImageMode = document.getElementById('use-image-mode');
this.bindEvents();
}
bindEvents() {
this.extractActionBtn.addEventListener('click', () => this.handleExtract());
this.useImageMode.addEventListener('change', (e) => {
this.extractActionBtn.textContent =
e.target.checked ? '이미지 모드로 추출' : '텍스트 모드로 추출';
});
}
async handleExtract() {
const file = this.extractorFileInput.files[0];
if (!file) {
alert('선택된 파일이 없습니다.');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('useImageMode', this.useImageMode.checked);
this.extractActionBtn.disabled = true;
this.extractActionBtn.textContent = '추출 중...';
try {
const response = await fetch('/api/pid/extract', {
method: 'POST',
body: formData
});
const result = await response.json();
this.showResult(result);
this.loadEquipmentList();
this.loadStatistics();
alert(`추출 완료! 총 ${result.totalCount}건 처리됨`);
} catch (error) {
console.error('추출 실패:', error);
alert('추출 중 오류가 발생했습니다.');
} finally {
this.extractActionBtn.disabled = false;
}
}
showResult(result) {
// 결과 표시 UI
alert(`${result.totalCount}${result.confidenceItems}건 신뢰도 높음`);
}
}
// 애플리케이션 초기화
document.addEventListener('DOMContentLoaded', () => {
new PidManager();
});
```
---
## 📝 작업 순서
### 단계 1: DB 구조 생성
1. [`PidDbContext.cs`](../src/Infrastructure/Database/PidDbContext.cs) 생성
2. [`PidEquipment.cs`](../src/Core/Domain/Entities/PidEquipment.cs) 엔티티 생성
3. [`PidAuditLog.cs`](../src/Core/Domain/Entities/PidAuditLog.cs) 엔티티 생성
4. [`Program.cs`](../src/Web/Program.cs)에 서비스 등록 (`AddDbContext<PidDbContext>`)
### 단계 2: 커맨드라인 도구 개발
1. [`PidExtractorService.cs`](../src/Core/Application/Services/PidExtractorService.cs) 개발
2. CLIP 기반 추출기 연동 (Python `pid_extractor.py`)
3. 테스트용 DXF/PDF 파일 생성
4. 통합 테스트 수행
### 단계 3: Web API 개발
1. [`IPidExtractorService.cs`](../src/Core/Application/Interfaces/IPidExtractorService.cs) 인터페이스 정의
2. [`PidController.cs`](../src/Web/Controllers/PidController.cs) 개발
3. CSV/Excel 다운로드 엔드포인트
4. 검증된 데이터 필터링 기능
### 단계 4: Firebase 연동
1. P&ID 추출된 태그와 Experion 실시간 태그 매핑
2. 실시간 값 업데이트 동기화
### 단계 5: Frontend UI
1. P&ID 추출 화면 추가
2. 장비 목록 표시 및 필터링
3. 신뢰도 시각화
4. 검토 필요 항목 표시
### 단계 6: 최적화 및 모듈화
1. PDF→이미지 변환 속도 최적화
2. 대용량 파일 처리 스트리밍
3. API 응답 최적화
---
## ⚠️ 주의사항
1. **권한 문제**: `/temp/` 디렉토리에 PDF 변환된 이미지를 저장하므로 쓰기 권한 확인 필요
2. **API 비용**: Claude Vision API 사용 시 비용 발생 가능 → 캐싱 전략 필요
3. **대용량 파일**: DXF 이미지 모드는 느림 → 사용자에게 선택권 제공
4. **네트워크**: Anthropic API 사용을 위해 외부 연결 필요
---
## 📊 성공 지표
- DXF/PDF 파일로부터 평균 성공 추출률 80% 이상
- 100MB 이하 파일 처리 시 응답 시간 30초 이내
- 신뢰도 0.7 이상 항목 자동 검증 기능
- Redis 캐싱으로 API 요청 50% 감소
---
## 🚀 다음 단계
1. 현재 코드 베이스 검토 (`Program.cs`, `ExperionDbContext.cs`)
2. `PID REST API` 기능 우선 구현
3. Frontend 인터페이스
4. Firebase 실시간 연동
5. 모델 최적화 및 테스트

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
using System;
using System.Text;
using UglyToad.PdfPig;
using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor;
class Program
{
static void Main(string[] args)
{
string pdfPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100.pdf";
string markdownPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100-extracted.md";
Console.WriteLine($"PdfPig 버전: {typeof(PdfDocument).Assembly.GetName().Version}");
Console.WriteLine($"PDF 파일: {pdfPath}");
Console.WriteLine();
using (var document = PdfDocument.Open(pdfPath))
{
var sb = new StringBuilder();
sb.AppendLine("# plant-9100.pdf 추출 결과");
sb.AppendLine();
sb.AppendLine("## PDF 정보");
sb.AppendLine();
sb.AppendLine($"- **버전**: {document.Version}");
sb.AppendLine($"- **페이지 수**: {document.NumberOfPages}");
sb.AppendLine($"- **제목**: {document.Information.Title ?? "()"}");
sb.AppendLine($"- **작성자**: {document.Information.Author ?? "()"}");
sb.AppendLine($"- **생성 프로그램**: {document.Information.Producer ?? "()"}");
sb.AppendLine($"- **생성일**: {document.Information.CreationDate ?? "()"}");
sb.AppendLine();
foreach (var page in document.GetPages())
{
sb.AppendLine($"## 페이지 {page.Number}");
sb.AppendLine();
sb.AppendLine($"- **크기**: {page.Width} x {page.Height}");
sb.AppendLine();
string text = page.Text;
sb.AppendLine("### 추출 텍스트");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine(text);
sb.AppendLine("```");
sb.AppendLine();
Console.WriteLine($"페이지 {page.Number} 추출 완료 ({text.Length}자)");
}
System.IO.File.WriteAllText(markdownPath, sb.ToString());
Console.WriteLine();
Console.WriteLine($"전체 추출 완료. 결과 저장: {markdownPath}");
}
}
}

View File

@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PdfPig" Version="0.1.9" />
</ItemGroup>
</Project>

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""
PdfPig를 사용하여 plant-9100.pdf에서 모든 문자열을 추출하여 마크다운 파일로 저장
"""
import subprocess
import sys
# C# 코드 작성
csharp_code = '''
using System;
using System.Text;
using UglyToad.PdfPig;
using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor;
class Program
{
static void Main(string[] args)
{
string pdfPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100.pdf";
string markdownPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100-extracted.md";
Console.WriteLine($"PdfPig 버전: {typeof(PdfDocument).Assembly.GetName().Version}");
Console.WriteLine($"PDF 파일: {pdfPath}");
Console.WriteLine();
using (var document = PdfDocument.Open(pdfPath))
{
var sb = new StringBuilder();
sb.AppendLine("# plant-9100.pdf 추출 결과");
sb.AppendLine();
sb.AppendLine("## PDF 정보");
sb.AppendLine();
sb.AppendLine($"- **버전**: {document.Version}");
sb.AppendLine($"- **페이지 수**: {document.NumberOfPages}");
sb.AppendLine($"- **제목**: {document.Information.Title ?? \"(없음)\"}");
sb.AppendLine($"- **작성자**: {document.Information.Author ?? \"(없음)\"}");
sb.AppendLine($"- **생성 프로그램**: {document.Information.Producer ?? \"(없음)\"}");
sb.AppendLine($"- **생성일**: {document.Information.CreationDate ?? \"(없음)\"}");
sb.AppendLine();
foreach (var page in document.GetPages())
{
sb.AppendLine($"## 페이지 {page.Number}");
sb.AppendLine();
sb.AppendLine($"- **크기**: {page.Width} x {page.Height}");
sb.AppendLine();
string text = page.Text;
sb.AppendLine("### 추출 텍스트");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine(text);
sb.AppendLine("```");
sb.AppendLine();
Console.WriteLine($"페이지 {page.Number} 추출 완료 ({text.Length}자)");
}
System.IO.File.WriteAllText(markdownPath, sb.ToString());
Console.WriteLine();
Console.WriteLine($"전체 추출 완료. 결과 저장: {markdownPath}");
}
}
}
'''
# C# 파일 저장
with open('futurePlan/extract_pdf.cs', 'w') as f:
f.write(csharp_code)
print("C# 코드 작성 완료: futurePlan/extract_pdf.cs")
# 프로젝트 파일 생성
project_code = '''<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PdfPig" Version="0.1.9" />
</ItemGroup>
</Project>
'''
with open('futurePlan/extract_pdf.csproj', 'w') as f:
f.write(project_code)
print("프로젝트 파일 작성 완료: futurePlan/extract_pdf.csproj")
# 빌드 및 실행
print()
print("빌드 및 실행 중...")
result = subprocess.run(
['dotnet', 'run', '--project', 'futurePlan/extract_pdf.csproj', '--configuration', 'Release'],
cwd='/home/windpacer/projects/ExperionCrawler',
capture_output=True,
text=True
)
print(result.stdout)
if result.stderr:
print("STDERR:", result.stderr)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,795 +0,0 @@
# P&ID AX 코딩 플랜 v4 (MCP 기반 로컬 LLM 구현)
> **수정일**: 2026-04-30 (v4 완료 — 백엔드 완료, MCP 툴 추가)
> **수정일**: 2026-04-30 (v4.1 — 프론트엔드 완료)
> **기준**: `p&id_ax_coding_plan3.md` 반영
> **목적**: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현
**백엔드 완료 요약**:
- 단계 1-8 완료 (P&ID 도메인, 서비스, 컨트롤러, DB, Program.cs 등록)
- MCP 툴 추가: `extract_pid_tags`, `match_pid_tags` (mcp-server/server.py)
- 빌드 검증: `dotnet build` 성공 (0 에러)
- API 엔드포인트: `/api/pid/*` (13개)
**프론트엔드 완료 요약 (v4.1)**:
- 단계 9-12 완료 (index.html, app.js, style.css, appsettings.json)
- P&ID 추출 탭 추가 (11개 탭)
- 추출 결과 테이블 + 페이지네이션
- CSV/Excel 내보내기 기능
- 통계 정보 표시
---
## 📋 개요
DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.
**주요 변경사항**:
- ❌ Anthropic Cloud Vision 제거
- ✅ MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8) 사용
- ✅ netDxf (DXF 파싱) + PdfPig (PDF 텍스트 추출) 사용
---
## 🎯 목표
1. P&ID 도면에서 장비 정보를 추출
2. 추출된 정보를 PostgreSQL 로 저장
3. 기존 Experion 데이터와 연동
4. 웹에서 시각화 및 관리
---
## ✅ 완료된 단계 (v4) — 백엔드 완료 (2026-04-30)
| 단계 | 내용 | 상태 |
|------|------|------|
| 1 | P&ID 도메인 엔티티 생성 (`PidEquipment`, `PidAuditLog`) | ✅ 완료 |
| 2 | DTOs 생성 (`PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos`) | ✅ 완료 |
| 3 | Interface 정의 (`IPidExtractorService`, `ITagMappingService`) | ✅ 완료 |
| 4 | TagMappingService 생성 | ✅ 완료 |
| 5 | PidExtractorService 생성 (Anthropic → MCP 로컬 LLM) | ✅ 완료 |
| 6 | Database Migration (`DbSet`, FK 설정) | ✅ 완료 |
| 7 | PidController 추가 (`ExperionPidController` - 13개 엔드포인트) | ✅ 완료 |
| 8 | Program.cs 등록 (`AddScoped<IPidExtractorService>`, `AddScoped<ITagMappingService>`) | ✅ 완료 |
---
## ✅ 완료된 단계 (v4) — MCP 툴 추가 (2026-04-30)
| 단계 | 내용 | 상태 |
|------|------|------|
| 9 | Python MCP 툴 `extract_pid_tags` 구현 | ✅ 완료 |
| 10 | Python MCP 툴 `match_pid_tags` 구현 | ✅ 완료 |
---
## ✅ 완료된 단계 (v4) — 상세
### 단계 1: P&ID 도메인 엔티티 생성 ✅
**파일**: [`src/Core/Domain/Entities/PidEquipment.cs`](../src/Core/Domain/Entities/PidEquipment.cs)
**파일**: [`src/Core/Domain/Entities/PidAuditLog.cs`](../src/Core/Domain/Entities/PidAuditLog.cs)
```csharp
// PidEquipment.cs
[Table("pid_equipment")]
public class PidEquipment
{
public long Id { get; set; }
[Required]
[MaxLength(50)]
public string TagNo { get; set; } = string.Empty;
[MaxLength(200)]
public string? EquipmentName { get; set; }
[MaxLength(10)]
public string? InstrumentType { get; set; }
[MaxLength(100)]
public string? LineNumber { get; set; }
[MaxLength(50)]
public string? PidDrawingNo { get; set; }
public double Confidence { get; set; }
public bool IsActive { get; set; } = true;
public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public int? ExperionTagId { get; set; }
public RealtimePoint? ExperionTag { get; set; }
}
// PidAuditLog.cs
[Table("pid_audit_log")]
public class PidAuditLog
{
public long Id { get; set; }
[MaxLength(50)]
public string Source { get; set; } = string.Empty;
[MaxLength(50)]
public string Action { get; set; } = string.Empty;
[MaxLength(50)]
public string TargetTagNo { get; set; } = string.Empty;
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public DateTime LoggedAt { get; set; } = DateTime.UtcNow;
}
```
> **현황**: 이미 코드베이스에 완료됨. `UpdatedAt`이 `DateTime?` (nullable)으로 정의되어 있음.
---
### 단계 2: DTOs 생성 ✅
**파일**: [`src/Core/Application/DTOs/PidEquipmentDto.cs`](../src/Core/Application/DTOs/PidEquipmentDto.cs)
**파일**: [`src/Core/Application/DTOs/PidExtractionResult.cs`](../src/Core/Application/DTOs/PidExtractionResult.cs)
**파일**: [`src/Core/Application/DTOs/TagMappingDtos.cs`](../src/Core/Application/DTOs/TagMappingDtos.cs)
```csharp
// PidEquipmentDto.cs
public record PidEquipmentDto(
long Id,
string TagNo,
string? EquipmentName,
string? InstrumentType,
string? LineNumber,
string? PidDrawingNo,
double Confidence,
bool IsActive,
DateTime ExtractedAt,
DateTime? UpdatedAt,
int? ExperionTagId,
string? ExperionTagName);
// PidExtractionResult.cs
public record PidExtractionResult(
int TotalCount,
int ConfidenceItems,
int LowConfidenceItems);
// TagMappingDtos.cs
public record TagMappingResult
{
public long PidEquipmentId { get; set; }
public string TagNo { get; set; } = string.Empty;
public string? EquipmentName { get; set; }
public string? InstrumentType { get; set; }
public string? LineNumber { get; set; }
public string? PidDrawingNo { get; set; }
public double Confidence { get; set; }
public bool IsActive { get; set; }
public int? ExperionTagId { get; set; }
public string? ExperionTagName { get; set; }
public string? ExperionNodeId { get; set; }
}
public record CreateMappingRequest(long PidEquipmentId, int ExperionTagId);
public record UpdateMappingRequest(int? ExperionTagId, bool? IsActive);
```
> **현황**: 이미 코드베이스에 완료됨. `ExtractedItem`/`MappingItem`은 `PidExtractorService.cs` 내부에 `public class`로 정의됨.
---
### 단계 3: Interface 정의 ✅
**파일**: [`src/Core/Application/Interfaces/IExperionServices.cs`](../src/Core/Application/Interfaces/IExperionServices.cs)
```csharp
// P&ID Extractor
public interface IPidExtractorService
{
Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false);
Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false);
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(string? tagNo, int page, int pageSize);
Task<PidEquipment?> GetByIdAsync(long id);
Task UpdateConfidenceAsync(long id, double confidence);
Task ActivateAsync(long id);
Task DeactivateAsync(long id);
Task<int> GetTotalCountAsync();
Task<int> GetConfidenceItemsCountAsync();
Task<int> GetLowConfidenceItemsCountAsync();
Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
Task<int> GetDrawingCountAsync();
Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items);
}
// P&ID Tag Mapping
public interface ITagMappingService
{
Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
Task<TagMappingResult?> GetMappingByIdAsync(long id);
Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
Task UpdateMappingAsync(long id, UpdateMappingRequest request);
Task ClearMappingAsync(long id);
Task<int> GetUnmappedCountAsync();
Task<int> GetMappedCountAsync();
Task<IEnumerable<string>> GetAvailableTagsAsync();
}
```
> **현황**: 이미 코드베이스에 완료됨. `IExperionServices.cs`에 `IPidExtractorService`, `ITagMappingService` 모두 정의됨.
---
### 단계 4: TagMappingService 생성 ✅
**파일**: [`src/Core/Application/Services/TagMappingService.cs`](../src/Core/Application/Services/TagMappingService.cs)
```csharp
public class TagMappingService : ITagMappingService
{
private readonly ExperionDbContext _dbContext;
public TagMappingService(ExperionDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize)
{
var query = from pe in _dbContext.PidEquipment
join rt in _dbContext.RealtimePoints
on pe.ExperionTagId equals rt.Id into joined
from rt in joined.DefaultIfEmpty()
select new TagMappingResult { ... };
var total = await query.CountAsync();
var items = await query
.OrderByDescending(e => e.Confidence)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (total, items);
}
public async Task<TagMappingResult?> GetMappingByIdAsync(long id) { ... }
public async Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request) { ... }
public async Task UpdateMappingAsync(long id, UpdateMappingRequest request) { ... }
public async Task ClearMappingAsync(long id) { ... }
public async Task<int> GetUnmappedCountAsync()
=> await _dbContext.PidEquipment.CountAsync(e => e.ExperionTagId == null);
public async Task<int> GetMappedCountAsync()
=> await _dbContext.PidEquipment.CountAsync(e => e.ExperionTagId != null);
public async Task<IEnumerable<string>> GetAvailableTagsAsync()
{
var mappedTagIds = await _dbContext.PidEquipment
.Where(e => e.ExperionTagId != null)
.Select(e => e.ExperionTagId)
.ToListAsync();
return await _dbContext.RealtimePoints
.Where(t => !mappedTagIds.Contains(t.Id))
.Select(t => t.TagName)
.OrderBy(t => t)
.ToListAsync();
}
}
```
> **현황**: 이미 코드베이스에 완료됨. `GetAvailableTagsAsync()`가 이미 매핑된 태그를 제외한 목록만 반환하는 올바른 구현이 있음.
---
### 단계 5: PidExtractorService 생성 ✅
**파일**: [`src/Core/Application/Services/PidExtractorService.cs`](../src/Core/Application/Services/PidExtractorService.cs)
```csharp
public class PidExtractorService : IPidExtractorService
{
private readonly McpClient _mcp;
private readonly ExperionDbContext _dbContext;
private readonly ILogger<PidExtractorService> _logger;
public PidExtractorService(McpClient mcp, ExperionDbContext dbContext, ILogger<PidExtractorService> logger)
{
_mcp = mcp;
_dbContext = dbContext;
_logger = logger;
}
public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
{
await using var stream = File.OpenRead(filePath);
return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode);
}
public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
string text = ext switch
{
".dxf" => ExtractDxfText(stream),
".pdf" => ExtractPdfText(stream),
_ => throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)")
};
if (string.IsNullOrWhiteSpace(text))
return new PidExtractionResult(0, 0, 0);
// MCP → vLLM 태그 추출
var sourceType = ext.TrimStart('.');
var json = await _mcp.ExtractPidTagsAsync(text, sourceType);
var extractedItems = ParseJson(json);
if (extractedItems.Count == 0)
{
_logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName);
return new PidExtractionResult(0, 0, 0);
}
// MCP → vLLM 태그 매핑 제안
var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList();
var experionTagNames = await _dbContext.RealtimePoints.Select(r => r.TagName).ToListAsync();
var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
var mappings = ParseMappingJson(mappingJson);
// DB 저장
var dbItems = new List<PidEquipment>();
foreach (var item in extractedItems)
{
mappings.TryGetValue(item.TagNo, out var matched);
var experionTag = matched != null
? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
: await FindFallbackTagAsync(item.TagNo);
dbItems.Add(new PidEquipment
{
TagNo = item.TagNo,
EquipmentName = item.EquipmentName,
InstrumentType = item.InstrumentType,
LineNumber = item.LineNumber,
PidDrawingNo = item.PidDrawingNo,
Confidence = item.Confidence,
ExperionTagId = experionTag?.Id,
ExtractedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
}
await _dbContext.PidEquipment.AddRangeAsync(dbItems);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("P&ID 추출 완료: {Total}건 저장 (파일: {FileName})", dbItems.Count, fileName);
return new PidExtractionResult(
TotalCount: dbItems.Count,
ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5));
}
private string ExtractDxfText(Stream stream) { ... }
private string ExtractPdfText(Stream stream) { ... }
private List<ExtractedItem> ParseJson(string json) { ... }
private Dictionary<string, string> ParseMappingJson(string json) { ... }
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo) { ... }
// CRUD / 통계 / 내보내기 메서드들...
}
```
**내부 파싱용 모델**:
```csharp
public class ExtractedItem
{
public string TagNo { get; set; } = "";
public string? EquipmentName { get; set; }
public string? InstrumentType { get; set; }
public string? LineNumber { get; set; }
public string? PidDrawingNo { get; set; }
public double Confidence { get; set; } = 0.5;
}
public class MappingItem
{
public string PidTag { get; set; } = "";
public string? ExperionTag { get; set; }
public double Confidence { get; set; }
}
```
> **현황**: `PidExtractorService.cs`가 MCP 기반으로 완료됨. `netDxf`, `PdfPig`, `EPPlus` 패키지 추가 완료.
---
## 📦 추가된 패키지
| 패키지 | 버전 | 용도 |
|--------|------|------|
| netDxf | 2022.11.2 | DXF 파일 파싱 |
| PdfPig | 0.1.9 | PDF 텍스트 추출 |
| EPPlus | 7.4.2 | Excel 내보내기 |
---
## ✅ 완료된 단계 (v4) - 추가
### 단계 6: Database Migration ✅
**파일**: [`src/Infrastructure/Database/ExperionDbContext.cs`](../src/Infrastructure/Database/ExperionDbContext.cs)
```csharp
public class ExperionDbContext : DbContext
{
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// PidEquipment → RealtimePoint FK
modelBuilder.Entity<PidEquipment>(entity =>
{
entity.HasOne(e => e.ExperionTag)
.WithMany()
.HasForeignKey(e => e.ExperionTagId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}
```
> **현황**: 이미 코드베이스에 완료됨. `DbSet`과 FK 설정 모두 정상 구현됨.
---
### 단계 7: PidController 추가 ✅
**파일**: [`src/Web/Controllers/ExperionControllers.cs`](../src/Web/Controllers/ExperionControllers.cs) (810-985행)
```csharp
[ApiController]
[Route("api/pid")]
public class ExperionPidController : ControllerBase
{
private readonly IPidExtractorService _extractor;
private readonly ITagMappingService _mapping;
public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
{
_extractor = extractor;
_mapping = mapping;
}
[HttpPost("extract")]
[RequestSizeLimit(100 * 1024 * 1024)]
public async Task<IActionResult> Extract(IFormFile file) { ... }
[HttpGet("equipment")]
public async Task<IActionResult> GetEquipment(...) { ... }
[HttpGet("statistics")]
public async Task<IActionResult> GetStatistics() { ... }
[HttpPut("{id:long}/confidence")]
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence) { ... }
[HttpPost("{id:long}/activate")]
public async Task<IActionResult> Activate(long id) { ... }
[HttpPost("{id:long}/deactivate")]
public async Task<IActionResult> Deactivate(long id) { ... }
[HttpGet("mappings")]
public async Task<IActionResult> GetMappings(...) { ... }
[HttpPost("mappings")]
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req) { ... }
[HttpPut("mappings/{id:long}")]
public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req) { ... }
[HttpDelete("mappings/{id:long}")]
public async Task<IActionResult> ClearMapping(long id) { ... }
[HttpGet("mappings/available-tags")]
public async Task<IActionResult> GetAvailableTags() { ... }
[HttpGet("export/csv")]
public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo) { ... }
[HttpGet("export/excel")]
public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo) { ... }
}
```
> **현황**: `ExperionControllers.cs`에 `ExperionPidController`가 완료됨.
> **API 엔드포인트**: `/api/pid/*` (13개 엔드포인트)
---
### 단계 8: Program.cs 등록 ✅
**파일**: [`src/Web/Program.cs`](../src/Web/Program.cs)
```csharp
// Line 85-86
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
```
> **현황**: 이미 코드베이스에 완료됨. `AddScoped` 등록 정상 구현됨.
---
### 단계 9~14: Frontend 및 기타 (2026-04-30)
> **상태**: 단계 8까지 백엔드 완료. MCP 툴(`extract_pid_tags`, `match_pid_tags`) 구현 완료.
> 단계 9-12 프론트엔드 구현 완료. 다음 단계는 통합 테스트입니다.
| 단계 | 내용 | 상태 |
|------|------|------|
| 9 | `index.html`에 P&ID 탭 + pane 추가 | ✅ 완료 (2026-04-30) |
| 10 | `app.js`에 P&ID 함수 추가 | ✅ 완료 (2026-04-30) |
| 11 | `style.css`에 P&ID 스타일 추가 | ✅ 완료 (2026-04-30) |
| 12 | `appsettings.json` Kestrel 설정 추가 | ✅ 완료 (2026-04-30) |
| 13 | 실제 DXF/PDF 파일 업로드 테스트 | 대기 |
| 14 | 수동 매핑 UI 동작 확인 | 대기 |
---
### 단계 9: index.html에 P&ID 탭 + pane 추가 ✅
**파일**: [`src/Web/wwwroot/index.html`](../src/Web/wwwroot/index.html)
**변경 내용**:
1. 탭 목록에 P&ID 탭 추가 (11번째 탭)
2. `pane-pid` 섹션 추가 (fastRecord 다음)
**구조**:
```html
<li class="nav-item" data-tab="pid">
<span class="ni">11</span>
<span class="nl">P&ID 추출</span>
</li>
<section class="pane" id="pane-pid">
<!-- 파일 업로드 카드 -->
<div class="card">
<div class="card-cap">P&ID 파일 업로드</div>
<input type="file" id="pid-file-input" accept=".dxf,.pdf"/>
<button onclick="pidExtract()">🚀 추출 시작</button>
</div>
<!-- 추출 결과 테이블 -->
<div class="card">
<table class="table" id="pid-table">
<thead>...</thead>
<tbody id="pid-table-body"></tbody>
</table>
</div>
<!-- 통계 카드 -->
<div class="card">
<div class="stat-box">
<div class="stat-label">총 추출 건수</div>
<div class="stat-value" id="pid-stat-total">0</div>
</div>
</div>
</section>
```
> **현황**: index.html에 P&ID 탭 및 pane 완료. fastRecord 다음에 추가.
---
### 단계 10: app.js에 P&ID 함수 추가 ✅
**파일**: [`src/Web/wwwroot/js/app.js`](../src/Web/wwwroot/js/app.js)
**추가된 함수**:
- `pidExtract()` — 파일 업로드 및 추출 시작
- `pidLoadTable(page)` — 추출 결과 테이블 로드
- `pidRenderPagination(total, currentPage)` — 페이지네이션 렌더링
- `pidUpdateStats()` — 통계 정보 업데이트
- `pidClearLog()` — 로그 지우기
- `pidOpenMapping(id)` — 매핑 모달 열기
**API 연동**:
- `/api/pid/extract` — POST (파일 업로드)
- `/api/pid/equipment` — GET (목록 조회)
- `/api/pid/export/csv` — GET (CSV 내보내기)
- `/api/pid/export/excel` — GET (Excel 내보내기)
> **현황**: app.js에 P&ID 함수 완료. 기존 패턴(인증서 관리, Text-to-SQL) 따름.
---
### 단계 11: style.css에 P&ID 스타일 추가 ✅
**파일**: [`src/Web/wwwroot/css/style.css`](../src/Web/wwwroot/css/style.css)
**추가된 스타일**:
- `#pane-pid .btn-sm` — 버튼 스타일 (btn-a, btn-b)
- `#pane-pid .badge` — 배지 스타일 (ok, warn, err)
- `#pane-pid .stat-box` — 통계 박스 스타일
- `#pane-pid .pagination` — 페이지네이션 스타일
- `#pane-pid .logbox` — 로그 박스 스타일
> **현황**: style.css에 P&ID 스타일 완료. 다크 테마 색상 사용.
---
### 단계 12: appsettings.json Kestrel 설정 추가 ✅
**파일**: [`src/Web/appsettings.json`](../src/Web/appsettings.json)
**추가된 설정**:
```json
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:5000"
}
},
"Limits": {
"MaxRequestBodySize": 104857600
}
}
}
```
**설명**:
- `MaxRequestBodySize`: 100MB (DXF/PDF 파일 업로드용)
- `Url`: 모든 네트워크 인터페이스에서 수신
> **현황**: appsettings.json에 Kestrel 설정 완료.
---
## 📋 구현 순서 및 체크리스트
### Phase 1 — 백엔드 (완료)
- [x] **단계 1**: `ExperionCrawler.csproj``netDxf`, `PdfPig`, `EPPlus` 패키지 추가 후 `dotnet build` 확인
- [x] **단계 2**: `McpClient.cs``ExtractPidTagsAsync`, `MatchPidTagsAsync` 메서드 추가
- [x] **단계 3**: Python MCP 서버에 `extract_pid_tags`, `match_pid_tags` 툴 추가 및 테스트
- [x] **단계 4**: `PidExtractorService.cs` 전체 교체
- [x] **단계 5**: `ExperionControllers.cs``ExperionPidController` 추가
- [x] **단계 6**: `dotnet build` 에러 0건 확인 (2026-04-30 확인)
- [ ] **단계 7**: Swagger(`/swagger`)에서 `/api/pid/*` 엔드포인트 노출 확인 (대기)
### Phase 2 — 프론트엔드 (완료)
- [x] **단계 9**: `index.html`에 P&ID 탭 + pane 추가 (2026-04-30)
- [x] **단계 10**: `app.js`에 P&ID 함수 추가 (2026-04-30)
- [x] **단계 11**: `style.css`에 P&ID 스타일 추가 (2026-04-30)
- [x] **단계 12**: `appsettings.json` Kestrel 설정 추가 (2026-04-30)
### Phase 3 — 통합 테스트 (대기)
- [ ] 실제 DXF 파일 업로드 → 태그 추출 확인
- [ ] 실제 PDF(텍스트) 파일 업로드 → 태그 추출 확인
- [ ] 추출 결과 → Experion 태그 자동 매핑 제안 확인
- [ ] 수동 매핑 UI 동작 확인
- [ ] CSV/Excel 내보내기 확인
- [ ] MCP 서버 다운 시 에러 처리 확인
---
## ⚠️ 주요 주의사항
| 항목 | 내용 |
|------|------|
| MCP 서버 의존성 | `localhost:5001` 응답 없으면 추출 실패 — UI에서 `ping` 사전 확인 권장 |
| netDxf 임시 파일 | DXF 파싱 시 `/tmp`에 임시 파일 생성/삭제 — 권한 및 디스크 여유 확인 |
| PDF 텍스트 추출 실패 | 스캔본 PDF는 `ExtractPdfText()`가 빈 문자열 반환 → Vision 미구현 안내 메시지 |
| 프롬프트 튜닝 | 도면 특성(한국어/영어, 태그 표기 방식)에 따라 MCP 서버 프롬프트 조정 필요 |
| 대용량 DXF | 1만 개 이상 엔티티 시 LLM 컨텍스트 초과 가능 → `text[:12000]` 슬라이싱으로 제한 중 |
| 태그 매핑 확신도 | `confidence < 0.7` 자동 매핑은 저장하지 않음 — 수동 매핑 유도 |
---
## 📝 코딩 가이드
### 1. 기존 패턴 엄수
```
새 기능 추가 시 반드시 기존 코드 패턴을 따를 것
✅ 엔티티: [Table("테이블명")], [Column("컬럼명")] 어트리뷰트 필수
✅ DbContext: ExperionDbContext 단일 컨텍스트 — 별도 DbContext 생성 금지
✅ 서비스 등록: Program.cs에 AddScoped<Interface, Implementation>() 형태
✅ 컨트롤러: ExperionControllers.cs 단일 파일에 추가 (별도 파일 생성 금지)
✅ 탭 진입: API 자동 호출 금지 — 버튼 클릭으로만 동작
✅ DOM 렌더링: innerHTML += 루프 금지 — rows 배열 .join('') 후 한번에 설정
✅ 로깅: Console.WriteLine 금지 — ILogger<T> 사용
```
### 2. MCP 서버 연동 패턴
```
C# 서비스에서 MCP 호출 시 McpClient를 직접 주입.
IMcpService를 통하지 않아도 됨 (McpClient는 Singleton 등록).
```
```csharp
// 올바른 패턴
public MyService(McpClient mcp, ExperionDbContext db, ILogger<MyService> logger)
// 금지
public MyService(IMcpService mcp) // 래핑 레이어가 필요할 때만 사용
```
### 3. Python MCP 툴 작성 규칙
```python
# 응답은 반드시 순수 JSON 문자열 반환
# 코드펜스(```json) 제거 후 반환
# LLM 응답에서 JSON 배열 추출: re.search(r'\[.*\]', raw, re.DOTALL)
# temperature=0.1 고정 (결정론적 출력)
# 텍스트 슬라이싱: text[:12000] (컨텍스트 초과 방지)
```
### 4. 프론트엔드 규칙
```
✅ 함수 기반 작성 — class 사용 금지
✅ 기존 헬퍼 함수 재사용: esc(), log(), setGlobal(), api()
✅ 페이지네이션: pidPagination() 패턴 — 전체 페이지 버튼 생성 금지 (±3 범위)
✅ 다크 테마 색상 사용: #1e1e1e, #2d2d2d, #ccc, .ok/.err/.warn CSS 클래스
✅ Bootstrap 클래스 사용 금지
✅ fetch 에러 처리: if (!res.ok) throw new Error(...)
```
### 5. 빌드 검증 체크포인트
```bash
# 각 단계 완료 후 실행
dotnet build src/Web/ExperionCrawler.csproj
# 목표: 경고 N건, 에러 0건
# netDxf/PdfPig/EPPlus 추가 후 새 경고 없어야 정상
```
---
## 🧐 감독자 진단 — 단계 8까지 완료 여부 (2026-04-30 기준)
| 항목 | 확인 내용 | 상태 |
|------|-----------|------|
| **엔티티** | `PidEquipment.cs`, `PidAuditLog.cs` 존재 | ✅ |
| **DbSet** | `ExperionDbContext.PidEquipment`, `PidAuditLog` 등록 | ✅ |
| **FK 설정** | `ExperionTagId``RealtimePoint.Id` FK 설정 | ✅ |
| **DTOs** | `PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos` 존재 | ✅ |
| **인터페이스** | `IPidExtractorService`, `ITagMappingService` 정의 | ✅ |
| **서비스** | `TagMappingService`, `PidExtractorService` 구현 | ✅ |
| **컨트롤러** | `ExperionPidController` (13개 엔드포인트) | ✅ |
| **Program.cs** | `AddScoped<IPidExtractorService>`, `AddScoped<ITagMappingService>` | ✅ |
| **MCP 툴** | `extract_pid_tags`, `match_pid_tags` 구현 | ✅ |
| **빌드** | `dotnet build` 에러 0건 | ✅ |
> **진단 결과**: 단계 1-8 완료. MCP 툴 추가 완료. 다음 단계는 프론트엔드 구현(단계 9-12) 및 통합 테스트(단계 13-14)입니다.
### 7. MCP 툴 독립 테스트
```bash
# Python MCP 서버 직접 테스트 (C# 없이)
curl -X POST http://localhost:5001/mcp \
-H "Content-Type: application/json" \
-H "mcp-protocol-version: 2025-03-26" \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call",
"params":{"name":"extract_pid_tags",
"arguments":{"text":"FT-101 Flow Transmitter\nPT-201 Pressure","source_type":"dxf"}}}'
# 기대 응답: [{"tagNo":"FT-101",...},{"tagNo":"PT-201",...}]
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

View File

@@ -1,612 +0,0 @@
"""
P&ID Extractor - DXF/PDF → Claude Vision API → CSV → PostgreSQL
Extracts: Equipment Name, Tag No., Instrument Type, Line Number, P&ID Drawing No.
"""
import os
import json
import csv
import base64
import re
import logging
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional
import anthropic
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("logs/extractor.log"),
logging.StreamHandler()
]
)
log = logging.getLogger(__name__)
# ─────────────────────────────────────────────
# Data Model
# ─────────────────────────────────────────────
@dataclass
class PIDItem:
"""Single extracted item from a P&ID drawing."""
pid_drawing_no: str = ""
tag_no: str = ""
equipment_name: str = ""
instrument_type: str = ""
line_number: str = ""
service_description: str = ""
confidence: float = 0.0
source_file: str = ""
extracted_at: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> dict:
return asdict(self)
# ─────────────────────────────────────────────
# File Converters
# ─────────────────────────────────────────────
def dxf_to_image(dxf_path: str, output_dir: str = "output") -> list[str]:
"""
Convert DXF file to PNG image(s) using ezdxf + matplotlib.
Returns list of image paths.
"""
try:
import ezdxf
from ezdxf.addons.drawing import RenderContext, Frontend
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
import matplotlib.pyplot as plt
doc = ezdxf.readfile(dxf_path)
msp = doc.modelspace()
fig = plt.figure(figsize=(24, 18), dpi=150)
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
Frontend(ctx, out).draw_layout(msp, finalize=True)
output_path = Path(output_dir) / (Path(dxf_path).stem + ".png")
fig.savefig(output_path, dpi=150, bbox_inches="tight",
facecolor="white", edgecolor="none")
plt.close(fig)
log.info(f"DXF converted: {output_path}")
return [str(output_path)]
except ImportError:
log.warning("ezdxf/matplotlib not installed. Run: pip install ezdxf matplotlib")
return []
except Exception as e:
log.error(f"DXF conversion failed: {e}")
return []
def pdf_to_images(pdf_path: str, output_dir: str = "output",
dpi: int = 200) -> list[str]:
"""
Convert PDF pages to PNG images using pdf2image.
Returns list of image paths.
"""
try:
from pdf2image import convert_from_path
pages = convert_from_path(pdf_path, dpi=dpi)
paths = []
stem = Path(pdf_path).stem
for i, page in enumerate(pages):
out_path = Path(output_dir) / f"{stem}_page{i+1:03d}.png"
page.save(str(out_path), "PNG")
paths.append(str(out_path))
log.info(f"PDF page {i+1} saved: {out_path}")
return paths
except ImportError:
log.warning("pdf2image not installed. Run: pip install pdf2image")
return []
except Exception as e:
log.error(f"PDF conversion failed: {e}")
return []
def dxf_text_extract(dxf_path: str) -> str:
"""
Directly extract all text entities from DXF file (faster, no image needed).
Returns concatenated text for pre-filtering.
"""
try:
import ezdxf
doc = ezdxf.readfile(dxf_path)
texts = []
for entity in doc.modelspace():
if entity.dxftype() in ("TEXT", "MTEXT", "ATTRIB", "ATTDEF"):
try:
txt = entity.dxf.text if hasattr(entity.dxf, "text") else ""
if txt.strip():
texts.append(txt.strip())
except Exception:
pass
return "\n".join(texts)
except Exception as e:
log.error(f"DXF text extraction failed: {e}")
return ""
# ─────────────────────────────────────────────
# Claude Vision Analyzer
# ─────────────────────────────────────────────
EXTRACTION_PROMPT = """You are an expert P&ID (Piping and Instrumentation Diagram) engineer.
Analyze this P&ID drawing image and extract ALL of the following items:
1. **P&ID Drawing Number** (도면번호) - usually found in title block
2. **Tag Numbers** (태그번호) - e.g. FT-1001, PT-2003, LT-1005, E-101, V-201
3. **Equipment Names** (장비명) - e.g. Heat Exchanger, Pump, Vessel, Compressor
4. **Instrument Types** (계기타입) - e.g. Flow Transmitter, Pressure Indicator, Level Controller
5. **Line Numbers** (라인번호) - e.g. 6\"-P-1001-A1A, 3\"-IA-2001
For each item found, return a JSON array with this exact structure:
[
{
"pid_drawing_no": "P&ID drawing number or sheet number",
"tag_no": "instrument or equipment tag (e.g. FT-1001)",
"equipment_name": "descriptive name in English (e.g. Flow Transmitter)",
"instrument_type": "ISA instrument type abbreviation (e.g. FT, PT, LT, E, V, P)",
"line_number": "pipe line number if associated",
"service_description": "brief service description if visible",
"confidence": 0.0 to 1.0 confidence score
}
]
Rules:
- Extract EVERY tag and instrument visible, do not skip any
- If a field is not visible/applicable, use empty string ""
- Return ONLY valid JSON array, no markdown, no explanation
- confidence: 1.0 = clearly readable, 0.5 = partially legible, 0.2 = guessed
"""
TEXT_EXTRACTION_PROMPT = """You are an expert P&ID engineer.
Below is raw text extracted from a DXF P&ID file.
Parse and extract ALL instrument tags, equipment tags, line numbers, and drawing info.
Text content:
{text_content}
Return a JSON array with this exact structure:
[
{{
"pid_drawing_no": "drawing number if found",
"tag_no": "tag number (e.g. FT-1001, E-101)",
"equipment_name": "equipment or instrument name",
"instrument_type": "ISA type abbreviation",
"line_number": "line number if found",
"service_description": "service description if found",
"confidence": 0.8
}}
]
Return ONLY valid JSON, no markdown.
"""
class PIDAnalyzer:
"""Claude-powered P&ID analyzer supporting both image and text modes."""
def __init__(self, api_key: Optional[str] = None):
self.client = anthropic.Anthropic(
api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
)
self.model = "claude-opus-4-20250514"
def analyze_image(self, image_path: str) -> list[PIDItem]:
"""Analyze a P&ID image using Claude Vision."""
log.info(f"Analyzing image: {image_path}")
with open(image_path, "rb") as f:
image_data = base64.standard_b64encode(f.read()).decode("utf-8")
# Detect media type
suffix = Path(image_path).suffix.lower()
media_map = {".png": "image/png", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".gif": "image/gif",
".webp": "image/webp"}
media_type = media_map.get(suffix, "image/png")
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": image_data
}
},
{"type": "text", "text": EXTRACTION_PROMPT}
]
}]
)
raw = response.content[0].text
return self._parse_response(raw, source_file=image_path)
def analyze_dxf_text(self, dxf_path: str) -> list[PIDItem]:
"""Analyze DXF by extracting text entities and sending to Claude."""
log.info(f"Analyzing DXF text: {dxf_path}")
text_content = dxf_text_extract(dxf_path)
if not text_content.strip():
log.warning("No text found in DXF, falling back to image mode")
images = dxf_to_image(dxf_path)
results = []
for img in images:
results.extend(self.analyze_image(img))
return results
prompt = TEXT_EXTRACTION_PROMPT.format(
text_content=text_content[:8000] # token limit guard
)
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[{"role": "user", "content": prompt}]
)
raw = response.content[0].text
return self._parse_response(raw, source_file=dxf_path)
def _parse_response(self, raw: str, source_file: str) -> list[PIDItem]:
"""Parse Claude's JSON response into PIDItem list."""
try:
# Strip markdown fences if present
clean = re.sub(r"```(?:json)?|```", "", raw).strip()
# Find JSON array
match = re.search(r"\[.*\]", clean, re.DOTALL)
if not match:
log.warning("No JSON array found in response")
return []
data = json.loads(match.group())
items = []
for d in data:
if not isinstance(d, dict):
continue
item = PIDItem(
pid_drawing_no=d.get("pid_drawing_no", ""),
tag_no=d.get("tag_no", ""),
equipment_name=d.get("equipment_name", ""),
instrument_type=d.get("instrument_type", ""),
line_number=d.get("line_number", ""),
service_description=d.get("service_description", ""),
confidence=float(d.get("confidence", 0.5)),
source_file=Path(source_file).name,
)
items.append(item)
log.info(f"Extracted {len(items)} items from {Path(source_file).name}")
return items
except json.JSONDecodeError as e:
log.error(f"JSON parse error: {e}\nRaw: {raw[:500]}")
return []
# ─────────────────────────────────────────────
# CSV Exporter
# ─────────────────────────────────────────────
CSV_COLUMNS = [
"pid_drawing_no", "tag_no", "equipment_name", "instrument_type",
"line_number", "service_description", "confidence",
"source_file", "extracted_at"
]
def export_csv(items: list[PIDItem], output_path: str) -> str:
"""Export extracted items to CSV file."""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
writer.writeheader()
for item in items:
writer.writerow(item.to_dict())
log.info(f"CSV saved: {output_path} ({len(items)} rows)")
return output_path
# ─────────────────────────────────────────────
# PostgreSQL Loader
# ─────────────────────────────────────────────
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS pid_equipment (
id SERIAL PRIMARY KEY,
pid_drawing_no VARCHAR(100),
tag_no VARCHAR(100),
equipment_name VARCHAR(255),
instrument_type VARCHAR(50),
line_number VARCHAR(100),
service_description TEXT,
confidence FLOAT,
source_file VARCHAR(255),
extracted_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Useful indexes
CREATE INDEX IF NOT EXISTS idx_pid_tag_no ON pid_equipment(tag_no);
CREATE INDEX IF NOT EXISTS idx_pid_drawing_no ON pid_equipment(pid_drawing_no);
CREATE INDEX IF NOT EXISTS idx_pid_instrument_type ON pid_equipment(instrument_type);
"""
INSERT_SQL = """
INSERT INTO pid_equipment
(pid_drawing_no, tag_no, equipment_name, instrument_type,
line_number, service_description, confidence, source_file, extracted_at)
VALUES
(%(pid_drawing_no)s, %(tag_no)s, %(equipment_name)s, %(instrument_type)s,
%(line_number)s, %(service_description)s, %(confidence)s,
%(source_file)s, %(extracted_at)s)
ON CONFLICT DO NOTHING;
"""
def load_to_postgres(items: list[PIDItem], dsn: str) -> int:
"""
Load extracted items into PostgreSQL.
DSN format: postgresql://user:password@host:5432/dbname
Returns number of rows inserted.
"""
try:
import psycopg2
conn = psycopg2.connect(dsn)
cur = conn.cursor()
# Create table if needed
cur.execute(CREATE_TABLE_SQL)
# Insert rows
rows = [item.to_dict() for item in items]
cur.executemany(INSERT_SQL, rows)
conn.commit()
count = cur.rowcount
cur.close()
conn.close()
log.info(f"Inserted {count} rows into PostgreSQL")
return count
except ImportError:
log.error("psycopg2 not installed. Run: pip install psycopg2-binary")
return 0
except Exception as e:
log.error(f"PostgreSQL error: {e}")
return 0
# ─────────────────────────────────────────────
# AX (Asset Excellence) Formatter
# ─────────────────────────────────────────────
AX_COLUMN_MAP = {
"tag_no": "Tag Number",
"equipment_name": "Asset Description",
"instrument_type": "Equipment Class",
"pid_drawing_no": "P&ID Reference",
"line_number": "Line Reference",
"service_description": "Service",
}
def export_ax_excel(items: list[PIDItem], output_path: str) -> str:
"""
Export data in AX (Asset Excellence / Hexagon) compatible Excel format.
Columns mapped to typical AX field names.
"""
try:
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "AX_Import"
# Header style
header_fill = PatternFill("solid", fgColor="1F4E79")
header_font = Font(color="FFFFFF", bold=True, size=11)
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin = Side(style="thin", color="CCCCCC")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
ax_columns = list(AX_COLUMN_MAP.values())
ax_columns.append("Confidence")
# Write header
for col_idx, col_name in enumerate(ax_columns, start=1):
cell = ws.cell(row=1, column=col_idx, value=col_name)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = border
# Write data rows
for row_idx, item in enumerate(items, start=2):
row_data = [
item.tag_no,
item.equipment_name,
item.instrument_type,
item.pid_drawing_no,
item.line_number,
item.service_description,
item.confidence,
]
for col_idx, value in enumerate(row_data, start=1):
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
cell.alignment = Alignment(vertical="center")
# Confidence color coding
if col_idx == len(ax_columns):
if isinstance(value, float):
if value >= 0.8:
cell.fill = PatternFill("solid", fgColor="C6EFCE") # green
elif value >= 0.5:
cell.fill = PatternFill("solid", fgColor="FFEB9C") # yellow
else:
cell.fill = PatternFill("solid", fgColor="FFC7CE") # red
# Column widths
col_widths = [20, 35, 25, 20, 20, 30, 12]
for i, w in enumerate(col_widths, start=1):
ws.column_dimensions[
openpyxl.utils.get_column_letter(i)
].width = w
ws.row_dimensions[1].height = 35
ws.freeze_panes = "A2"
wb.save(output_path)
log.info(f"AX Excel saved: {output_path} ({len(items)} rows)")
return output_path
except ImportError:
log.error("openpyxl not installed. Run: pip install openpyxl")
return ""
except Exception as e:
log.error(f"Excel export error: {e}")
return ""
# ─────────────────────────────────────────────
# Main Pipeline
# ─────────────────────────────────────────────
def run_pipeline(
input_files: list[str],
output_dir: str = "output",
db_dsn: Optional[str] = None,
use_image_mode: bool = False,
) -> dict:
"""
Full pipeline: files → AI extraction → CSV + Excel + optional DB load.
Args:
input_files: List of DXF or PDF file paths
output_dir: Directory for output files
db_dsn: PostgreSQL DSN (optional)
use_image_mode: Force image conversion even for DXF
Returns:
Summary dict with counts and output paths
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
Path("logs").mkdir(exist_ok=True)
analyzer = PIDAnalyzer()
all_items: list[PIDItem] = []
for file_path in input_files:
suffix = Path(file_path).suffix.lower()
log.info(f"Processing: {file_path}")
try:
if suffix == ".pdf":
image_paths = pdf_to_images(file_path, output_dir)
for img in image_paths:
all_items.extend(analyzer.analyze_image(img))
elif suffix == ".dxf":
if use_image_mode:
image_paths = dxf_to_image(file_path, output_dir)
for img in image_paths:
all_items.extend(analyzer.analyze_image(img))
else:
all_items.extend(analyzer.analyze_dxf_text(file_path))
elif suffix in (".png", ".jpg", ".jpeg"):
all_items.extend(analyzer.analyze_image(file_path))
else:
log.warning(f"Unsupported file type: {suffix}")
except Exception as e:
log.error(f"Failed processing {file_path}: {e}")
if not all_items:
log.warning("No items extracted from any file")
return {"total": 0, "csv": None, "excel": None, "db_rows": 0}
# Sort by drawing + tag
all_items.sort(key=lambda x: (x.pid_drawing_no, x.tag_no))
# Export CSV
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
csv_path = str(Path(output_dir) / f"pid_extracted_{ts}.csv")
export_csv(all_items, csv_path)
# Export AX Excel
excel_path = str(Path(output_dir) / f"pid_AX_import_{ts}.xlsx")
export_ax_excel(all_items, excel_path)
# Load to DB
db_rows = 0
if db_dsn:
db_rows = load_to_postgres(all_items, db_dsn)
summary = {
"total": len(all_items),
"csv": csv_path,
"excel": excel_path,
"db_rows": db_rows,
"files_processed": len(input_files),
}
log.info(f"Pipeline complete: {summary}")
return summary
# ─────────────────────────────────────────────
# CLI Entry Point
# ─────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="P&ID Extractor: DXF/PDF → CSV/Excel/PostgreSQL"
)
parser.add_argument("files", nargs="+", help="DXF or PDF file paths")
parser.add_argument("--output-dir", default="output", help="Output directory")
parser.add_argument("--db-dsn", help="PostgreSQL DSN (optional)")
parser.add_argument("--image-mode", action="store_true",
help="Force DXF → image conversion (slower but more accurate)")
args = parser.parse_args()
result = run_pipeline(
input_files=args.files,
output_dir=args.output_dir,
db_dsn=args.db_dsn,
use_image_mode=args.image_mode,
)
print("\n===== Extraction Summary =====")
for k, v in result.items():
print(f" {k}: {v}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
# plant-9100.pdf 추출 결과
## PDF 정보
- **버전**: 1.6
- **페이지 수**: 1
- **제목**: (없음)
- **작성자**: (없음)
- **생성 프로그램**: ezPDF Builder Supreme
- **생성일**: D:20260430083400+09'00'
## 페이지 1
- **크기**: 595.2 x 841.92
### 추출 텍스트
```
E-1011710th PLANT 증설공사주식회사 한울T-10101P-10114P-10101F-10102A/BP-10116E-10115BC-10111E-10117E-10119T-10100T-3210VP-10117SP-10601D-10113E-10112P-10118DP-1010112024.04.04K.S.YJ.O.YH.J.IAS BUILT23---4---5---6---TTE-10115A2025.06.17---REVISIONE-10103OGDEN PUMPTOGDEN PUMP2025.06.20REVISIONREVISION2025.06.232025.07.02REVISION2025.07.07REVISION
```

View File

@@ -1,70 +0,0 @@
--- Page 1 ---
주식회사
한울
10th
PLANT
증설공사
1
2024.04.04
AS
BUILT
K.S.Y
J.O.Y
H.J.I
2
2025.06.17
REVISION
-
-
-
E-10115A
E-10119
3
2025.06.20
REVISION
-
-
-
P-10101
4
2025.06.23
REVISION
-
-
-
P-10116
5
2025.07.02
REVISION
-
-
-
6
2025.07.07
REVISION
-
-
-
F-10102A/B
T
E-10103
OGDEN
PUMP
T
P-10118
E-10115B
T-10101
T-10100
DP-10101
E-10117
P-10114
OGDEN
PUMP
T
E-10117
T-3210
SP-10601
VP-10117
C-10111
D-10113 E-10112

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

Binary file not shown.