# 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로 충분.
