Files
ExperionCrawler/SubArea-추가플랜.md
windpacer f81044c451 feat: Sub-Area(세부 Area) 분류 기능 + 포인트 삭제 시 메타데이터/이력 정리
하나의 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>
2026-05-24 06:31:51 +09:00

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 서비스는 인터페이스만 의존함.

근거:

  • GetAreaByTagNameAsyncIExperionDbService(Core/Interfaces:146)에 선언, ExperionDbService(Infrastructure/Database:1695)에서 구현
  • DigitalEventDetectorServiceIExperionDbService.GetAreaByTagNameAsync를 호출 (OpcUa/DigitalEventDetectorService.cs:200)

교차 검증 Q3 (의도적 설계인가): 플랜이 SubAreaResolverService를 "sub_area 결정 로직 (pid_equipment + prefix fallback)"용 신규 서비스로 제안하는 것은 seed 로직(비즈니스 규칙)을 Core에 두려는 의도로 볼 수 있으나, 현재 GetAreaByTagNameAsync 패턴과 일관되지 않음.

영향: 동작에는 영향 없음. 유지보수 시 두 계층에서 같은 로직을 찾아야 하는 혼란 가능.

수정: GetAreaByTagNameAsync와 동일한 패턴을 따를 것:

  1. IExperionDbServiceGetSubAreaByTagNameAsync 인터페이스 추가
  2. ExperionDbService에 구현
  3. seed 로직은 ExperionDbService.SeedSubAreaAsync에 포함 (별도 서비스 불필요)
  4. SubAreaResolverService는 제거 또는 seed 전용으로 축소

4. pid_equipment 태그 대소문자 불일시 매핑 실패 (MED)

문제: tag_metadata.base_tag는 소문자로 저장됨 (MetadataLoaderService.cs:44LOWER(split_part(...)) 참조). 반면 pid_equipment.tag_no는 DXF에서 추출한 원본 대소문자를 유지할 가능성이 있음.

근거:

  • tag_metadata UNIQUE 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_statearea_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:1235SELECT 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. 핵심 원칙

  1. prefix(번호) 규칙은 휴리스틱일 뿐, ground truth가 아니다. 실제 배관 연결이 변경되면서 p-5101a(5xx)가 P8에 속하거나, p-6201(62xx)이 P6-1/P6-2 공용으로 쓰이는 예외가 존재한다.
  2. Sub-area의 Single Source of Truth는 tag_metadatasub_area attribute다. OPC UA에서 오는 값이 아니라, ExperionCrawler가 결정하여 저장한다.
  3. pid_equipment가 ground truth 검증 수단이다. role 컬럼에 "C-6111/..." 같은 Column 연결 정보가 있어, prefix와 실제 연결이 일치하는지 교차검증 가능하다.
  4. 공용(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와의 관계:

  • 현재 MetadataLoaderServiceattribute IN ('desc', 'area')만 읽어서 Upsert
  • sub_area는 이 서비스가 전혀 건드리지 않음 (OPC UA에 없는 정보이므로)
  • 수동 설정 or seed 로직으로만 생성/수정

2.2 조회 방식 (IExperionDbService 패턴)

별도 서비스 생성 금지. 기존 GetAreaByTagNameAsync와 동일한 계층 구조를 따른다:

  1. IExperionDbService(Core/Interfaces)에 GetSubAreaByTagNameAsync 인터페이스 추가
  2. 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. 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. 2순위: prefix 규칙 fallback

    • pid_equipment에서 매핑되지 않은 태그 중, tag 번호 prefix로 판별
    • 61xx → P6-1, 62xx → P6-2 등
  3. 결정 불가 태그는 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_tablesub_area 저장 (검토 필요)

현재 의사결정: 저장하지 않음. sub_area는 태그에 귀속된 속성이라 tag_metadata에만 두고, 이벤트 조회 시 join.

만약 저장하기로 결정 시:

  • DigitalEventDetectorService에서 GetSubAreaByTagNameAsync 호출 후 DigitalEventRecord.SubArea에 설정
  • event_history_tablesub_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에 명확한 규칙 정의
MetadataLoaderService가 sub_area를 덮어쓸 위험 LOW 해결됨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, 공통: 21272129, 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. 작업 재개 가이드

작업이 끊겼을 때:

  1. 이 문서 읽기 — 설계 의도와 전체 범위 파악
  2. Phase 0 확인 — 미확정 사항이 있으면 사용자와 논의
  3. Phase 순서대로 진행 — 각 Phase는 이전 Phase에 의존
  4. 각 TODO 항목 완료 후 체크박스 업데이트
  5. commit 메시지 예시: feat: add sub_area to tag_metadata (phase 1)
  6. 테스트 명령: dotnet build src/Web/ExperionCrawler.csproj (빌드 확인)
  7. 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