# DXF P&ID 추출 개선 2차 — 배관번호·펌프 태그 누락 버그 수정

작성일: 2026-05-17  
작성: Claude Sonnet 4.6

---

## 문제 개요

P&ID DXF 파일에서 아래 태그들이 추출되지 않는 버그 보고:

- `P-10101` (10차 펌프 장비 태그)
- `P-10101-25A-F1A-n` (10차 프로세스 배관번호)
- `P-9101`, `P-9102` (9차 펌프 장비 태그)
- `P-9107-25A-F-n` 등 (9차 배관번호)

사용자가 "중복방지 로직에 차단된 것 아닌가" 질문함 → 실제로는 **regex 불일치** 와 **레이어 처리 누락** 이 원인.

---

## 실제 DXF 구조 조사 결과

### 파일별 배관번호 형식 차이

| DXF 파일 | 배관번호 레이어 | 형식 예시 | 필드 수 |
|----------|--------------|---------|--------|
| `p9-p&id-20.03.19.dxf` (9차) | `LINENO` | `P-9107-25A-F-n` | **5필드** |
| `p9-p&id-20.03.19.dxf` (9차) | `14-D-PIPELINE-LINE` | `CHR-9641-50A-F-C50` | **6필드** |
| `plant-10100-only.dxf` (10차) | `LINENO` | `P-10101-25A-F1A-n` | **7필드** |
| `10차플랜트-P&ID.dxf` | `LINENO` | `P-10138-600A-F2A-H100` | **7필드** |

### 펌프 태그 레이어

| DXF 파일 | 레이어 | 예시 |
|----------|-------|------|
| 9차 플랜트 | `0` | `P-9101`, `P-9116`, `P-9201` |
| 10차 플랜트 | `0`, `1` | `P-10101`, `VP-10117`, `DP-10101` |

펌프 태그는 모두 일반 TEXT 엔티티 → 추출 자체는 가능했으나 배관번호로 오인되는 문제 있었음.

---

## 버그 분석

### Bug 1: `_PID_LINENO_FULL_RE` regex가 9차 배관번호 형식 불일치

**파일**: `mcp-server/server.py`

**기존 regex (7필드 고정)**:
```
^([A-Z][A-Z0-9]{0,3})-(\d{3,6})-(\d{1,4}[A-Z]?)-([A-Z])(\d)([A-Z])-([A-Za-z0-9]+)$
```

그룹 구조: `SERVICE - LINENUM - SIZE - MATERIAL - FLANGE_DIGIT - INSUL_CODE - INSUL_THICK`

| 입력 | 결과 | 이유 |
|------|------|------|
| `P-10101-25A-F1A-n` | ✓ MATCH | F→1→A→n 순서 맞음 |
| `P-9107-25A-F-n` | ✗ FAIL | F 다음에 `\d` 기대하나 `-n` 등장 |
| `CHR-9641-50A-F-C50` | ✗ FAIL | 동일 이유 |

9차 플랜트는 플랜지등급 숫자와 단열코드가 분리되지 않고 `F-n`, `F-H50` 형식으로 통합되어 있어 7필드 regex에 걸리지 않음.

---

### Bug 2: `_extract_pid_dxf_fast`가 레이어별로만 배관번호 판단

**파일**: `mcp-server/server.py`

**기존 로직**:
```python
if layer == 'LINENO':          # LINENO 레이어만 배관번호 처리
    parsed = _parse_pid_lineno(txt)
    ...
    continue
if _PID_TAG_RE.match(txt):     # 그 외 레이어는 TAG_RE만 체크
    ...
```

결과:
- `14-D-PIPELINE-LINE` 레이어의 `CHR-9641-50A-F-C50` → TAG_RE 불일치 → **완전 누락**
- 다른 도면에서 배관번호 레이어 이름이 다르면 → **모두 누락**

레이어 이름 하드코딩은 도면 간 이식성이 없음. 레이어 이름이 아닌 **regex 패턴으로 판단**해야 함.

---

### Bug 3: `build_pid_graph_parallel` pump extractor가 배관번호를 펌프로 오인

**파일**: `mcp-server/worker/pid_extract_prompts.py`

LLM pump extractor 프롬프트에 5자리 예시만 있고 배관번호 제외 지시 없음:
```
Examples: P-10101, VP-10117, DP-10101, C-10201, CP-10301, BP-10401
```

DXF 전체 텍스트에 `P-10101-25A-F1A-n`이 포함되어 있을 때 LLM이 이를 보고 `P-10101`로 잘못 추출.
→ Phase 4 `seen_tagnos` 중복 제거에서 실제 펌프 P-10101과 충돌
→ 배관번호 P-10101-25A-F1A-n은 graph에서 완전 누락

(사용자가 의심한 "중복방지 로직 차단"은 이 케이스에 해당 — 다만 원인은 LLM의 잘못된 추출임)

---

## 수정 내용

### Fix 1: `_PID_LINENO_FULL_RE` — 5~7필드 통합 regex

**`mcp-server/server.py`**

```python
# 기존 (7필드 고정)
_PID_LINENO_FULL_RE = re.compile(
    r'^([A-Z][A-Z0-9]{0,3})-(\d{3,6})-(\d{1,4}[A-Z]?)-([A-Z])(\d)([A-Z])-([A-Za-z0-9]+)$'
)

# 수정 (5~7필드 통합: pipe_spec이 F, F1A, F2A 등 가변)
_PID_LINENO_FULL_RE = re.compile(
    r'^([A-Z][A-Z0-9]{0,3})-(\d{3,6})-(\d{1,4}[A-Z]?)-([A-Za-z][A-Za-z0-9]*)-([A-Za-z0-9]+)$'
)
```

새 그룹: `(service, line_no, size, pipe_spec, insul)`

| 입력 | 매칭 | pipe_spec | insul |
|------|------|-----------|-------|
| `P-9107-25A-F-n` | ✓ | F | n |
| `P-9113-20A-F-H50` | ✓ | F | H50 |
| `CHR-9641-50A-F-C50` | ✓ | F | C50 |
| `P-10101-25A-F1A-n` | ✓ | F1A | n |
| `P-10138-600A-F2A-H100` | ✓ | F2A | H100 |
| `VG-6203-15A-F1A-n` | ✓ | F1A | n |

`_parse_pid_lineno` 반환값도 그룹 수에 맞게 단순화:
```python
# 기존: material_spec, flange_rating, insul_code, insul_thickness (4개 필드)
# 수정: pipe_spec, insul (2개 필드로 통합)
return {
    "raw": token, "service": service, "fluid": ...,
    "line_no": line_no, "size": size,
    "pipe_spec": pipe_spec,   # F, F1A, F2A 등
    "insul": insul,           # n, H50, H100, C50 등
}
```

---

### Fix 2: `_extract_pid_dxf_fast` — regex 우선, 레이어는 보조 힌트로만

**`mcp-server/server.py`**

```python
# 기존: 레이어 이름 == 'LINENO' 이면 배관번호
if layer == 'LINENO':
    parsed = _parse_pid_lineno(txt)
    ...
    continue
if _PID_TAG_RE.match(txt):
    ...

# 수정: FULL_RE 매칭 → 레이어 무관 배관번호, 짧은 형식만 레이어 힌트 사용
if _PID_LINENO_FULL_RE.match(txt):          # 완전한 배관번호 → 레이어 무관
    parsed = _parse_pid_lineno(txt)
    if parsed is not None:
        linenos.append(parsed)
    continue

if 'LINENO' in layer.upper():               # 레이어 이름에 LINENO 포함 → 짧은 형식도 배관번호
    parsed = _parse_pid_lineno(txt)         # (P-10101 같은 단순형은 펌프와 구분 불가능,
    if parsed is not None:                  #  레이어 힌트 불가피)
        linenos.append(parsed)
    continue

if _PID_TAG_RE.match(txt):                  # 일반 장비/계기 태그
    ...
```

핵심 원칙: **완전한 배관번호는 regex로 식별, 레이어 이름에 의존하지 않음**

---

### Fix 3: pump extractor 프롬프트 개선

**`mcp-server/worker/pid_extract_prompts.py`**

```python
_PUMP_PROMPT = _PROMPT_HEADER + """
Extract ONLY pumps and compressors (simple equipment tags, NO pipe size suffix).

Target equipment types: P (pump), VP (vertical pump), DP (dual pump),
C (compressor), CP (centrifugal pump), BP (booster pump), SP (sump pump),
and their variants.

Examples (4~5 digit loop numbers): P-10101, VP-10117, DP-10101, C-10201, P-9101, P-9116, VP-9201

IMPORTANT: Do NOT extract pipeline/line numbers that have a pipe size suffix (e.g. 25A, 50A, 100A).
  SKIP (pipeline, not a pump): P-10101-25A-F1A-n, P-9107-25A-F-n, CHR-9641-50A-F-C50
  INCLUDE (pump tag):          P-10101, VP-10117, P-9101
"""
```

변경점:
- 4자리 번호 예시 추가 (`P-9101`, `P-9116`, `VP-9201`)
- 배관번호 제외 지시 명시 (파이프 사이즈 suffix 있으면 제외)
- SKIP / INCLUDE 예시로 명확하게 구분

---

## 검증 결과

### regex 단위 테스트 (14/14 통과)

```
✓ P-9107-25A-F-n       → pipe  (9차 5필드)
✓ P-9113-20A-F-H50     → pipe  (9차 단열)
✓ P-9127-500A-F-H100   → pipe  (9차 대구경)
✓ P-10101-25A-F1A-n    → pipe  (10차 7필드)
✓ P-10138-600A-F2A-H100→ pipe  (10차 대구경)
✓ CHR-9641-50A-F-C50   → pipe  (냉각수 6필드)
✓ VG-6203-15A-F1A-n    → pipe  (벤트가스)
✓ SW-10810-25A-F1A-E50 → pipe  (소프트워터)
✓ P-10101              → tag   (10차 펌프)
✓ P-9101               → tag   (9차 펌프)
✓ VP-10117             → tag   (진공펌프)
✓ FIT-10101            → tag   (유량계)
✓ FCV-6113             → tag   (유량제어밸브)
✓ PT-9101              → tag   (압력계)
```

### 실제 DXF 엔드투엔드 검증

```
=== p9-p&id-20.03.19.dxf (9차) ===
  배관번호 총 242개 (P-: 83개)  ← 수정 전: 0개
  P 배관번호 예시: P-9107-25A, P-9114-20A, P-9113-20A, ...
  펌프 태그: P-9101, P-6101, P-201, P-9201, P-9116

=== plant-10100-only.dxf (10차) ===
  배관번호 총 96개 (P-: 57개)
  P 배관번호 예시: P-10138-600A, P-10143-32A, P-10127-65A, ...
  펌프 태그: P-10101, P-10114, P-10116, P-10118
```

---

## 수정 파일 목록

| 파일 | 변경 라인 | 내용 |
|------|---------|------|
| `mcp-server/server.py` | ~221 | `_PID_LINENO_FULL_RE` regex 교체 |
| `mcp-server/server.py` | ~244 | `_parse_pid_lineno` 반환값 `pipe_spec`/`insul`로 단순화 |
| `mcp-server/server.py` | ~305 | `_extract_pid_dxf_fast` 레이어/배관번호 처리 로직 수정 |
| `mcp-server/server.py` | ~359 | `_extract_pid_tags_from_text` step 1 출력에 `pipeSpec`/`insul` 추가 |
| `mcp-server/worker/pid_extract_prompts.py` | ~61 | `_PUMP_PROMPT` 개선 |

---

## 설계 결정 사항

| 항목 | 결정 | 이유 |
|------|------|------|
| regex 필드 통합 방식 | 5필드 통합 (`pipe_spec`이 F, F1A, F2A 통합) | 플랜트마다 배관 사양 코드 체계가 달라 고정 필드 분해는 취약 |
| 레이어 이름 역할 | FULL_RE 불일치 시 보조 힌트로만 사용 | 레이어 이름은 회사·도면마다 다름. regex가 primary. |
| 짧은 배관번호(`P-10101`) 처리 | LINENO 계열 레이어에서만 배관번호로 인식 | `P-10101`은 펌프 태그와 텍스트가 동일 → 레이어 힌트 불가피 |
| C# PidExtractorService | 미수정 | 펌프 태그(TEXT 엔티티)는 기존 코드에서 정상 추출됨. ATTRIB 읽기 추가는 별도 검토 필요 |
