하나의 area(P6)를 Column 단위 sub_area(P6-1/P6-2)로 분류. tag_metadata
attribute='sub_area'(EAV)에 저장, 공용 설비는 "P6-1,P6-2" 형식 + 토큰 매칭.
- 백엔드: GetSubAreaListByAreaAsync/UpdateSubAreaAsync/SeedSubAreaAsync,
SubAreaController(GET/PUT/POST seed), SubAreaDtos
- 포인트 삭제 개선: DeleteRealtimePointAsync(purgeHistory) — 잔여 0이면
고아 메타데이터(desc/area/sub_area) 정리, opt-in 시 history_table 영구 삭제
- MCP: find_tags(sub_area=...) + _area_or_subarea_filter('-' 포함 시 자동 토큰 매칭)
- 문서: prompts/plant_context.md, AGENTS.md, SubArea-추가플랜.md
- UI: 포인트빌더 Sub-Area 관리 카드(조회/수정/seed) + 행별 이력 삭제 체크박스
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
55 KiB
Sub-Area (세부 Area) 추가 플랜
✅ 구현 완료 + 진단 정정 (2026-05-23, 실데이터 검증)
아래 "🔍 코드 진단 결과"는 코드 작성 전 정적 분석이었고, 실제 DB 데이터로 검증하니 seed 설계의 핵심 전제가 틀렸음이 드러났다. 정정 후 전 Phase를 구현·시드·검증 완료했다.
진단 정정 (실데이터로 뒤집힌 전제)
| # | 원 플랜 전제 | 실데이터 | 정정 |
|---|---|---|---|
| A | pid_equipment의 role에 Column 연결정보(C-6111/C-6211)가 있어 1순위 매핑 |
role은 자유서술("C-6111 / 리보일러", "원료 투입 펌프"). role ILIKE '%C-6211%' = 0건, C-6111만 19건. P9/P10/P1/P2 Column ID는 아예 없음 |
prefix가 1순위. pid_equipment는 "공용(공용/공통)" 검출 보조로만 사용 |
| B | seed에 unnest(ARRAY[from_tag,to_tag]) 사용 |
from_tag/to_tag는 연결 대상(예: E-6115A의 from/to = "C-6111"). 장비 자신이 아님 → 잘못된 base_tag 부여 | unnest 폐기. area(tag_metadata) + 태그번호 prefix를 area-scoped로 매칭 |
| C | 파일 src/Infrastructure/Database/ExperionDbService.cs 수정 |
그런 파일 없음. ExperionDbService 클래스는 ExperionDbContext.cs(line 270) 안에 있음 |
ExperionDbContext.cs에 메서드 추가 |
| D | MCP SQL에서 eht.tag_name, eht.prev_state_duration_s |
실제 컬럼은 tagname, duration_seconds |
정정 + EXISTS 조인은 split_part(tagname,'.',1) |
| E | v_tag_summary.area = 'P6' 비교 |
area는 원본형식 {12 | P6 | } → 직접 비교 실패 |
area 코드 비교는 trim(split_part(area,'|',2)) |
공용(shared) 태그 — 두 sub_area 동시 부여 (사용자 요구 반영)
vp-2127a/b(진공펌프)처럼 P2-1·P2-2가 함께 쓰는 설비는 NULL이 아니라 두 코드를 콤마로 저장
("P2-1,P2-2"). 다른 플랜트도 동일(P6: p-6201/ficq-6201 → "P6-1,P6-2"). (fi-6201은 실재하지 않는 태그라 제외 — 추적 결과 참조)
모든 필터는 토큰 매칭 '<code>' = ANY(string_to_array(value, ',')) 사용 → P2-1 필터·P2-2 필터
양쪽에서 공용 설비가 잡힌다.
최종 seed 규칙 (area-scoped 번호 prefix)
P6: 61→P6-1, 62→P6-2 | P9: 91→P9-1, 92→P9-2 | P10: 101→P10-1, 102→P10-2
P1: 11→P1-1, 12/13→P1-2 | P2: 211→P2-1, 212/213→P2-2
공용(→ 두 코드): pid_equipment role '%공용%/%공통%', P6 6201*, P2 2127*/2128*/2129*
그 외(66xx/67xx/69xx 유틸 등) → NULL (수동 분류 여지)
시드 결과 (실행 완료, 356행)
| P6 | P9 | P10 | P1 | P2 | |
|---|---|---|---|---|---|
| -1 | 56 | 48 | 34 | 14 | 22 |
| -2 | 49 | 30 | 30 | 26 | 36 |
| 공용 | 3 | - | - | - | 8 |
구현 파일 (전부 빌드/컴파일 통과)
| 영역 | 파일 | 내용 |
|---|---|---|
| Core | Interfaces/IExperionServices.cs + DTOs/SubAreaDtos.cs |
4개 메서드 + 3 DTO |
| Infra | Database/ExperionDbContext.cs |
Get/List/Update/SeedSubAreaAsync + v_tag_summary에 sub_area 컬럼 |
| Web | Controllers/ExperionControllers.cs |
SubAreaController (GET/PUT/POST seed, camelCase) |
| MCP | mcp-server/server.py |
_area_or_subarea_filter 헬퍼 + find_tags(sub_area)·active_alarms·query_events. summarize_events/generate_status_report는 위임으로 자동 적용 |
| Docs | prompts/plant_context.md, AGENTS.md |
sub-area 표·호출법·공용 규칙 |
| FE | wwwroot/index.html, js/app.js |
Sub-Area 관리 카드 + load/update/seed |
검증:
dotnet build(0 err) ·py_compile(OK) ·node -c(OK) · DB 필터 쿼리 5종(공용 양방향 노출 포함) 통과.
🔍 코드 진단 결과 (diagnosis-checklist.md 기준)
⚠️ 아래는 코드 작성 전 정적 분석. 2·3·4번 항목(seed SQL/파일경로)은 위 "진단 정정"으로 대체됨.
진단일: 2026-05-23 / 대상: SubArea-추가플랜.md + 관련 소스 코드
실행 단계 요약
| 단계 | 상태 | 비고 |
|---|---|---|
| STEP 1 맥락 파악 | ✅ | 설계/의사결정 기록 + TODO, 코드 미작성 상태 |
| STEP 2 구조 탐색 | ✅ | tag_metadata EAV, v_tag_summary VIEW, pid_equipment 확인 |
| STEP 3 코드 읽기 | ✅ | IExperionServices.cs, ExperionDbContext.cs, MetadataLoaderService.cs, DigitalEventDetectorService.cs, server.py 전체 읽음 |
| STEP 4 호출 계층 | ✅ | area 흐름: tag_metadata → GetAreaByTagNameAsync → DigitalEventDetector → event_history_table |
| STEP 5 패턴 매칭 | ✅ | 체크리스트 순회 완료 |
| STEP 6 교차 검증 | ✅ | 4개 질문(Q1~Q4) 각 항목에 적용 |
1. MetadataLoaderService sub_area 덮어쓰기 위험 (Q3 통과 → 제거)
지적: 플랜 6. 고려사항에서 "MetadataLoaderService가 sub_area를 덮어쓸 위험"을 리스크로 등재.
교차 검증 Q3 (의도적 설계인가): MetadataLoaderService.cs:20-23에서 MetaAttributes = { "desc", "area" }로 고정되어 있으며, UPSERT SQL은 attribute IN ('desc', 'area')로 제한됨. sub_area는 이 배열에 없으므로 절대 건드리지 않음.
결론: 실제 리스크 아님. 플랜에 이미 "sub_area는 절대 건드리지 않도록 코드 보호"라고 적혀 있으나, 이는 코드 구조상 이미 보장되는 사항임. 리스크 테이블에서 제거해도 무방.
2. seed SQL 예시의 EXCEPT 구문 불완전 (MED)
문제: 플랜 2.4의 seed 쿼리에서 EXCEPT 이하의 2차 SELECT가 AND (from_tag IN (...) OR to_tag IN (...)) 패턴을 사용하는데, ... 부분이 실제 서브쿼리로 대체되지 않음.
근거: SubArea-추가플랜.md:112-119
-- C-6111과 C-6211 모두 연결된 태그 제외 (공용)
SELECT DISTINCT unnest(ARRAY[from_tag, to_tag]), 'P6-1'
FROM pid_equipment
WHERE (role ILIKE 'C-6211%' OR role ILIKE '%C-6211%')
AND (from_tag IN (...) OR to_tag IN (...)) ← 불완전
영향: 이 SQL을 그대로 실행하면 구문 오류 발생. 하지만 플랜이 "예시"로 명시하고 있고, 실제 구현은 Phase 2에서 작성하므로 즉시 실패는 아님.
수정: 완성된 서브쿼리로 대체하거나, CTE 기반 rewrite 권장:
WITH c6111_tags AS (
SELECT DISTINCT unnest(ARRAY[from_tag, to_tag]) AS tag
FROM pid_equipment WHERE role ILIKE 'C-6111%'
),
c6211_tags AS (
SELECT DISTINCT unnest(ARRAY[from_tag, to_tag]) AS tag
FROM pid_equipment WHERE role ILIKE 'C-6211%'
)
SELECT tag, 'P6-1' FROM c6111_tags
EXCEPT
SELECT c6111_tags.tag, 'P6-1' FROM c6111_tags
INNER JOIN c6211_tags ON c6111_tags.tag = c6211_tags.tag;
3. SubAreaResolverService vs ExperionDbContext 중복 계층 (LOW)
문제: 플랜은 SubAreaResolverService.cs(Core) + ExperionDbContext.cs 메서드 추가를 모두 제안. 그러나 기존 아키텍처에서 DB 접근은 ExperionDbService(Infrastructure)가 전담하고, Core 서비스는 인터페이스만 의존함.
근거:
GetAreaByTagNameAsync는IExperionDbService(Core/Interfaces:146)에 선언,ExperionDbService(Infrastructure/Database:1695)에서 구현DigitalEventDetectorService는IExperionDbService.GetAreaByTagNameAsync를 호출 (OpcUa/DigitalEventDetectorService.cs:200)
교차 검증 Q3 (의도적 설계인가): 플랜이 SubAreaResolverService를 "sub_area 결정 로직 (pid_equipment + prefix fallback)"용 신규 서비스로 제안하는 것은 seed 로직(비즈니스 규칙)을 Core에 두려는 의도로 볼 수 있으나, 현재 GetAreaByTagNameAsync 패턴과 일관되지 않음.
영향: 동작에는 영향 없음. 유지보수 시 두 계층에서 같은 로직을 찾아야 하는 혼란 가능.
수정: GetAreaByTagNameAsync와 동일한 패턴을 따를 것:
IExperionDbService에GetSubAreaByTagNameAsync인터페이스 추가ExperionDbService에 구현- seed 로직은
ExperionDbService.SeedSubAreaAsync에 포함 (별도 서비스 불필요) SubAreaResolverService는 제거 또는 seed 전용으로 축소
4. pid_equipment 태그 대소문자 불일시 매핑 실패 (MED)
문제: tag_metadata.base_tag는 소문자로 저장됨 (MetadataLoaderService.cs:44의 LOWER(split_part(...)) 참조). 반면 pid_equipment.tag_no는 DXF에서 추출한 원본 대소문자를 유지할 가능성이 있음.
근거:
tag_metadataUNIQUE constraint:(base_tag, attribute)— 소문자 기준pid_equipment.tag_no는 DXF 텍스트에서 직접 추출 — 대소문자 불명확- seed SQL에서
tag_metadata.base_tag = pid_equipment.tag_no조인 시 대소문자 불일치로 매핑 실패 가능
교차 검증 Q4 (재현 시나리오): DXF에서 "P-6101"로 추출된 태그가 pid_equipment.tag_no = 'P-6101'로 저장되고, tag_metadata.base_tag = 'p-6101'로 저장되면, WHERE tag_metadata.base_tag = pid_equipment.tag_no 조인이 실패함.
영향: seed 시 일부 태그가 매핑되지 않아 prefix fallback으로 가거나 NULL 유지됨. 수동 수정 필요.
수정: seed SQL에서 LOWER() 적용 또는 ILIKE 사용:
-- tag_metadata.base_tag는 이미 소문자이므로 pid_equipment.tag_no도 LOWER
INSERT INTO tag_metadata (base_tag, attribute, value)
SELECT LOWER(pe.tag_no), 'sub_area', 'P6-1'
FROM pid_equipment pe
WHERE pe.role ILIKE 'C-6111%'
AND LOWER(pe.tag_no) NOT IN (
SELECT LOWER(tag_no) FROM pid_equipment WHERE role ILIKE 'C-6211%'
)
ON CONFLICT (base_tag, attribute) DO NOTHING;
5. v_plant_running_state sub_area 집계 미포함 (LOW)
문제: 플랜 3.2에서 "v_plant_running_state sub_area 기준 집계 추가 검토"를 TODO로 등재했으나, 현재 뷰는 area_code로만 GROUP BY. sub_area가 도입되면 P6-1/P6-2별 운전 상태가 필요해질 수 있음.
근거: ExperionDbContext.cs:443-473 — 현재 v_plant_running_state는 area_code(P6, P9 등)로만 집계.
교차 검증 Q4 (재현 시나리오): 운전원이 "P6-1만 정지했어?"라고 물으면 현재 뷰로는 P6 전체만 보여서 구분 불가.
영향: 현재는 area 레벨로 충분하므로 즉시 장애 아님. sub_area 도입 후 필요 시 추가.
수정: Phase 2 이후 별도 작업으로 처리. 뷰에 sub_area_code CTE 추가 또는 별도 v_plant_subarea_running_state 뷰 생성.
6. MCP server find_tags SQL 컬럼 수 불일치 (실제 구현 시 주의)
문제: 플랜 3.4에서 find_tags 응답에 sub_area 포함을 제안. 현재 server.py:1235-1251의 SQL은 v_tag_summary에서 6컬럼(base_tag, pv, sp, op, description, area)을 SELECT하는데, sub_area를 추가하려면 VIEW 변경 + SQL 변경 + 응답 객체 변경이 모두需要同步.
근거: server.py:1235 — SELECT base_tag, pv, sp, op, description, area
교차 검증 Q2 (다른 레이어에서 처리되는가): 아니음. VIEW 변경(Phase 2)이 먼저 필요하며, MCP 변경(Phase 5)은 VIEW 변경에 의존.
영향: Phase 순서가 이미 2 → 5로 되어 있으므로 의존성 문제는 아님. 다만 구현 시 3곳을 함께 수정해야 함.
수정: 플랜에 명시적 의존성 주석 추가 권장.
7. DigitalEventDetectorService에 sub_area 캐시 누락 (LOW)
문제: DigitalEventDetectorService.cs:196-204에서 GetAreaAsync는 _areaCache(in-memory Dictionary)를 사용하지만, 향후 GetSubAreaAsync를 추가할 경우 별도 캐시가 필요함. 현재 area 캐시는 Dictionary<string, string?> 타입으로 area 전용.
근거: DigitalEventDetectorService.cs:198 — _areaCache.TryGetValue(tagName, out var cached)
교차 검증 Q4 (재현 시나리오): sub_area를 이벤트 기록 시 매 태그마다 DB 조회하면高频 이벤트 시 성능 저하 가능. 하지만 현재 플랜은 event_history_table에 sub_area를 저장하지 않기로 결정했으므로, 이벤트 기록 시 sub_area 조회가 필요하지 않음.
결론: 현재 설계에서는 영향 없음. 향후 event_history_table에 sub_area 저장을 도입할 경우 캐시 추가 필요.
자가 검증 결과
- 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킴
- HIGH 항목 없음 (재현 가능한 즉시 실패 시나리오 없음)
- 교차 검증 4개 질문을 모두 통과한 항목만 포함
- 보고서의 수정 예시가 현재 코드에 아직 적용되지 않은 내용
- "더 좋은 방법 제안"과 "현재 코드가 틀렸다"를 혼동하지 않음
목적: Experion HS R530 서버가 제공하는 area 코드(P6, P9, P10 등)는 1개의 증류탑이 아닌 2개 Column을 포함하는 영역이다. 서버를 변경할 수 없으므로, ExperionCrawler 측에서 태그 단위로 sub-area(P6-1, P6-2 등)를 식별하여 저장·조회·필터링할 수 있게 한다.
문서 성격: 설계/의사결정 기록 + 작업 TODO. 코드 미작성 상태. 추후 재개 시 이 문서를 읽고 바로 이어갈 수 있도록 구성.
작성일: 2026-05-23 / 최종 업데이트: 2026-05-23 / 담당: Agent + 사용자 논의 합의 완료 / 대상 영역: P6(P6-1/P6-2), P9(P9-1/P9-2), P10(P10-1/P10-2), P1(P1-1/P1-2), P2(P2-1/P2-2)
0. 핵심 원칙
- prefix(번호) 규칙은 휴리스틱일 뿐, ground truth가 아니다. 실제 배관 연결이 변경되면서
p-5101a(5xx)가 P8에 속하거나,p-6201(62xx)이 P6-1/P6-2 공용으로 쓰이는 예외가 존재한다. - Sub-area의 Single Source of Truth는
tag_metadata의sub_areaattribute다. OPC UA에서 오는 값이 아니라, ExperionCrawler가 결정하여 저장한다. - pid_equipment가 ground truth 검증 수단이다.
role컬럼에"C-6111/..."같은 Column 연결 정보가 있어, prefix와 실제 연결이 일치하는지 교차검증 가능하다. - 공용(shared) 태그는 sub_area를 저장하지 않는다. (NULL 유지)
pid_equipment테이블이 이미 다중 연결을 저장하고 있고, LLM은plant_context.md규칙에 따라pid_equipment를 참조하여 이해한다.
1. Sub-Area 정의
1.1 현재 알려진 분류
| Area | Sub-Area | Column | 번호 패턴 | 비고 |
|---|---|---|---|---|
| P6 | P6-1 | C-6111 | 61xx | PGMEA 제품 |
| P6 | P6-2 | C-6211 | 62xx | HBM 제품 |
| P6 | (공용) | - | 66xx, 67xx, 69xx | cooling tower, steam, N2 |
| P9 | P9-1 | C-9111 | 91xx | |
| P9 | P9-2 | C-9211 | 92xx | |
| P9 | (공용) | - | 96xx | cooling tower |
| P10 | P10-1 | C-10111 | 101xx | |
| P10 | P10-2 | C-10211 | 102xx | |
| P10 | (공용) | - | 106xx~109xx | cooling tower, steam, water, N2/IA |
| P1 | P1-1 | C-1111 | 11x | 1-1차 플랜트 |
| P1 | P1-2 | C-1211 | 12x, 13x | 1-2차 플랜트 |
| P2 | P2-1 | C-2111 | 211x | 2-1차 플랜트 |
| P2 | P2-2 | C-2121 | 212x, 213x | 2-2차 플랜트 |
| P2 | (공통 설비) | - | 2127(Vacuum Pump), 2128(Scrubber), 2129(Scrubber Circ Pump) | |
| P2 | (공통 저장설비) | - | T-2202(Product Tank), T-2101/2102/2103(Waste Stripper Tank), T-2502(Waste PR Tank) |
1.2 추후 확장
// SubAreaRule — prefix 기반 seed 규칙 (fallback용)
// 실제 seed는 pid_equipment 기반이 우선, 없는 태그만 이 규칙 사용
public class SubAreaRule
{
public string Area { get; set; } = ""; // "P6"
public string NumberPrefix { get; set; } = ""; // "61"
public string SubArea { get; set; } = ""; // "P6-1"
public string Column { get; set; } = ""; // "C-6111"
public string Product { get; set; } = ""; // "PGMEA"
}
2. 아키텍처 결정 사항
2.1 저장 방식: tag_metadata EAV 패턴
기존 스키마 변경 없음. tag_metadata 테이블은 이미 (base_tag, attribute, value, node_id, loaded_at) 구조로 되어 있어, attribute='sub_area' 행을 추가하기만 하면 된다.
-- 예시 저장 데이터
('p-6201', 'sub_area', 'P6-1', NULL, now())
('ti-6111a','sub_area', 'P6-1', NULL, now())
('ti-6211a','sub_area', 'P6-2', NULL, now())
-- 공용 태그(p-6201 등)는 sub_area 행을 저장하지 않음 (NULL)
MetadataLoaderService와의 관계:
- 현재
MetadataLoaderService는attribute IN ('desc', 'area')만 읽어서 Upsert sub_area는 이 서비스가 전혀 건드리지 않음 (OPC UA에 없는 정보이므로)- 수동 설정 or seed 로직으로만 생성/수정
2.2 조회 방식 (IExperionDbService 패턴)
별도 서비스 생성 금지. 기존 GetAreaByTagNameAsync와 동일한 계층 구조를 따른다:
IExperionDbService(Core/Interfaces)에GetSubAreaByTagNameAsync인터페이스 추가ExperionDbService(Infrastructure/Database)에 구현
// === IExperionDbService.cs (Core/Interfaces)에 추가 ===
Task<string?> GetSubAreaByTagNameAsync(string tagName);
Task<List<(string baseTag, string? subArea)>> GetSubAreaListByAreaAsync(string area, int page, int pageSize);
Task<int> GetSubAreaTotalByAreaAsync(string area);
Task<bool> UpdateSubAreaAsync(string baseTag, string? subArea);
Task<SeedResultDto> SeedSubAreaAsync(bool dryRun);
// === ExperionDbService.cs (Infrastructure/Database)에 추가 ===
public async Task<string?> GetSubAreaByTagNameAsync(string tagName)
{
var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
return await _context.TagMetadata
.Where(m => m.BaseTag == baseTag && m.Attribute == "sub_area")
.Select(m => m.Value)
.FirstOrDefaultAsync();
}
2.3 seed 로직 (최초 1회, 이후 수동 유지보수)
-
1순위: pid_equipment 기반 매핑
pid_equipment에서role LIKE 'C-6111/%'인 태그들을 수집- 각각
('base_tag', 'sub_area', 'P6-1')로 insert (ON CONFLICT DO NOTHING) - C-6111과 C-6211 모두 연결된 태그는 skip (공용 = NULL 유지)
-
2순위: prefix 규칙 fallback
pid_equipment에서 매핑되지 않은 태그 중, tag 번호 prefix로 판별61xx→ P6-1,62xx→ P6-2 등
-
결정 불가 태그는 NULL 유지
- 이후 Web UI에서 수동 지정
2.4 pid_equipment 기반 seed 쿼리 (CTE 기반, LOWER 적용)
교차 검증 수정사항 반영:
- EXCEPT 구문의 불완전
...→ CTE 기반 rewrite pid_equipment.tag_no대소문자 불일치 →LOWER()적용
-- P6-1 seed (C-6111 전용, C-6211과 중복 태그 제외)
WITH c6111_tags AS (
SELECT DISTINCT LOWER(unnest(ARRAY[from_tag, to_tag])) AS base_tag
FROM pid_equipment
WHERE (from_tag IS NOT NULL OR to_tag IS NOT NULL)
AND (role ILIKE 'C-6111%' OR role ILIKE '%C-6111%')
),
c6211_tags AS (
SELECT DISTINCT LOWER(unnest(ARRAY[from_tag, to_tag])) AS base_tag
FROM pid_equipment
WHERE (from_tag IS NOT NULL OR to_tag IS NOT NULL)
AND (role ILIKE 'C-6211%' OR role ILIKE '%C-6211%')
)
INSERT INTO tag_metadata (base_tag, attribute, value)
SELECT c6111_tags.base_tag, 'sub_area', 'P6-1'
FROM c6111_tags
LEFT JOIN c6211_tags ON c6111_tags.base_tag = c6211_tags.base_tag
WHERE c6211_tags.base_tag IS NULL -- C-6211과 중복되지 않는 태그만
ON CONFLICT (base_tag, attribute) DO NOTHING;
-- P6-2 seed (대칭 구조)
WITH c6211_tags AS (
SELECT DISTINCT LOWER(unnest(ARRAY[from_tag, to_tag])) AS base_tag
FROM pid_equipment
WHERE (from_tag IS NOT NULL OR to_tag IS NOT NULL)
AND (role ILIKE 'C-6211%' OR role ILIKE '%C-6211%')
),
c6111_tags AS (
SELECT DISTINCT LOWER(unnest(ARRAY[from_tag, to_tag])) AS base_tag
FROM pid_equipment
WHERE (from_tag IS NOT NULL OR to_tag IS NOT NULL)
AND (role ILIKE 'C-6111%' OR role ILIKE '%C-6111%')
)
INSERT INTO tag_metadata (base_tag, attribute, value)
SELECT c6211_tags.base_tag, 'sub_area', 'P6-2'
FROM c6211_tags
LEFT JOIN c6111_tags ON c6211_tags.base_tag = c6111_tags.base_tag
WHERE c6111_tags.base_tag IS NULL
ON CONFLICT (base_tag, attribute) DO NOTHING;
P9/P10/P1/P2도 동일한 패턴으로 적용. Column ID만 변경:
- P9: C-9111 ↔ C-9211
- P10: C-10111 ↔ C-10211
- P1: C-1111 ↔ C-1211
- P2: C-2111 ↔ C-2121 (공통 설비/저장설비는 수동 제외)
P2 공통 설비/저장설비 수동 제외:
-- P2 공통 설비/저장설비는 sub_area를 부여하지 않음 (NULL 유지)
DELETE FROM tag_metadata
WHERE attribute = 'sub_area'
AND base_tag IN ('p-2127', 'p-2128', 'p-2129', 't-2202', 't-2101', 't-2102', 't-2103', 't-2502');
---
## 3. 변경 대상 파일 목록
### 3.1 Core (도메인/서비스)
| 파일 | 변경 내용 | 상태 |
|------|----------|------|
| `src/Core/Domain/Entities/ExperionEntities.cs` | 변경 불필요 (tag_metadata는 EAV 구조) | ✅ 불필요 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService`에 sub_area 메서드 추가 (GetSubAreaByTagNameAsync 등) | ⏳ TODO |
| ~~`src/Core/Application/Services/SubAreaResolverService.cs`~~ | **생성 금지** — 기존 `IExperionDbService` 패턴 따름 | ❌ 제거 |
### 3.2 Infrastructure (DB 접근)
| 파일 | 변경 내용 | 상태 |
|------|----------|------|
| `src/Infrastructure/Database/ExperionDbService.cs` | `GetSubAreaByTagNameAsync()` 구현 | ⏳ TODO |
| `src/Infrastructure/Database/ExperionDbService.cs` | `SeedSubAreaAsync()` 구현 (pid_equipment 기반 bulk seed) | ⏳ TODO |
| `src/Infrastructure/Database/ExperionDbService.cs` | `UpdateSubAreaAsync(baseTag, subArea)` 구현 (단일 수정) | ⏳ TODO |
| `src/Infrastructure/Database/ExperionDbService.cs` | `GetSubAreaListByAreaAsync()` 구현 (페이지네이션 목록 조회) | ⏳ TODO |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰에 `sub_area` 컬럼 추가 (LEFT JOIN tag_metadata) | ⏳ TODO |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `v_plant_running_state` sub_area 추가 → **미뤄도 무방** (Phase 2 이후 별도 작업) | ⏭️ 미룸 |
### 3.3 Web (API)
| 파일 | 변경 내용 | 상태 |
|------|----------|------|
| `src/Web/Controllers/ExperionControllers.cs` | `SubAreaController` 또는 기존 TagMetadataController에 sub_area API 추가 | ⏳ TODO |
| `src/Web/wwwroot/index.html` | 메타데이터 관리 탭에 sub_area 섹션 추가 | ⏳ TODO |
| `src/Web/wwwroot/js/app.js` | sub_area 조회/수정 로직, 이벤트 검색 sub_area 필터 추가 | ⏳ TODO |
| `src/Web/wwwroot/css/style.css` | 필요시 스타일 추가 | ⏳ TODO |
### 3.4 MCP Server (접근 A: area 파라미터에 sub_area 값 전달)
**핵심 로직:** `area` 파라미터에 "P6-1" 같은 sub_area 값이 오면, `tag_metadata.sub_area` 조인하여 필터링. 기존 area 값("P6")은 그대로 동작.
| 파일 | 변경 내용 | 상태 |
|------|----------|------|
| `mcp-server/server.py` | `find_tags` 응답에 `sub_area` 포함 (v_tag_summary에서) | ⏳ TODO |
| `mcp-server/server.py` | `find_tags`에 `sub_area` 필터 파라미터 추가 | ⏳ TODO |
| `mcp-server/server.py` | `active_alarms` — `area="P6-1"` 받으면 tag_metadata.sub_area 조인 필터 | ⏳ TODO |
| `mcp-server/server.py` | `query_events` — 동일하게 sub_area 조인 필터 | ⏳ TODO |
| `mcp-server/server.py` | `summarize_events`, `generate_status_report`에 sub_area 필터 추가 | ⏳ TODO |
### 3.5 문서
| 파일 | 변경 내용 | 상태 |
|------|----------|------|
| `prompts/plant_context.md` | sub-area 정의 테이블 추가 (P6-1/P6-2/P9-1/P9-2/P10-1/P10-2) | ⏳ TODO |
| `prompts/plant_context.md` | "공용 태그 판정은 pid_equipment 참조" 규칙 추가 | ⏳ TODO |
| `AGENTS.md` | sub_area 관련 내용 추가 | ⏳ TODO |
---
## 4. API 설계 (초안)
### 4.1 태그 sub_area 조회 (GET)
GET /api/tags/sub-area?area=P6&page=1&pageSize=50
Response:
```json
{
"total": 121,
"page": 1,
"pageSize": 50,
"tags": [
{ "baseTag": "p-6101", "area": "P6", "subArea": "P6-1", "description": "..." },
{ "baseTag": "p-6201", "area": "P6", "subArea": null, "description": "원료 투입 펌프 (6-1차, 6-2차 공용)" }
]
}
4.2 태그 sub_area 수정 (PUT)
PUT /api/tags/sub-area
Request:
{
"baseTag": "p-6201",
"subArea": "P6-1"
}
Response:
{
"success": true,
"baseTag": "p-6201",
"subArea": "P6-1"
}
subArea: null을 보내면 해당 태그의 sub_area를 삭제 (공용으로 재설정).
4.3 Bulk seed (POST, 관리자 전용)
POST /api/tags/sub-area/seed
Request:
{
"dryRun": true
}
Response (dryRun):
{
"success": true,
"dryRun": true,
"pidEquipmentMatched": 85,
"prefixFallback": 23,
"unchanged": 10,
"details": [
{ "baseTag": "p-6201", "action": "skip", "reason": "both C-6111 and C-6211" }
]
}
5. DB 변경 사항
5.1 v_tag_summary 뷰 변경
CREATE OR REPLACE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
sub_area_md.value AS sub_area -- ← 신규 컬럼
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area';
5.2 event_history_table에 sub_area 저장 (검토 필요)
현재 의사결정: 저장하지 않음. sub_area는 태그에 귀속된 속성이라 tag_metadata에만 두고, 이벤트 조회 시 join.
만약 저장하기로 결정 시:
DigitalEventDetectorService에서GetSubAreaByTagNameAsync호출 후DigitalEventRecord.SubArea에 설정event_history_table에sub_area TEXT컬럼 추가- MCP
query_events응답에sub_area포함
6. 고려사항 및 리스크
| 리스크 | 심각도 | 완화 방안 |
|---|---|---|
| pid_equipment에 없는 태그는 sub_area를 알 수 없음 | MED | prefix fallback + Web UI 수동 지정 |
| Column 이름(C-6111 등)이 변경될 수 있음 | LOW | seed는 1회성, 이후 수동 유지보수 |
| sub_area가 LLM 프롬프트에서 혼란을 줄 수 있음 | LOW | plant_context.md에 명확한 규칙 정의 |
해결됨 — attribute IN ('desc', 'area')로 고정되어 sub_area 건드리지 않음 |
7. TODO (작업 목록)
✅ Phase 1~7 전부 구현·시드·검증 완료 (2026-05-23). 상세는 문서 최상단 "✅ 구현 완료 + 진단 정정" 참조. 아래 Phase별 코드 스니펫은 초안이며, 실제 구현은 정정된 설계(prefix 1순위, 공용 콤마값, 토큰 매칭)를 따랐다.
Phase 0: 설계 확정 ✅ 완료
- P1/P2 sub-area 분류 방식 확정
- P1-1: 11x, P1-2: 12x/13x
- P2-1: 211x, P2-2: 212x/213x, 공통: 2127
2129, T-2202, T-210103, T-2502
event_history_table에 sub_area 저장 여부: 저장하지 않음 (tag_metadata에만, 조회 시 JOIN)- naming convention:
P6-1(대시) — 운전원이 "6-1차 플랜트"라고 부르는 자연어 우선 - MCP 접근: A) 기존
area파라미터에 sub_area 값("P6-1") 전달, server.py에서 tag_metadata 조인 필터 - 아키텍처: 별도 서비스 생성 금지,
IExperionDbService패턴 따름
Phase 1: Core - 인터페이스 추가
파일: src/Core/Application/Interfaces/IExperionServices.cs
IExperionDbService 인터페이스에 다음 메서드 추가:
// sub_area 단일 조회
Task<string?> GetSubAreaByTagNameAsync(string tagName);
// area별 sub_area 태그 목록 (페이지네이션)
Task<(List<SubAreaTagDto> tags, int total)> GetSubAreaListByAreaAsync(string area, int page, int pageSize);
// 단일 태그 sub_area 수정/삭제
Task<bool> UpdateSubAreaAsync(string baseTag, string? subArea);
// bulk seed (pid_equipment 기반 + prefix fallback)
Task<SeedResultDto> SeedSubAreaAsync(bool dryRun);
DTO 정의 (같은 파일 하단 또는 별도 DTO 파일):
public class SubAreaTagDto
{
public string BaseTag { get; set; }
public string? SubArea { get; set; }
public string? Description { get; set; }
}
public class SeedResultDto
{
public int PidEquipmentMatched { get; set; }
public int PrefixFallback { get; set; }
public int Unchanged { get; set; }
public int SkippedShared { get; set; }
public List<SeedDetailDto> Details { get; set; }
}
public class SeedDetailDto
{
public string BaseTag { get; set; }
public string Action { get; set; } // "insert", "skip", "update", "unchanged"
public string? Reason { get; set; }
public string? SubArea { get; set; }
}
SubAreaRule config 클래스 (ExperionDbService 내부 static class 또는 별도 파일):
// prefix 기반 fallback 규칙 — pid_equipment에 없는 태그만 적용
public static class SubAreaRules
{
public static readonly SubAreaRule[] Rules = new[]
{
new SubAreaRule { Area = "P6", NumberPrefixes = new[] { "61" }, SubArea = "P6-1", Column = "C-6111" },
new SubAreaRule { Area = "P6", NumberPrefixes = new[] { "62" }, SubArea = "P6-2", Column = "C-6211" },
new SubAreaRule { Area = "P9", NumberPrefixes = new[] { "91" }, SubArea = "P9-1", Column = "C-9111" },
new SubAreaRule { Area = "P9", NumberPrefixes = new[] { "92" }, SubArea = "P9-2", Column = "C-9211" },
new SubAreaRule { Area = "P10", NumberPrefixes = new[] { "101" }, SubArea = "P10-1", Column = "C-10111" },
new SubAreaRule { Area = "P10", NumberPrefixes = new[] { "102" }, SubArea = "P10-2", Column = "C-10211" },
new SubAreaRule { Area = "P1", NumberPrefixes = new[] { "11" }, SubArea = "P1-1", Column = "C-1111" },
new SubAreaRule { Area = "P1", NumberPrefixes = new[] { "12", "13" }, SubArea = "P1-2", Column = "C-1211" },
new SubAreaRule { Area = "P2", NumberPrefixes = new[] { "211" }, SubArea = "P2-1", Column = "C-2111" },
new SubAreaRule { Area = "P2", NumberPrefixes = new[] { "212", "213" }, SubArea = "P2-2", Column = "C-2121" },
};
}
public class SubAreaRule
{
public string Area { get; set; } = "";
public string[] NumberPrefixes { get; set; } = Array.Empty<string>();
public string SubArea { get; set; } = "";
public string Column { get; set; } = "";
}
Phase 2: Infrastructure - DB 접근 + DDL
파일: src/Infrastructure/Database/ExperionDbService.cs
2.1 GetSubAreaByTagNameAsync 구현
public async Task<string?> GetSubAreaByTagNameAsync(string tagName)
{
var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
return await _context.TagMetadata
.Where(m => m.BaseTag == baseTag && m.Attribute == "sub_area")
.Select(m => m.Value)
.FirstOrDefaultAsync();
}
2.2 GetSubAreaListByAreaAsync 구현 (페이지네이션)
public async Task<(List<SubAreaTagDto> tags, int total)> GetSubAreaListByAreaAsync(
string area, int page, int pageSize)
{
// area가 "P6-1" 같은 sub_area 값일 수도 있으므로 두 경우 모두 처리
var isSubArea = area.Contains('-');
var query = from rt in _context.RealtimeTable
group rt by split_part(rt.TagName, '.', 1) into g
let baseTag = g.Key
join descMd in _context.TagMetadata
on baseTag equals descMd.BaseTag && descMd.Attribute == "desc"
into descGroup
from desc in descGroup.DefaultIfEmpty()
join areaMd in _context.TagMetadata
on baseTag equals areaMd.BaseTag && areaMd.Attribute == "area"
into areaGroup
from area in areaGroup.DefaultIfEmpty()
join subAreaMd in _context.TagMetadata
on baseTag equals subAreaMd.BaseTag && subAreaMd.Attribute == "sub_area"
into subAreaGroup
from subArea in subAreaGroup.DefaultIfEmpty()
where isSubArea
? subArea.Value == area
: area.Value == area
select new SubAreaTagDto
{
BaseTag = baseTag,
SubArea = subArea.Value,
Description = desc.Value
};
// EF Core에서 split_part 사용 불가 → raw SQL 사용 권장
// 실제 구현은 아래 SQL 기반
}
실제 구현 권장: raw SQL (split_part, LEFT JOIN 필요)
public async Task<(List<SubAreaTagDto> tags, int total)> GetSubAreaListByAreaAsync(
string area, int page, int pageSize)
{
var isSubArea = area.Contains('-');
var sql = $@"
SELECT
rt_base.base_tag,
sub_area_md.value AS sub_area,
desc_md.value AS description
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area'
WHERE {isSubArea ? "sub_area_md.value = @area" : "area_md.value = @area"}
ORDER BY rt_base.base_tag";
// Dapper 또는 FromSqlRaw 사용
// total 쿼리 + 페이지네이션 LIMIT/OFFSET 적용
}
2.3 UpdateSubAreaAsync 구현
public async Task<bool> UpdateSubAreaAsync(string baseTag, string? subArea)
{
if (string.IsNullOrEmpty(subArea))
{
// 삭제 (공용으로 재설정)
var sql = "DELETE FROM tag_metadata WHERE base_tag = @baseTag AND attribute = 'sub_area'";
return await _context.Database.ExecuteSqlRawAsync(sql,
new NpgsqlParameter("@baseTag", baseTag.ToLower())) > 0;
}
else
{
// UPSERT
var sql = @"INSERT INTO tag_metadata (base_tag, attribute, value)
VALUES (@baseTag, 'sub_area', @subArea)
ON CONFLICT (base_tag, attribute) DO UPDATE SET value = @subArea";
return await _context.Database.ExecuteSqlRawAsync(sql,
new NpgsqlParameter("@baseTag", baseTag.ToLower()),
new NpgsqlParameter("@subArea", subArea)) > 0;
}
}
2.4 SeedSubAreaAsync 구현 (핵심 로직)
public async Task<SeedResultDto> SeedSubAreaAsync(bool dryRun)
{
var result = new SeedResultDto { Details = new List<SeedDetailDto>() };
// 1순위: pid_equipment 기반 매핑
foreach (var rule in SubAreaRules.Rules)
{
var columnPattern = rule.Column; // "C-6111"
// 같은 area의 다른 column 찾기 (공용 제외용)
var sameAreaRules = SubAreaRules.Rules
.Where(r => r.Area == rule.Area && r.Column != rule.Column)
.Select(r => r.Column)
.ToList();
// pid_equipment에서 role에 column이 포함된 태그 수집
var sql = @"
SELECT DISTINCT LOWER(unnest(ARRAY[from_tag, to_tag])) AS base_tag
FROM pid_equipment
WHERE (from_tag IS NOT NULL OR to_tag IS NOT NULL)
AND (role ILIKE @pattern OR role ILIKE @pattern2)";
var tags = await _context.Database
.FromSqlRaw(sql,
new NpgsqlParameter("@pattern", $"%{columnPattern}%"),
new NpgsqlParameter("@pattern2", $"{columnPattern}%"))
.ToListAsync();
// 같은 area의 다른 column과 중복된 태그 제외
foreach (var otherCol in sameAreaRules)
{
var sharedSql = @"
SELECT DISTINCT LOWER(unnest(ARRAY[from_tag, to_tag])) AS base_tag
FROM pid_equipment
WHERE role ILIKE @pattern";
var sharedTags = await _context.Database
.FromSqlRaw(sharedSql, new NpgsqlParameter("@pattern", $"%{otherCol}%"))
.ToListAsync();
tags = tags.Except(sharedTags).ToList();
}
foreach (var tag in tags)
{
if (dryRun)
{
result.Details.Add(new SeedDetailDto
{ BaseTag = tag, Action = "insert", SubArea = rule.SubArea });
result.PidEquipmentMatched++;
}
else
{
await _context.Database.ExecuteSqlRawAsync(@"
INSERT INTO tag_metadata (base_tag, attribute, value)
VALUES (@baseTag, 'sub_area', @subArea)
ON CONFLICT (base_tag, attribute) DO NOTHING",
new NpgsqlParameter("@baseTag", tag),
new NpgsqlParameter("@subArea", rule.SubArea));
result.PidEquipmentMatched++;
}
}
}
// 2순위: prefix 규칙 fallback (pid_equipment에 없는 태그만)
var seededTags = result.Details.Select(d => d.BaseTag).ToHashSet();
foreach (var rule in SubAreaRules.Rules)
{
var prefixSql = @"
SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag
FROM realtime_table
WHERE base_tag NOT IN (SELECT base_tag FROM tag_metadata WHERE attribute = 'sub_area')
AND base_tag NOT IN @seeded
AND (" + string.Join(" OR ", rule.NumberPrefixes.Select(p =>
$"base_tag LIKE '{p}%' OR base_tag LIKE '_{p}%' OR base_tag LIKE '__{p}%'")) + @")";
// 실제 구현에서는 LIKE 패턴을 동적으로 구성
}
return result;
}
파일: src/Infrastructure/Database/ExperionDbContext.cs
2.5 v_tag_summary 뷰 DDL 변경
CREATE OR REPLACE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
sub_area_md.value AS sub_area -- ← 신규 컬럼
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area';
실행 위치: ExperionDbContext.OnModelCreating() 또는 마이그레이션 DDL
Phase 3: Web - API
파일: src/Web/Controllers/ExperionControllers.cs
기존 TagMetadataController에 sub_area 엔드포인트 추가 (또는 신규 SubAreaController 생성).
3.1 GET /api/tags/sub-area
[HttpGet("tags/sub-area")]
public async Task<IActionResult> GetSubAreaList(
[FromQuery] string area,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
{
var (tags, total) = await _dbService.GetSubAreaListByAreaAsync(area, page, pageSize);
return Ok(new
{
total,
page,
pageSize,
tags = tags.Select(t => new
{
baseTag = t.BaseTag,
subArea = t.SubArea,
description = t.Description
})
});
}
주의: JSON 키는 camelCase (baseTag, subArea, description) — PropertyNamingPolicy = null이므로 명시적 anonymous object 사용 필수.
3.2 PUT /api/tags/sub-area
[HttpPut("tags/sub-area")]
public async Task<IActionResult> UpdateSubArea([FromBody] SubAreaUpdateRequest req)
{
var success = await _dbService.UpdateSubAreaAsync(req.BaseTag, req.SubArea);
return Ok(new { success, baseTag = req.BaseTag, subArea = req.SubArea });
}
public class SubAreaUpdateRequest
{
public string BaseTag { get; set; }
public string? SubArea { get; set; }
}
3.3 POST /api/tags/sub-area/seed
[HttpPost("tags/sub-area/seed")]
public async Task<IActionResult> SeedSubArea([FromBody] SeedRequest req)
{
var result = await _dbService.SeedSubAreaAsync(req.DryRun);
return Ok(new
{
success = true,
dryRun = req.DryRun,
pidEquipmentMatched = result.PidEquipmentMatched,
prefixFallback = result.PrefixFallback,
unchanged = result.Unchanged,
skippedShared = result.SkippedShared,
details = result.Details.Select(d => new
{
baseTag = d.BaseTag,
action = d.Action,
reason = d.Reason,
subArea = d.SubArea
})
});
}
public class SeedRequest
{
public bool DryRun { get; set; } = true;
}
Phase 4: Frontend
파일: src/Web/wwwroot/index.html
메타데이터 관리 탭에 sub_area 섹션 추가. 기존 탭 구조에 sub-area 서브탭 또는 섹션 추가.
<!-- 메타데이터 탭 내부에 추가 -->
<div id="subarea-section" class="section" style="display:none;">
<h3>Sub-Area 관리</h3>
<div class="form-row">
<label>Area 선택:</label>
<select id="subarea-area-select">
<option value="P6">P6 (6차 플랜트)</option>
<option value="P9">P9 (9차 플랜트)</option>
<option value="P10">P10 (10차 플랜트)</option>
<option value="P1">P1 (1차 플랜트)</option>
<option value="P2">P2 (2차 플랜트)</option>
</select>
<button onclick="loadSubAreaList()">조회</button>
<button onclick="seedSubArea(true)">Seed DryRun</button>
<button onclick="seedSubArea(false)">Seed 실행</button>
</div>
<table id="subarea-table">
<thead>
<tr>
<th>Base Tag</th>
<th>Description</th>
<th>Sub-Area</th>
<th>수정</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
파일: src/Web/wwwroot/js/app.js
async function loadSubAreaList() {
const area = document.getElementById('subarea-area-select').value;
const res = await fetch(`/api/tags/sub-area?area=${area}&page=1&pageSize=100`);
const data = await res.json();
const tbody = document.querySelector('#subarea-table tbody');
tbody.innerHTML = '';
data.tags.forEach(t => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${t.baseTag}</td>
<td>${t.description || '-'}</td>
<td>${t.subArea || '(공용)'}</td>
<td>
<select onchange="updateSubArea('${t.baseTag}', this.value)">
<option value="">(공용)</option>
<option value="P6-1" ${t.subArea==='P6-1'?'selected':''}>P6-1</option>
<option value="P6-2" ${t.subArea==='P6-2'?'selected':''}>P6-2</option>
<!-- area별 옵션 동적 생성 -->
</select>
</td>`;
tbody.appendChild(tr);
});
}
async function updateSubArea(baseTag, subArea) {
const res = await fetch('/api/tags/sub-area', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseTag, subArea: subArea || null })
});
const data = await res.json();
if (data.success) alert(`${baseTag} → ${subArea || '(공용)'}`);
}
async function seedSubArea(dryRun) {
if (dryRun && !confirm('DryRun 모드로 실행합니다. 실제 저장되지 않습니다.')) return;
if (!dryRun && !confirm('실제 seed를 실행합니다. 계속하시겠습니까?')) return;
const res = await fetch('/api/tags/sub-area/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dryRun })
});
const data = await res.json();
alert(`pidEquipmentMatched: ${data.pidEquipmentMatched}, prefixFallback: ${data.prefixFallback}`);
}
Phase 5: MCP Server (접근 A: area 파라미터에 sub_area 값 전달)
파일: mcp-server/server.py
5.1 find_tags 응답에 sub_area 포함
변경 위치: find_tags 함수 내 SQL 쿼리
# 변경 전
sql = """
SELECT base_tag, pv, sp, op, description, area
FROM v_tag_summary
WHERE ...
"""
# 변경 후
sql = """
SELECT base_tag, pv, sp, op, description, area, sub_area
FROM v_tag_summary
WHERE ...
"""
# 응답 객체에 sub_area 추가
return {
"base_tag": row[0],
"pv": row[1],
"sp": row[2],
"op": row[3],
"description": row[4],
"area": row[5],
"sub_area": row[6] # ← 신규
}
5.2 find_tags에 sub_area 필터 파라미터 추가
# 함수 시그니처 변경
def find_tags(query: str, area: Optional[str] = None, sub_area: Optional[str] = None, top_k: int = 20):
...
# WHERE 조건에 sub_area 필터 추가
if sub_area:
conditions.append("sub_area_md.value = %s")
params.append(sub_area)
elif area:
# 기존 area 필터 — area가 "P6-1" 같은 sub_area 형식인지 확인
if '-' in area:
conditions.append("sub_area_md.value = %s")
params.append(area)
else:
conditions.append("area_md.value = %s")
params.append(area)
5.3 active_alarms — sub_area 조인 필터
변경 위치: active_alarms 함수
def active_alarms(area: Optional[str] = None, limit: int = 100):
...
# area가 sub_area 형식("P6-1")인지 확인
is_sub_area = area and '-' in area
if is_sub_area:
# sub_area 기준 필터링 — tag_metadata 조인
sql = """
SELECT DISTINCT ON (eht.tag_name)
eht.tag_name, eht.event_type, eht.event_time,
eht.prev_state_duration_s, tm.value AS sub_area
FROM event_history_table eht
LEFT JOIN tag_metadata tm ON tm.base_tag = eht.tag_name
AND tm.attribute = 'sub_area'
WHERE tm.value = %s
AND eht.event_type IN ('ALARM', 'TRIP')
ORDER BY eht.tag_name, eht.event_time DESC
LIMIT %s
"""
params = [area, limit]
elif area:
# 기존 area 필터
sql = """
SELECT DISTINCT ON (eht.tag_name)
eht.tag_name, eht.event_type, eht.event_time,
eht.prev_state_duration_s, eht.area
FROM event_history_table eht
WHERE eht.area = %s
AND eht.event_type IN ('ALARM', 'TRIP')
ORDER BY eht.tag_name, eht.event_time DESC
LIMIT %s
"""
params = [area, limit]
else:
# 전체 조회
...
5.4 query_events — 동일하게 sub_area 조인 필터
def query_events(tag_name: Optional[str] = None, event_type: Optional[str] = None,
area: Optional[str] = None, since: Optional[str] = None,
until: Optional[str] = None, limit: int = 100):
...
is_sub_area = area and '-' in area
if is_sub_area:
conditions.append("tm.value = %s")
params.append(area)
# tag_metadata 조인 추가
join_clause = "LEFT JOIN tag_metadata tm ON tm.base_tag = eht.tag_name AND tm.attribute = 'sub_area'"
5.5 summarize_events, generate_status_report에 sub_area 필터
def summarize_events(since: Optional[str] = None, area: Optional[str] = None, ...):
...
# active_alarms, query_events와 동일한 sub_area 판정 로직 재사용
is_sub_area = area and '-' in area
if is_sub_area:
# tag_metadata 조인하여 sub_area 필터링
...
def generate_status_report(area: Optional[str] = None, hours: int = 24):
...
# 동일하게 sub_area 판정 로직 적용
공통 헬퍼 함수 추출 권장:
def _is_sub_area(area: Optional[str]) -> bool:
"""area가 sub_area 형식("P6-1")인지 판정"""
return area is not None and '-' in area
def _build_area_filter(area: Optional[str], table_alias: str = "eht") -> tuple[str, list]:
"""area/sub_area 필터 SQL 조건 + 파라미터 반환"""
if not area:
return "", []
if _is_sub_area(area):
return (
f"EXISTS (SELECT 1 FROM tag_metadata tm "
f"WHERE tm.base_tag = {table_alias}.tag_name "
f"AND tm.attribute = 'sub_area' AND tm.value = %s)",
[area]
)
else:
return f"{table_alias}.area = %s", [area]
Phase 6: 문서화
파일: prompts/plant_context.md
기존 "단위(Area / Unit) 명명" 섹션 아래에 sub-area 섹션 추가:
## Sub-Area (세부 Area) 명명
각 Area는 2개 Column으로 나뉘며, 태그 단위로 sub_area가 할당되어 있습니다.
공용 태그(여러 Column 연결)는 sub_area가 NULL입니다.
### 운전원 호칭 → sub_area 코드 변환 규칙
사용자가 "6-1차 플랜트", "6-1차"라고 부르면 → **sub_area = `P6-1`** 로 변환하세요.
| 운전원 호칭 | sub_area 코드 | area 코드 | Column | 번호 패턴 | 제품 |
|---|---|---|---|---|---|
| 6-1차 플랜트 | `P6-1` | `P6` | C-6111 | 61xx | PGMEA |
| 6-2차 플랜트 | `P6-2` | `P6` | C-6211 | 62xx | HBM |
| 9-1차 플랜트 | `P9-1` | `P9` | C-9111 | 91xx | |
| 9-2차 플랜트 | `P9-2` | `P9` | C-9211 | 92xx | |
| 10-1차 플랜트 | `P10-1` | `P10` | C-10111 | 101xx | |
| 10-2차 플랜트 | `P10-2` | `P10` | C-10211 | 102xx | |
| 1-1차 플랜트 | `P1-1` | `P1` | C-1111 | 11x | |
| 1-2차 플랜트 | `P1-2` | `P1` | C-1211 | 12x, 13x | |
| 2-1차 플랜트 | `P2-1` | `P2` | C-2111 | 211x | |
| 2-2차 플랜트 | `P2-2` | `P2` | C-2121 | 212x, 213x | |
### sub_area 도구 호출 방법
- `active_alarms(area="P6-1")` — sub_area 코드 전달 가능 (server.py가 tag_metadata 조인)
- `find_tags(query="펌프", sub_area="P6-1")` — sub_area 필터 파라미터 사용
- `query_events(area="P6-1")` — 동일하게 sub_area 코드 전달
### ⚠️ 공용 태그
공용 태그(여러 Column/Area 연결)는 `sub_area`가 NULL입니다.
공용 여부는 `pid_equipment` 테이블에서 같은 태그가 여러 Column에 연결되어 있는지 확인하세요.
P2 공통 설비: 2127(Vacuum Pump), 2128(Scrubber), 2129(Scrubber Circ Pump)
P2 공통 저장설비: T-2202(Product Tank), T-2101/2102/2103(Waste Stripper Tank), T-2502(Waste PR Tank)
파일: AGENTS.md
기존 "Database" 섹션에 sub_area 관련 내용 추가:
### sub_area (세부 Area)
- `tag_metadata` 테이블의 `attribute='sub_area'`에 저장 (EAV 패턴)
- Single Source of Truth: tag_metadata, OPC UA가 아님
- 공용 태그는 NULL 유지 (pid_equipment에서 다중 연결 확인)
- event_history_table에는 저장하지 않음 (조회 시 tag_metadata JOIN)
- MCP server.py에서 `area="P6-1"` 같은 sub_area 값 받으면 tag_metadata 조인 필터
Phase 7: seed 실행 및 검증
7.1 dryRun 실행
# API 호출 (dryRun)
curl -X POST http://localhost:5000/api/tags/sub-area/seed \
-H "Content-Type: application/json" \
-d '{"dryRun": true}'
검증 항목:
pidEquipmentMatched수가 각 area별 태그 수와 대략 일치하는지skippedShared에 공용 태그(예: p-6201)가 포함되어 있는지details에서 action="skip"인 항목의 reason이 "both C-6111 and C-6211"인지
7.2 실제 seed 실행
# API 호출 (실제 실행)
curl -X POST http://localhost:5000/api/tags/sub-area/seed \
-H "Content-Type: application/json" \
-d '{"dryRun": false}'
7.3 DB 정합성 검증 쿼리
-- 각 sub_area별 태그 수 확인
SELECT sub_area, COUNT(*) AS tag_count
FROM tag_metadata
WHERE attribute = 'sub_area'
GROUP BY sub_area
ORDER BY sub_area;
-- P6-1 vs P6-2 태그 수 비교 (대략 비슷해야 함)
-- P6 전체 태그 수와 비교 (P6-1 + P6-2 + 공용 ≈ P6 전체)
-- 공용 태그 확인 (sub_area가 NULL인 P6 태그)
SELECT base_tag, description
FROM v_tag_summary
WHERE area = 'P6' AND sub_area IS NULL;
-- pid_equipment와 tag_metadata 정합성
-- C-6111에 연결된 태그 중 sub_area가 P6-1이 아닌 것
SELECT pe.tag_no, tm.value AS current_sub_area
FROM pid_equipment pe
LEFT JOIN tag_metadata tm ON tm.base_tag = LOWER(pe.tag_no) AND tm.attribute = 'sub_area'
WHERE pe.role ILIKE '%C-6111%'
AND (tm.value IS NULL OR tm.value != 'P6-1');
7.4 P2 공통 설비/저장설비 검증
-- P2 공통 설비가 sub_area를 가지지 않는지 확인
SELECT base_tag, value AS sub_area
FROM tag_metadata
WHERE attribute = 'sub_area'
AND base_tag IN ('p-2127', 'p-2128', 'p-2129', 't-2202', 't-2101', 't-2102', 't-2103', 't-2502');
-- 결과: 0행 (NULL 유지 확인)
Phase 8: LLM 테스트
8.1 sub_area 필터링 테스트
| 질문 | 기대 동작 | 검증 항목 |
|---|---|---|
| "6-1차 플랜트 활성 알람 보여줘" | active_alarms(area="P6-1") 호출 |
tag_metadata 조인으로 P6-1 태그만 필터링 |
| "6-2차 플랜트 펌프 어떤 게 돌아가?" | v_plant_running_state + sub_area 필터 |
P6-2 전용 펌프만 표시 |
| "9-1차 온도 태그 찾아줘" | find_tags(query="온도", sub_area="P9-1") |
P9-1 태그만 반환 |
| "p-6201은 어느 area야?" | find_tags(query="p-6201") → sub_area: null |
"공용 태그"라고 응답 |
| "6차 플랜트 전체 알람" | active_alarms(area="P6") |
기존 동작 유지 (sub_area 구분 없음) |
8.2 plant_context.md 주입 확인
LLM이 "6-1차" → "P6-1" 변환 규칙을 학습했는지 확인:
- 채팅에서 "6-1차"라는 표현이 나오면 LLM이 sub_area="P6-1"으로 해석하는지
- "P6-1"과 "6-1차"가 동일한 개념으로 처리되는지
8. 작업 재개 가이드
작업이 끊겼을 때:
- 이 문서 읽기 — 설계 의도와 전체 범위 파악
- Phase 0 확인 — 미확정 사항이 있으면 사용자와 논의
- Phase 순서대로 진행 — 각 Phase는 이전 Phase에 의존
- 각 TODO 항목 완료 후 체크박스 업데이트
- commit 메시지 예시:
feat: add sub_area to tag_metadata (phase 1) - 테스트 명령:
dotnet build src/Web/ExperionCrawler.csproj(빌드 확인) - seed 실행 전 반드시 dryRun 먼저
9. 참고: 관련 파일 경로
| 항목 | 경로 |
|---|---|
| 솔루션 파일 | ExperionCrawler.sln |
| 웹 프로젝트 | src/Web/ExperionCrawler.csproj |
| DbContext | src/Infrastructure/Database/ExperionDbContext.cs |
| 인터페이스 | src/Core/Application/Interfaces/IExperionServices.cs |
| 컨트롤러 | src/Web/Controllers/ExperionControllers.cs |
| MCP 서버 | mcp-server/server.py |
| 프론트 HTML | src/Web/wwwroot/index.html |
| 프론트 JS | src/Web/wwwroot/js/app.js |
| LLM 컨텍스트 | prompts/plant_context.md |
| 설계서 | futurePlan/schema.sql |