Files
ExperionCrawler/plans/P&ID-추출-PREFIX-DB-수정플랜-bySonnet.md

736 lines
34 KiB
Markdown

# P&ID 추출 PREFIX 분류 — `tag_dcs` 컬럼 도입 플랜
> **작성일**: 2026-05-27
> **작성자**: Sonnet4.6
> **목적**: `pid_prefix_rules`와 `pid_equipment` 두 테이블에 `tag_dcs BOOLEAN` 컬럼을 추가해,
> P&ID 추출 **시작 시점**부터 현장 계기(field instrument)와 DCS 태그(DCS function block)를 구별한다.
---
> ## 상세 진단 리포트 (실제 코드 대비 교차 검증)
>
> **진단일**: 2026-05-27
> **진단자**: big-pickle
> **방법**: `diagnosis-checklist.md` 8-Step — 전체 코드베이스 read + 교차검증
>
> ---
>
> ### 읽은 파일 (STEP 3 — 코드 읽기)
>
> | 레이어 | 파일 | 비고 |
> |--------|------|------|
> | Domain | `PidPrefixRule.cs`, `PidEquipment.cs` | 엔티티 현재 상태 |
> | DTO | `PidPrefixRuleDto.cs`, `PidEquipmentDto.cs` | Record 정의 |
> | Interface | `IExperionServices.cs` | IPidExtractorService 시그니처 |
> | Service | `PidExtractorService.cs` (1105줄 전체) | **핵심** — MatchCategory/ClassifyTagClass/Export/Import/CRUD |
> | DbContext | `ExperionDbContext.cs` (DDL + Fluent API + Seed + Views) | DDL, seed 46개 prefix, v_pump_signal_map |
> | Controller | `PidController.cs` | GetPrefixRules/GetEquipment 응답 형식 |
> | Web UI | `pid.js`, `panes/pid.html`, `css/pid.css` | Prefix rules UI + equipment table |
> | Python MCP | `server.py` (전체) | upsert_pid_connection, _classify_pid_tag, _DB_SCHEMA, trace_connections |
> | Python | `worker/sql_prompt.py` | NL2SQL DB_SCHEMA |
> | Prompt | `prompts/plant_context.md` | LLM 시스템 프롬프트 |
> | 기타 | `validators.py`, `infer.py` | 영향 없음 확인 |
>
> ### 호출 계층 (STEP 4)
>
> ```
> [Prefix Rule CRUD]
> POST/PUT/GET /api/pid/prefix-rules → PidController
> → PidExtractorService.Get/Create/Update/DeletePrefixRulesAsync
> → DbContext.PidPrefixRules → InvalidateRulesCache()
>
> [P&ID 추출]
> ExtractFromStreamAsync()
> → MatchCategoryAsync(tagNo) ← string? 만 반환 (tagDcs 없음)
> → ClassifyTagClass(tagNo, cat, hasLink) ← ISA heuristic
> → PidEquipment { Category, TagClass, ... } 저장
>
> [Category Backfill]
> ApplyCategoriesToExistingAsync()
> → MatchCategoryAsync() + ClassifyTagClass() (2회, line 902/922)
>
> [CSV/Excel Export]
> ExportToCsvAsync() ← 14개 컬럼, TagClass는 있음
> ExportToExcelAsync() ← 17개 컬럼 (col16=TagClass, col17=id)
> ImportFromExcelAsync() ← col17=id → hasIdCol 감지
>
> [MCP Python]
> upsert_pid_connection() ← 직접 SQL (UPDATE/INSERT)
> _classify_pid_tag() ← 자체 prefix 상수 (DB 미조회)
> trace_connections() ← SELECT tag_no, from_tag, to_tag, role
> ```
>
> ---
>
> ### 🔴 HIGH — 즉시 수정 필요 (실제 장애 발생)
>
> #### H1. Excel 17번열 충돌 — `tag_dcs` 추가 시 `id`(안정 키) 덮어쓰여 데이터 손실
>
> **문제**: 계획서 §2.4-변경4가 "Excel 열 추가 (17번 열)"라고 명시했으나, 현재 `ExportToExcelAsync`는 col17을 **라운드트립용 안정 키 `id`**로 사용 중.
>
> **근거**:
> - `PidExtractorService.cs:535` — `worksheet.Cells[1, 17].Value = "id"`
> - `PidExtractorService.cs:623` — `hasIdCol` 감지: `ws.Cells[1, 17].Text` == `"id"`
> - `PidExtractorService.cs:684-691` — `hasIdCol=true`일 때만 id 기반 in-place UPDATE 수행
>
> **영향**: col17에 `"DCS태그"`가 들어가면 `hasIdCol=false` → **모든 행이 TagNo fallback(old format) 매칭**으로 폴백. 같은 TagNo에 다중 경로가 있으면 전부 동일한 값으로 덮어써져 **다중경로 데이터 손실**. 수동으로 교정한 `connection_locked` 행도 초기화 위험.
>
> **수정 방향**:
> 1. `tag_dcs`는 **col18**에 배치, col17=`id` 유지
> 2. `ExportToExcelAsync:535` — `worksheet.Cells[1, 18].Value = "DCS태그"` 추가
> 3. `ExportToExcelAsync:562-568` — row write에 col18 추가
> 4. `ImportFromExcelAsync` — col18 읽기 + `Apply()`에 `e.TagDcs` 설정
> 5. 계획서 §2.4-변경5의 Excel import 설명에 col18 반영
>
> ---
>
> #### H2. `upsert_pid_connection` — boolean 인자 + SQL 전면 수정 필요
>
> **문제**: MCP에서 `pid_equipment`를 직접 SQL 조작하는 유일한 경로. `tag_dcs` 컬럼 추가로 SELECT/UPDATE/INSERT 3개 SQL 모두 수정 + Python boolean 타입 핸들링 필요.
>
> **근거**: `mcp-server/server.py:989-1101`
>
> **수정 항목 상세**:
>
> | 위치 | 현재 코드 | 변경 |
> |------|----------|------|
> | 함수 시그니처 (990-997) | `tag_class: str \| None = None` | `tag_dcs: bool \| None = None` 추가 |
> | `_n()` 처리 (1035) | `tag_class = _n(tag_class)` | `tag_dcs = bool(tag_dcs) if tag_dcs is not None else None` — `_n()`은 str 전용이므로 bool은 별도 처리 |
> | `_SNAP` (1037) | 9개 항목 | `"tag_dcs"` 추가 (10개) |
> | SELECT 스냅샷 (1040) | `SELECT tag_no, ..., connection_locked` | `tag_dcs` 추가 |
> | UPDATE SET (1078-1084) | `tag_class=COALESCE(%s, tag_class)` | `tag_dcs=COALESCE(%s, tag_dcs)` 추가 (COALESCE는 boolean도 정상 동작) |
> | INSERT 컬럼 리스트 (1094-1096) | `(tag_no, ..., tag_class, connection_locked)` | `tag_dcs` 추가 — None이면 DEFAULT FALSE |
> | INSERT param (1101) | 9개 param | +1 param |
>
> **위험**: `COALESCE(%s, tag_dcs)`에서 `tag_dcs=False`는 `False`로 정상 전달되므로 문제 없음. 단, `_n()` 함수 사용 시 주의 — boolean은 `str(v)` 후 `strip()` 하면 안 됨.
>
> ---
>
> ### 🟠 MED — 조건부 장애 (특정 상황에서 발생)
>
> #### M1. `_classify_pid_tag` — DCS prefix vs instrument prefix 이중 정의 (동기화 위험)
>
> **문제**: `server.py:_classify_pid_tag()` (257-270)는 DB `pid_prefix_rules`를 전혀 참조하지 않고 자체 Python 상수(`_PID_EQUIPMENT_PREFIX`, `_PID_INSTRUMENT_FIRST`)로 분류. 계획서 §2.9-변경1이 여기에 `_DCS_PREFIXES` 집합을 추가하면 **C# seed와 Python 상수 간 동기화가 수동으로 유지되어야 함**.
>
> **근거**:
> - `mcp-server/server.py:257-270` — `_classify_pid_tag()`는 DB 미조회, 자체 prefix 분류
> - `mcp-server/server.py:263` — `if prefix in _PID_EQUIPMENT_PREFIX:`
> - 계획서 §2.9-변경1: `_DCS_PREFIXES = {"FIC","TIC","PIC","LIC","FY","TY","PY","LY","FV","TV","PV","LV"}`
> - C# seed: `ExperionDbContext.cs:632-696` — DB에 저장된 prefix 목록
>
> **영향**: DXF 파싱 결과(server.py)와 DB extraction(C#)의 `tag_dcs` 판정이 불일치할 수 있음. 예: Python에만 DCS prefix를 추가/제거하면 DXF 추출 결과와 DB 추출 결과의 분류가 달라짐.
>
> **수정 방향**: `_classify_pid_tag`의 `tag_dcs` 필드는 **DXF 추출 결과에만** 사용되며 DB 저장 시 C#에서 다시 판정하므로, 일시적 불일치는 허용됨. 단, Phase 6 검증 시 DXF ↔ DB 분류 일관성 확인 필요. 또는 `_DCS_PREFIXES`를 별도 공유 모듈로 분리 고려.
>
> ---
>
> #### M2. `ClassifyTagClass` — Override 우선순위 모호 (tag_dcs vs hasExperionLink)
>
> **문제**: 계획서 §4는 `tag_dcs=TRUE → TagClass='system'`을 **prefix rule이 ground truth**로 제안. 그러나 현재 `ClassifyTagClass`(812-831)는 `hasExperionLink`(Experion DB 연결 존재)를 최우선 확정 신호로 사용. `tag_dcs=FALSE`인데 `hasExperionLink=true`인 경계 사례에서 모순 발생.
>
> **근거**: `PidExtractorService.cs:817-818` — 현재 1순위: `if (hasExperionLink) return TagClassSystem;`
>
> ```csharp
> // 경계 사례: prefix는 FT(전송기, tag_dcs=FALSE)지만 Experion DB 연결이 있음
> // tag_dcs 기준: "field" vs hasExperionLink 기준: "system"
> // 어느 쪽 우선?
> ```
>
> **영향**: field instrument가 `hasExperionLink=true`로 인해 `TagClass="system"`이 되는 기존 동작이 `tag_dcs` 도입 후에도 유지된다면, `tag_dcs` 컬럼 도입 의미 반감.
>
> **수정 방향**: 설계 결정 명확화:
> - **옵션 A** (계획서 §4): `tag_dcs=TRUE`가 `TagClassSystem`을 강제. 경계 사례에서 `tag_dcs` 우선.
> - **옵션 B**: `tag_dcs`는 정보용 flag, `TagClass`는 기존 로직 유지. `tag_dcs`는 prefix 기반 빠른 필터로만 사용.
>
> ---
>
> #### M3. Seed 데이터 — 46개 prefix DCS 분류 + compound형 누락
>
> **문제**: `ExperionDbContext.cs:632-696`의 46개 seed prefix를 DCS 여부로 분류해야 하나, 계획서의 DCS 목록은 12개 기본형(FIC/TIC/PIC/LIC/FY/TY/PY/LY/FV/TV/PV/LV)만 포함. ISA 후속문자 `{I, C, A, Q, Y, R}`를 가진 compound prefix(FICQ, FICA, TICQ, PICA, LICA, FICR 등)는 시드에 없음.
>
> **근거**:
> - `ClassifyTagClass.cs:803` — `_systemFuncLetters = {'I', 'C', 'A', 'Q', 'Y', 'R'}`
> - 현재 시드: `FIC`, `TIC`, `PIC`, `LIC`만 있음 — `FICQ`, `FICA`, `TICQ` 등 없음
> - `PidExtractorService.cs:793` — `MatchCategoryAsync`는 `StartsWith` 매칭이므로 `FICQ-6113` → `FIC` rule에 매칭되어 category = 'instrument', tag_dcs = TRUE는 되지 않음
> - 계획서 §5에서 인지 ("FICQ 등 suffix prefix")
>
> **영향**: Phase 2.1 UPDATE SQL이 `prefix IN ('FIC',...)`으로만 조건을 걸면 `FICQ` 등은 UPDATE되지 않아 `tag_dcs=FALSE(default)`로 남음.
>
> **수정 방향**: UPDATE SQL을 `StartsWith` 기반으로 변경하거나:
> ```sql
> UPDATE pid_prefix_rules SET tag_dcs = TRUE
> WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV')
> OR prefix LIKE 'FI%' -- FIC, FICA, FICQ 등
> OR prefix LIKE 'TI%' -- TIC, TICA, TICQ 등
> ```
> 또는 seed INSERT에 compound형을 명시적으로 추가. 단, 이 방식은 prefix가 `StartsWith` 매칭이므로 `FI`만 있어도 `FIC/FICA/FICQ/FIR` 등을 모두 커버 — `FIC`를 포함한 `FI` prefix면 충분.
>
> ---
>
> ### 🟡 LOW — 동작에 영향 없음 (유지보수성)
>
> #### L1. `ApplyCategoriesToExistingAsync` — backfill에 `tag_dcs` 누락
>
> **문제**: `ApplyCategoriesToExistingAsync`(884-929)는 기존 `Category == null`인 행과 `TagClass == null`인 행을 backfill. `tag_dcs` 도입 시 이 backfill에도 포함되어야 함.
>
> **근거**: `PidExtractorService.cs:884-929` — 2개 배치 루프:
> - 1차 (890-907): Category = null인 행 → MatchCategoryAsync + ClassifyTagClass
> - 2차 (912-927): Category='instrument' AND TagClass = null인 행 → ClassifyTagClass
>
> **수정**: 각 루프에서 `tag_dcs`도 함께 조회/설정. `ClassifyTagClass` 시그니처에 `tagDcs` 파라미터 추가 시 이 호출부(902, 922) 함께 수정.
>
> ---
>
> #### L2. `MatchCategoryAsync` — `(category, tagDcs)` 튜플 반환 또는 분리
>
> **문제**: 현재 `MatchCategoryAsync`(787-795)는 `string?`(category만) 반환. `tag_dcs`가 필요하면 별도 API 필요. 계획서 §2.4-변경1에서 이슈 인지.
>
> **근거**:
> - `PidExtractorService.cs:90` — `var category = await MatchCategoryAsync(item.TagNo);`
> - `PidExtractorService.cs:787-795` — return `string?`
> - `GetRulesCachedAsync`(754)가 이미 `List<PidPrefixRule>`를 캐싱하므로 `tag_dcs`도 함께 사용 가능
>
> **수정 방향**: 3가지 옵션
> 1. `MatchCategoryAsync` → `(string? category, bool tagDcs)` 튜플: 리팩터 범위 큼 (호출부 3곳)
> 2. 별도 `ResolveTagDcsAsync(tagNo)` 추가: 최소 침습
> 3. `GetRulesCachedAsync()` 결과에서 직접 조회: 캐시 직접 접근 → 캡슐화 위반
>
> ---
>
> #### L3. `PidEquipmentDto` — TagMappingService 미반영
>
> **문제**: `PidEquipmentDto`(3-19)는 `TagClass`도 없고 `TagDcs`도 없음. 매핑 탭에서 이 DTO를 사용하므로, 매핑 UI에도 DCS 여부를 표시하려면 추가 필요.
>
> **근거**: `src/Core/Application/DTOs/PidEquipmentDto.cs:3-19` — 16개 필드, TagClass/TagDcs 없음
>
> **수정**: 선택사항 — 매핑 탭에 표시 불필요 시 생략 가능.
>
> ---
>
> #### L4. `_DB_SCHEMA` / `sql_prompt.py` — pid_equipment 미등록
>
> **문제**: `_DB_SCHEMA`(server.py:678)와 `DB_SCHEMA`(sql_prompt.py:9) 모두 `pid_equipment` 테이블이 없음. LLM이 NL2SQL로 "DCS 태그 몇 개?" 같은 질문을 SQL 변환해도 실행 불가.
>
> **근거**:
> - `mcp-server/server.py:678-694` — history_table, realtime_table, tag_metadata, event_history_table만 있음
> - `mcp-server/worker/sql_prompt.py:9-104` — 동일
> - 계획서 §2.9-변경2, §2.9-변경3에서 server.py / sql_prompt.py 수정 언급
>
> **수정**: 계획서 §2.9-변경2/3, §2.10에 따라 추가. 단, `_DB_SCHEMA` 추가 시 SQL injection 방지를 위해 테이블/컬럼명을 LLM 프롬프트에만 포함하고 실제 쿼리는 MCP 도구로 제한하는 현재 아키텍처 유지.
>
> ---
>
> ### 🔵 영향 없음 (No Change)
>
> | 파일 | 이유 | 근거 |
> |------|------|------|
> | `IExperionServices.cs` | 인터페이스 시그니처 = DTO 타입 그대로 | DTO 변경만으로 자동 반영 |
> | `PidController.cs:GetPrefixRules` | 이미 anonymous object camelCase 반환 | `tagDcs: r.TagDcs` 추가만 필요 |
> | `ExperionDbContext.cs:v_pump_signal_map` | `FROM pid_equipment ft` — `ft.from_tag, ft.tag_no, ft.category`만 조회 | tag_dcs 미사용 |
> | `server.py:trace_connections` | SELECT `tag_no, from_tag, to_tag, role` | tag_dcs 미조회 |
> | `validators.py:37` | `SELECT DISTINCT tag_no FROM pid_equipment` | tag_dcs 무관 |
> | `instrument_inference/infer.py` | 독립 실행 모듈, DB 미의존 | 계획서 §2.11 판단 올바름 |
> | `SeedSubAreaAsync` SQL | `FROM pid_equipment WHERE role ILIKE '%공용%'` | tag_dcs 무관 |
>
> ---
>
> ### 수정 필요 파일 요약 (총 19곳)
>
> | 등급 | 파일명 | 변경 내용 |
> |------|--------|---------|
> | 🔴 H1 | `PidExtractorService.cs` | Excel col17→col18 재배치 + Import hasIdCol 보호 + Apply() TagDcs |
> | 🔴 H2 | `server.py:upsert_pid_connection` | `tag_dcs` bool 인자 + SELECT/UPDATE/INSERT 3개 SQL |
> | 🟠 M1 | `server.py:_classify_pid_tag` | `_DCS_PREFIXES` 추가 — C# seed와 동기화 문서화 |
> | 🟠 M2 | `PidExtractorService.cs:ClassifyTagClass` | `tag_dcs` 가중치 결정 및 시그니처 변경 |
> | 🟠 M3 | `ExperionDbContext.cs:seed` | 46개 prefix DCS 분류 + compound형(FICQ 등) |
> | 🟡 L1 | `PidExtractorService.cs:ApplyCategoriesToExistingAsync` | backfill에 tag_dcs 추가 |
> | 🟡 L2 | `PidExtractorService.cs:MatchCategoryAsync` | tagDcs 반환 경로 추가 |
> | 🟡 L3 | `PidEquipmentDto.cs` | 선택적 TagDcs 추가 |
> | 🟡 L4 | `server.py:_DB_SCHEMA` + `sql_prompt.py` | pid_equipment + tag_dcs 설명 추가 |
> | 🟢 | `PidPrefixRule.cs` | `TagDcs` property 추가 |
> | 🟢 | `PidEquipment.cs` | `TagDcs` property 추가 |
> | 🟢 | `PidPrefixRuleDto.cs` | 3개 record에 `bool TagDcs` |
> | 🟢 | `PidController.cs:GetPrefixRules/GetEquipment` | `tagDcs` 필드 추가 |
> | 🟢 | `PidExtractorService.cs:ExtractFromStreamAsync` | 추출 시 TagDcs 저장 |
> | 🟢 | `PidExtractorService.cs:Create/UpdatePrefixRuleAsync` | TagDcs 전달 |
> | 🟢 | `PidExtractorService.cs:ExportToCsvAsync` | 헤더+행 열 추가 |
> | 🟢 | `ExperionDbContext.cs` | DDL + ALTER TABLE IF NOT EXISTS + Fluent API |
> | 🟢 | `wwwroot/js/pid.js` | Prefix UI checkbox + Add/Update body |
> | 🟢 | `wwwroot/panes/pid.html` | 선택적 열 헤더 |
> | 🟢 | `prompts/plant_context.md` | tag_dcs 설명 추가 |
>
> ---
---
## 0. 배경 및 문제
### 현재 구조의 문제
현재 `pid_prefix_rules.category = 'instrument'` 아래에 두 종류가 혼재:
| 종류 | 예시 prefix | 실제 의미 |
|------|------------|---------|
| **현장 계기** (field) | FT, PT, LT, TT, FCV, PCV, PSV, XV, FG, PG | 물리적 기기, 현장 설치 |
| **DCS 함수블록** (system) | FIC, TIC, PIC, LIC, FY, TY, PY, LY | DCS/SCADA 내부 연산 블록, 물리 기기 없음 |
기존 `tag_class = 'field'/'system'` 컬럼이 이를 구별하려 했으나:
- **추출 후 후처리**에서 판정 (ISA 후속문자 분석 + Experion 연결 여부)
- **PREFIX 정의 UI**에서는 전혀 보이지 않아 운전원이 구별 불가
- `hasExperionLink`를 "DCS 태그 여부"의 proxy로 잘못 사용 — FT 전송기도 Experion에 연결되므로 'system'으로 오분류됨
- LLM이 pid_equipment 조회 시 instrument를 한꺼번에 가져와 혼동
### 목표
`pid_prefix_rules` 테이블에 `tag_dcs BOOLEAN` 추가 → PREFIX 분류 정의 시점부터 DCS 여부 명시.
`pid_equipment` 테이블에도 동일 컬럼 전파 → 추출 결과 전체에 flag 유지.
---
## 1. DCS vs Field 분류 기준
### 판단 원칙: 물리 기기 존재 여부
FT-6113(전송기)은 Experion에 연결되어 있어도 **현장 계기(field)**다.
Experion 연결 = 현장 신호를 DCS가 읽어오는 것이지, 기기 자체가 DCS 소프트웨어가 되는 게 아님.
### DCS 태그 (`tag_dcs = TRUE`) — 물리 기기 없음, DCS 함수블록만 존재
| Prefix | 설명 | ISA 후속문자 |
|--------|------|------------|
| FIC, FICA, FICQ, FICR | Flow Indicator Controller (+ Alarm/Totalizer/Recorder 변형) | C, A, Q, R |
| TIC, TICA, TICQ | Temperature Indicator Controller 변형 | C, A, Q |
| PIC, PICA | Pressure Indicator Controller 변형 | C, A |
| LIC, LICA | Level Indicator Controller 변형 | C, A |
| FY, TY, PY, LY | Relay/Converter/Computing | Y |
| FV, TV, PV, LV | Valve function block output (DCS 출력 전용, 물리 FCV와 구별) | V(fb) |
> `_systemFuncLetters = {I, C, A, Q, Y, R}` — ISA 표준 제어시스템 후속문자 전체 포함
> **주의**: FCV/PCV/LCV/TCV는 물리적 제어밸브 → `tag_dcs = FALSE` (field 유지)
### 현장 계기 (`tag_dcs = FALSE`) — 물리 기기
| Prefix | 설명 |
|--------|------|
| FT, TT, PT, LT | 1차 측정 전송기 (Transmitter) — Experion 연결 여부 무관하게 field |
| FG, TG, PG, LG | 게이지류 (Gauge) |
| FCV, TCV, PCV, LCV | 제어밸브 (물리 기기) |
| PSV | 안전밸브 |
| XV | 차단밸브 |
| VIP, VIT | 진동 프로브/전송기 |
| DP | 차압계 |
| BV | 볼/버터플라이 밸브 |
---
## 2. 영향 범위 전체 목록
### 2.1 데이터베이스 (4곳)
| 대상 | 변경 내용 |
|------|---------|
| `pid_prefix_rules` 테이블 | `tag_dcs BOOLEAN NOT NULL DEFAULT FALSE` 컬럼 추가 |
| `pid_prefix_rules` 시드 | DCS prefix에 `tag_dcs = TRUE` UPDATE |
| `pid_equipment` 테이블 | `tag_dcs BOOLEAN NOT NULL DEFAULT FALSE` 컬럼 추가 |
| `pid_equipment` 기존 행 | prefix rule StartsWith 기반 backfill |
**마이그레이션 SQL** (Boot DDL에 추가, 재기동 시 자동 실행):
```sql
-- Step 1: pid_prefix_rules 컬럼 추가
ALTER TABLE pid_prefix_rules
ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
-- Step 2: DCS prefix 마킹 (기본형 — compound형은 Step 4에서 커버)
UPDATE pid_prefix_rules
SET tag_dcs = TRUE
WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV');
-- Step 3: pid_equipment 컬럼 추가
ALTER TABLE pid_equipment
ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
-- Step 4: 기존 행 backfill — StartsWith 매칭 (compound형 FICQ/FICA 등 자동 포함)
-- pid_equipment.instrument_type LIKE 'FIC%' → FIC, FICA, FICQ, FICR 모두 해당
UPDATE pid_equipment pe
SET tag_dcs = TRUE
FROM pid_prefix_rules pr
WHERE pe.instrument_type LIKE (pr.prefix || '%')
AND pr.tag_dcs = TRUE;
```
> **compound prefix 처리**: `MatchCategoryAsync`가 이미 StartsWith 매칭이므로 C# 추출 경로에서는
> `FICQ-6113` → `FIC` rule의 `tag_dcs=TRUE`가 자동 상속됨. backfill SQL도 동일 방식 적용.
---
### 2.2 C# 도메인 엔티티 (2파일)
#### `src/Core/Domain/Entities/PidPrefixRule.cs`
```csharp
// 추가
[Column("tag_dcs")]
public bool TagDcs { get; set; } = false;
```
#### `src/Core/Domain/Entities/PidEquipment.cs`
```csharp
// 추가 (tag_class 아래)
[Column("tag_dcs")]
public bool TagDcs { get; set; } = false;
```
---
### 2.3 DTOs (1파일, 3개 record)
#### `src/Core/Application/DTOs/PidPrefixRuleDto.cs`
```csharp
// 수정 후 (TagDcs 추가)
public record PidPrefixRuleDto(
int Id, string Prefix, string Category, bool TagDcs,
string? Description, int SortOrder, DateTime CreatedAt);
public record CreatePidPrefixRuleRequest(
string Prefix, string Category,
bool TagDcs = false, string? Description = null, int SortOrder = 0);
public record UpdatePidPrefixRuleRequest(
string Prefix, string Category,
bool TagDcs = false, string? Description = null, int SortOrder = 0);
```
> `PidEquipmentDto`(`PidEquipmentDto.cs`)는 매핑 탭 전용이며 `TagClass`도 없으므로
> 이번 범위에서 제외 (선택사항, 매핑 탭 DCS 배지 표시 필요 시 별도 추가).
---
### 2.4 Application Services (1파일)
#### `src/Core/Application/Services/PidExtractorService.cs`
**변경 1**: `tag_dcs` 조회 경로 — 별도 `ResolveTagDcsAsync(tagNo)` 추가 (L2 결정)
```csharp
// MatchCategoryAsync 시그니처 유지, 별도 메서드 추가 (최소 침습)
private async Task<bool> ResolveTagDcsAsync(string tagNo)
{
var rules = await GetRulesCachedAsync();
var upper = tagNo.ToUpperInvariant();
// StartsWith 매칭 (compound형 자동 포함)
var rule = rules
.Where(r => upper.StartsWith(r.Prefix.ToUpperInvariant()))
.OrderByDescending(r => r.Prefix.Length) // 가장 긴 prefix 우선
.FirstOrDefault();
return rule?.TagDcs ?? false;
}
```
**변경 2**: `ClassifyTagClass()` 재설계 — tag_dcs 우선, hasExperionLink 역할 변경 (M2 결정: Option A)
```csharp
// 수정 후 시그니처 — tagDcs 파라미터 추가, hasExperionLink는 fallback용으로만 유지
private static string? ClassifyTagClass(string tagNo, string? category, bool tagDcs, bool hasExperionLink)
{
if (category != PidEquipment.CategoryInstrument) return null;
// tag_dcs가 true면 prefix rule이 ground truth → system 확정
// (FT 전송기가 Experion에 연결돼도 field — hasExperionLink 무관)
if (tagDcs) return PidEquipment.TagClassSystem;
// tag_dcs=FALSE: 현장 계기 → field
// hasExperionLink는 더 이상 TagClass 결정에 사용하지 않음
// (ExperionTagId FK로 연결 정보는 보존됨)
return PidEquipment.TagClassField;
}
```
**변경 3**: 추출 저장 시 `TagDcs` 채우기
```csharp
var category = await MatchCategoryAsync(item.TagNo);
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
var tagClass = ClassifyTagClass(item.TagNo, category, tagDcs, experionTag != null);
item.Category = category;
item.TagDcs = tagDcs;
item.TagClass = tagClass;
```
**변경 4**: CSV/Excel export — **col18**에 `TagDcs` 추가 (col17=`id` 보호 ⚠️ H1)
```csharp
// ExportToExcelAsync
worksheet.Cells[1, 17].Value = "id"; // 기존 유지 — hasIdCol 감지 키
worksheet.Cells[1, 18].Value = "DCS태그"; // 신규 추가
// row write
worksheet.Cells[row, 17].Value = item.Id;
worksheet.Cells[row, 18].Value = item.TagDcs ? "DCS" : "현장";
```
**변경 5**: Excel import — col18 읽기 + `Apply()`에 TagDcs 설정
```csharp
// ImportFromExcelAsync
// hasIdCol 감지: col17 = "id" (기존 동일)
var hasDcsCol = ws.Cells[1, 18].Text == "DCS태그";
// Apply() 내부
if (hasDcsCol)
{
var dcsVal = ws.Cells[row, 18].Text.Trim();
e.TagDcs = dcsVal == "DCS";
}
```
**변경 6**: `ApplyCategoriesToExistingAsync()` — 두 backfill 루프에 tag_dcs 추가 (L1)
```csharp
// 1차 루프 (Category=null 행)
item.Category = category;
item.TagDcs = await ResolveTagDcsAsync(item.TagNo); // 추가
item.TagClass = ClassifyTagClass(item.TagNo, category, item.TagDcs, item.ExperionTagId != null);
// 2차 루프 (Category='instrument' AND TagClass=null 행)
item.TagDcs = await ResolveTagDcsAsync(item.TagNo); // 추가
item.TagClass = ClassifyTagClass(item.TagNo, item.Category, item.TagDcs, item.ExperionTagId != null);
```
> 메서드명 정정: 계획서 초안의 `BackfillTagClassAsync()` 오기 → 실제 메서드는 `ApplyCategoriesToExistingAsync()` (line 884)
**변경 7**: `CreatePrefixRuleAsync` / `UpdatePrefixRuleAsync``request.TagDcs``rule.TagDcs` 저장 후 `InvalidateRulesCache()` 호출 (기존 패턴 동일).
---
### 2.5 인터페이스 (변경 없음)
#### `src/Core/Application/Interfaces/IExperionServices.cs`
DTO record 변경만으로 `CreatePrefixRuleAsync` / `UpdatePrefixRuleAsync` 시그니처 자동 반영. 추가 수정 불필요.
---
### 2.6 EF Core DbContext (1파일)
#### `src/Infrastructure/Database/ExperionDbContext.cs`
**변경 1**: Boot DDL에 §2.1 마이그레이션 SQL 4개 Step 추가 (재기동 시 자동 실행)
**변경 2**: 시드 INSERT는 `ON CONFLICT DO NOTHING` → 기존 행 미반영.
Step 2 UPDATE로 기존 행의 `tag_dcs` 갱신 (Boot DDL에서 연속 실행).
**변경 3**: `modelBuilder.Entity<PidPrefixRule>()` — Column attribute로 자동 매핑되므로 Fluent API 추가 불필요.
---
### 2.7 Web Controllers (1파일)
#### `src/Web/Controllers/PidController.cs`
**변경 1**: `GetPrefixRules` — 익명객체 camelCase 반환 방식이므로 `tagDcs: r.TagDcs` 1줄 추가.
(진단 확인: 이미 anonymous object camelCase 사용 중 → 별도 `[JsonPropertyName]` 불필요)
**변경 2**: `CreatePrefixRule` / `UpdatePrefixRule` — DTO에 `TagDcs` 추가되므로 컨트롤러 수정 불필요.
---
### 2.8 Web UI (2파일)
#### `src/Web/wwwroot/js/pid.js`
**변경 1**: PREFIX 그룹 렌더링 (`pidRenderPrefixGroups`) — 각 행에 DCS/현장 배지 추가:
```javascript
// view row
`<td><span class="badge ${r.tagDcs ? 'warn' : 'ok'}">${r.tagDcs ? 'DCS' : '현장'}</span></td>`
// edit row
`<input type="checkbox" id="pid-dcs-${r.id}" ${r.tagDcs ? 'checked' : ''} />`
`<label for="pid-dcs-${r.id}">DCS</label>`
```
**변경 2**: `pidAddPrefixRule(category)` 요청 body에 `tagDcs` 추가
**변경 3**: `pidUpdatePrefixRule(id)` 요청 body에 `tagDcs` 추가
#### `src/Web/wwwroot/panes/pid.html`
- PREFIX 분류 정의 패널 테이블 헤더에 "DCS태그" 열 추가
---
### 2.9 MCP Server Python (2파일)
#### `mcp-server/server.py`
**변경 1**: `_DCS_PREFIXES` 상수 추가 + `_classify_pid_tag()` 반환에 `tag_dcs` 포함
```python
# compound형 포함 — ISA _systemFuncLetters 기준 확장
_DCS_PREFIXES: frozenset[str] = frozenset({
"FIC", "FICA", "FICQ", "FICR",
"TIC", "TICA", "TICQ",
"PIC", "PICA",
"LIC", "LICA",
"FY", "TY", "PY", "LY",
"FV", "TV", "PV", "LV",
})
# _classify_pid_tag() 반환
return {
"kind": "instrument",
"prefix": prefix,
"type": type_name,
"tag_dcs": prefix in _DCS_PREFIXES,
}
```
> ⚠️ M1 동기화 주의: `_DCS_PREFIXES`(Python)와 C# seed UPDATE 목록은 수동 동기화 필요.
> 양쪽 변경 시 함께 수정. 향후 공유 모듈(`dcs_prefixes.py`) 분리 고려.
**변경 2**: `_DB_SCHEMA``pid_equipment` 테이블 추가 (L4)
```python
_DB_SCHEMA = """
...
테이블: pid_equipment (P&ID 추출 장비/계기)
tag_no TEXT - 태그번호 (예: FIC-6113, FT-6113)
category TEXT - 'instrument' / 'power_equipment' / 'storage_equipment' / ...
tag_dcs BOOL - TRUE=DCS 함수블록(FIC/TIC/PIC류), FALSE=현장 물리 계기(FT/FCV류)
tag_class TEXT - 'field'(현장) / 'system'(DCS) — tag_dcs 기반
instrument_type TEXT - ISA prefix (FT/FIC/P 등)
from_tag TEXT - 연결 상류 태그
to_tag TEXT - 연결 하류 태그
...
"""
```
**변경 3**: `upsert_pid_connection``tag_dcs` bool 인자 추가 + SQL 3곳 수정 (H2 확정)
| 위치 | 변경 내용 |
|------|---------|
| 함수 시그니처 (line 990-997) | `tag_dcs: bool \| None = None` 파라미터 추가 |
| `_n()` 처리 (line 1035) | bool은 `_n()` 미사용 — `tag_dcs = bool(tag_dcs) if tag_dcs is not None else None` |
| `_SNAP` 목록 (line 1037) | `"tag_dcs"` 추가 |
| SELECT 스냅샷 (line 1040) | `tag_dcs` 컬럼 추가 |
| UPDATE SET (line 1078) | `tag_dcs=COALESCE(%s, tag_dcs)` 추가 |
| INSERT 컬럼/값 (line 1094) | `tag_dcs` 추가, None이면 `DEFAULT FALSE` |
```python
# bool 처리 예시
tag_dcs_val = bool(tag_dcs) if tag_dcs is not None else None
# COALESCE는 boolean도 정상 동작 — False 전달 시 False로 저장됨
```
#### `mcp-server/worker/sql_prompt.py`
`DB_SCHEMA` 상수에 `pid_equipment` 추가 (L4):
```
테이블: pid_equipment(tag_no TEXT, category TEXT, tag_dcs BOOL, tag_class TEXT,
instrument_type TEXT, from_tag TEXT, to_tag TEXT)
※ tag_dcs=TRUE: DCS 함수블록(FIC/TIC/PIC류), FALSE: 현장 물리 계기(FT/FCV류)
※ 연결 추적: from_tag(상류) → tag_no → to_tag(하류)
```
---
### 2.10 프롬프트 / 지식 파일 (1파일)
#### `prompts/plant_context.md`
```markdown
## pid_equipment.tag_dcs — 현장 계기 vs DCS 함수블록 구별
- tag_dcs = TRUE: DCS 내부 함수블록 (FIC, TIC, PIC, LIC, FY, TY, PY, LY 등 compound형 포함)
- 물리 기기 없음. Experion DB 포인트로만 존재
- tag_dcs = FALSE: 현장 물리 계기 (FT, PT, LT, FCV, PSV, XV 등)
- P&ID 도면에 기기 심벌로 표시되는 실물. Experion 연결 여부 무관하게 field
쿼리 예:
- "DCS 태그 몇 개?" → SELECT COUNT(*) FROM pid_equipment WHERE tag_dcs=TRUE
- "현장 계기 목록" → SELECT * FROM pid_equipment WHERE tag_dcs=FALSE AND category='instrument'
- "FIC-6113이 DCS 태그인가?" → SELECT tag_dcs FROM pid_equipment WHERE tag_no='FIC-6113'
```
---
### 2.11 instrument_inference — 변경 없음
`infer.py`는 독립 실행 모듈(DB 미의존). 내부 `_dcs_internal_roles` 로직은 infer 전용으로 유지.
---
### 2.12 영향 없음 확인 (진단 결과)
| 파일 | 근거 |
|------|------|
| `IExperionServices.cs` | DTO 변경으로 자동 반영 |
| `ExperionDbContext.cs:v_pump_signal_map` | `from_tag, tag_no, category`만 조회 |
| `server.py:trace_connections` | `tag_no, from_tag, to_tag, role`만 조회 |
| `verifier/validators.py` | `SELECT DISTINCT tag_no FROM pid_equipment` |
| `SeedSubAreaAsync` SQL | `WHERE role ILIKE '%공용%'` |
---
## 3. 단계별 구현 순서
### Phase 1: DB 스키마 (재기동으로 자동 적용)
1. `ExperionDbContext.cs` Boot DDL에 §2.1 마이그레이션 SQL(Step 1~4) 추가
2. 웹 서버 재기동 → ALTER TABLE + UPDATE 자동 실행
### Phase 2: 도메인/DTO/서비스 (C# 코어)
1. `PidPrefixRule.cs``TagDcs bool` 프로퍼티 추가
2. `PidEquipment.cs``TagDcs bool` 프로퍼티 추가
3. `PidPrefixRuleDto.cs` — 3개 record에 `TagDcs` 추가
4. `PidExtractorService.cs`:
- `ResolveTagDcsAsync()` 신규 추가
- `ClassifyTagClass()` 시그니처 변경 + hasExperionLink 역할 변경
- `ExtractFromStreamAsync()` TagDcs 저장
- `ExportToExcelAsync()` col18 추가
- `ImportFromExcelAsync()` col18 읽기
- `ApplyCategoriesToExistingAsync()` 두 루프에 tag_dcs 추가
- `CreatePrefixRuleAsync()` / `UpdatePrefixRuleAsync()` TagDcs 전달
5. `dotnet build` — 경고 0/에러 0 확인
### Phase 3: Web Controller
1. `PidController.cs:GetPrefixRules` — 익명객체에 `tagDcs: r.TagDcs` 추가
### Phase 4: Web UI
1. `pid.js` — PREFIX 그룹 렌더링 DCS/현장 배지 + Add/Update body
2. `panes/pid.html` — "DCS태그" 열 헤더
### Phase 5: MCP / LLM 경로
1. `server.py``_DCS_PREFIXES` + `_classify_pid_tag` + `_DB_SCHEMA` + `upsert_pid_connection`
2. `worker/sql_prompt.py``pid_equipment` 테이블 추가
3. `prompts/plant_context.md` — tag_dcs 설명 추가
### Phase 6: 검증
1. `dotnet build` — 경고 0/에러 0
2. `python3 -m py_compile mcp-server/server.py` — OK
3. DB 확인: `SELECT tag_dcs, COUNT(*) FROM pid_equipment GROUP BY tag_dcs`
4. Excel 라운드트립: export → 열기 → col17=`id` 확인 → import → hasIdCol=true 확인
5. 웹 UI: PREFIX 분류 탭 DCS/현장 배지 정상 표시
6. `ApplyCategoriesToExistingAsync` API 호출 후 `tag_dcs` backfill 확인
---
## 4. 설계 결정
| 항목 | 결정 | 근거 |
|------|------|------|
| 컬럼 타입 | `tag_dcs BOOLEAN` (별도 category 아님) | category 변경 시 뷰/필터 전파 과도. Boolean이 최소 침습적 |
| `tag_class` 유지 | 유지 (deprecated 아님) | `ExperionTagId` FK와 함께 연결 증거 보존. `tag_dcs`는 prefix 기반 빠른 flag |
| **M2: ClassifyTagClass 우선순위** | **tag_dcs 우선 (Option A)** | 전송기(FT)는 Experion 연결 여부와 무관하게 현장 계기. `hasExperionLink`는 DCS 함수블록 판별의 proxy로 부정확 |
| `hasExperionLink` 역할 | TagClass 결정에서 제외 | 연결 정보는 `ExperionTagId`로 보존됨. 더 이상 TagClass 결정에 사용 안 함 |
| FCV/PCV/LCV/TCV | `tag_dcs = FALSE` | 물리 제어밸브. DCS가 제어하지만 기기 자체는 현장 |
| FV/TV/PV/LV | `tag_dcs = TRUE` | ISA 표준 "Valve function block output" — 물리 기기 아닌 DCS 출력 |
| compound prefix | LIKE StartsWith 매칭으로 자동 커버 | `FICQ LIKE 'FIC%'` = TRUE. 시드에 compound형 개별 추가 불필요 |
| Excel 열 위치 | **col18** (col17=`id` 보호) | col17 덮어쓰면 `hasIdCol=false` → 다중경로 데이터 손실 (H1) |
| `MatchCategoryAsync` | 시그니처 유지, 별도 `ResolveTagDcsAsync()` 추가 | 호출부 3곳 리팩터 없이 최소 침습 (L2 Option 2) |
| backfill 트리거 | Boot DDL(SQL) + API(`ApplyCategoriesToExistingAsync`) 양쪽 | Boot DDL은 컬럼+초기 UPDATE, 추출 오류 행은 API로 재실행 |
| `PidEquipmentDto` | 이번 범위 제외 | 매핑 탭은 TagClass도 없음. 필요 시 별도 추가 |
---
## 5. 잔여/고려사항
- **`_DCS_PREFIXES` 동기화**: Python `server.py`와 C# Boot DDL UPDATE 목록은 수동 동기화 필요.
양쪽 변경 시 함께 수정. 향후 `mcp-server/worker/dcs_prefixes.py` 분리로 단일 소스화 가능.
- **compound prefix 시드 미등록**: `pid_prefix_rules``FICQ`가 없으면 UI에서 직접 추가한 경우 `tag_dcs=FALSE`(default)로 저장될 수 있음. 운전원이 UI에서 FICQ를 추가할 때 DCS 체크박스를 직접 체크해야 함.
- **`ApplyCategoriesToExistingAsync` 수동 실행**: Boot DDL의 Step 4 UPDATE로 기존 데이터 초기 backfill은 완료됨. 이후 신규 추출/오류 행은 P&ID 탭의 "Category 재적용" 버튼으로 API 호출.
- **재추출 여부**: 불필요. backfill SQL로 충분.