# 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

```sql
-- 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 권장:

```sql
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`와 동일한 패턴을 따를 것:
1. `IExperionDbService`에 `GetSubAreaByTagNameAsync` 인터페이스 추가
2. `ExperionDbService`에 구현
3. seed 로직은 `ExperionDbService.SeedSubAreaAsync`에 포함 (별도 서비스 불필요)
4. `SubAreaResolverService`는 제거 또는 seed 전용으로 축소

---

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

**문제**: `tag_metadata.base_tag`는 소문자로 저장됨 (`MetadataLoaderService.cs:44`의 `LOWER(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` 사용:

```sql
-- 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 저장을 도입할 경우 캐시 추가 필요.

---

### 자가 검증 결과

- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킴
- [x] HIGH 항목 없음 (재현 가능한 즉시 실패 시나리오 없음)
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함
- [x] 보고서의 수정 예시가 현재 코드에 아직 적용되지 않은 내용
- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다"를 혼동하지 않음

---

> **목적**: 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_metadata`의 `sub_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 추후 확장

```csharp
// 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'` 행을 추가하기만 하면 된다.

```sql
-- 예시 저장 데이터
('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`와 동일한 계층 구조를 따른다:
1. `IExperionDbService`(Core/Interfaces)에 `GetSubAreaByTagNameAsync` 인터페이스 추가
2. `ExperionDbService`(Infrastructure/Database)에 구현

```csharp
// === 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()` 적용

```sql
-- 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 공통 설비/저장설비 수동 제외:**
```sql
-- 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:
```json
{
  "baseTag": "p-6201",
  "subArea": "P6-1"
}
```

Response:
```json
{
  "success": true,
  "baseTag": "p-6201",
  "subArea": "P6-1"
}
```

`subArea: null`을 보내면 해당 태그의 sub_area를 삭제 (공용으로 재설정).

### 4.3 Bulk seed (POST, 관리자 전용)

```
POST /api/tags/sub-area/seed
```

Request:
```json
{
  "dryRun": true
}
```

Response (dryRun):
```json
{
  "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` 뷰 변경

```sql
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`에 명확한 규칙 정의 |
| ~~MetadataLoaderService가 sub_area를 덮어쓸 위험~~ | ~~LOW~~ | **해결됨** — `attribute IN ('desc', 'area')`로 고정되어 sub_area 건드리지 않음 |

---

## 7. TODO (작업 목록)

> ✅ **Phase 1~7 전부 구현·시드·검증 완료** (2026-05-23). 상세는 문서 최상단 "✅ 구현 완료 + 진단 정정" 참조.
> 아래 Phase별 코드 스니펫은 **초안**이며, 실제 구현은 정정된 설계(prefix 1순위, 공용 콤마값, 토큰 매칭)를 따랐다.

### Phase 0: 설계 확정 ✅ 완료
- [x] P1/P2 sub-area 분류 방식 확정
  - P1-1: 11x, P1-2: 12x/13x
  - P2-1: 211x, P2-2: 212x/213x, 공통: 2127~2129, T-2202, T-2101~03, T-2502
- [x] `event_history_table`에 sub_area 저장 여부: **저장하지 않음** (tag_metadata에만, 조회 시 JOIN)
- [x] naming convention: **`P6-1` (대시)** — 운전원이 "6-1차 플랜트"라고 부르는 자연어 우선
- [x] MCP 접근: **A)** 기존 `area` 파라미터에 sub_area 값("P6-1") 전달, server.py에서 tag_metadata 조인 필터
- [x] 아키텍처: 별도 서비스 생성 금지, `IExperionDbService` 패턴 따름

### Phase 1: Core - 인터페이스 추가

**파일: `src/Core/Application/Interfaces/IExperionServices.cs`**

`IExperionDbService` 인터페이스에 다음 메서드 추가:

```csharp
// 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 파일):**

```csharp
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 또는 별도 파일):**

```csharp
// 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` 구현

```csharp
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` 구현 (페이지네이션)

```csharp
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 필요)**

```csharp
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` 구현

```csharp
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` 구현 (핵심 로직)

```csharp
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 변경

```sql
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

```csharp
[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

```csharp
[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

```csharp
[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` 서브탭 또는 섹션 추가.

```html
<!-- 메타데이터 탭 내부에 추가 -->
<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`**

```javascript
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 쿼리

```python
# 변경 전
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 필터 파라미터 추가

```python
# 함수 시그니처 변경
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` 함수

```python
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 조인 필터

```python
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 필터

```python
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 판정 로직 적용
```

**공통 헬퍼 함수 추출 권장:**

```python
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 섹션 추가:

```markdown
## 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 관련 내용 추가:

```markdown
### 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 실행

```bash
# 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 실행

```bash
# API 호출 (실제 실행)
curl -X POST http://localhost:5000/api/tags/sub-area/seed \
  -H "Content-Type: application/json" \
  -d '{"dryRun": false}'
```

#### 7.3 DB 정합성 검증 쿼리

```sql
-- 각 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 공통 설비/저장설비 검증

```sql
-- 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` |
