# 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`를 캐싱하므로 `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 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()` — 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 `${r.tagDcs ? 'DCS' : '현장'}` // edit row `` `` ``` **변경 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로 충분.