chore: 루트 문서 정리 — knowledge/ CANON 소스 신설 + 흩어진 문서 루트 밖 격리

seed 품질 확보(GIGO 차단). 루트에 흩어졌던 ~150개 문서를 용도별 분리.

- knowledge/ 신설 = 단일 CANON 지식 소스 (RAG/지식은 여기만 참조)
  · 플랜트 지식 7: 구조설명 6-1/6-2차, 측류추출 관계식·시간지연, PGMEA 일반상식·운전주의점
  · 도면-데이터시트/: As-Built 15 + FCV 데이터시트 2 (PDF 바이너리는 .gitignore, 디스크 유지)
- 계획·진단·대화로그·멀티모델 초안(byQwen/byGemma 등)·완료작업(dxf-graph/·fastTable/·plans/)은
  **프로젝트 루트 밖 저장소로 격리**(삭제 아닌 이동, 복원 가능):
    /home/windpacer/projects/ReferenceSources/ExperionCrawler/
  (ExperionCrawler.Tests/ 도 동일 위치 — 완료/실패분, 필요시 복원)
- .gitignore: 대용량 PDF(knowledge 104M + src/Web/uploads 157M)·*.backup 제외

근거 플랜(아카이브): ReferenceSources/.../plans/online-lora-학습-파이프라인-실행계획-byOPUS.md Phase -1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-26 09:55:19 +09:00
parent ab3e36680f
commit 3e9f3076ef
367 changed files with 1566 additions and 2525740 deletions

View File

@@ -1,15 +0,0 @@
파워포인트 파일 작성, 아래 요구사항에 따라서
1) AX 도입의 필연성 및 효율성 (1 페이지)
2) AX화 를 위해서 전산팀(소프트웨어 개발 부서)외의 현업 부서에서 해야할 사전 준비 항목 서술
- 신규 프로젝트 시 ,
- DXF, PDF GRAPH화를 위한 캐드 도면 작성 규칙
- 문서 작성 규칙
- 기존 프로젝트 RAG화 방안
- 좋은 방법 제안해줘
- 현실적인 OCR 등의 문제(기존 도면 및 문서의 표준 PDF, DXF 화 불가능시)를 감안
- 다른 관점에서도 제안
3) 현재 ExperionCrawler 소개
4) 위의 AX화를 위한 문제점을 ExperionCrawler에서 어떻게 보완하고 있는가 설명
5) 너의 의견 및 제안 환영함!!!

View File

@@ -1,288 +0,0 @@
현재까지 작업한 것의 사용자가 해야할 일 정리해줘
● 사용자 액션 가이드 (Phase 0~5 구현물 사용 절차)
1. 사전 환경 셋업 (1회만)
1.1 Python 의존성 추가 설치 — Phase 2에서 openpyxl/python-docx/pdfplumber를 추가했으므로
mcp-server 가상환경에 설치 필요:
cd ~/projects/ExperionCrawler/mcp-server
uv pip install -e .
1.2 외부 서비스 동작 확인 — 셋 다 떠 있어야 함:
curl -s http://localhost:6333/collections | jq # Qdrant
curl -s http://localhost:11434/api/tags | jq # Ollama (nomic-embed-text 필요)
curl -s http://localhost:8000/v1/models | jq # vLLM (Qwen3.6-27B-FP8)
- Ollama에 nomic-embed-text 모델이 없으면: ollama pull nomic-embed-text
1.3 (선택) 관리자 초기 비번을 환경변수로 미리 설정 — 콘솔 로그에 비번 노출이 싫으면:
export KB_ADMIN_INITIAL_PASSWORD='원하는초기비번'
설정 안 하면 첫 부팅 시 자동 생성 후 콘솔에 1회 출력.
---
2. 첫 부팅
2.1 mcp-server 재시작 — 새 도구(parse_document, search_kb) 등록:
# 기존 프로세스 종료 후
cd ~/projects/ExperionCrawler/mcp-server
# 기존 기동 방식 그대로 (예: uv run / python server.py --http 등)
2.2 ExperionCrawler 앱 빌드/기동:
cd ~/projects/ExperionCrawler
dotnet build src/Web/ExperionCrawler.csproj
dotnet run --project src/Web/ExperionCrawler.csproj
# 또는 deploy.sh 사용
2.3 콘솔 로그 확인 — 다음 두 줄이 보이는지:
[Kb] 관리자 초기 비밀번호 자동 생성: XXXXXXXXXXXXXXXX ← 즉시 변경하세요
[Kb] Qdrant 컬렉션 ensure 완료: 5건
랜덤 비번을 즉시 메모. (env var 사용했다면 "환경변수 사용" 로그 1줄만 출력)
2.4 Qdrant 컬렉션 5개 생성 확인:
curl -s http://localhost:6333/collections | jq '.result.collections[] | .name'
# kb_system_instrument, kb_plant_operation, kb_procedure, kb_report, kb_vendor_doc
포함되어야 함
---
3. 관리자 첫 로그인 + 비번 변경 (필수)
1. 브라우저 → http://localhost:5000 → 사이드바 14 RAG 관리 클릭
2. 콘솔에서 받은 초기 비번 입력 → 로그인
3. 상단 [비밀번호 변경] → 새 비번 (6자 이상) 설정 → 자동 로그아웃
4. 새 비번으로 재로그인
---
4. (선택) plant_context.md 작성
prompts/plant_context.md는 현재 빈 골격입니다. 채워두면 채팅의 시스템 프롬프트에 자동
주입되어 답변 품질이 좋아집니다:
nano prompts/plant_context.md
채울 항목 (파일 안 주석 참고):
- 단위 (Unit A: 압축, Unit B: 분리 …)
- 계기 prefix 약어 (FIC/PT/TI/XV …)
- 태그 명명 규칙 (소문자, .pv/.sp/.op/.instate0~7)
- 시간대 (UTC 저장, KST 표시)
- 예시 질문 → 도구 매핑
수정 후 앱 재기동 없이 즉시 반영됩니다 (요청 시마다 파일 읽음).
---
5. KB 문서 업로드 워크플로우
1. 14번 탭 → [📁 파일 업로드] 클릭
2. 컬렉션 선택 (5종 중 1개):
- system_instrument — 계기 datasheet, P&ID 사양서
- plant_operation — 재고, 생산현황, 정비이력, 교대일지
- procedure — SOP, 정비 절차, 알람 대응 매뉴얼
- report — 일/주/월 보고서
- vendor_doc — 벤더 카탈로그, 매뉴얼
3. 제목(기본: 파일명) / 태그(콤마 구분, 예: unit-a, P-6201) 입력
4. 파일 선택 → [업로드]
5. 목록에 새 행이 pending 상태로 등장 → 1.5초마다 자동 갱신 → parsing → embedding → indexed
6. 실패(failed) 시 해당 행의 에러 메시지 확인, [↻] 재인덱스 가능
지원 형식: .xlsx / .xlsm / .pdf / .docx / .md / .txt
---
6. 채팅에서 KB 활용
1. 사이드바 13 로컬 LLM 채팅 클릭
2. LLM 종류 = vLLM 선택 (tool 사용은 vLLM 전용)
3. 모델 = Qwen3.6-27B-FP8 선택
4. MCP 도구 ✓ 체크박스 켜기
5. welcome 화면의 추천 칩 중 하나 클릭 (예: "P-6201 펌프의 정비 이력") → 자동으로 입력창에
채워짐 → Enter
6. 진행 상황 가시화:
- 회색 spinning 카드: 툴 호출 시작
- 녹색 ✓: 성공 + 결과 (클릭하면 전체 펼침, 표/KB hits 자동 렌더)
- 빨간 ✗: 실패 + 에러 메시지
7. KB에서 가져온 문서 제목이 답변 본문에 점선 밑줄 링크로 자동 표시됨 → 클릭하면 원본 파일
다운로드
---
7. 검증 (옵션)
항목: 작은 txt 업로드 → 30초 내 indexed
확인 방법: 14번 탭에서 상태 칩 색상 변화
────────────────────────────────────────
항목: 큰 PDF 실패 시 재시도 동작
확인 방법: jobs 테이블 attempts 누적 (앱 콘솔 로그)
────────────────────────────────────────
항목: 세션 60분 자동 만료
확인 방법: 60분 방치 후 동작 시 재로그인 요구
────────────────────────────────────────
항목: KB 인용 다운로드
확인 방법: search_kb 호출 후 답변 안의 제목 링크 클릭
────────────────────────────────────────
항목: Qdrant 청크 확인
확인 방법: curl http://localhost:6333/collections/kb_plant_operation | jq
'.result.points_count'
---
8. 운영 팁
- 재인덱스: 청킹 정책을 바꿨거나 파일 손상 시 → 행의 [↻] 버튼
- 임시 숨김: 잘못 올렸지만 보관은 유지 → [🚫] (비활성화, 검색 결과에서 제외)
- 영구 삭제: [✖] 또는 하단 [비활성화 영구삭제(90일 이상)]
- 동일 제목 일괄 비활성화: 재업로드 후 구버전 한 번에 비활성화 — 하단 [🚫 동일 제목 일괄
비활성화]
- 로그 위치: mcp-server/logs/, dotnet 콘솔 — [Kb] [Kb][Worker] [Kb][Qdrant] 접두사로 grep
---
9. 알려진 제약 / 다음 단계
- 시계열 시각화는 표만 자동 렌더됨 (스파클라인 보류)
- 툴 실행 카드는 스트리밍 중에만 보임 — 메시지 영구 보존은 Phase 6 이후
- 이벤트/알람 도구 (active_alarms 등), find_tags, generate_status_report 매크로는 Phase
6에서 추가 예정 — 현재 추천 칩 중 일부는 모델이 직접 SQL을 짜서 답변
- run_sql 안전장치 (자동 LIMIT, statement_timeout)도 Phase 6
---
막힘이 있는 단계가 있으면 어디서 멈췄는지 알려주시면 함께 보겠습니다.
1. KbStorageService.SaveAsync — SHA256 TransformBlock 사용 (MED)
문제: SHA256.TransformBlock() / TransformFinalBlock()은 .NET 8에서 Deprecated되었으며, 스트리밍 입력에서 단일 패스에서 SHA256 계산 시 버퍼 경계 처리가 정확하지 않을 수 있다. 특히 input.ReadAsync(buffer.AsMemory(0, buffer.Length))로 읽은 데이터를 TransformBlock(buffer, 0, read, null, 0)에 넘기는데, TransformBlock은 내부 버퍼링을 하며 마지막 블록에서 TransformFinalBlock을 호출해야 하는데, 여기서는 빈 배열을 전달한다. 이 자체는 동작하지만 SHA256.Hash가 TransformFinalBlock 호출 후에만 유효하므로 로직상 문제는 없다. 하지만 SHA256.Create()의 인스턴스가 using 처리되지 않아 리소스 누수 가능성이 있다.
근거: KbStorageService.cs:36 — using var sha = SHA256.Create(); → 실제로는 using이 적용되어 있음.
교차 검증: 실제 코드를 다시 확인하니 using var sha = SHA256.Create();로 되어 있고 TransformFinalBlock(Array.Empty<byte>(), 0, 0) 호출 후 sha.Hash를 참조함. Q1 통과 — 이미 올바르게 구현됨. 보고서에서 제거.
2. KbIngestWorker — FOR UPDATE SKIP LOCKED 미사용 (MED)
문제: 플랜 3.7절에서 "FOR UPDATE SKIP LOCKED 큐 폴링(2초)"으로 명시되어 있으나, 실제 구현은 WHERE j.FinishedAt == null && j.Stage == "parse" && j.Attempts < _maxAttempts + OrderBy(j => j.EnqueuedAt).FirstOrDefaultAsync()로 단순 SELECT 후 업데이트 패턴이다. 동시에 여러 인스턴스가 실행되면 같은 잡을 중복으로 처리할 수 있다.
근거: KbIngestWorker.cs:74-77
교차 검증:
Q2: 다른 레이어에서 처리되는가? → Program.cs에서 KbIngestWorker는 단일 AddHostedService로 등록됨. 단일 인스턴스만 실행되므로 동시성 문제는 발생하지 않음.
Q3: 의도적 설계인가? → 플랜에는 SKIP LOCKED가 명시되었으나, 단일 인스턴스 아키텍처에서는 불필요함.
Q2/Q3 통과 — 보고서에서 제거.
3. KbQdrantClient — HttpClient 직접 생성 (LOW)
문제: KbQdrantClient 생성자에서 new HttpClient를 직접 생성하고 있다. .NET best practice는 IHttpClientFactory를 사용하는 것이다. 현재 Singleton으로 등록되므로 단일 인스턴스에서는 문제가 없으나, using 처리되지 않아Dispose 누수 가능성이 있다.
근거: KbQdrantClient.cs:21 — new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) }
교차 검증:
Q2: KbQdrantClient가 Singleton으로 등록되어 수명주기 전체에서 하나의 HttpClient를 사용하므로 소켓 고갈 문제는 없음.
Q4: 실제 장애 시나리오? → 앱 종료 시 HttpClient가 명시적으로 Dispose되지 않지만, 프로세스 종료 시 OS가 정리하므로 실제 영향 없음.
Q2/Q4 통과 → LOW 강등
3. KbQdrantClient — HttpClient 직접 생성 (LOW)
문제: KbQdrantClient.cs:21 — new HttpClient 직접 생성. IHttpClientFactory 미사용.
영향: Singleton 등록이므로 단일 인스턴스에서는 소켓 고갈 없음. 하지만 명시적 Dispose가 없어 이론적 리소스 누수. 프로세스 종료 시 정리됨.
수정: Program.cs에서 AddHttpClient("KbQdrant") 팩토리 등록 후 KbQdrantClient에 주입
4. nl2sql_worker.py — 비동기 함수 내 blocking DB 연결 (MED)
문제: _run_sql, _query_pv_history, _get_tag_metadata, _list_drawings, _query_with_nl 함수들이 async def로 선언되어 있으나, 내부에서 _get_db_connection()을 asyncio.to_thread 없이 직접 호출한다. psycopg3의 connect()는 blocking TCP 호출이며, 이벤트 루프를 블로킹한다.
근거: nl2sql_worker.py:208-210 — conn = _get_db_connection() (async 함수 내에서 blocking)
교차 검증:
Q1: 이미 수정된 문제인가? → server.py의 _get_db_connection()은 asyncio.to_thread로 감싸져 있으나, nl2sql_worker.py는 별도 워커로 독립 구현되어 있고 여기서는 감싸지 않음.
Q2: 다른 레이어에서 처리되는가? → 이 워커는 FastAPI + uvicorn으로 독립 실행되며, /execute 엔드포인트가 HTTP 요청을 처리한다. 이벤트 루프 블로킹은 동시 HTTP 처리 성능에 영향을 줄 수 있음.
Q4: 실제 시나리오? → 여러 HTTP 요청이 동시 도착 시 DB 연결 대기 중 이벤트 루프 블로킹으로 다른 요청 지연.
Q1/Q2/Q4 통과 — 유지
4. nl2sql_worker.py — 비동기 함수 내 blocking DB 연결 (MED)
문제: async def 함수 내에서 psycopg.connect()를 asyncio.to_thread 없이 직접 호출하여 이벤트 루프 블로킹.
근거: nl2sql_worker.py:210 (_run_sql), 238 (_query_pv_history), 270 (_get_tag_metadata), 303 (_list_drawings), 346 (_query_with_nl)
영향: 동시 HTTP 요청이 여러 개 들어올 때 DB 연결 대기 중 이벤트 루프 블로킹 → 응답 지연
수정: 각 함수에서 conn = await asyncio.to_thread(_get_db_connection)으로 변경
5. nl2sql_worker.py:_list_drawings — 데이터 파싱 버그 (HIGH)
문제: cur.fetchall() 결과가 (name,) 튜플인데, dict(zip(columns, row[0]))로 처리하고 있다. row[0]는 문자열(name 컬럼 값)이며, 문자열은 이터러블이므로 각 문자가 개별 값으로 분리되어 {"name": "f"} 같은 잘못된 결과가 생성된다.
근거: nl2sql_worker.py:327 — data = [dict(zip(columns, row[0])) for row in rows]
교차 검증:
Q1: 이미 수정된 문제인가? → 확인함. 아직 수정되지 않음.
Q4: 실제 시나리오? → list_drawings 도구 호출 시 반환된 names 배열이 각 문자로 분리된 객체가 됨 → 프론트엔드에서 렌더링 불가.
Q1/Q4 통과 — 유지
5. nl2sql_worker.py:_list_drawings — 데이터 파싱 버그 (HIGH)
문제: row[0]가 문자열인데 dict(zip(columns, row[0]))로 전달 → 문자열이 문자 단위 이터레이션되어 잘못된 dict 생성.
근거: nl2sql_worker.py:327
영향: list_drawings 도구 호출 시 names 필드가 {"name": "f"} 같은 잘못된 데이터 반환
수정: dict(zip(columns, row)) 또는 단순히 [row[0] for row in rows]로 변경
6. OllamaController — plant_context.md 매 요청마다 파일 읽기 (LOW)
문제: LoadPlantContext()는 매 호출마다 File.ReadAllText()를 수행한다. ComposeSystemPrompt()는 모든 채팅 요청마다 호출되며, 이는 불필요한 디스크 I/O를 발생시킨다.
근거: OllamaController.cs:76-89 — LoadPlantContext(), 호출 위치 cs:147
교차 검증:
Q3: 의도적 설계인가? → 파일 변경을 실시간으로 반영하기 위한 것으로 보이나, plant_context.md는 운영 중 자주 변경되지 않음.
Q4: 실제 장애 시나리오? → 파일 읽기는 빠르지만 고빈도 채팅 시 누적 오버헤드. 캐싱으로 개선 가능하나 동작에는 영향 없음.
Q3/Q4 → LOW 강등
6. OllamaController — plant_context.md 매 요청 파일 읽기 (LOW)
문제: LoadPlantContext()가 ComposeSystemPrompt() 호출마다 File.ReadAllText() 실행.
근거: OllamaController.cs:76-89
영향: 고빈도 채팅 시 불필요한 디스크 I/O. 동작에는 영향 없음.
수정: Lazy<string> 또는 MemoryCache로 캐싱 (파일 변경 감지 옵션)
7. KbAuthService.EnsureCredentialAsync — 자동 생성 비밀번호 로깅 (LOW)
문제: 자동 생성된 초기 비밀번호가 _logger.LogWarning()로 평문 로깅된다. 로그 파일이 적절히 보호되지 않은 환경에서는 비밀번호 노출 위험이 있다.
근거: KbAuthService.cs:64 — _logger.LogWarning("[Kb] 관리자 초기 비밀번호 자동 생성: {Pw} ← 즉시 변경하세요", initial)
교차 검증:
Q3: 의도적 설계인가? → 환경변수가 설정되지 않은 경우 사용자에게 비밀번호를 전달할 방법이 로그밖에 없으므로 의도적임.
Q4: 실제 시나리오? → 로그 파일이 여러 사용자에게 노출되는 환경에서만 문제.
Q3/Q4 → LOW 강등
7. KbAuthService — 자동 생성 비밀번호 평문 로깅 (LOW)
문제: 자동 생성 비밀번호가 로그에 평문으로 기록됨.
근거: KbAuthService.cs:64
영향: 로그 파일 접근 권한이 느슨한 환경에서 비밀번호 노출. 한 번만 기록되며 즉시 변경 요구.
수정: 부분 마스킹({Pw[:4]}****) 또는 콘솔 출력 전용
8. KbIngestWorker — 임베딩 실패 시 전체 실패 (LOW)
문제: 단일 청크의 임베딩 실패(vec == null) 시 전체 문서 인덱싱이 실패한다. 부분 실패 처리가 없어 일부 청크만 문제가 있어도 전체가 재시도된다.
근거: KbIngestWorker.cs:123 — if (vec == null) throw new Exception("임베딩 실패(부분)")
교차 검증:
Q3: 의도적 설계인가? → 임베딩 모델(Ollama)이 응답하지 않는 상태에서는 부분 인덱싱보다 전체 실패가 더 안전한 선택.
Q4: 재시도 로직이 attempts 기반이므로 3회 시도 후 failed 상태로 전환됨.
Q3/Q4 → LOW 강등
8. KbIngestWorker — 단일 청크 임베딩 실패 시 전체 실패 (LOW)
문제: 청크 1개 임베딩 실패 시 전체 문서 인덱싱 abort.
근거: KbIngestWorker.cs:123
영향: 대용량 문서에서 일부 청크만 문제 있어도 전체 재시도. 3회 시도 후 failed 전환.
수정: 실패 청크 제외하고 나머지 인덱싱 진행 (선택적)
요약
# 심각도 항목 상태
5 🔴 HIGH nl2sql_worker.py:_list_drawings 데이터 파싱 버그 수정 필요
4 🟠 MED nl2sql_worker.py blocking DB 연결 (5개 함수) 수정 필요
3 🟡 LOW KbQdrantClient HttpClient 직접 생성 개선 권장
6 🟡 LOW OllamaController plant_context.md 매 요청 파일 읽기 개선 권장
7 🟡 LOW 자동 생성 비밀번호 평문 로깅 개선 권장
8 🟡 LOW 단일 청크 임베딩 실패 시 전체 실패 개선 권장

View File

@@ -1,765 +0,0 @@
# 로컬 LLM 채팅 + 지식 증강 (RAG) 플랜
작성 시점: 2026-05-12
대상: 탭 #13 로컬 LLM 채팅 + 신규 탭 #14 RAG 관리
---
## 0. 배경 및 목적
운전원이 채팅 UI에서 자연어로 "공장 상황 보고 / 계기 상태 / 현장 재고 / 고장 이력" 등을
질문하고, 시스템이 다음 두 가지 소스를 합성해 답한다.
1. **실시간/이력 데이터** — PostgreSQL(history_table, realtime_table, event_history_table,
tag_metadata, node_map_master)
2. **사용자 지식 베이스(RAG)** — 관리자가 첨부한 문서(엑셀, 워드, PDF, MD, TXT 등)에서
추출한 청크를 Qdrant에 인덱스, 의미 검색으로 활용
본 문서는 두 갈래를 통합한 **컨셉 합의 결과**와 **구현 계획**을 기록한다.
코드는 골격/예시 위주이며, 실제 구현은 본 플랜을 기준으로 단계별 진행한다.
---
## 1. 현 상태 검토 요약
### 1.1 잘 짜여 있는 부분
- `OllamaController` — Ollama / vLLM 이중 백엔드, SSE 스트리밍, MCP 툴콜 루프(최대 10라운드),
`tool_calls` API 미지원 모델용 JSON 텍스트 폴백 추출(`ExtractFirstJsonObject`).
- `app.js` 채팅 UI — localStorage 세션 관리, 모델/툴 토글, 중단 버튼, 코드블록 포맷팅.
- MCP 툴셋(`mcp-server/server.py`) — `run_sql`, `query_pv_history`, `get_tag_metadata`,
`list_drawings`, `query_with_nl`, RAG 3종(`search_codebase`, `search_r530_docs`, `rag_query`,
`ask_iiot_llm`).
- 임베딩·OCR·PDF/DXF 추출 인프라가 이미 존재(`_embed`, `_ocr`, `_extract_text_from_pdf`,
`_extract_text_from_pdf_ocr`, `_extract_text_from_dxf`).
### 1.2 "공장 상황 보고" 사용 대비 격차
| # | 격차 | 비고 |
|---|---|---|
| G1 | 이벤트/알람 질의 도구 부재 | event_history_table은 적재만 됨, MCP 노출 X |
| G2 | "보고서" 합성 도구 부재 | 모델이 멀티 툴을 알아서 호출해야 함 |
| G3 | 현장 재고 데이터 자체 없음 | 별도 자료 수급 또는 RAG로 흡수 |
| G4 | 태그 의미 → 태그명 시맨틱 검색 부재 | `get_tag_metadata`는 패턴 매칭만 |
| G5 | 시스템 프롬프트 빈약(영어 1줄 하드코딩) | 플랜트 용어집·계기 prefix·예시 미주입 |
| G6 | 툴 결과가 raw JSON으로 버블에 박힘 | 표/차트 렌더링 없음, 실행된 SQL 미노출 |
| G7 | 툴 실행 중 UX 공백 | tool_calls 1라운드 비스트리밍 → 침묵 |
| G8 | SQL 안전장치 약함 | run_sql LIMIT/타임아웃 없음 |
| G9 | 장기 대화 컨텍스트 관리 없음 | 매 턴 전체 messages 전송 |
| G10 | 추천 질문/빠른 액션 없음 | 빈 화면 진입 장벽 |
| G11 | 사용자 지식 베이스(KB) 부재 | 본 플랜의 핵심 추가 |
### 1.3 발견된 자잘한 결함
- `mcp-server/worker/nl2sql_worker.py:244``time_bucket('1 min', ts)` 사용. `realtime_table`에는
`ts` 컬럼이 없고 시계열은 `history_table.recorded_at`. 호출 시 깨짐. 본체 `server.py`는 정상.
- `mcp-server/llm-model.json``"Qwen3.6-27B-FP8"`. 실제 vLLM 서빙 모델명과 동기화 확인 필요
(메모리에는 Qwen3-Coder-Next-FP8 운영 기록).
- `OllamaController.cs:608` — 시스템 프롬프트 하드코딩 영어 문자열. 한글 + plant_context 합성으로
교체 권장.
---
## 2. 합의된 결정 사항 (체크리스트)
채팅 전 합의 과정에서 확정된 항목.
### 2.1 채팅 페이지 개선 방향 (별도 사용자 결정 대기 항목 있음)
| # | 항목 | 합의 | 상태 (2026-05-13) |
|---|---|---|---|
| C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 | ✅ 완료 (Phase 5.5) |
| C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 | ✅ 완료 (Phase 0.3, 빈 골격 생성) |
| C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 | ⏳ Phase 6.1 |
| C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 | ✅ 완료 (Phase 5.1, 5.2) |
| C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 | ✅ 표 완료 / 스파클라인 보류 (Phase 5.4) |
| C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 | ⏳ Phase 6.3 |
| C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 | ⏳ Phase 6.2 |
| C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 | ⏳ Phase 6.4 |
| C9 | NL2SQL 의도 라우터 | 검토 후 결정 | ⏳ 보류 (Phase 7.1) |
| C10 | 대화 요약/압축 | 후순위 | ⏳ 보류 (Phase 7.2) |
| C11 | 에이전트 모드(자율 멀티스텝) | 후순위 | ⏳ 보류 (Phase 7.3) |
| C12 | 위 1.3 결함 픽스 | 즉시 진행 | ✅ 완료 (Phase 0.1, 0.2) |
### 2.2 지식 증강(RAG ingest) 결정
| # | 항목 | 결정 |
|---|---|---|
| Q1 | ingest 권한 | **관리자만** |
| Q2 | 컬렉션 구조 | **doc_type별 분리 컬렉션** (마스터 사전 정의 + 자유 태그 세분화) |
| Q3 | 관리자 권한 | **비번 인증** (해시 + 솔트, 세션 토큰 60분) |
| Q4 | 출처 인용 | **위치(시트/페이지/헤딩) + 다운로드 링크까지** |
| Q5 | 엑셀 청킹 | **행 단위 + 시트 단위 둘 다 저장** |
| Q6 | ingest 트리거 | **첨부 버튼 (관리 탭 안)** — 채팅창에는 첨부 없음 |
| Q7 | 처리 방식 | **전 시스템 비동기 큐** |
| R | 재업로드 정책 | **누적** (버전 관리 없음, 시간순 누적) |
| N1 | 카테고리 운용 | **마스터 사전 정의(5종) + 자유 태그 보조** |
| N2 | PDF 청킹 | **섹션 + 표 별도 추출**, OCR PDF는 페이지 단위 fallback |
| N3 | 누적 잡음 완화 | **검색 시 최신 가중치 + 관리탭 일괄 비활성화 버튼** |
| N4 | 다운로드 인증 | **누구나 다운로드 가능** |
| M1 | 저장소 cleanup | **수동만** — 관리탭 "오래된 비활성화 일괄 영구삭제" 버튼만 제공 |
| M2 | 임베딩 모델 | **추천 채택** — BGE-M3 또는 multilingual-e5-large (한국어 품질 우선) |
| M3 | 비번 저장 | **해시(PBKDF2/Argon2) + 솔트**, 초기 비번은 환경변수 또는 콘솔 출력, 관리탭에서 변경 |
---
## 3. 통합 시스템 설계
### 3.1 전체 아키텍처
```
┌─────────────────────────┐
│ 채팅 탭 #13 (운전원) │
│ - 추천 질문 칩 │
│ - 스트리밍 + 툴 가시화 │
│ - 테이블/차트 자동 렌더 │
└──────────┬──────────────┘
│ /api/ollama/vllm/chat/stream
┌──────────────────────────┐
│ OllamaController │
│ - tool_calls 루프 │
│ - SSE 이벤트 발행 │
└──────────┬───────────────┘
│ MCP tool call
┌──────────────────────────────────────────────────────┐
│ MCP Server (server.py) │
│ ┌────────────┬─────────────┬──────────────────────┐ │
│ │ DB 도구 │ KB 도구 │ R530 docs / Code │ │
│ │ run_sql │ search_kb │ search_r530_docs │ │
│ │ q_pv_hist │ parse_doc │ search_codebase │ │
│ │ events* │ ... │ ... │ │
│ └────────────┴─────────────┴──────────────────────┘ │
└──────┬─────────────────┬──────────────────────┬──────┘
│ │ │
▼ ▼ ▼
PostgreSQL Qdrant collections Filesystem
(시계열 + KB 메타) (kb_* 5개 + 기존 2개) (storage/kb/)
┌─────────────────────────┐
│ RAG 관리 탭 #14 (관리자) │
│ - 비번 인증 │
│ - 업로드 (드래그앤드롭) │
│ - 문서 목록/상태/삭제 │
│ - 진행률 폴링 │
└──────────┬──────────────┘
│ /api/kb/*
┌──────────────────────────┐
│ KbController │
│ + KbIngestWorker │
│ (BackgroundService) │
└──────────────────────────┘
```
### 3.2 시드 카테고리 (kb_collections 초기 데이터)
| `collection_key` | 표시명 | 청킹 정책 (chunking_policy JSONB) | 대표 자료 |
|---|---|---|---|
| `system_instrument` | 시스템 & 계기 정보 | `{"pdf":"section+table","xlsx":"row+sheet","docx":"heading"}` | 계기 datasheet, P&ID, 사양서, 노드맵 |
| `plant_operation` | 공장 운전 정보 | `{"xlsx":"row","docx":"heading","md":"heading"}` | 재고, 생산현황, 고장이력, 교대일지 |
| `procedure` | 절차서/SOP | `{"docx":"heading","md":"heading","pdf":"section"}` | SOP, 정비 절차, 알람 대응 매뉴얼 |
| `report` | 보고서 | `{"pdf":"section+table","docx":"heading"}` | 일/주/월 보고, 사고보고, 분석보고 |
| `vendor_doc` | 벤더 자료 | `{"pdf":"section+table","docx":"heading"}` | 카탈로그, 매뉴얼, 인증서 |
**공통 보조 태그** (kb_documents.tags, 자유 입력):
- `area` — Unit A, Unit B 등
- `equipment` — P-6201, FIC-6113 등
- `date` — 자료 기준일 (YYYY-MM-DD)
- `language` — ko / en
---
### 3.3 데이터 모델 (PostgreSQL)
```sql
-- 컬렉션 레지스트리
CREATE TABLE kb_collections (
collection_key TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
qdrant_name TEXT NOT NULL UNIQUE,
chunking_policy JSONB NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 문서 메타
CREATE TABLE kb_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collection_key TEXT REFERENCES kb_collections,
title TEXT NOT NULL,
original_path TEXT NOT NULL, -- storage/kb/2026-05/{uuid}.{ext}
file_sha256 TEXT NOT NULL,
file_size BIGINT,
mime_type TEXT,
tags TEXT[],
status TEXT NOT NULL DEFAULT 'pending',
-- pending / parsing / embedding / indexed / failed / disabled
chunk_count INT DEFAULT 0,
error_message TEXT,
uploaded_by TEXT, -- 관리자 세션 식별자
uploaded_at TIMESTAMPTZ DEFAULT NOW(),
indexed_at TIMESTAMPTZ,
disabled_at TIMESTAMPTZ
);
CREATE INDEX idx_kb_docs_coll_status ON kb_documents(collection_key, status, uploaded_at DESC);
CREATE INDEX idx_kb_docs_title ON kb_documents(title);
-- 비동기 작업 큐
CREATE TABLE kb_ingest_jobs (
id BIGSERIAL PRIMARY KEY,
doc_id UUID REFERENCES kb_documents,
stage TEXT NOT NULL, -- parse / embed / index
attempts INT DEFAULT 0,
last_error TEXT,
enqueued_at TIMESTAMPTZ DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_kb_jobs_pending ON kb_ingest_jobs(stage, finished_at)
WHERE finished_at IS NULL;
-- 관리자 인증 (단일 행)
CREATE TABLE kb_admin_credential (
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
password_hash TEXT NOT NULL, -- Argon2 또는 PBKDF2
salt TEXT NOT NULL,
algorithm TEXT NOT NULL DEFAULT 'argon2id',
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 관리자 세션
CREATE TABLE kb_admin_sessions (
token TEXT PRIMARY KEY,
issued_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
client_ip TEXT
);
```
### 3.4 Qdrant payload 표준
모든 KB 컬렉션 공통:
```json
{
"doc_id": "uuid",
"collection_key": "plant_operation",
"title": "정비이력_2026Q1.xlsx",
"chunk_kind": "row | sheet | section | table | page | paragraph",
"locator": "sheet=Pump-A; row=12-15",
"uploaded_at": "2026-05-12T10:00:00Z",
"tags": ["unit-a", "P-6201"]
}
```
- `locator` — UI에 그대로 표시될 사람용 위치 문자열
- `uploaded_at` — 검색 시 최신 가중치(decay) 계산용
- `chunk_kind` — 같은 문서에서 행 청크 vs 시트 청크 중복 발생 시 dedup/우선순위 결정용
---
### 3.5 API 엔드포인트 (.NET)
```
[관리자 인증]
POST /api/kb/auth/login {password} → {token, expiresAt}
POST /api/kb/auth/logout header: X-Kb-Token
GET /api/kb/auth/status header: X-Kb-Token → {valid, expiresAt}
POST /api/kb/auth/change-password {oldPassword, newPassword}
[컬렉션 — 누구나 조회 가능]
GET /api/kb/collections → [{key, name, chunkCount, ...}]
[업로드 / 관리 — admin only]
POST /api/kb/upload multipart → {docId, status:"pending"}
fields: file, collectionKey, title?, tags[]?
GET /api/kb/documents?collection=&status=&q=&page= → 페이지네이션 목록
GET /api/kb/documents/{id} → 상세 + chunks 미리보기(상위 N)
DELETE /api/kb/documents/{id} → Qdrant + storage 동시 정리
POST /api/kb/documents/{id}/reindex → status 초기화 + 재큐
POST /api/kb/documents/{id}/disable → status='disabled'
POST /api/kb/documents/bulk-disable {title} → 동일 제목 일괄 비활성화
POST /api/kb/documents/purge-disabled {olderThanDays?} → 비활성화 영구삭제(수동)
GET /api/kb/jobs?status=&docId= → 진행 중/실패 작업
[다운로드 — 누구나]
GET /api/kb/download/{docId} → 원본 스트림 (Content-Disposition)
```
### 3.6 MCP 측 신규/변경 도구 (server.py)
```python
# 신규 — KB 인덱싱 (워커 전용, 채팅 노출 X)
parse_document(doc_id: str) -> dict
"""형식별 청킹. xlsx는 행+시트 둘 다, pdf는 섹션+표 별도, OCR PDF는 페이지 fallback."""
# 신규 — KB 검색 (채팅 노출 O)
search_kb(query: str,
collection_keys: list[str] = None, # None이면 전체
top_k: int = 8,
tags: list[str] = None,
since: str = None, # ISO date — 이후 업로드된 것만
boost_recent: bool = True) -> str
"""다중 컬렉션 의미 검색. uploaded_at 기반 최신 가중치 적용."""
# 신규 — 이벤트/알람
query_events(time_from: str, time_to: str,
severity: str = None, area: str = None) -> str
summarize_events(time_window: str = "24h") -> str
active_alarms() -> str
# 신규 — 태그 시맨틱 검색
find_tags(query: str, top_k: int = 10) -> str
"""tag_metadata.value(desc, area) 벡터 인덱스 사용. '냉각수 펌프 토출' 같은 표현 지원."""
# 신규 — 보고서 합성
generate_status_report(scope: str = "shift", # shift / daily / event
area: str = None) -> str
# 통합 — 기존 rag_query 확장
rag_query(question: str,
search_code: bool = False,
search_docs: bool = True,
search_kb: bool = True, # 신규 — KB 통합
kb_collections: list[str] = None)
```
### 3.7 비동기 파이프라인
```
[관리자 PC] ── multipart 업로드 ──▶ [.NET KbController]
(응답 즉시 반환) ├─ storage/kb/{yyyy-mm}/{uuid}.{ext} 저장
├─ SHA256 계산
├─ kb_documents INSERT (status='pending')
└─ kb_ingest_jobs INSERT (stage='parse')
[KbIngestWorker — BackgroundService]
├─ FOR UPDATE SKIP LOCKED 큐 폴링(2초)
├─ stage='parse'
│ └─ MCP parse_document → chunks 목록 반환
├─ stage='embed'
│ └─ MCP _embed 배치 호출
├─ stage='index'
│ └─ Qdrant upsert (collection_key별)
├─ 성공: kb_documents.status='indexed', chunk_count=N
└─ 실패: attempts++, ≥3이면 status='failed'
[관리 탭] 1초 폴링으로 진행률 표시
"정비이력_2026Q1.xlsx — 임베딩 중 (43/120)"
완료 시 ✓ 마크
```
#### 형식별 청킹 디테일
**xlsx (행 + 시트 둘 다)**
- 시트 청크: 시트 전체를 Markdown 표로 직렬화 → 1개 chunk per 시트
- 행 청크: 각 행을 `{시트명}: 컬럼1=값1, 컬럼2=값2, …` 한 줄로 직렬화 → N개 chunk
- locator: `sheet=Pump-A`, `sheet=Pump-A; row=12`
**pdf (섹션 + 표 별도)**
- pdfplumber로 헤딩 구조 추출 시도
- 헤딩 추출 성공: 섹션별 chunk + 페이지 내 표만 별도 chunk
- 헤딩 추출 실패(OCR 등): 페이지 단위 fallback
- locator: `section=Specifications`, `page=5; table=Performance`
**docx**
- python-docx로 헤딩 트리 추출 → 헤딩 path별 chunk
- locator: `heading=5.2 펌프 기동`
**md / txt**
- `#` 헤딩 기반 splitter (md), 빈 줄 두 개 기반 (txt)
- locator: `heading=…` 또는 `paragraph=N`
#### 청크 크기 가이드
- 평균 400~800 토큰, 헤딩 단위가 너무 크면 추가 분할
- overlap 100 토큰
---
### 3.8 채팅 통합
채팅 탭은 **읽기 전용** — 첨부 버튼 없음 (Q6 결정).
```
사용자 질문
[vLLM tool calling]
├─ 의도: 시스템/제품 사양 질문
│ → search_r530_docs + search_kb(['system_instrument','vendor_doc'])
├─ 의도: 운전 데이터 질문
│ → query_with_nl (SQL) + search_kb(['plant_operation','procedure'])
│ + 필요시 query_events / active_alarms
└─ 의도: 보고서 작성
→ generate_status_report + search_kb(['report'])
[SSE 이벤트 발행]
event: tool_start data: {name, args}
event: tool_result data: {name, ok, summary, rows}
event: message data: 모델 출력 토큰
[프론트엔드]
├─ 툴 실행 카드 (접이식) 렌더
├─ 모델 인용("정비이력_2026Q1.xlsx > 시트:Pump-A > 행 12-15")을 정규식으로 잡아
│ → [정비이력_2026Q1.xlsx 다운로드 ↓] 링크로 치환
└─ 시계열 결과 → mini sparkline, 일반 표 → HTML table
```
---
### 3.9 관리 탭 #14 UI 스케치
```
┌─ 14 RAG 관리 ────────────────────────────────────┐
│ 🔒 관리자 비번 [______________] [로그인] │
│ 세션 만료: 60분 (재로그인 필요) │
├─────────────────────────────────────────────────│
│ 필터 컬렉션 [▼ 전체] 상태 [▼ indexed] │
│ 태그 [#unit-a #P-6201] │
│ 검색 [제목/태그 검색.................] │
├─────────────────────────────────────────────────│
│ [📁 파일 업로드] [🔄 새로고침] [비번 변경] │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ ☐ 제목 컬렉션 chunk 업로드 상태│ │
│ │ ──────────────────────────────────────────│ │
│ │ ☐ 정비이력… 운전 127 05-12 ✓ │ │
│ │ ☐ FIC-6113… 계기 48 05-12 ⏳│ │
│ │ ☐ SOP-펌프… 절차서 23 05-11 ✓ │ │
│ │ ☐ 사양서… 계기 0 05-10 ✗ │ │
│ │ … │ │
│ └────────────────────────────────────────────┘ │
│ [선택삭제] [재인덱스] [일괄 비활성화(동일제목)] │
│ [비활성화 영구삭제(수동)] │
└─────────────────────────────────────────────────┘
```
**업로드 모달**:
- 드래그앤드롭 + 파일 선택 버튼
- `collection_key` 드롭다운 **강제 선택**
- 제목(기본: 파일명, 편집 가능)
- 태그(자유 입력, 콤마 분리)
- 업로드 → 즉시 doc_id 반환 → 모달은 닫히고 목록에 status=pending으로 새 행 등장
---
### 3.10 보안 / 저장소 관리 / 임베딩 모델
**관리자 비번**
- 첫 실행 시 `kb_admin_credential` 비어있으면 환경변수 `KB_ADMIN_INITIAL_PASSWORD`로 초기화,
없으면 콘솔에 랜덤 비번 1회 출력 후 강제 변경 요구.
- 알고리즘: Argon2id (또는 PBKDF2-SHA256 100k iter)
- 세션 토큰: `Guid.NewGuid().ToString("N")` 또는 RandomNumberGenerator 32바이트, `X-Kb-Token` 헤더로 전달.
- 세션 만료 60분, 슬라이딩 갱신 옵션은 추후.
**저장소 cleanup (M1 = c 수동만)**
- 자동 삭제 잡 없음.
- 관리탭에 `[비활성화 영구삭제]` 버튼: `disabled_at < NOW() - INTERVAL '90 days'` 등 옵션 입력
후 일괄 영구삭제(Qdrant + storage).
**임베딩 모델 (M2 추천 채택)**
- 후보: `BAAI/bge-m3` (멀티링구얼 + 1024차원, dense+sparse 지원) 또는
`intfloat/multilingual-e5-large` (1024차원, 단순 사용 용이).
- 점검 액션: 현재 `_embed`가 사용하는 모델/차원 확인 → 한국어 sample 검색 품질 A/B 테스트 후 결정.
- 컬렉션을 doc_type별로 새로 만드므로 모델 교체에 따른 마이그레이션 부담은 적음.
---
## 4. 구현 순서 (Todo)
### Phase 0 — 사전 정비 (반나절) ✅ 완료
0.1 ✅ `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정 (history_table.recorded_at 사용, `_get_tag_metadata`도 같이 수정)
0.2 ✅ `mcp-server/llm-model.json` — 실제 서빙 `Qwen3.6-27B-FP8`과 이미 동기화 (memory만 갱신)
0.3 ✅ `OllamaController.ComposeSystemPrompt(...)` — 한글 base + `prompts/plant_context.md`(빈 골격) + tool guide 합성, app.js 영문 하드코딩 제거
### Phase 1 — 데이터 모델 & 인증 (1일) ✅ 완료
1.1 ✅ DDL 5개 + 시드 5건 (ExperionDbContext.InitializeAsync 자동 적용, pgcrypto 활성화)
1.2 ✅ KbStartupService — 부팅 시 활성 컬렉션 5개 Qdrant idempotent ensure (`KbQdrantClient.EnsureCollectionAsync`)
1.3 ✅ KbAuthController (login/logout/status/change-password) + `PasswordHasher` (Konscious Argon2id), `X-Kb-Token` 헤더, 초기 비번 env or 자동 생성
### Phase 2 — 업로드 & 비동기 워커 (2일) ✅ 완료
2.1 ✅ KbController.Upload — multipart 수신, `storage/kb/{yyyy-MM}/{uuid}.{ext}` 저장, SHA256, kb_documents/kb_ingest_jobs INSERT
2.2 ✅ KbIngestWorker — 2초 폴링, parse→embed→index 단일 패스, attempts ≥3=failed
2.3 ✅ MCP `parse_document` + `parsers/{xlsx,pdf,docx,text}_parser.py` (행+시트 / 페이지+표 / 헤딩 path / md헤딩·txt단락)
2.4 ✅ KbEmbeddingClient (Ollama nomic-embed-text 768-dim) + KbQdrantClient.UpsertAsync (collection_key 라우팅)
2.5 ✅ Documents/Jobs/Download/Delete/Reindex/Disable/BulkDisable/PurgeDisabled
### Phase 3 — 관리 탭 #14 (1일) ✅ 완료
3.1 ✅ 사이드바 14번 + pane-kbadmin
3.2 ✅ 비번 로그인 → sessionStorage 토큰 (X-Kb-Token)
3.3 ✅ 컬렉션/상태/제목 필터
3.4 ✅ 업로드 모달 (collection_key 드롭다운 강제 + 제목 + 태그) — 드래그앤드롭은 후순위
3.5 ✅ 목록, 다운로드, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제
3.6 ✅ 1.5초 폴링 (pending/parsing/embedding 있을 때만 새로고침)
### Phase 4 — 다운로드 & 검색 (반나절) ✅ 완료
4.1 ✅ /api/kb/download/{docId} — Content-Disposition + MIME (KbController.Download)
4.2 ✅ MCP `search_kb``_search_kb_collection`(태그 filter), `_recency_factor`(7d+10%/30d+5%/90d+2%), `_search_kb_raw`(다중 컬렉션 + since 후필터 + doc_id::locator dedup)
4.3 ✅ rag_query 확장 — `search_kb`, `kb_collections` 인자 + `_format_kb_results` 인용 직렬화
### Phase 5 — 채팅 통합 (1~2일) ✅ 완료
5.1 ✅ SSE `tool_start`/`tool_result` 이벤트 — `EmitToolStart/EmitToolResult` 헬퍼, 공식 tool_calls 경로 + JSON-텍스트 폴백 경로 둘 다 발행
5.2 ✅ 툴 실행 카드 (접이식, running/ok/err 색상, spin 애니메이션)
5.3 ✅ KB 인용 자동 링크 — `llmKbDocMap`에 search_kb hits title→docId 누적, `llmLinkKbCitations`로 본문 치환
5.4 ✅ 표 자동 렌더 (`{success, columns, data}``data:[{...}]` 감지, 최대 50행) / 스파클라인은 보류
5.5 ✅ 추천 질문 칩 7종 (활성 알람, Unit A 요약, FIC-6113 추이, 디지털 이벤트, 정비 이력, 주간 보고, find_tags)
5.6 ✅ Phase 0.3에서 이미 완료
### Phase 6 — 보강 도구 (1일) ⏳ 미구현
6.1 MCP `query_events`, `summarize_events`, `active_alarms` (event_history_table 기반)
6.2 MCP `find_tags` — tag_metadata 시맨틱 검색 (별도 Qdrant 컬렉션 또는 KB와 통합)
6.3 MCP `generate_status_report` — 매크로 툴
6.4 `run_sql` LIMIT 자동 + `SET LOCAL statement_timeout = 10s`
### Phase 7 — 운영 보강 (옵션) ⏳ 미구현
7.1 NL2SQL 의도 라우터
7.2 대화 요약/압축 (장기 세션)
7.3 에이전트 모드 (자율 멀티스텝 계획)
7.4 KB 청크 미리보기/편집 UI
---
## 5. 영향 받는 파일 목록 (예상)
### 신규
- `plans/LLM채팅+지식증강플랜.md` (본 문서)
- `prompts/plant_context.md` (도메인 용어집/계기 prefix/예시)
- `src/Web/Controllers/KbController.cs`
- `src/Web/Controllers/KbAuthController.cs`
- `src/Infrastructure/Kb/KbIngestWorker.cs` (BackgroundService)
- `src/Infrastructure/Kb/PasswordHasher.cs`
- `src/Core/Domain/Entities/KbEntities.cs` (KbCollection, KbDocument, KbIngestJob, KbAdminCredential, KbAdminSession)
- `src/Core/Application/Interfaces/IKbServices.cs`
- `mcp-server/parsers/xlsx_parser.py`
- `mcp-server/parsers/pdf_parser.py`
- `mcp-server/parsers/docx_parser.py`
- `mcp-server/parsers/text_parser.py`
### 수정
- `src/Web/Program.cs``KbIngestWorker` HostedService 등록, HttpClient 추가 (필요 시)
- `src/Web/Controllers/OllamaController.cs` — SSE tool_start/tool_result 이벤트, system prompt 합성
- `src/Web/wwwroot/index.html` — 14번 탭 + pane-kbadmin 섹션, 채팅 추천 질문 칩
- `src/Web/wwwroot/js/app.js` — kbAuth/kbUpload/kbList/kbDelete/kbDownload, 채팅 툴카드 렌더 + 인용 변환
- `src/Web/wwwroot/css/style.css` — KB 관리 스타일, 툴카드 스타일
- `src/Infrastructure/Database/ExperionDbContext.cs` — kb_* DbSet + 마이그레이션 DDL
- `mcp-server/server.py``parse_document`, `search_kb`, `query_events`, `summarize_events`,
`active_alarms`, `find_tags`, `generate_status_report` 추가; `rag_query` 확장
- `mcp-server/worker/nl2sql_worker.py` — time_bucket 버그 픽스
- `mcp-server/llm-model.json` — 모델명 정정
### 미수정 (영향 없음 명시)
- 인증서 관련 (`ExperionCertificateService.cs` 등) — 손대지 않음
- OPC UA 서버/클라이언트 — 손대지 않음
- 기존 nodemap/포인트빌더/이력조회 탭 — 손대지 않음
---
## 6. 부록
### 6.1 골격 코드 예시 (참고용, 실제 구현 시 상세 추가)
#### Argon2 해시 유틸 (PasswordHasher.cs)
```csharp
using Konscious.Security.Cryptography; // NuGet: Konscious.Security.Cryptography.Argon2
using System.Security.Cryptography;
using System.Text;
public static class PasswordHasher
{
public static (string Hash, string Salt) Hash(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(16);
var argon = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = saltBytes,
DegreeOfParallelism = 4,
MemorySize = 65536,
Iterations = 3
};
var hashBytes = argon.GetBytes(32);
return (Convert.ToBase64String(hashBytes), Convert.ToBase64String(saltBytes));
}
public static bool Verify(string password, string hashB64, string saltB64)
{
var argon = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = Convert.FromBase64String(saltB64),
DegreeOfParallelism = 4,
MemorySize = 65536,
Iterations = 3
};
var computed = argon.GetBytes(32);
return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(hashB64));
}
}
```
#### xlsx parser 골격 (parsers/xlsx_parser.py)
```python
from openpyxl import load_workbook
def parse_xlsx(path: str, doc_id: str, title: str) -> list[dict]:
"""행 단위 + 시트 단위 청크 둘 다 생성."""
wb = load_workbook(path, read_only=True, data_only=True)
chunks = []
for sheet in wb.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header = [str(c) if c is not None else "" for c in rows[0]]
# (1) 시트 단위 chunk — markdown 표
md_lines = ["| " + " | ".join(header) + " |",
"| " + " | ".join(["---"] * len(header)) + " |"]
for r in rows[1:]:
md_lines.append("| " + " | ".join(str(c) if c is not None else "" for c in r) + " |")
chunks.append({
"text": "\n".join(md_lines),
"chunk_kind": "sheet",
"locator": f"sheet={sheet.title}"
})
# (2) 행 단위 chunk
for i, r in enumerate(rows[1:], start=2):
parts = [f"{header[j]}={r[j]}" for j in range(len(header))
if j < len(r) and r[j] is not None]
if not parts:
continue
chunks.append({
"text": f"{sheet.title}: " + ", ".join(parts),
"chunk_kind": "row",
"locator": f"sheet={sheet.title}; row={i}"
})
return chunks
```
#### SSE tool_start/tool_result 이벤트 (OllamaController.cs 발췌)
```csharp
// VllmChatStreamWithTools 안, MCP 호출 직전/직후
await Response.WriteAsync(
$"event: tool_start\ndata: {JsonSerializer.Serialize(new {name=funcName, args=funcArgs})}\n\n");
await Response.Body.FlushAsync();
var toolResult = await _mcpClient.CallToolAsync(funcName, args, HttpContext.RequestAborted);
// 결과 요약(처음 200자) — 큰 JSON은 전체 전송 대신 요약
var preview = toolResult.Length > 200 ? toolResult.Substring(0, 200) + "..." : toolResult;
await Response.WriteAsync(
$"event: tool_result\ndata: {JsonSerializer.Serialize(new {name=funcName, ok=true, preview, length=toolResult.Length})}\n\n");
await Response.Body.FlushAsync();
```
#### 프론트 SSE 처리(app.js 발췌)
```javascript
// llmSend 안의 SSE 파싱 루프에서
for (const line of lines) {
if (line.startsWith('event: tool_start')) {
eventType = 'tool_start';
} else if (line.startsWith('event: tool_result')) {
eventType = 'tool_result';
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
}
}
if (eventType === 'tool_start') {
const t = JSON.parse(eventData);
llmAppendToolCard(t.name, t.args, 'running');
} else if (eventType === 'tool_result') {
const t = JSON.parse(eventData);
llmUpdateToolCard(t.name, t.preview, t.length, t.ok);
}
```
### 6.2 추천 질문 칩 (welcome 화면 시드)
```
- "지금 활성 알람을 보여줘"
- "Unit A의 24시간 운전 상황을 요약해줘"
- "FIC-6113.PV 최근 1시간 추이"
- "오늘 발생한 디지털 이벤트 정리"
- "P-6201 펌프의 정비 이력" ← KB 연동
- "이번 주 보고서를 작성해줘"
- "냉각수 펌프 토출 압력 태그를 찾아줘" ← find_tags
```
### 6.3 plant_context.md 시드 (개요)
```markdown
# 플랜트 운전 컨텍스트
## 단위(Area / Unit)
- Unit A: ...
- Unit B: ...
- ...
## 계기 명명 약어
- FIC: Flow Indicator Controller (유량 지시 제어)
- FT: Flow Transmitter (유량 발신기)
- PT: Pressure Transmitter
- TI: Temperature Indicator
- LIC: Level Indicator Controller
- XV: Digital On/Off Valve
- ...
## 태그 명명 규칙
- 모두 소문자 (예: ficq-6113.pv)
- 접미사: .pv (Process Value), .sp (Setpoint), .op (Output),
.instate0..7 (Boolean 상태비트)
## 시간대
- DB 저장: UTC
- 사용자 입력: KST (UTC+9), 자동 변환
- 응답 표시: KST
## 사용 가능 도구
- run_sql: PostgreSQL SELECT 실행
- query_pv_history: 태그 이력 조회
- query_events: 이벤트/알람 조회
- active_alarms: 현재 진행 중 알람
- search_kb: 사용자 지식 베이스 검색
- find_tags: 자연어 → 태그 시맨틱 매칭
- generate_status_report: 상황 보고 합성
## 예시 질문
- "지금 활성 알람 보여줘" → active_alarms
- "Unit A 24시간 요약" → generate_status_report(scope='daily', area='unit-a')
- "정비이력 알려줘" → search_kb(['plant_operation','procedure'])
```
### 6.4 빌드/테스트 시 확인 사항
- `dotnet build` — 경고는 기존 대비 신규 발생 없을 것
- Qdrant 컬렉션 5개 생성 확인 (`curl http://localhost:6333/collections`)
- 비번 첫 설정 후 로그인/만료/재로그인 동작
- 작은 텍스트 업로드 → 30초 내 status='indexed'
- 큰 PDF(OCR) 업로드 → 워커가 stage 단계 진행, 실패 시 attempts 카운트
- 채팅에서 KB 인용 → 다운로드 링크 클릭 시 원본 다운로드
---
## 7. 결정 보류/추후 합의 항목
다음은 본 플랜에서는 결정하지 않고 구현 진행 중 별도 합의 예정:
- **C9** NL2SQL 의도 라우터 도입 시점 — Phase 5 후 모델 호출 패턴을 측정 후 결정
- **C10/C11** 대화 요약 / 에이전트 모드 — Phase 5 완료 후 사용성 평가에 따라
- **현장 재고** 데이터 소스 — RAG로 흡수(MES 엑셀 정기 업로드) vs 별도 inventory_table — 데이터 출처 확정 후 결정
- **임베딩 모델 최종 선택** — BGE-M3 vs multilingual-e5-large 한국어 sample 검증 후 결정
- **세션 토큰 슬라이딩 갱신 / 다중 관리자 / IP 제한** — 운영 형태 확정 후

View File

@@ -1,591 +0,0 @@
# P&ID 추출 데이터 이용방안 플랜
작성일: 2026-05-14
배경: `pid_equipment` 테이블에 No-10_Plant_PID.dxf로부터 460건의 구조화 데이터(태그·라인번호·fluid·종류·area) 적재 완료. Experion realtime_table과의 자동 매핑(exact + prefix only, false positive 제거)도 동작 중. 다음 단계로 이 데이터를 무엇에 쓸지 결정.
## 현재 보유 데이터
| 컬럼 | 의미 | 예시 |
|------|------|------|
| `tag_no` | 추출된 원본 태그/라인번호 | `P-10138-600A-F2A-H100`, `PSV-10217`, `FCV-6113` |
| `instrument_type` | prefix 분류 | `FCV`, `PSV`, `P`, `T`, `BT`, `LIA` |
| `line_number` | LineNo 식별번호 (pipe만) | `10138`, `6203` |
| `confidence` | 추출 신뢰도 | 0.95~0.99 |
| `experion_tag_id` | realtime_table FK (매핑 시) | NULL or int |
| `is_active` | 활성 여부 | true/false |
서버 내부 사전(코드)으로 `_PID_FLUID_DICT`(P=PROCESS, CWS=COOLING WATER SUPPLY 등 19종), `_PID_EQUIPMENT_PREFIX`(P=Pump, T=Tank, F=Filter 등 14종), `_PID_INSTRUMENT_FIRST`/`_PID_INSTRUMENT_MODIFIER`(ISA letter 코드)도 함께 보유.
---
## 갈래 1 — 채팅 컨텍스트 보강 (최우선 추천)
**가치**: 운전원이 즉시 체감. 작업량 최소.
**작업량**: 0.5~1일.
### 구현
MCP 도구 2개 추가 (mcp-server/server.py):
```python
@mcp.tool()
async def find_pid_equipment(query: str, kind: str | None = None,
area: str | None = None, limit: int = 20) -> str:
"""P&ID 장비/계기 검색. tag_no ILIKE 매칭 + kind/area 필터.
kind: 'pipe'|'equipment'|'instrument' (없으면 전체)
area: 6-1 → tag_no가 P-61XX/T-61XX 등 6+1로 시작
"""
@mcp.tool()
async def get_pid_equipment_info(tag_no: str) -> str:
"""단일 태그 상세. 매핑된 Experion 태그도 함께 반환.
응답: {tag_no, type(fluid 또는 장비종류), instrument_type, line_number,
experion_tag, latest_value (있으면)}
"""
```
`ToolGuideKo`(OllamaController.cs)에 새 도구 설명 추가 → LLM이 자동 활용.
### 효과 예시
- "PSV-10217이 뭐야?" → "Pressure Safety Valve (mechanical, OPC 신호 없음). 10차 area"
- "10차에 펌프 몇 대?" → `find_pid_equipment(kind='equipment', area='10')` → 카운트 + 리스트
- "P-10138 어떤 fluid?" → "PROCESS FLUID, 600A, F2A spec, H100 단열"
- "FCV-6113 지금 값은?" → 매핑된 experion 태그로 자동 join → 실시간값
---
## 갈래 2 — 알람·이벤트 컨텍스트 자동 주입
**가치**: 알람 보고서가 풍부해짐. 운전원이 태그 코드만 보고 의미 파악 가능.
**작업량**: 0.5일. 기존 `summarize_events`/`generate_status_report`에 join 추가.
### 구현
- `query_events` 결과를 `pid_equipment`와 LEFT JOIN해서 각 이벤트에 PID 컨텍스트 첨부
- 예: ALARM event `tagname=fcv-6113.pv` → 컨텍스트 "FCV-6113, Flow Control Valve, 6-1 area, P-10138 라인의 PROCESS 흐름 제어"
- `generate_status_report` 마크다운 출력에 "## 영향 장비" 섹션 추가 (해당 area의 PID 인벤토리 요약)
### 효과
- 알람 요약 LLM이 "단순 태그 나열" → "공정 맥락 포함된 운전 보고서"로 격상
- KB 문서에서 같은 area의 절차서 자동 인용 (search_kb area 필터 + PID 데이터 연동)
---
## 갈래 3 — P&ID 다이어그램 시각화 (UI)
**가치**: 시각적 자산 파악. 도면 없이도 area별 장비 구조 한눈에 보기.
**작업량**: UI 작업 중심. 그래프 보기까지 가려면 from-to 추출 추가 필요.
### Phase 3a (즉시 가능)
현재 데이터만으로 — area별·종류별 트리뷰/리스트뷰:
```
└─ 10차 area
├─ 펌프 (15) P-10101, P-10116, …
├─ 탱크 (19) T-10101, T-10102, …
├─ 필터 (4) F-10101A, F-10101B, …
├─ 안전밸브 (18) PSV-10101, PSV-10103, …
└─ 라인 (172) P-10138, CWS-10612, …
```
14번 P&ID 탭 또는 신규 탭에서 구현.
### Phase 3b (확장, 작업 큼)
DXF에서 라인 끝점 좌표 + 장비 좌표 추출 후 KD-tree로 from-to 추정 → cytoscape/d3로 노드 그래프. 사용자 이미 결정한 "위치좌표 계산 없이" 방향과 상충 — **추후 필요해질 때만 착수**.
---
## 갈래 4 — 자산 마스터 + KB 통합 (장기 그림)
**가치**: 단순 검색을 넘어 정비·운영 데이터 통합 플랫폼.
**작업량**: 큼. 다른 데이터 소스 통합 필요.
### 구성요소
1. **equipment_registry.yaml** (이전 논의) — Experion htm에서 추출한 좌표·area + PID 추출 데이터 머지
2. **정비/점검 일정 테이블** — 종류별 주기 정의 (PSV 1년, FCV 6개월, …) → 다음 점검일 자동 계산
3. **누락 검출 리포트**`pid_equipment`(P&ID 기준) vs `realtime_table`(Experion 기준) 양방향 차집합
- P&ID에만 있는 것 → 측정 누락 (or mechanical/지표 없는 장비)
- Experion에만 있는 것 → 도면 누락 (or 시스템 신호)
4. **KB 문서 자동 인용**`search_kb`에 PID 태그 정보 자동 추가해서 검색 품질 향상
### 의존성
- equipment_registry yaml 구축이 선결 (이전 결정: htm 파서 추가)
- 정비 일정은 별도 사용자 입력 또는 기존 정비 시스템 연동 필요
---
## 갈래 5 — 기존 `infer_field_instruments`와의 통합 (보완 관계, 죽이지 않음)
**배경**: RAG 관리 → Field Instrument 탭의 기존 기능(`infer_field_instruments` → Excel 초안 다운로드)이 `parse_pid_dxf` 등장 후 의미를 잃었는지 의문 제기됨. **결론: 의미 없어지지 않음, 오히려 통합 가치가 생김**.
### 두 기능의 본질적 차이
| 구분 | `infer_field_instruments` (기존) | `parse_pid_dxf` (신규) |
|------|--------------------------------|-----------------------|
| **입력 소스** | Experion DB의 OPC 태그 (`v_tag_summary`) | DXF 도면 파일 |
| **추론 방향** | "실제 측정점이 있다 → 현장에 계기 있을 것" | "도면에 그려져 있다 → 자산으로 존재" |
| **`ficq-6101` 처리** | base 보고 FT-6101 / FIC-6101 / FCV-6101 **3개로 분해** | 도면에 그려진 그대로 (보통 FCV-6101, FIC-6101 등) 인식 |
| **PSV-10217** (mechanical, 신호 없음) | ❌ 못 만듦 | ✅ 도면에 있으면 추출 |
| **추가된 임시 계기** (OPC 신호만 존재) | ✅ 추론 가능 | ❌ 도면에 없으면 못 잡음 |
| **출력** | Excel 초안 (운전원 수동 검수 후 KB 업로드) | `pid_equipment` 테이블 |
| **동력기기** | `power_equipment` role로 별도 분류 | Pump prefix(`P`) 등으로 분류 |
### 잡는 누락이 정반대 — 단독으로는 불완전
- **OPC 신호 있지만 도면 미반영**: `infer_field_instruments`만 잡음 (도면 업데이트 누락 케이스)
- **도면엔 있지만 OPC 신호 없음**: `parse_pid_dxf`만 잡음 (PSV/수동밸브/게이지/파이프 등 mechanical 자산)
- **둘 다 존재**: `match_pid_tags`가 자동 연결 (exact + prefix)
### 통합으로 새로 가능해지는 작업
1. **`infer_field_instruments` 출력 자동 검증**
- infer가 추론한 FT-6101이 실제 도면(`pid_equipment`)에 있는지 즉시 대조
- 추론 정확도 자동 측정 → 추론 룰 개선 피드백 루프 가능
2. **Excel 초안 자동 라벨링 보강**
- 도면에는 PSV-10217이 있는데 infer는 못 만들었음 → "OPC 신호 없는 mechanical" 라벨로 Excel에 자동 행 추가
- 도면 LineNo 정보(`P-10138-600A-F2A-H100`)로 fluid/size/material 컬럼 자동 채움
- infer가 만든 row와 P&ID에서만 보이는 row를 한 Excel에 합쳐서 운전원이 한 번에 검수
3. **양방향 차집합 리포트** (갈래 4의 핵심 산출물)
| 차집합 | 의미 | 운영 액션 |
|--------|------|-----------|
| `pid_equipment` `realtime_table` (도면 ⊖ 실제) | 도면엔 있는데 측정 없는 자산 | (a) mechanical → 정상, "OPC 신호 없음" 라벨로 마스터에 등록<br>(b) 측정 누락 → 신호 추가 검토 |
| `realtime_table` `pid_equipment` (실제 ⊖ 도면) | 측정은 있는데 도면 없는 신호 | 도면 업데이트 누락 → P&ID 도면 보완 요청 |
| 양쪽 모두 | 정상 매핑 | UI에서 ✅ 표시 (현재 상태) |
### 구현 (작업량 적음)
- `infer_field_instruments`의 dedup 단계 뒤에 `pid_equipment` LEFT JOIN 한 번
- infer 결과 row마다 `in_pid_drawing: bool`, `pid_line_number`, `pid_drawing_no` 컬럼 추가
- infer가 만들지 못한 `pid_equipment` row를 별도 시트(`Pid_Only_Mechanical`)로 Excel에 추가
- `parse_pid_dxf`는 손대지 않음. infer 쪽에만 join 추가
- 운전원이 다운받는 Excel이 그대로 더 풍부해짐 — 추가 UI 변경 불필요
### 의존성
- 갈래 1·2 없어도 가능. 단독 진행 가능
- `infer_field_instruments`의 출력 Excel 포맷 일부 변경 (시트 추가) — 기존 운영 흐름에 영향 적음
---
## 갈래 6 — PID 추출 → 옵시디언 노트 부트스트랩 → 하이브리드 RAG
**배경**: `plans/옵시디언-구조적용-플랜.md`는 마크다운 노트 + `[[wikilinks]]` + frontmatter 기반 그래프 RAG를 제안하지만 **아직 미구현** (`notes/` 디렉토리·DB 테이블 없음, 위키링크가 들어있는 .md는 계획 문서 자체뿐). 옵시디언 노트 시스템을 0에서 시작하면 "초기 비어있음" 문제가 큰데, PID 추출 결과를 활용하면 **첫날부터 460개 anchor 노트**로 부트스트랩 가능.
### 역할 분담
| 영역 | PID 추출 (`pid_equipment`) | 옵시디언 노트 (planned) |
|------|---------------------------|------------------------|
| 채우는 것 | **뼈대** — 모든 태그/라인/장비 자동 인벤토리 | **살** — 절차, 루프 의미, 트러블슈팅, 벤더 정보, 운전 노하우 |
| 입력 | 도면 1번 + 자동 | 운전원/엔지니어 수기 + 채팅 저장 |
| 갱신 | 도면 바뀌면 재추출 | 사람이 알게 될 때마다 |
| 핵심 식별자 | `tag_no` (FCV-6113, P-10101) | 노트 ID `tag/fcv-6113`, `loop/compression-a` |
**공통 anchor가 tag ID** — PID가 만든 모든 태그는 곧 노트 ID가 되고, 사람이 쓴 노트는 `[[tag/fcv-6113]]` 위키링크로 PID 인벤토리에 자동 연결.
### 구현 단계
#### Step 1 — PID stub 노트 자동 생성
`pid_equipment` 460건을 각각 frontmatter-only `.md` 파일로 발행:
```yaml
---
id: tag/fcv-6113
kind: tag
prefix: FCV
type: Flow Control Valve
area: 6-1
line_number: 6113
experion_tag: fcv-6113.pv
source_drawing: No-10_Plant_PID.dxf
source: pid_equipment.id=42
generated_at: 2026-05-14
---
<!-- 본문은 비어둠 — 운전원이 채움 -->
```
- 디렉토리: `notes/tag/`, `notes/area/`, `notes/drawing/`
- 한 파일 = 한 노트 = `pid_equipment` 1행
- 본문 비어있어도 anchor 역할 → 위키링크 타깃으로 즉시 사용 가능
#### Step 2 — 사람 노트는 위에 자라남
운전원/엔지니어가 만드는 손 노트는 별도 kind:
- `notes/loop/compression-a.md` — "이 루프는 [[tag/fcv-6113]] [[tag/pt-6111]] [[tag/ft-6113]]로 구성됨"
- `notes/procedure/start-up-a.md` — "기동 시 [[loop/compression-a]] 먼저 안정화"
- `notes/event-pattern/surge-recovery.md` — "[[tag/fcv-6113]] surge 발생 시 [[procedure/anti-surge]] 적용"
**원칙**: 사람은 신규 마크다운만 쓰고 PID stub은 안 건드림. 재추출 시 stub 안전하게 덮어쓰기 가능.
#### Step 3 — Stub 갱신 정책
PID 재추출 시:
- frontmatter는 항상 최신 `pid_equipment` 값으로 덮어씀
- 본문(`<!-- comment -->` 아래)에 운전원이 추가한 텍스트가 있으면 보존 (merge 로직)
- 새로 등장한 tag → 신규 stub 생성
- 사라진 tag → `archived: true` frontmatter 추가 (파일 삭제 안 함, 위키링크 깨짐 방지)
#### Step 4 — KB 인덱싱 + 그래프 엣지 추출
`notes/` 디렉토리를 KbIngestWorker가 별도 컬렉션(예: `kb_notes`)으로 처리:
- 노트 1개 = 1 청크 (헤딩으로 자르지 않음 — 작은 단위 유지)
- frontmatter → Qdrant payload (area, kind, prefix 필터링 가능)
- 본문에서 `[[note-id]]` 정규식 추출 → 신규 테이블 `kb_note_edges (source_id, target_id, link_type)`에 적재
#### Step 5 — 채팅 RAG에 그래프 1-hop 결합
`search_kb`가 top-k 청크 검색 → 각 청크가 노트면 `kb_note_edges`에서 1-hop 위키링크 타깃 조회 → 그 타깃 노트들의 frontmatter+첫 200자를 컨텍스트에 함께 주입.
```python
# 의사 코드
chunks = search_kb(query, top_k=5)
extra_context = []
for chunk in chunks:
if chunk.note_id:
targets = db.fetch_edges(chunk.note_id, depth=1)
extra_context.extend(targets) # 5개 청크 + N개 1-hop 노트
return chunks + extra_context # LLM에 함께 전달
```
LLM은 청크 5개만이 아니라 "이 청크와 명시적으로 연결된 절차/루프/이벤트 패턴"까지 함께 보고 답변.
### 트레이드오프
- (+) 옵시디언 시스템의 "초기 비어있음" 문제 해결 — 첫날부터 460개 anchor 보유
- (+) 운전원이 채팅에서 받은 답변을 `loop/surge-recovery.md` 한 줄로 영구화 가능 → 지식 자산이 자라남
- (+) 마크다운이라 외부 옵시디언 앱으로 열어 편집해도 호환 (부수효과)
- () stub 갱신 시 본문 보존 머지 로직 필요 (text diff 충돌 검출)
- () 운전원이 위키링크 작성 습관 안 들이면 anchor만 잔뜩에 살이 없는 상태 → **저진입 UX 필수**:
- 채팅에서 "이 답변을 [[loop/compression-a]]에 저장" 같은 한 클릭 액션
- 노트 작성 시 `[[` 입력하면 자동완성 드롭다운으로 PID stub 추천
### 의존성
- 갈래 1 권장 선결 — PID 데이터 조회 인프라가 있어야 stub 생성 스크립트 작성이 쉬움
- `옵시디언-구조적용-플랜.md`의 데이터 모델 결정사항 따름 (note ID 규칙, kind 9종, frontmatter 스키마)
- KbIngestWorker에 notes 디렉토리 전용 처리 분기 추가
### 작업 분할
| 단계 | 산출물 | 작업량 |
|------|--------|--------|
| Step 1 | `mcp-server/notes_sync.py` — pid_equipment → notes/tag/*.md stub 생성기 | 0.5일 |
| Step 3 | 머지 보존 로직 + archived 처리 | 0.5일 |
| Step 4 | KbIngestWorker notes 분기 + `kb_note_edges` 테이블 + 위키링크 파서 | 1일 |
| Step 5 | `search_kb` 그래프 1-hop 보강 + 채팅 통합 | 0.5일 |
| 사람 노트 저진입 UX | "답변 → 노트 저장" 버튼, `[[` 자동완성 | 1일 (UI) |
| **합계** | | **3.5일** |
---
## 갈래 7 — Excel 라운드트립으로 service/role 메타데이터 보강 (마지막 연결고리)
**배경**: DXF 파서는 tag·LineNo·fluid(파이프 한정)·prefix 분류는 자동 추출하지만, **장비의 실질 정보(service, role, contents, from-to)는 도면에 텍스트로 없음**. 예: T-201이 원료탱크인지 폐액탱크인지, 무슨 fluid를 담는지, 어디로 보내는지 — LLM이 답할 컨텍스트가 없음. 운전원이 가장 익숙한 도구(Excel)로 한 번에 채우는 round-trip이 가장 실용적인 해결책.
**갈래 5와의 차이**: 갈래 5는 `infer_field_instruments` Excel(현장 계기 초안, **다운로드 전용**)에 PID 정보를 합치는 것. **갈래 7은 PID 메타데이터를 사람이 채워 다시 시스템에 반영하는 round-trip — 편집 가능한 마스터 시트**.
### 시트 구성 (장비 종류별 분리)
| 시트 | 자동 채워진 컬럼 | 사용자가 채울 컬럼 |
|------|----------------|------------------|
| `Tanks` (T-XXX) | tag_no, area, source_drawing | service, role(feed/buffer/product/waste), contents(PGMEA/HBM/...), capacity, notes |
| `Drums` (D-XXX) | 위와 동일 | service, role, contents |
| `Filters` (F-XXX) | tag_no, area | service, medium, from_tag, to_tag |
| `Heat Exchangers` (E-XXX) | tag_no, area | service, hot_side, cold_side, duty |
| `Columns` (C-XXX) | tag_no, area | service, product, overhead, bottom |
| `Pumps` (P-XXX) | tag_no, area, mapped_experion_tag | service, driver(motor/turbine), from_tag, to_tag, power_kw |
| `Compressors / Fans` | tag_no, area | service, driver, power_kw |
| `Cooling Towers` | tag_no, area | service, capacity |
| `Manual Valves` (BV/GV/XV) | tag_no, area | service, normal_position(open/closed), purpose |
| `Pipes` (LineNo) | service, fluid, size, spec, insul (이미 파싱됨) | from_tag, to_tag (오직 이거만) |
| `Legend` | dropdown 후보 (role 종류, fluid 코드, position 등) | — |
| `Index` | 시트별 완성도 (채워진 비율) | — |
### Round-trip 흐름
```
[다운로드]
pid_equipment → 장비 종류별 그룹핑 → 시트별 분리 → Excel 생성
(자동 채운 컬럼 + 빈 컬럼 + dropdown legend)
[운전원이 채움 (며칠~몇 주, 영역별 분할 가능)]
[업로드]
Excel 파싱 → 검증 (dropdown 값, 필수 필드, 참조 tag_no 존재 여부)
→ pid_equipment에 신규 컬럼들(service, role, contents, from_tag, to_tag, …) UPDATE
→ 변경 diff 리포트 (몇 건 채워졌는지, 무효 값 몇 건)
→ (갈래 6 도입 후) tag stub 노트 frontmatter 자동 갱신
```
### 데이터 모델 확장
`pid_equipment` 테이블에 컬럼 추가:
```sql
ALTER TABLE pid_equipment
ADD COLUMN service TEXT, -- 운전원 입력 fluid/용도 (P=PROCESS와 별개로 자유 텍스트)
ADD COLUMN role TEXT, -- feed_tank, buffer_tank, waste_tank, transfer_pump, ...
ADD COLUMN contents TEXT, -- PGMEA, HBM, CITY_WATER, ...
ADD COLUMN from_tag TEXT, -- 흐름 상류 tag (수동 입력)
ADD COLUMN to_tag TEXT, -- 흐름 하류 tag
ADD COLUMN driver TEXT, -- motor / turbine / electric / steam
ADD COLUMN capacity TEXT, -- 자유 텍스트 (단위 포함)
ADD COLUMN power_kw NUMERIC,
ADD COLUMN user_notes TEXT, -- 운전원 자유 메모
ADD COLUMN metadata_filled_at TIMESTAMPTZ; -- 마지막 사용자 갱신 시각
```
### 갈래 5/6과의 관계 — 같은 그림의 다른 단계
| 단계 | 역할 | 갈래 |
|------|------|------|
| 자동 추출 | 뼈대 (모든 태그 자동 인벤토리) | PID 파서 (완료) |
| 자동 검증 Excel | mechanical/누락 자산 검출 (read-only) | 갈래 5 |
| **수기 보강 round-trip** | **구조화 슬롯 일괄 채우기 (service/role/from-to)** | **갈래 7 (이 항목)** |
| 노트 자동 생성 | tag stub frontmatter에 갈래 7 데이터 자동 반영 | 갈래 6 Step 1 |
| 채팅 RAG 강화 | 위 모두 통합 검색 | 갈래 1+2+6 |
**갈래 7이 진짜 마지막 연결고리**. 이게 없으면 갈래 6 stub의 frontmatter가 빈약하고, 채팅도 "T-201이 뭐 담는지" 답을 못 함.
### 트레이드오프
- (+) Excel은 운전원이 가장 자연스럽게 다루는 도구 — 마크다운 학습 비용 0
- (+) 한 번 다운로드받아 오프라인에서도 채울 수 있음
- (+) 시트별 분리로 "오늘은 펌프만, 내일은 탱크만" 같은 분할 작업 가능
- (+) dropdown legend로 데이터 일관성 자동 확보
- () Excel은 discrete batch — 동시에 여러 사람이 채우면 머지 충돌 위험. 한 번에 한 사람 권장
- () 다운로드 받은 파일이 오래되면 사이에 PID 재추출로 신규 tag 생긴 경우 머지 필요 — 업로드 시 "신규 tag는 이번 Excel에 없음" 경고
- () 한 번에 460건 다 채우라 하면 부담 → 영역별/종류별 분할 가이드 필요 ("이번 주는 6-1 area Pump만")
### 구현 (작업량)
| 단계 | 산출물 | 작업량 |
|------|--------|--------|
| DB 마이그레이션 | `pid_equipment` 신규 컬럼 ALTER + InitializeAsync DDL 갱신 | 0.3일 |
| 다운로드 엔드포인트 | `GET /api/pid/metadata/excel` — 시트별 분리 Excel 생성 (openpyxl) | 0.5일 |
| 업로드 엔드포인트 | `POST /api/pid/metadata/excel` — 파싱+검증+UPDATE+diff 리포트 | 1일 |
| UI (RAG 관리 또는 P&ID 탭) | 다운로드/업로드 버튼 + 진행률 + diff 표시 | 0.5일 |
| Legend dropdown 사전 | role/contents/driver 등 유효값 정의 + 운영 후 확장 가능하게 | 0.2일 |
| **합계** | | **2.5일** |
### 의존성
- 갈래 1·5와 독립적으로 진행 가능
- 갈래 6보다 먼저 끝나야 stub frontmatter가 풍부해짐 → **Phase B 또는 Phase C 초반에 진행 권장**
---
## 갈래 8 — LineNo 파생 정보 컬럼 추가 (작지만 모든 갈래에 가치 전파)
**배경**: 현업 통찰 — 운영 부서는 LineNo(`P-10138`, `16456` 등 시공·단관 제작용 번호)로 파이프를 부르지 않음. 가치 있는 건 거기서 파생되는 **{service, fluid, size, material, flange_rating, insulation}**. LineNo 자체는 시공·정비 참조용으로 보존하되, 파생 정보를 별도 컬럼으로 저장해야 운영 검색·통계·일관성 검증이 가능해짐.
**현재 상태**: `_parse_pid_lineno`가 MCP 응답에 7개 필드를 다 담고 있지만, C# 측이 `line_number` 하나만 DB에 저장하고 나머지는 버리고 있음. 추출은 되는데 활용이 안 됨.
### 구현
```sql
-- pid_equipment 컬럼 추가 (line_number는 그대로 유지)
ALTER TABLE pid_equipment
ADD COLUMN service_code TEXT, -- 'P', 'CWS', 'ST', 'VG', ...
ADD COLUMN fluid_name TEXT, -- 'PROCESS FLUID', 'STEAM' (legend 변환)
ADD COLUMN pipe_size TEXT, -- '25A', '600A'
ADD COLUMN material_spec TEXT, -- 'F', 'S'
ADD COLUMN flange_rating INT, -- 1=150#, 2=300#
ADD COLUMN insul_code TEXT, -- 'A', 'B'
ADD COLUMN insul_thickness TEXT; -- 'H50', 'H100', 'E50', 'n'
```
장비/계기 행은 이 컬럼들이 NULL — 정상. 한 테이블 유지, 정규화 분리 안 함.
### 변경 파일
| 파일 | 변경 |
|------|------|
| `ExperionDbContext.InitializeAsync` | ALTER TABLE 7개 컬럼 추가 (또는 새 설치용 CREATE TABLE 갱신) |
| `PidEquipment` 엔티티 | 7개 속성 + `[Column("snake_case")]` 매핑 |
| `ExtractedItem` DTO | 7개 필드 추가 (PipeService/FluidName/PipeSize/...) |
| `PidExtractorService.ParseJson` | MCP 응답의 pipe 필드를 ExtractedItem에 매핑 |
| `PidExtractorService.ExtractFromStreamAsync` | save 시 7개 컬럼 채움 (pipe 행만, instrument/equipment는 null) |
| MCP `extract_pid_tags` / `parse_pid_dxf` | 이미 7개 필드 응답 중 — 변경 불필요 |
### 활용은 자동으로 따라옴
컬럼만 채워지면 기존 갈래들이 즉시 더 풍부해짐:
- **갈래 1 채팅 검색**: "CWS 25A 이상 라인", "스팀 H100 단열 라인" 같은 속성 조합 검색 즉시 가능
- **갈래 6 stub frontmatter**: pipe 노트에 `service: P`, `fluid: PROCESS FLUID`, `pipe_size: 25A`, `insul_thickness: H50` 자동 반영 → 노트가 빈약하지 않음
- **갈래 7 Pipes 시트**: 자동 채워진 컬럼이 7개로 늘어남, 사용자는 from_tag/to_tag/서비스별칭만 채우면 됨
- **(부수효과)** 사양 분포 리포트, 일관성 검증 같은 분석이 가능해짐 — 필요할 때 별도 도구로 노출하면 됨 (지금은 컬럼만 추가)
### 작업량
| 단계 | 산출물 | 작업량 |
|------|--------|--------|
| DB 컬럼 추가 | ALTER TABLE + InitializeAsync DDL 갱신 | 0.2일 |
| `PidEquipment` 엔티티 + `[Column]` 매핑 | 7개 속성 추가 | 0.1일 |
| `ExtractedItem` 7개 필드 + ParseJson 매핑 | DTO 확장 | 0.2일 |
| `PidExtractorService` save 경로 | 7개 컬럼 INSERT | 0.2일 |
| 기존 460건 재추출 (또는 in-place UPDATE 스크립트) | truncate 후 재추출이 간단 | 0.1일 |
| **합계** | | **0.8일** |
### 의존성
- 없음. 단독 진행 가능
- **갈래 1·6·7의 가치를 증폭시키므로 가장 먼저 배치 권장**
---
## 갈래 9 — 추출 결과 저장·폐기 선택 UX (실수 방지)
**배경**: 현재 P&ID 추출 UI는 "추출 시작" 클릭 시 MCP 추출 → `pid_equipment` 즉시 INSERT가 자동으로 이뤄짐. 운전원이 실수로 다른 파일(다른 플랜트, 구버전 도면 등)을 추출해도 되돌릴 방법이 없음. TRUNCATE는 정상 데이터까지 날아가서 사용 불가. 추출 전/후에 사용자가 검토·취사선택할 수 있어야 함.
**결정 보류**: 아래 3개 옵션 중 어느 방향으로 갈지 사용자 결정 필요.
### 옵션 비교
#### 옵션 A — Dry-run + 저장 버튼 (사전 검토)
```
[추출 시작] → MCP 추출만 (DB 저장 X) → "미리보기 460건" 표시
├── [✅ DB에 저장] → INSERT 수행
└── [🗑️ 폐기] → 결과 버림
```
| 항목 | 내용 |
|------|------|
| 장점 | 가장 안전 — DB 들어가기 전 검토 가능. 운전원 의도 명확 ("이걸 저장할까?") |
| 단점 | UI 흐름 2단계로 늘어남. 결과를 서버 임시 캐시 또는 클라이언트 메모리에 보관 필요. 사용자가 검토 중 페이지 떠나면 데이터 잃음 |
| 작업량 | 약 1일 |
| 구현 | `PidExtractorService.ExtractWithoutSaveAsync` 추가, `IMemoryCache` 결과 보관, UI에 미리보기 + 저장/폐기 버튼 |
#### 옵션 B — 자동 저장 + 즉시 취소 버튼 (사후 롤백)
```
[추출 시작] → MCP 추출 → DB 저장 (extraction_batch_id 부여)
→ "✅ 추출 완료: 460건" + [🗑️ 이번 추출 취소] 버튼
batch_id WHERE 절로 일괄 삭제
```
| 항목 | 내용 |
|------|------|
| 장점 | 기존 UX 거의 그대로. 추출 이력 자체가 자동으로 남아 추후 분석 가능 |
| 단점 | 저장 후 후속 작업(confidence 수정 등)이 일어났으면 일괄 삭제가 그 작업까지 날림 → "최근 N분만 취소 가능" 같은 가드 필요. 다른 사용자가 잠깐 잘못된 데이터 볼 수 있음 |
| 작업량 | 약 0.5일 |
| 구현 | `pid_equipment.extraction_batch_id` (UUID) 컬럼 추가, 추출 시 부여, UI에 "이번 추출 취소" 버튼 + 시간 가드 |
#### 옵션 C — A + 영구 batch_id 보존 (절충)
```
[추출 시작] → MCP 추출 (DB 저장 X, staging 보관)
├── [저장] → batch_id와 함께 INSERT
│ → 이후에도 "추출 이력" 페이지에서 batch_id로 일괄 삭제 가능
└── [폐기] → staging만 버림
```
| 항목 | 내용 |
|------|------|
| 장점 | A의 안전성 + B의 사후 롤백 모두 확보. "추출 이력" 페이지에서 과거 batch 단위 관리 가능 |
| 단점 | 작업량 가장 큼. UI도 가장 복잡 (미리보기 + 저장 + 이력 관리) |
| 작업량 | 약 1.5~2일 |
| 구현 | 옵션 A + 옵션 B 합친 형태. staging 캐시 + batch_id 컬럼 + 이력 페이지 |
### 추천 (결정 보류 상태)
| 우선 고려 | 추천 옵션 |
|----------|----------|
| 안전성 우선 | **옵션 A** |
| 작업량 최소 | 옵션 B |
| 장기 운영 (감사·이력) | 옵션 C |
운전원이 자주 추출하는 운영 환경이면 옵션 A의 사전 검토가 정신적 부담 적고 안전 — **기본 추천은 옵션 A**. 단, "추출 이력 관리" 같은 요구가 미래에 나올 가능성이 크면 옵션 C가 미리 대응됨.
### 의존성
- 옵션 A: `IMemoryCache` 또는 클라이언트 세션 — 표준 ASP.NET Core 기능
- 옵션 B/C: `pid_equipment.extraction_batch_id` 컬럼 (UUID) — 갈래 8 컬럼 추가와 함께 ALTER 묶어 처리하면 효율적
- 어느 옵션이든 갈래 8과 같은 시점에 진행하면 마이그레이션 1번으로 끝낼 수 있음
### 결정 시점
- 갈래 1~5 진행 중 사용 패턴 보면서 결정 가능 (당장 결정 안 해도 됨)
- 단, 옵션 B/C를 선택한다면 갈래 8 마이그레이션 시 `extraction_batch_id` 컬럼을 함께 추가하는 게 효율적 → **갈래 8 착수 전에 한 번 가볍게 결정 권장**
---
## 추천 진행 순서
| 우선순위 | 항목 | 작업량 | 가치 | 의존성 |
|----------|------|--------|------|--------|
| **0** | **갈래 8 (LineNo 파생 정보 컬럼 추가)** | 0.8일 | **모든 갈래에 가치 전파** | 없음 |
| **0+** | **갈래 9 (추출 결과 저장/폐기 UX)** | 0.5~2일 (옵션별) | 높음 (실수 방지) | 갈래 8과 함께 마이그레이션 묶기 권장 |
| **1** | 갈래 1 (MCP 도구 2개) | 0.5~1일 | 높음 | 갈래 8 권장 |
| **2** | 갈래 5 (infer ↔ pid_equipment 통합 Excel) | 0.5일 | 높음 | 없음 |
| **3** | 갈래 2 (이벤트 컨텍스트) | 0.5일 | 높음 | 갈래 1 |
| **4** | 갈래 7 (Excel round-trip으로 service/role 보강) | 2.5일 | 매우 높음 | 갈래 8 권장 |
| **5** | 갈래 6 (옵시디언 노트 부트스트랩 + 하이브리드 RAG) | 3.5일 | 매우 높음 (장기) | 갈래 1·7·8 권장 |
| 6 | 갈래 3a (트리뷰 UI) | 1~2일 | 중 | 없음 |
| 7 | 갈래 4 일부 (누락 검출 리포트 단독) | 0.5일 | 중 | 갈래 5와 중복 — 갈래 5에 통합 권장 |
| 보류 | 갈래 3b (그래프 시각화) | 큼 | 사용 패턴 보고 결정 | from-to 추출 |
| 보류 | 갈래 4 전체 (정비 일정·KB 통합) | 큼 | 큼 | equipment_registry, 정비 시스템 |
### 추천 흐름
**Phase 0 (반나절, 0.8일)** — 모든 갈래의 가치 증폭
0. **갈래 8**: `pid_equipment`에 service/fluid/size/material/flange/insul 7개 컬럼 추가 + 460건 재추출
- 이후 모든 갈래가 더 풍부한 데이터로 동작
**Phase A (이번 주, 1~2일)** — 즉시 효과
1. **갈래 1**: `find_pid_equipment` / `get_pid_equipment_info` 추가 → 갈래 8로 채워진 7개 필드까지 검색·표시
2. **갈래 5** 병행: `infer_field_instruments``pid_equipment` join 추가 → Excel 초안에 mechanical/누락 자산 자동 포함
**Phase B (Phase A 사용 패턴 본 뒤, 0.5일)** — 정착 단계
3. **갈래 2**: 알람·이벤트 컨텍스트에 PID 정보 자동 첨부 → 보고서 품질 향상
**Phase C (2.5일 + 운전원 채움 기간)** — 마지막 연결고리
4. **갈래 7**: Excel round-trip 인프라 구축 → 운전원이 service/role/contents/from-to 채움
- 자동 컬럼이 갈래 8 덕분에 7개 더 풍부 → 사용자 부담 줄어듦
- 구축 자체는 2.5일이지만 **운전원이 채우는 데 며칠~몇 주** 걸림 (영역별 분할)
- 일부라도 채워지면 즉시 갈래 1·2의 답변 품질이 올라감 (T-201 → "PGMEA 원료 탱크" 답변 가능)
**Phase D (Phase C 진행 중 병행, 3.5일)** — 지식 자산화로 진화
5. **갈래 6**: PID stub 자동 생성 → 옵시디언 노트 시스템 부트스트랩 → 하이브리드 RAG
- 갈래 8·7의 결과가 stub frontmatter로 자동 반영되어 빈약하지 않음
- 운전원이 채팅에서 노트 작성 습관 들이면 시간이 갈수록 그래프가 자라남
- **장기적으로는 이 갈래가 시스템의 핵심 자산** — PID/Experion은 자동 입력 소스, 노트는 사람의 운영 지식 저장소
**Phase E (필요해질 때)** — 확장
6. **갈래 3a**: 시각적 인벤토리 필요해지면 트리뷰 UI 작업
7. **갈래 4 전체** 또는 **갈래 3b**: 정비 시스템 연동/그래프 시각화 등은 실제 운영 요구가 명확해진 시점에 착수
### 우선순위 배치 근거
- **갈래 8 최우선**: 0.8일 작업으로 갈래 1·6·7 모두의 데이터 가치를 증폭. ROI 극단적으로 높음. 다른 모든 작업 전에 끝내는 게 합리적
- **갈래 5 > 갈래 4**: 갈래 4의 핵심 산출물(양방향 차집합 리포트)이 갈래 5의 Excel 시트로 자연스럽게 포함됨. 운전원이 이미 익숙한 UX 위에 얹는 거라 학습 비용 0
- **갈래 7 > 갈래 6**: 갈래 6 stub의 frontmatter는 갈래 7의 결과(service/role/contents)로 채워질 때 비로소 가치가 있음. 갈래 7 없이 갈래 6만 가면 빈 anchor만 잔뜩
- **갈래 7 > 갈래 3a**: service/role이 채워지지 않은 상태에서 시각화는 "이름만 있는 그림" — 우선 의미를 채워야 함
- **갈래 6은 갈래 1·2·7·8 이후가 적기**: 채팅에서 PID 데이터 활용 패턴이 나오고 메타데이터가 채워진 뒤라야 노트 시스템이 의미 있음
---
## 결정 필요 항목
- [ ] **갈래 8** 즉시 착수 여부 — `pid_equipment` 7개 컬럼 추가 + 460건 재추출 (0.8일, ROI 최고)
- [ ] **갈래 9** 옵션 선택 — A(dry-run, 1일) / B(자동저장+취소, 0.5일) / C(절충, 1.5~2일) 중 하나 선택. **갈래 8 착수 전 결정 권장** (B/C면 `extraction_batch_id` 컬럼을 갈래 8 마이그레이션에 함께 묶음)
- [ ] **갈래 1** 즉시 착수 여부 — `find_pid_equipment` / `get_pid_equipment_info` MCP 도구 추가
- [ ] **갈래 5** 즉시 착수 여부 — `infer_field_instruments``pid_equipment` LEFT JOIN + 별도 시트 추가
- [ ] 갈래 2 진행 여부 — Phase A 정착 후
- [ ] **갈래 7** 데이터 모델 확정 — `pid_equipment` 컬럼 추가 항목(service/role/contents/from_tag/to_tag/driver/capacity/power_kw/user_notes) 확인
- [ ] **갈래 7** legend dropdown 사전 시드값 — role/contents/driver 유효값 누가 정의
- [ ] **갈래 7** 운전원 채우기 가이드 — 영역별 분할 방식(어떤 area부터?)
- [ ] **갈래 6 Step 1·2** 일정 — PID stub 생성기 + 머지 보존 로직 (갈래 8·7 컬럼이 stub frontmatter로 흐르도록 같이 설계)
- [ ] 갈래 6 Step 5 (그래프 1-hop RAG) — `search_kb` 수정 시점 확정 필요
- [ ] 갈래 6 저진입 UX (채팅 답변 → 노트 저장 버튼) — UI 작업 일정
- [ ] 갈래 3a 트리뷰 UI 작업 일정 — 갈래 7 보강 데이터가 채워진 뒤가 효과적
- [ ] 갈래 4 단독 누락 검출 리포트 — 갈래 5에 흡수할지, 별도 페이지로 만들지
## 잔여 데이터 정합성 이슈
- **중복 추출**: 현재 460건 중 동일 tag_no가 2번씩 들어간 행 다수 발견 (예: PSV-10101이 2회). DXF에서 같은 텍스트가 여러 위치에 그려져서 발생. unique index 또는 (tag_no, pid_drawing_no) 복합키로 해결 필요.
- **pid_drawing_no NULL**: 현재 모든 행 NULL. 도면 파일명을 자동 채우거나 사용자 입력 받도록 보강 필요 — 갈래 4 누락 검출에 도면 단위 추적 필요할 때.
- **prefix 오분류**: PFD, SP, SC, TR 등 도면 라벨/약어가 instrument로 잘못 분류되는 케이스 — `_PID_TAG_RE` 또는 `_classify_pid_tag`에 예외 사전 추가 필요.

File diff suppressed because it is too large Load Diff

View File

@@ -1,407 +0,0 @@
# RAG 지식 증강 보완 플랜 — Field-Instrument 자동 유추 + 관계 그래프
> 옵시디언 vault 풀구현(`plans/옵시디언-구조적용-플랜.md`) 이전에, **신규 인프라 0** + **휴먼-인-더-루프**로 RAG 품질을 빠르게 끌어올리는 단계.
>
> DCS 로직 태그(`ficq-6101.pv` 등) → 글자/loop 룰로 현장 계기 유추 → role/from/to까지 채운 Excel 초안 → 운영자가 검토·수정 → 기존 KB 업로드 흐름으로 인덱싱.
작성: 2026-05-14 | 위치: 14번 탭(RAG 관리) 확장 | 의존: 기존 `xlsx_parser`, `KbIngestWorker`, Qdrant `system_instrument` 컬렉션
---
## 1. 목적과 이유
### 1.1 해결하려는 문제
- `node_map_master`/`v_tag_summary`에는 **DCS 로직 블록 태그**만 있다 (`ficq-6101`, `xv-6124` ...).
- 운전원 현장 언어는 **계기 단위**다 ("FT-6101 막혔어", "FCV-6101 포지셔너 점검").
- 사이의 **번역 갭**을 RAG가 메우려면 명시적 매핑이 필요한데, 현재 KB에는 그 매핑이 없다.
- 게다가 도면/PDF만으로는 "이 송신기 → 이 컨트롤러 → 저 밸브" 같은 **신호 흐름**이 LLM 컨텍스트로 들어오지 않는다.
### 1.2 왜 지금 이 단계인가
- 옵시디언 vault 풀구현은 데이터 모델 5개 테이블 + 워커 + UI 확장 + 그래프 검색까지 1~2주 작업.
- 이 단계는 **0.5~1일** 작업으로 RAG 품질이 즉시 좋아지고, 만들어진 Excel은 vault 마이그레이션 시 **그대로 시드**가 된다 → 손해보는 작업이 아니다.
- LLM은 지루한 초안만, 사람은 도메인 검증만 → 가장 비용 효율적인 분업.
### 1.3 비채택 옵션
- LLM 100% 추론 (옵션 C) — 2000행에 27B 호출 비용 큼, 환각 위험. 채택 안 함.
- 자동 인덱싱 (운영자 검토 없이 바로 KB) — 부정확한 매핑이 RAG 답변에 그대로 섞이면 신뢰도 손상. 채택 안 함.
---
## 2. 채택: 규칙 + 부분 LLM (옵션 B)
| 부분 | 방법 | 처리량 |
|------|------|--------|
| 계기 구조 추론 (FT/FCV/Totalizer) | **결정론적 룰** | 2000행 < 5초 |
| role/from/to 기본값 | **결정론적 룰** | 위와 함께 |
| description 한국어 초안 (없을 때) | **LLM 배치** (옵션 토글) | 2000행 ≈ 수십 분 |
| confidence / needs_review | 룰 적중도 계산 | 즉시 |
`use_llm=false`로도 운영자가 즉시 작업 시작 가능. LLM은 옵션.
---
## 3. Excel 스키마 — Long Format (1행 = 1계기)
> 이전 안의 "1행 = 1 base_tag, 컬럼에 FT/FCV/..." 와이드 포맷을 버리고 **롱 포맷** 채택. role/from/to를 1열에 자연스럽게 표현할 수 있고, 향후 vault note 1개 ↔ 행 1개 대응이 깔끔.
### 3.1 시트 1: `instruments`
| 열 | 타입 | 설명 | 예 |
|----|------|------|----|
| `instrument_id` | text (PK) | 정규화 ID, 소문자 kebab | `ft-6101` |
| `display_name` | text | 운전원이 부르는 이름 | `FT-6101` |
| `parent_base_tag` | text | 이 계기가 속한 DCS 태그 | `ficq-6101` |
| `role` | enum | §4의 역할 표 | `transmitter` |
| `loop` | text | loop 번호 | `6101` |
| `area` | text | unit/area | `A` |
| `measures` | enum/text | 측정량 (transmitter류) | `flow` |
| `data_points` | text(csv) | DCS 데이터포인트 | `.pv` 또는 `.qv,.qv.value` |
| `from` | text | 신호/물질 출처 | `process/compressor-a-suction` |
| `to` | text | 신호/물질 목적지 | `tag/ficq-6101` |
| `description` | text | 한국어 설명 | `압축기 A열 입구 유량 송신기` |
| `confidence` | enum | `high` / `medium` / `low` | `high` |
| `needs_review` | bool | TRUE면 운영자 검토 필수 | `FALSE` |
| `inference_basis` | text | 어떤 룰로 유추됐는지 | `F+T@modifier rule` |
| `operator_notes` | text | 운영자 자유 입력 | `2024년 교체. P&ID 105` |
| `delete` | bool | TRUE면 재업로드 시 제외 | `FALSE` |
### 3.2 시트 2: `naming_convention` (참조용, read-only)
운영자가 시트 1을 수정할 때 참조하는 룰 표. §6 YAML을 그대로 시트로 풀어둠.
### 3.3 시트 3: `unmatched_tags` (운영자 보충용)
룰이 전혀 매칭 안 된 base_tag 목록 (예: 비표준 prefix, 한글 prefix). 운영자가 직접 시트 1에 행 추가해야 함.
### 3.4 예시 5행
| instrument_id | display_name | parent_base_tag | role | loop | area | measures | data_points | from | to | description | confidence | needs_review | inference_basis |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ft-6101 | FT-6101 | ficq-6101 | transmitter | 6101 | A | flow | .pv | process/inlet-a | tag/ficq-6101 | 입구 유량 송신기 | high | FALSE | F+(implied T) |
| fic-6101 | FIC-6101 | ficq-6101 | controller | 6101 | A | flow | .sp,.op | tag/ft-6101 | tag/fcv-6101 | 입구 유량 제어기 | high | FALSE | F+I+C |
| fcv-6101 | FCV-6101 | ficq-6101 | control-valve | 6101 | A | — | .op | tag/fic-6101 | process/compressor-a-suction | 입구 제어밸브 | high | FALSE | C → CV |
| fq-6101 | Totalizer-6101 | ficq-6101 | totalizer | 6101 | A | flow | .qv,.qv.value | tag/ficq-6101 | (none) | 입구 유량 적산기 | medium | FALSE | Q → totalizer |
| xv-6124 | XV-6124 | xv-6124 | shutdown-valve | 6124 | A | — | .instate0..7 | safety/esd-loop-1 | process/header-a | ESD 차단밸브 | low | **TRUE** | digital-only |
---
## 4. Role 정의 (시드 표)
> 옵시디언 vault의 `kind: instrument` 노트 frontmatter `role` 필드와 1:1 매칭되도록 설계.
| role | 의미 | 룰 글자 | 일반 data_point |
|------|------|---------|-----------------|
| `transmitter` | 측정 송신기 (FT/PT/TT/LT/AT) | 수식어 T 또는 측정 letter 단독 | `.pv` |
| `indicator` | 표시기 (계기 없이 DCS 화면) | I | (가상) |
| `controller` | 제어기 (FIC/PIC/TIC/...) | C | `.sp`, `.op`, `.mode` |
| `recorder` | 기록계 | R | (가상) |
| `totalizer` | 적산기 | Q | `.qv`, `.qv.value` |
| `switch` | 스위치 (FS/PS/LS/...) | S | `.instate0..n` |
| `alarm` | 알람 단독 | A | (이벤트 채널) |
| `control-valve` | 제어밸브 (FCV/PCV/TCV) | 제어기 C 동반 시 자동 추론 | `.op` 참조 |
| `shutdown-valve` | 차단/ESD 밸브 (XV/SDV) | X/SDV prefix | `.instate*` |
| `check-valve` | 체크밸브 | (운영자 수동) | — |
| `positioner` | 포지셔너 (FZ) | Z | — |
| `interlock-relay` | 인터록 릴레이 (FY) | Y | — |
| `motor` | 모터 (M, KM) | 별도 prefix | `.run`, `.fault` |
| `pump` | 펌프 (P) | 별도 prefix | `.run`, `.flow` |
| `compressor` | 압축기 (K, C) | 별도 prefix | (다중) |
| `analyzer` | 분석기 (AT, AIT) | A 단독+T | `.pv` |
| `damper` | 댐퍼 | (운영자 수동) | — |
| `equipment` | 그 외 정적 장치 | (운영자 수동) | — |
→ 운영자가 신규 role을 만들어도 그대로 보존. role 자체가 frontmatter라 시스템 enum 위반은 경고만(차단 X).
---
## 5. From / To 의미론
> "**신호 또는 물질의 흐름**"을 표현. 운영자 직관과 맞아야 함.
### 5.1 기본 규칙 (role별 자동 채움)
| role | from 기본값 | to 기본값 |
|------|------------|----------|
| `transmitter` | `process/<area>-<loop-측정점>` (placeholder) | `tag/<parent>` |
| `controller` | `tag/<measurement-transmitter>` | `tag/<paired-valve>` |
| `control-valve` | `tag/<paired-controller>` | `process/<area>-<loop-downstream>` (placeholder) |
| `totalizer` | `tag/<parent>` | `(none)` |
| `switch` | `process/<area>-<loop-측정점>` | `safety/<interlock-id>` (운영자 보충) |
| `shutdown-valve` | `safety/<esd-trigger>` (운영자 보충) | `process/<area>-<downstream>` |
| 그 외 | `(none)` | `(none)` |
`process/...`, `safety/...` 경로는 **placeholder**. 운영자가 실제 ID(있다면)로 바꾸거나 그대로 둬도 됨. vault 마이그레이션 시 자동으로 `process` kind 노트가 빨간 링크로 잡힘 (= 채워야 할 노트 목록 = §10 옵시디언 플랜의 `vault_unresolved` 기능).
### 5.2 작성 형식
운영자는 4가지 중 하나로 적을 수 있음:
1. **vault note id**: `tag/ficq-6101`, `instrument/ft-6101`, `process/header-a` — 향후 vault 그래프로 직변환
2. **base_tag 그대로**: `ficq-6101` — 자동 정규화로 `tag/ficq-6101`
3. **자유 텍스트**: `압축기 1열 입구 헤더` — 보존만, 그래프 엣지로는 안 잡힘
4. **`(none)`**: 명시적 없음
룰 엔진은 4번을 디폴트로 두지 않고, 가능한 한 1번 형식의 placeholder를 채움.
### 5.3 검증
업로드 시:
- `from` / `to`가 형식 1·2면 자동 엣지로 인식
- 형식 3은 그대로 보존하되 "그래프 엣지 미생성" 정보 행 카운트 표시
- 형식 4는 명시적 제외
---
## 6. 룰 테이블 (`prompts/instrument_inference.yaml`)
> 코드 외부에 두는 이유: 운영자/엔지니어가 plant_context.md처럼 직접 편집 가능.
```yaml
# 첫 글자 = 측정량
measurement:
F: flow
P: pressure
T: temperature
L: level
A: analysis
S: speed
W: weight
D: density
J: power
M: moisture
# 두 번째 이후 = 기능 수식어 (순서대로 별도 계기 생성)
modifiers:
I: { role: indicator, virtual: true }
R: { role: recorder, virtual: true }
T: { role: transmitter, data_points: [.pv] }
C: { role: controller, data_points: [.sp, .op] }
Q: { role: totalizer, data_points: [.qv, "qv.value"] }
S: { role: switch, data_points: [.instate0, .instate1] }
A: { role: alarm }
Y: { role: interlock-relay }
Z: { role: positioner }
# 컨트롤러가 있으면 자동으로 제어밸브 1개 생성
auto_pair:
- if_role: controller
create:
role: control-valve
id_pattern: "{meas}cv-{loop}"
display_pattern: "{MEAS}CV-{loop}"
# 특수 prefix (글자 분해 룰 미적용)
special_prefixes:
xv: { role: shutdown-valve, measures: null }
sdv: { role: shutdown-valve, measures: null }
fy: { role: interlock-relay, measures: flow }
fz: { role: positioner, measures: flow }
km: { role: motor }
p: { role: pump }
k: { role: compressor }
# data_point 패턴별 보정 (룰 추론 결과 검증)
data_point_validation:
has_qv_when: role == "totalizer"
has_sp_op_when: role == "controller"
has_instate_when: role in ["switch", "shutdown-valve"]
# 신뢰도 계산
confidence:
high_when:
- prefix matches measurement OR special_prefixes
- all modifiers resolved
- data_points present and consistent
medium_when:
- prefix matches but some data_points missing
- or modifiers contain unknown letter
low_when:
- special_prefixes only AND data_points unusual
- or first letter not in measurement table
```
---
## 7. 유추 알고리즘 (Python 의사코드)
```python
def infer_instruments_for_base_tag(base_tag: str, data_points: list[str], area: str) -> list[dict]:
# 1. 분해
head, loop = split_letters_and_number(base_tag) # "ficq", "6101"
# 2. 특수 prefix 우선
if head in rules["special_prefixes"]:
return [build_special_instrument(head, loop, data_points, area)]
# 3. 첫 글자 = 측정량
if head[0] not in rules["measurement"]:
return [build_unmatched(base_tag, area)] # confidence=low, needs_review=TRUE
meas = rules["measurement"][head[0]]
instruments = []
# 4. 수식어 글자별로 계기 생성
has_transmitter = False
for letter in head[1:]:
mod = rules["modifiers"].get(letter)
if not mod or mod.get("virtual"):
continue # I, R은 가상
inst = build_instrument(meas, mod["role"], loop, area, data_points)
instruments.append(inst)
if mod["role"] == "transmitter":
has_transmitter = True
# 5. T 글자가 명시 안 됐어도 측정 letter만으로 송신기 암시 (FI-6101 → FT-6101 묵시)
if not has_transmitter and any(m["role"] == "controller" for m in instruments):
instruments.insert(0, build_implicit_transmitter(meas, loop, area))
# 6. 컨트롤러 → 제어밸브 자동 생성 (auto_pair)
for inst in list(instruments):
if inst["role"] == "controller":
instruments.append(build_paired_valve(meas, loop, area))
# 7. from/to 채우기 (§5.1)
link_signal_flow(instruments)
# 8. data_point 검증 → confidence 계산
for inst in instruments:
inst["confidence"] = score_confidence(inst, data_points)
inst["needs_review"] = inst["confidence"] == "low"
return instruments
```
---
## 8. LLM 보강 (옵션, `use_llm=true`)
- **대상**: `description` 열이 비어 있거나 confidence=low인 행만.
- **호출**: 한 번에 최대 50행 묶어서 vLLM에 한국어 요약 요청. 시스템 프롬프트는 `prompts/plant_context.md` + role 표.
- **출력**: description 한 줄(≤80자). 환각 방지 위해 "확실치 않으면 비워두라" 명시.
- **캐시**: 같은 (parent_base_tag, role) 조합 결과는 재실행 시 재사용.
- **토큰 예산**: 50행 묶음 × ~40 묶음 = 호출 40회, 각 ≤ 2k 토큰.
---
## 9. 워크플로
```
[14번 탭 > Field Instrument 모드]
1. ▼ 초안 생성 (use_llm 토글)
POST /api/kb/instruments/infer → 백그라운드 잡 → 진행률 폴링
2. ▼ Excel 다운로드 (instruments_draft_YYYYMMDD.xlsx)
3. 운영자가 로컬에서 Excel 편집
- confidence=low / needs_review=TRUE 행 우선
- from/to 보충 (process/, safety/ placeholder 채우기)
- delete=TRUE로 잘못된 추론 제외
- 시트 3(unmatched_tags)에서 행 추가
4. ▼ 같은 탭에 업로드 (collection = system_instrument)
5. 기존 KbIngestWorker → 시트별/행별 청킹 → Qdrant 인덱싱
6. 채팅에서 즉시 search_kb로 활용
- "FT-6101 점검 이력" → instrument_id, parent_base_tag, description 청크 hit
- "ficq-6101 관련 계기" → parent_base_tag 매칭으로 4행(FT/FIC/FCV/Q) 회수
```
재실행 시: 운영자가 수정한 Excel을 시드로 받아 (`POST /api/kb/instruments/infer?seed_doc_id=...`) **사용자 수정사항 보존 + 신규 base_tag만 추가** 모드.
---
## 10. 구현 단계
### Phase A — 룰 엔진 + Excel 생성 (0.5일)
- `prompts/instrument_inference.yaml` 작성 (§6)
- `mcp-server/instrument_inference/` 신규 모듈
- `rules.py` — YAML 로더
- `infer.py``infer_instruments_for_base_tag()` (§7)
- `excel.py` — openpyxl 기반 3시트 생성기
- `@mcp.tool() infer_field_instruments(use_llm=False, seed_doc_id=None)` 신규 도구
- 단위 테스트 10건 (ficq, xv, pic, tic, ts, lt, fy, km, 비표준 prefix, area 누락)
### Phase B — API + UI (0.3일)
- `GET /api/kb/instruments/infer/start` (admin) — 백그라운드 잡 ID 반환
- `GET /api/kb/instruments/infer/status/{jobId}` — 진행률
- `GET /api/kb/instruments/infer/download/{jobId}` — xlsx 스트림
- 14번 탭: "Field Instrument 초안" 버튼 + 진행률 카드 + 다운로드 링크
- 업로드는 **기존 14번 탭 업로드** 그대로 (system_instrument 컬렉션 선택)
### Phase C — LLM 보강 (0.2일, 옵션)
- `infer.py``enrich_with_llm(rows, batch=50)` 추가
- vLLM 호출, 캐시 dict
- 토글로 on/off
### Phase D — 검증/소비 강화 (0.2일)
- `search_kb``system_instrument` 컬렉션 우선 가중 (이미 가능, 가중치만)
- `ToolGuideKo`에 "FT/FCV 등 현장 계기명 질의는 system_instrument 컬렉션 우선" 한 줄 추가
- 채팅 답변에 instrument hit 시 `parent_base_tag` 동시 노출
### Phase E (선택) — diff 도구
- 운영자가 수정한 Excel ↔ 룰 출력 비교 → 어떤 룰을 추가/수정하면 되는지 리포트
- 룰 진화 피드백 루프
총 1.2일 (옵션 제외 0.8일).
---
## 11. vault 마이그레이션 시 변환 (미래)
> 옵시디언 vault 구현이 시작되면 이 Excel이 **그대로 시드**.
| Excel 열 | vault 변환 |
|---------|----------|
| `instrument_id` | `instrument/{id}` 노트 ID |
| `display_name` | `title` |
| `role` | frontmatter `role` |
| `parent_base_tag` | 본문에 `[[tag/{parent}]]` 위키링크 |
| `loop`, `area` | frontmatter |
| `measures`, `data_points` | frontmatter |
| `from` (vault id 형식) | 본문 `## 입력` 섹션에 `[[from]]` |
| `to` (vault id 형식) | 본문 `## 출력` 섹션에 `[[to]]` |
| `description` | 본문 첫 단락 |
| `operator_notes` | 본문 `## 운영자 메모` 섹션 |
→ 변환기 1개로 노트 N개 + `vault_links` M개 자동 생성. Excel 수정 이력이 그대로 그래프가 됨.
---
## 12. 산출물 미리보기 (작업 후 KB가 답할 수 있는 질문)
| 질의 | 응답 가능성 (전) | 응답 가능성 (후) |
|------|----------------|----------------|
| "FT-6101 어디 붙어있어?" | ❌ (계기 매핑 없음) | ✅ parent_base_tag + area + from |
| "FCV-6101은 뭐가 제어해?" | ❌ | ✅ `to: tag/ficq-6101` 백트래킹 |
| "ficq-6101 관련 현장 계기 다 보여줘" | △ description ILIKE만 | ✅ parent_base_tag로 4행 일괄 |
| "ESD 차단밸브 목록" | ❌ | ✅ role=shutdown-valve 필터 |
| "FT-6101 점검 이력" | △ (운영자 노트가 KB 어딘가에 있다면) | ✅ 동일 + operator_notes 청크 매칭 |
---
## 13. 잔여 결정 (사용자 확인 필요)
| 항목 | 옵션 | 추천 |
|------|------|------|
| 첫 실행 LLM 사용 | (A) off (B) description만 on | **A** — 빠른 1차 산출, 운영자 작업 시작 |
| Excel 컬렉션 | `system_instrument` | 기존 시드 그대로 |
| ID 정규화 정책 | 무조건 소문자 kebab | 운영자가 대문자 입력해도 자동 변환 |
| 운영자 신규 role 허용 | 허용(경고만) / 차단 | **허용** — frontmatter 자유도 유지 |
| `from`/`to` 미입력 시 | placeholder 자동 / 공란 | **placeholder** — vault 시 빨간 링크로 가시화 |
| 재실행 정책 | 매번 새 Excel / seed_doc_id 기반 머지 | **머지** — 운영자 수정 보존 |
| unmatched_tags 시트 알림 | UI에 카운트 | 운영자 행동 유도 |
---
## 14. 핵심 메시지
- **신규 인프라 0** — 기존 xlsx 인덱싱·KB 업로드·Qdrant 컬렉션 그대로 활용.
- **롱 포맷 + role/from/to** — 단순 매핑 표가 아니라 **계기 그래프의 노드+엣지**. vault로 그대로 이전.
- **휴먼-인-더-루프** — LLM은 초안만, 운영자는 검증만. 백지에서 작성하는 것 대비 100배 빠름.
- **재실행 안전** — 룰 개선 시 운영자 수정 보존하고 신규만 추가.
- **즉시 효과** — 업로드 직후 채팅이 현장 계기명으로 답할 수 있음. 옵시디언 vault 풀구현을 기다리지 않아도 됨.

View File

@@ -1,14 +0,0 @@
# 현재 운엉상의 문제점
개선안 1) RAG관리 -> 초안생성시 : 계기 유추 전혀 안됨, FICQ-6101 로 부터 FT-6101, FCV-6101를 유추해서 초안에 만들어 줘야 함
예) 1. FICQ-XXXXX, FIC-XXXXX -> FT-6101, FCV-6101 유추, PICX-> PT & PCV, LICX->LT & LCV 등등 ( 태그명 XXCXX- 'C'가 있으면 CONTROL VALVE가 있다고 생각)
2. FI-XXXXX, FIA-XXXXX -> FT-XXXXX
3. TI-XXXXX, TIA-XXXXX, TIS-XXXXX -> TE-XXXXX (Temperature Element)
4. LI-XXXXX, LIA-XXXXX, LIS-XXXXX -> LT-XXXXX
5. PI-XXXXX, PIA-XXXXX, PIS-XXXXX -> PT-XXXXX
수정완료[x]
개선안 2) :
1. display_name : FT-10114a --> 모두 대문자로
2. 규칙에서 어긋난 태그명 : unmatched_tags로 분류할 것, fica-3102_op -> ft-3102_op, fcv-3102_op
3. lt-9113-lo-esd, lcv-9113-lo-esd 등등 %-esd% 인 것들 --> 시스템 포인트 임 계기 아님

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +0,0 @@
# 클로드 코드 검수 가이드
> GLM-4.7-Flash 수정 완료 후 Claude Code에서 실행
---
## Step 1 — 변경 범위 파악
```bash
git log --oneline # GLM이 남긴 fix 커밋 목록 확인
git diff main HEAD # 전체 변경사항 한눈에 보기
git diff main HEAD --stat # 파일별 변경 라인 수
```
## Step 2 — REVIEW_REQUEST.md 읽기
GLM이 작성한 `REVIEW_REQUEST.md`를 열어
- 수정 완료 목록과 우려사항 확인
- 수정 보류(needs-review) 항목 특별 주의
## Step 3 — 파일별 diff 검토
```bash
git show HEAD~N:src/파일.cs # 수정 전 원본
git diff HEAD~1 -- src/파일.cs # 해당 커밋 단일 변경
```
Claude Code에게 물어볼 것:
- "이 변경이 기존 동작을 바꾸는가?"
- "edge case가 있는가?"
- "OPC UA 프로토콜 관점에서 올바른가?"
## Step 4 — 빌드 및 동작 확인
```bash
dotnet build src/Web/ExperionCrawler.csproj
dotnet test ExperionCrawler.Tests/ --no-build 2>/dev/null || echo "테스트 없음"
```
## Step 5 — 판정
| 결과 | 처리 |
|------|------|
| 승인 | `git checkout main && git merge --no-ff fix/glm-review` |
| 부분 승인 | 문제 커밋만 `git revert`, 나머지 병합 |
| 거부 | `git reset --hard HEAD~N` 후 Claude Code가 직접 재수정 |
## needs-review 항목 처리
GLM이 판단 보류한 항목은 Claude Code가 직접 검토 후:
```
rag_query("해당 문제 설명", search_code=True) # MCP로 관련 코드 컨텍스트 확인
```
판단 후 직접 수정하거나 issues.md에 wont-fix 표시

View File

@@ -1,804 +0,0 @@
# 디지털 태그 추가 작업 계획 (Phase 1)
## 개요
`realtime-tag-expansion-design.md` 설계안을 기반으로 Phase 1 기본 확장을 구현합니다.
기존 `realtime_table` 구조를 유지하면서 디지털 장비(Pump, XV) 태그와 메타데이터 지원을 추가합니다.
---
## Todo List (작업 진행 상태 추적)
> 각 단계는 독립적으로 실행 가능하며, 완료 상태를 표시하여 다음 작업에서 바로 읽을 수 있습니다.
- [x] **Step 1**: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
- [x] **Step 2**: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
- [x] **Step 3**: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
- [x] **Step 4**: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
- [x] **Step 5**: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
- [x] **Step 6**: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가 + `Program.cs` SqlValidatorOptions `AllowedTables` 업데이트
- [ ] **Step 7**: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인 (수동 테스트 필요)
---
## Step 1: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
### 상태: [x] 완료
### 변경 파일
- `src/Infrastructure/Database/ExperionDbContext.cs`
### 변경 내용
[`ExperionDbService.InitializeAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:176) 메서드 내에 다음 DDL을 추가합니다.
기존 DDL(`realtime_table` 생성 이후, `EnsureCreatedAsync` 설명 주석 이전)에 추가합니다.
**추가 위치:** 275번 라인 (`// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음`) 직후
```sql
// tag_metadata ( - )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL,
attribute TEXT NOT NULL,
value TEXT,
node_id TEXT,
loaded_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(base_tag, attribute)
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
""");
// v_tag_summary ( + )
await _ctx.Database.ExecuteSqlRawAsync("""
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,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
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 s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
""");
```
### 검증 방법
1. 앱을 실행하여 DB 초기화가 자동으로 수행되는지 확인
2. psql로 접속하여 `SELECT * FROM tag_metadata LIMIT 1;` 실행
3. `SELECT * FROM v_tag_summary LIMIT 5;` 실행하여 뷰가 정상 동작하는지 확인
---
## Step 2: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
### 상태: [x] 완료
### 변경 파일
- `src/Core/Domain/Entities/ExperionEntities.cs` (신규 Entity 추가)
- `src/Infrastructure/Database/ExperionDbContext.cs` (DbSet + ModelBuilder 등록)
### 2.1 신규 Entity 클래스 추가
**파일:** [`src/Core/Domain/Entities/ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs:138)
[`FastRecord`](src/Core/Domain/Entities/ExperionEntities.cs:129) 클래스 직후에 다음 Entity를 추가합니다.
```csharp
/// <summary>tag_metadata — 태그 메타데이터 (변경 드묾)</summary>
[Table("tag_metadata")]
public class TagMetadata
{
[Column("id")] public int Id { get; set; }
[Column("base_tag")] public string BaseTag { get; set; } = string.Empty;
[Column("attribute")] public string Attribute { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
[Column("node_id")] public string? NodeId { get; set; }
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
}
```
### 2.2 DbContext 에 DbSet 등록
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:20)
기존 DbSet 목록 ([`FastRecords`](src/Infrastructure/Database/ExperionDbContext.cs:23) 이후, P&ID DbSet 이전) 에 추가:
```csharp
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>();
```
### 2.3 ModelBuilder 설정 추가
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:30)
[`OnModelCreating()`](src/Infrastructure/Database/ExperionDbContext.cs:30) 메서드 내, [`FastRecord`](src/Infrastructure/Database/ExperionDbContext.cs:75) 설정 이후, P&ID 엔티티 설정 이전에 추가:
```csharp
// DDL에서 UNIQUE(base_tag, attribute)와 idx_tag_metadata_base 인덱스를 이미 생성하므로
// ModelBuilder 설정은 EF Core Migration 호환성을 위해 유지하되 실제 인덱스 생성은 DDL이 담당
modelBuilder.Entity<TagMetadata>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique(); // DDL UNIQUE 제약과 대응
e.HasIndex(x => x.BaseTag); // DDL idx_tag_metadata_base와 대응
});
```
### 2.4 변경 후 파일 구조 확인
[`ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs) 최종 구조:
```
ExperionTag (line 7)
ExperionRecord (line 21)
ExperionServerConfig (line 32)
RawNodeMap (line 47)
NodeMapMaster (line 59)
RealtimePoint (line 71)
HistoryRecord (line 82)
ExperionStatusCodeInfo (line 92)
PidGraphStatus (line 102)
FastSession (line 113)
FastRecord (line 130)
TagMetadata (신규 추가) ← 여기
```
[`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs) DbSet 최종 구조:
```csharp
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>(); // ← 신규
// P&ID DbSet ...
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Entity 클래스가 `[Table("tag_metadata")]` 속성으로 DB 테이블과 매핑되는지 확인
3. DbContext에서 `TagMetadata` DbSet를 통해 CRUD가 가능한지 확인
---
## Step 3: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
### 상태: [x] 완료
### 신규 파일
- `src/Infrastructure/OpcUa/MetadataLoaderService.cs`
### 변경 파일
- `src/Core/Application/Interfaces/IExperionServices.cs` (인터페이스 추가)
- `src/Web/Program.cs` (DI 등록)
### 3.1 인터페이스 정의
**파일:** [`src/Core/Application/Interfaces/IExperionServices.cs`](src/Core/Application/Interfaces/IExperionServices.cs)
파일 끝에 다음 인터페이스 추가:
```csharp
// ── Metadata Loader ──────────────────────────────────────────────────────────
public interface IMetadataLoaderService
{
/// <summary>
/// 태그 등록 시 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 한 번 읽어서 tag_metadata 저장
/// </summary>
Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags);
/// <summary>
/// UI 재로드 버튼 클릭 시 메타데이터 재조회 + tag_metadata UPDATE
/// 참고: node_map_master UPSERT는 Phase 2 자동 속성 제안 기능에서 처리 예정
/// </summary>
Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
}
```
### 3.2 서비스 구현
**파일:** `src/Infrastructure/OpcUa/MetadataLoaderService.cs` (신규)
> **수정 이력 (진단):**
> - `using Npgsql;` 추가 (누락 시 빌드 오류)
> - `NpgsqlTypes.NpgsqlParameter` → `Npgsql.NpgsqlParameter` (잘못된 네임스페이스)
> - CTE 컬럼 목록에 `loaded_at` 추가 (4열 선언 vs 5값 불일치 → SQL 런타임 오류)
> - 사용되지 않는 `updateSql` 변수 제거 (데드코드)
> - `baseTags`를 메서드 진입 시 `ToList()`로 구체화 (이중 열거 방지)
```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var baseTagList = baseTags.ToList(); // 이중 열거 방지
// ── Step 1: 모든 노드 ID 수집 ──────────────────────────────────────
var nodeMap = new Dictionary<string, (string baseTag, string attr)>();
foreach (var baseTag in baseTagList)
{
foreach (var attr in MetaAttributes)
{
var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
var fullNodeId = $"ns=1;s={nodeId}";
nodeMap[fullNodeId] = (baseTag.ToLowerInvariant(), attr);
}
}
// ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ────────────────────────
var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys);
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var result in results)
{
if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta))
{
entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId));
}
}
// ── Step 3: 단일 배치 UPSERT ──────────────────────────────────────
if (entries.Count > 0)
{
// VALUES 절을 동적으로 생성하여 한 번에 INSERT
// CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at
var valuesSql = string.Join(", ", entries.Select((e, i) =>
$"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())"));
await _ctx.Database.ExecuteSqlRawAsync(@"
WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS (
VALUES " + valuesSql + @"
)
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()",
entries.SelectMany((e, i) => new object[] {
new NpgsqlParameter($"@bt{i}", e.baseTag),
new NpgsqlParameter($"@attr{i}", e.attr),
new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value),
new NpgsqlParameter($"@nid{i}", e.nodeId)
}).ToArray());
}
// v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN)
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagList.Count);
return entries.Count;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
// baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
var tags = baseTags?.ToList() ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
return await LoadMetadataAsync(cfg, tags);
}
}
```
### 3.3 DI 등록
**파일:** `src/Web/Program.cs`
기존 서비스 등록 부분 근처에 추가:
```csharp
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. 테스트용 코드에서 `LoadMetadataAsync` 호출 시 OPC UA에서 desc/area 값이 정상 읽히는지 로그 확인
3. DB에서 `SELECT * FROM tag_metadata;` 실행하여 데이터 저장 확인
---
## Step 4: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
### 상태: [x] 완료
### 변경 파일
- `src/Web/Controllers/ExperionControllers.cs` (신규 컨트롤러 추가)
### 4.1 컨트롤러 추가 (최종 형태)
**파일:** [`src/Web/Controllers/ExperionControllers.cs`](src/Web/Controllers/ExperionControllers.cs)
[`ExperionPointBuilderController`](src/Web/Controllers/ExperionControllers.cs:272) 직후, [`ExperionRealtimeController`](src/Web/Controllers/ExperionControllers.cs:348) 이전에 추가:
```csharp
// ── 태그 메타데이터 관리 ──────────────────────────────────────────────────────
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
private readonly IMetadataLoaderService _metaSvc;
private readonly ExperionDbContext _ctx;
private readonly IConfiguration _config;
public TagMetadataController(
IMetadataLoaderService metaSvc,
ExperionDbContext ctx,
IConfiguration config)
{
_metaSvc = metaSvc;
_ctx = ctx;
_config = config;
}
/// <summary>
/// 지정된 태그(또는 전체)의 메타데이터를 OPC UA에서 재조회하여 갱신
/// 설정은 appsettings.json의 "Experion:Default" 섹션에서 읽음
/// </summary>
[HttpPost("reload")]
public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
{
// appsettings.json에서 기본 설정 읽기 (비밀번호 하드코딩 금지)
var section = _config.GetSection("Experion:Default");
var cfg = new ExperionServerConfig
{
ServerHostName = req?.ServerHostName ?? section["ServerHostName"] ?? throw new InvalidOperationException("ServerHostName이 설정되지 않았습니다."),
Port = req?.Port ?? section.GetValue<int?>("Port") ?? 4840,
ClientHostName = req?.ClientHostName ?? section["ClientHostName"] ?? "dbsvr",
UserName = req?.UserName ?? section["UserName"] ?? "",
Password = req?.Password ?? section["Password"] ?? ""
};
try
{
var count = await _metaSvc.ReloadMetadataAsync(cfg, req?.BaseTags);
return Ok(new { success = true, count, message = $"{count}개 메타데이터 갱신 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>
/// tag_metadata 전체 조회
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
{
var query = _ctx.TagMetadata.AsQueryable();
if (!string.IsNullOrEmpty(baseTag))
query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());
var items = await query.Select(t => new
{
t.BaseTag, t.Attribute, t.Value, t.NodeId, t.LoadedAt
}).ToListAsync();
return Ok(new { count = items.Count, items });
}
}
public record MetadataReloadRequest(
string? ServerHostName,
int? Port,
string? ClientHostName,
string? UserName,
string? Password,
IEnumerable<string>? BaseTags);
```
> **주의**: `appsettings.json`에 다음 섹션이 있어야 합니다:
> ```json
> {
> "Experion": {
> "Default": {
> "ServerHostName": "192.168.0.20",
> "Port": 4840,
> "ClientHostName": "dbsvr",
> "UserName": "mngr",
> "Password": "mngr"
> }
> }
> }
> ```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Postman/curl로 `POST /api/tags/metadata/reload` 호출하여 메타데이터 갱신 확인
3. `GET /api/tags/metadata?baseTag=xv-6124` 호출하여 조회 확인
---
## Step 5: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
### 상태: [x] 완료
### 변경 파일
- `src/Web/wwwroot/index.html` (포인트빌더 탭 HTML 변경)
- `src/Web/wwwroot/js/app.js` (JS 로직 변경)
### 5.1 HTML 변경 — 포인트빌더 최대 10개로 확장
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:422)
현재 포인트빌더 탭(`pane-pb`) 내 name 선택 드롭다운이 8개(`pb-n1`~`pb-n8`)입니다. 10개로 확장:
**변경 위치:** `pb-name-grid` 내부 select 요소 2개 추가
```html
<!-- 신규 추가 -->
<select id="pb-n9" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n10" class="inp"><option value="">— 선택 안 함 —</option></select>
```
**레이블 텍스트 변경:**
```html
<label>이름(name) 선택 <em>(OR 조건, 최대 10개)</em>
```
### 5.2 HTML 변경 — 메타데이터 재로드 버튼 추가
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:482)
`pb-rt-status` 로그 박스 직후, 포인트 목록 카드 이전에 추가:
```html
<div class="card" style="margin-top:18px">
<div class="card-cap">메타데이터 관리</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
</p>
<div class="btn-row">
<button class="btn-a" onclick="metaReload()">🔄 메타데이터 재로드</button>
<button class="btn-b" onclick="metaView()">📋 메타데이터 조회</button>
</div>
<div id="meta-log" class="logbox hidden" style="margin-top:8px"></div>
<div id="meta-view" class="hidden" style="margin-top:10px;max-height:300px;overflow:auto"></div>
</div>
```
### 5.3 JS 변경 — PB_NAME_IDS 배열 확장
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:575)
```javascript
// 변경 전:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];
// 변경 후:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10'];
```
### 5.4 JS 변경 — 메타데이터 재로드/조회 함수 추가
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:723)
`rtStatus()` 함수 직후에 추가:
```javascript
/* ── 메타데이터 관리 ─────────────────────────────────────────── */
async function metaReload() {
const body = {
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
clientHostName: document.getElementById('pb-rt-client').value.trim(),
userName: document.getElementById('pb-rt-user').value.trim(),
password: document.getElementById('pb-rt-pw').value
};
const logEl = document.getElementById('meta-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">⏳ 메타데이터 재로드 중...</div>';
try {
const d = await api('POST', '/api/tags/metadata/reload', body);
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function metaView() {
const viewEl = document.getElementById('meta-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', '/api/tags/metadata');
const items = d.items || [];
if (items.length === 0) {
viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
return;
}
viewEl.innerHTML = `
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
<tbody>
${items.map(m => `
<tr>
<td style="font-weight:600">${esc(m.baseTag)}</td>
<td>${esc(m.attribute)}</td>
<td>${esc(m.value || '-')}</td>
<td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
```
### 검증 방법
1. 브라우저에서 포인트빌더 탭 확인 — 드롭다운이 10개인지 확인
2. "메타데이터 재로드" 버튼 클릭 시 API 호출되고 결과 로그 표시 확인
3. "메타데이터 조회" 버튼 클릭 시 테이블 형태로 메타데이터 표시 확인
---
## Step 6: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
### 상태: [x] 완료
### 변경 파일
- `mcp-server/worker/nl2sql_worker.py` (DB_SCHEMA 변수 업데이트)
- `mcp-server/server.py` (_DB_SCHEMA 변수 업데이트)
### 6.1 nl2sql_worker.py 변경
**파일:** [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:61)
기존 `DB_SCHEMA` 변수에 `tag_metadata` 테이블과 `v_tag_summary` 뷰 정의를 추가:
```python
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv')
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
base_tag TEXT - 기본 태그명
pv TEXT - 현재 프로세스 값
sp TEXT - 설정값
op TEXT - 출력값
instate0 TEXT - 상태 비트 0 (true/false)
instate1 TEXT - 상태 비트 1 (true/false)
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double)
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지
"""
```
### 6.2 server.py 변경
**파일:** [`mcp-server/server.py`](mcp-server/server.py:430)
`_DB_SCHEMA` 변수도 동일하게 업데이트 (nl2sql_worker.py와 동일한 내용).
### 6.3 Program.cs SqlValidatorOptions 업데이트
> **진단에서 발견:** `Program.cs`의 C# `SqlValidator`가 `AllowedTables`를 화이트리스트로 관리함.
> `tag_metadata`, `v_tag_summary`, `realtime_table`이 없으면 C# NL2SQL 경로(`ITextToSqlService`)에서 이 테이블을 조회하는 SQL이 거부됨.
> MCP Python 경로는 자체 검증 로직을 사용하므로 별도 영향 없음.
**파일:** `src/Web/Program.cs` (68~73번 라인)
```csharp
// 변경 전:
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master"],
MaxSubqueryDepth = 4
});
// 변경 후:
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master", "realtime_table", "tag_metadata", "v_tag_summary"],
MaxSubqueryDepth = 4
});
```
### 6.4 NL2SQL 사용 예시 (새로운 시나리오)
| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |
### 검증 방법
1. MCP 서버 재시작 후 NL2SQL 도구 호출 시 새로운 스키마가 반영되는지 확인
2. "xv-6124 상태 알려줘" 같은 질문으로 v_tag_summary 조회 테스트
3. tag_metadata 테이블 조회가 정상적으로 되는지 확인
4. `Program.cs` SqlValidatorOptions 업데이트 후 C# NL2SQL 경로에서도 `tag_metadata`, `v_tag_summary` 쿼리 허용 확인
---
## Step 7: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인
### 상태: [ ] 수동 테스트 필요
### 테스트 시나리오
#### 7.1 디지털 태그 등록 테스트
**목표:** xv-6124의 pv, op, instate0~2를 realtime_table에 등록하고 OPC UA 구독 확인
**단계:**
1. 포인트빌더 탭에서 이름에 `xv-6124` 선택
2. 데이터 타입에 `Int32`, `Boolean` 입력
3. "🔨 테이블 작성하기" 클릭
4. 포인트 목록에 xv-6124.pv, xv-6124.op, xv-6124.instate0~2가 생성되는지 확인
**기대 결과:**
- `realtime_table`에 xv-6124 관련 행이 INSERT됨
- 포인트 목록 UI에 표시됨
#### 7.2 OPC UA 구독 테스트
**목표:** 실시간 값 업데이트 확인
**단계:**
1. "▶ 구독 시작" 클릭
2. 구독 상태 로그에 xv-6124 관련 노드가 포함된지 확인
3. 몇 분 후 포인트 목록 새로고침
4. `livevalue` 컬럼에 값이 업데이트되는지 확인
**기대 결과:**
- xv-6124.pv → Int32 BCD 값 (예: 0, 1, 3, 5 등)
- xv-6124.instate0 → Boolean (true/false)
- 구독 카운터에 xv-6124 태그가 포함됨
#### 7.3 메타데이터 로드 테스트
**목표:** desc, area, state descriptor 값이 tag_metadata에 저장되는지 확인
**단계:**
1. "🔄 메타데이터 재로드" 버튼 클릭
2. 로그에 갱신된 속성 수 확인
3. "📋 메타데이터 조회" 버튼 클릭
4. xv-6124 관련 메타데이터 행 확인
**기대 결과:**
- `tag_metadata`에 xv-6124 관련 행 INSERT됨
- desc: 장비 설명 문자열
- area: 소속 플랜트 (예: "Unit-A")
- state0descriptor: "Open/Close" 또는 유사한 문자열
#### 7.4 v_tag_summary 뷰 테스트
**목표:** 실시간값 + 메타데이터 통합 조회 확인
**단계:**
```sql
-- psql에서 실행
SELECT * FROM v_tag_summary WHERE base_tag = 'xv-6124';
```
**기대 결과:**
| base_tag | pv | op | instate0 | instate1 | instate2 | description | area | state0_descriptor | state1_descriptor | state2_descriptor |
|----------|-----|-----|----------|----------|----------|-------------|------|-------------------|-------------------|-------------------|
| xv-6124 | 1 | 1 | true | false | false | XV-6124 설명 | Unit-A | Open/Close | Remote/Local | Fault/Normal |
#### 7.5 NL2SQL 통합 테스트
**목표:** 자연어 질문으로 디지털 태그 상태 조회
**단계:**
1. Text-to-SQL 탭에서 "xv-6124 현재 상태 알려줘" 입력
2. 생성된 SQL이 v_tag_summary를 사용하는지 확인
3. 결과에 instate0~2 값과 state descriptor가 포함되는지 확인
**기대 결과:**
- SQL: `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'`
- 결과 테이블에 상태 비트와 의미 표시됨
### 테스트 체크리스트
- [ ] xv-6124 포인트 realtime_table에 등록됨
- [ ] OPC UA 구독 시 livevalue 업데이트됨
- [ ] 메타데이터 재로드 시 tag_metadata에 저장됨
- [ ] v_tag_summary 뷰에서 통합 조회 가능
- [ ] NL2SQL에서 자연어 질문으로 상태 조회 가능
- [ ] 포인트빌더에서 최대 10개 태그 선택 가능

View File

@@ -1,188 +0,0 @@
# pvC UA EnumValueType 메타데이터 저장 최적화 제안서
> 작성일: 2026-05-07
> 상태: 분석 중
> 목적: xv, p-xxxxx 등 디지털 장비 태그의 pv 값 (i=7594) 저장 방식 최적화
---
## 1. 현재 pv 값 저장 방식 분석
### 1.1 pvC UA EnumValueType (i=7594) 데이터 흐름
디지털 장비(xv 밸브, p-xxxxx 펌프 등)의 pv(Output) 값은 pvC UA 표준 데이터 타입인 `EnumValueType (i=7594)`를 사용한다. 이 값은 pvC UA 서버에서 읽을 때 `{정수 | DisplayName | }` 형식의 문자열로 반환된다.
### 1.2 실제 DB 저장 예시
`realtime_table`에 pv 값이 저장되는 실제 데이터:
| tagname | livevalue | node_id |
|---------|-----------|---------|
| xv-3202.pv | `{0 \| L-MID \| }` | ns=1;s=sinamserver:xv-3202.pv |
| xv-3202_hs.pv | `{0 \| CLOSE \| }` | ns=1;s=sinamserver:xv-3202_hs.pv |
| xv-360.pv | `{0 \| MID \| }` | ns=1;s=sinamserver:xv-360.pv |
| p-10602a.pv | `{0 \| L-STpv \| }` | ns=1;s=sinamserver:p-10602a.pv |
| p-2816a.pv | `{0 \| L-STpv \| }` | ns=1;s=sinamserver:p-2816a.pv |
| p-203_hs.pv | `{0 \| STpv \| }` | ns=1;s=sinamserver:p-203_hs.pv |
### 1.3 pv 값의 구조
pv 값 `{0 \| L-STpv \| }`는 다음 3가지 정보를 포함:
- **정수 코드 (0)**: 실제 상태 코드 (0~7 범위)
- **DisplayName (L-STpv)**: 화면에 표시될 상태 이름
- **빈 설명 영역**: 현재 사용되지 않음
### 1.4 각 태그당 존재하는 속성 수
`node_map_master`에서 한 태그(예: p-10214)당 **426개 속성**이 존재한다. 주요 속성:
- 핵심 데이터: pv, pv, sp, md
- 상태 플래그: instate0~instate7
- 상태 설명: state0descriptor~state7descriptor
- 메타데이터: desc, area
- 품질/시간: pvquality, pvlastscannedtime 등
- 설정값: state0ondelay, alarmpriority 등
- 기타: equipprpverties, templateid, guid 등
---
## 2. tag_metadata 테이블 분석
### 2.1 현재 tag_metadata 테이블 스키마
```sql
CREATE TABLE tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL,
attribute TEXT NOT NULL,
value TEXT,
node_id TEXT,
loaded_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(base_tag, attribute)
)
```
### 2.2 MetadataLoaderService가 로드하는 속성
| 속성 | 데이터 타입 | 설명 |
|------|------------|------|
| desc | String | 태그 설명 |
| area | i=7594 | 소속 플랜트/Asset (EnumValueType) |
| state0descriptor | String | 상태 0 설명 |
| state1descriptor | String | 상태 1 설명 |
| state2descriptor | String | 상태 2 설명 |
| state3descriptor | String | 상태 3 설명 |
| state4descriptor | String | 상태 4 설명 |
| state5descriptor | String | 상태 5 설명 |
| state6descriptor | String | 상태 6 설명 |
| state7descriptor | String | 상태 7 설명 |
### 2.3 현재 tag_metadata 테이블 상태
**테이블이 비어 있음** — 메타데이터 로드가 아직 실행되지 않았거나 실패한 상태.
### 2.4 문제점: pv 값과 state descriptor의 중복
pv 값 `{0 | L-STpv | }` 자체가 **상태 코드 + DisplayName**을 포함하고 있다.
즉, `state0descriptor` 등의 값을 별도로 저장할 필요 없이 pv 값에서 파싱하면 된다.
| 데이터 소스 | 포함 정보 |
|------------|----------|
| `realtime_table`의 pv livevalue | `{0 \| L-STpv \| }` → 코드=0, 이름=L-STpv |
| `tag_metadata`의 state0descriptor | "L-STpv" (pv 값과 동일한 정보) |
**결론: pv 값에 이미 state descriptor 정보가 포함되어 있으므로 중복 저장.**
---
## 3. 메타데이터 컬럼 유지 여부 제안
### 3.1 컬럼 분류
tag_metadata 테이블에서 각 속성을 3가지 카테고리로 분류:
| 카테고리 | 속성 | 필요성 | 이유 |
|----------|------|--------|------|
| **필수 유지** | desc | ⭐ 필수 | 태그 설명은 pv 값에 포함되지 않음 |
| **필수 유지** | area | ⭐ 필수 | 소속 플랜트 정보는 pv 값에 포함되지 않음 |
| **불필요** | state0~7descriptor | ❌ 중복 | pv 값 `{코드 \| 이름 \| }`에 이미 포함 |
### 3.2 제안: desc, area만 저장
**tag_metadata 테이블에는 `desc`와 `area`만 저장하고, `state0~7descriptor`는 저장하지 말 것.**
#### 근거
1. **정보 중복 제거**: pv 값 `{0 | L-STpv | }`에서 상태 이름 "L-STpv"을 파싱 가능
2. **저장소 절감**: 태그당 8개 state descriptor 행 제거 (10개 → 2개 속성)
3. **로드 시간 단축**: pvC UA에서 읽어야 할 속성 10개 → 2개로 감소
4. **데이터 일관성**: state descriptor는 pvC 서버 설정에 따라 변경될 수 있음. pv 값이 실시간 상태이므로 더 신뢰할 수 있음
#### pv 값 파싱 방법
```
pv 값: "{0 | L-STpv | }"
파싱: 코드=0, 상태이름="L-STpv"
pv 값: "{0 | MID | }"
파싱: 코드=0, 상태이름="MID"
```
프론트엔드에서 pv 값 표시 시 `{``|`를 파싱하여 상태 이름만 표시하면 됨.
### 3.3 수정이 필요한 코드
| 파일 | 수정 내용 |
|------|----------|
| `MetadataLoaderService.cs` | `MetaAttributes` 배열에서 state0~7descriptor 제거 |
| `ExperionDbContext.cs` | `v_tag_summary` 뷰에서 state descriptor JOIN 제거 |
| `app.js` | pv 값 표시 시 파싱 로직 추가 |
### 3.4 tag_metadata 테이블의 node_id, loaded_at 컬럼 유지 여부
| 컬럼 | 유지 제안 | 이유 |
|------|----------|------|
| node_id | 유지 | pvC UA 노드 ID는 재로드 시 변경될 수 있으므로 추적 필요 |
| loaded_at | 유지 | 메타데이터 갱신 시점 확인에 유용 |
**결론: node_id와 loaded_at은 유지하는 것이 좋음.**
---
## 4. 요약 및 다음 단계
### 4.1 핵심 발견
1. **pv 값 저장 방식**: `realtime_table``livevalue``{코드 | DisplayName | }` 형식으로 저장됨
2. **정보 중복**: pv 값에 state descriptor 정보가 이미 포함되어 있음
3. **tag_metadata 비어있음**: 메타데이터 로드가 아직 실행되지 않은 상태
4. **속성 과다**: 각 태그당 426개 속성 중 실제로 필요한 것은 desc, area 2개뿐
### 4.2 최종 제안
| 항목 | 결정 |
|------|------|
| tag_metadata 저장 속성 | desc, area 만 |
| state0~7descriptor | 저장하지 않음 (pv 값에서 파싱) |
| node_id 컬럼 | 유지 (추적용) |
| loaded_at 컬럼 | 유지 (갱신 시점 확인) |
### 4.3 기대 효과
- **pvC UA 읽기 요청 80% 감소**: 10개 속성 → 2개 속성
- **tag_metadata 행 80% 감소**: 태그당 10행 → 2행
- **메타데이터 로드 시간 단축**: 네트워크 트래픽과 DB 삽입량 대폭 감소
- **데이터 일관성 향상**: pv 값이 실시간 상태이므로 더 신뢰할 수 있음
### 4.4 다음 단계
1. `MetadataLoaderService.cs` 수정: state descriptor 제거
2. `ExperionDbContext.cs` 수정: v_tag_summary 뷰 단순화
3. 프론트엔드 pv 값 파싱 로직 추가
4. 메타데이터 로드 테스트 실행
5. P-XXXXX, XV-XXXXX.pv 등의 상태변화를 감지하여 EVENT TABLE에 기록하여 LLM의 플랜트 상태보고에 활용하게 하고, NL2SQL에서도 언제부터 언제까지의 이벤트 정보를 표시 할 수있게 하자(UI 'Event 조회' 별도 페이지 만들어서 )
EVENT TABLE은 컬럼 TimeStamp, Tagname, Value, StateText 를 p-xxxxx, xv-xxxxx등의 pv 값{ 값 | 상태 텍스트 | }에서 가져오고 값의 변경이 일어났을때만 , event history 테이블에 기록.
event 테이블을 아날로그값에도 적용할지는 나중에 생각하고 일단 Brain Storming 기록만
- ficq-6113 : pid controller 류 들의 Manual : pv 기준, Auto : sp 기준으로 값이 변경되면 event 히스토리에 변경값 기록 이전값->변경값 두개다 기록
- Auto <-> Manual 전환도 기록 : ficq-6113.md 기준 -> 실시간 테이블 스키마 변경
- user 전환 (근무교대 시프트 기록) ---> 이건 아직 파악 못했슴 (node_map_master 에 정보 없슴)

View File

@@ -1,610 +0,0 @@
# Enum Metadata 최적화 - 코딩 계획
> 작성일: 2026-05-08
> 상태: 진행 중
> 기반 문서: [`plans/enum-metadata-optimization.md`](plans/enum-metadata-optimization.md)
> 목적: `state0~7descriptor` 제거, `desc`/`area`만 유지, pv 값 파싱 로직 추가
---
## 작업 Todo 리스트
각 단계는 독립적으로 완료 여부를 추적할 수 있다. 체크박스를 사용하여 진행 상황을 기록한다.
- [ ] STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업
- [ ] STEP 2 — `MetadataLoaderService.cs`: `MetaAttributes` 배열에서 state0~7descriptor 제거
- [ ] STEP 2.5 — `MetadataLoaderService.cs`: 클래스 주석 업데이트
- [ ] STEP 3 — `MetadataLoaderService.cs` + `ExperionDbContext.cs`: 빌드 검증
- [ ] STEP 4 — `ExperionDbContext.cs`: `v_tag_summary` 뷰에서 state descriptor JOIN 제거
- [ ] STEP 4.5 — `tag_metadata` 고아 데이터 삭제 (선택적)
- [ ] STEP 5 — `ExperionDbContext.cs` 변경 후 빌드 검증
- [ ] STEP 6 — `app.js`: pv 값 파싱 헬퍼 함수 `parseEnumPv()` 추가
- [ ] STEP 6.5 — NL2SQL DB_SCHEMA 동기화 (`server.py` + `nl2sql_worker.py`)
- [ ] STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
- [ ] STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트
- [ ] STEP 9 — git 커밋 및 정리
---
## 변경 대상 파일 목록
| # | 파일 | 변경 내용 | 영향 범위 |
|---|------|-----------|-----------|
| 1 | `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | `MetaAttributes` 배열 수정 + 주석 업데이트 | 메타데이터 로딩 |
| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰 단순화 + 고아 데이터 삭제 | DB 뷰 |
| 3 | `src/Web/wwwroot/js/app.js` | pv 파싱 헬퍼 + 표시 로직 변경 | 프론트엔드 UI |
| 4 | `mcp-server/server.py` | DB_SCHEMA에서 state0~2_descriptor 제거 | NL2SQL |
| 5 | `mcp-server/worker/nl2sql_worker.py` | DB_SCHEMA에서 state0~2_descriptor 제거 | NL2SQL |
---
## 각 단계 상세 계획
---
### STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업
**목적**: 수정 전 원본 보존. 실패 시 복원 가능.
**실행 명령**:
```bash
TIMESTAMP=$(date +%Y%m%d%H%M)
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/OpcUa
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Web/wwwroot/js
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/mcp-server/worker
cp src/Infrastructure/OpcUa/MetadataLoaderService.cs .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/OpcUa/
cp src/Infrastructure/Database/ExperionDbContext.cs .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database/
cp src/Web/wwwroot/js/app.js .rooBackup/enum-opt-$TIMESTAMP/src/Web/wwwroot/js/
cp mcp-server/server.py .rooBackup/enum-opt-$TIMESTAMP/mcp-server/
cp mcp-server/worker/nl2sql_worker.py .rooBackup/enum-opt-$TIMESTAMP/mcp-server/worker/
```
**검증 기준**:
- [ ] `.rooBackup/enum-opt-YYYYMMDDHHMM/` 폴더가 생성되고 5개 파일이 복사됨
- [ ] 원본 파일과 백업 파일의 체크섬이 일치함 (`md5sum` 비교)
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.3: 수정이 필요한 코드 — `MetadataLoaderService.cs`, `ExperionDbContext.cs`, `app.js`
---
### STEP 2 — `MetadataLoaderService.cs`: `MetaAttributes` 배열에서 state0~7descriptor 제거
**파일**: [`MetadataLoaderService.cs`](src/Infrastructure/OpcUa/MetadataLoaderService.cs:20)
**변경 위치**: 20~26줄 (`MetaAttributes` 배열 정의)
**변경 전 코드**:
```csharp
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
```
**변경 후 코드**:
```csharp
// 로드할 메타데이터 속성 목록 (state0~7descriptor 제거 — pv 값에서 파싱)
private static readonly string[] MetaAttributes =
{
"desc", "area"
};
```
**diff**:
```diff
-// 로드할 메타데이터 속성 목록
+// 로드할 메타데이터 속성 목록 (state0~7descriptor 제거 — pv 값에서 파싱)
private static readonly string[] MetaAttributes =
{
- "desc", "area",
- "state0descriptor", "state1descriptor", "state2descriptor",
- "state3descriptor", "state4descriptor", "state5descriptor",
- "state6descriptor", "state7descriptor"
+ "desc", "area"
};
```
**변경 이유**:
- pv 값 `{코드 | DisplayName | }`에 이미 state descriptor 정보가 포함되어 있으므로 중복 저장 제거
- OPC UA 읽기 요청 80% 감소 (10개 속성 → 2개 속성)
- tag_metadata 행 80% 감소 (태그당 10행 → 2행)
**영향 분석**:
- `LoadMetadataAsync()` 메서드: `MetaAttributes.Contains(n.Name)`으로 `node_map_master`에서 필터링하므로 state descriptor 노드는 더 이상 조회되지 않음
- `ReadTagsAsync()`: 8개 menos의 nodeId로 배치 읽기 → 네트워크 트래픽 감소
- UPSERT 쿼리: 8개 menos의 행 삽입 → DB 부하 감소
- 클래스 주석 (11줄): `state0~7descriptor` 언급도 함께 제거 필요
**검증 기준**:
- [ ] `MetaAttributes` 배열이 `["desc", "area"]` 두 개만 포함
- [ ] 컴파일 오류 없음
- [ ] `LoadMetadataAsync()` 메서드의 로직 변경 없이 배열 변경만으로 동작
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.2: desc, area만 저장
- 섹션 3.3: `MetadataLoaderService.cs` 수정 — `MetaAttributes` 배열에서 state0~7descriptor 제거
- 섹션 4.2: tag_metadata 저장 속성 — desc, area 만
---
### STEP 2.5 — `MetadataLoaderService.cs`: 클래스 주석 업데이트
**파일**: [`MetadataLoaderService.cs`](src/Infrastructure/OpcUa/MetadataLoaderService.cs:11)
**변경 위치**: 11줄 (클래스 XML 주석)
**변경 전 코드** (11줄):
```csharp
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
```
**변경 후 코드**:
```csharp
/// 메타데이터(desc, area)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
```
**diff**:
```diff
-/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
+/// 메타데이터(desc, area)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
```
**변경 이유**:
- STEP 2에서 `state0~7descriptor`를 제거했는데 주석에는 여전히 남아 있음
- 주석과 코드 불일치로 인한 혼란 방지
**검증 기준**:
- [ ] 주석이 `desc, area`만 언급함
- [ ] 컴파일 오류 없음 (주석 변경이므로 영향 없음)
---
### STEP 3 — `MetadataLoaderService.cs` 변경 후 빌드 검증
**목적**: STEP 2 변경이 컴파일 오류 없이 통과하는지 확인
**실행 명령**:
```bash
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
```
**검증 기준**:
- [ ] 빌드 성공 (exit code 0)
- [ ] 경고 없음 (또는 기존 경고만)
- [ ] `MetaAttributes` 배열 참조하는 다른 코드가 없는지 확인
**실패 시 대응**:
- 컴파일 오류가 발생하면 오류 메시지를 읽고 원인 분석
- `MetaAttributes` 길이에 의존하는 코드가 있으면 해당 코드도 수정
---
### STEP 4 — `ExperionDbContext.cs`: `v_tag_summary` 뷰에서 state descriptor JOIN 제거
**파일**: [`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:302)
**변경 위치**: 302~330줄 (`v_tag_summary` 뷰 생성 SQL)
**변경 전 코드** (315~329줄):
```sql
desc_md.value AS description,
area_md.value AS area,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
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 s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
```
**변경 후 코드**:
```sql
desc_md.value AS description,
area_md.value AS 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'
```
**diff**:
```diff
desc_md.value AS description,
area_md.value AS area,
- s0d_md.value AS state0_descriptor,
- s1d_md.value AS state1_descriptor,
- s2d_md.value AS state2_descriptor
+ area_md.value AS 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 s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
- LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
- LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
""");
```
**변경 이유**:
- state0~2descriptor가 더 이상 tag_metadata에 저장되지 않으므로 JOIN 제거
- 뷰 조회 성능 향상 (3개 LEFT JOIN 제거)
**영향 분석**:
- `v_tag_summary` 뷰를 조회하는 코드가 `state0_descriptor`, `state1_descriptor`, `state2_descriptor` 컬럼을 참조하면 NULL 반환
- 이 뷰를 사용하는 곳이 있는지 검색 필요 (현재는 DB 초기화 시에만 사용)
**검증 기준**:
- [ ] `v_tag_summary` 뷰 SQL에서 state descriptor 관련 JOIN 3개 제거됨
- [ ] `area_md.value AS area` 뒤 쉼표 제거 (마지막 SELECT 컬럼)
- [ ] SQL 문법 오류 없음
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.3: `ExperionDbContext.cs` 수정 — v_tag_summary 뷰에서 state descriptor JOIN 제거
---
### STEP 4.5 — `tag_metadata` 고아 데이터 삭제 (선택적)
**파일**: [`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:185)
**변경 위치**: `InitializeAsync()` 메서드 내 `v_tag_summary` 뷰 생성 직후 (330줄 이후)
**추가할 코드**:
```csharp
// state descriptor 고아 데이터 정리 (state0~7descriptor는 더 이상 로딩하지 않음)
await _ctx.Database.ExecuteSqlRawAsync("""
DELETE FROM tag_metadata WHERE attribute IN (
'state0descriptor', 'state1descriptor', 'state2descriptor',
'state3descriptor', 'state4descriptor', 'state5descriptor',
'state6descriptor', 'state7descriptor'
)
""");
```
**삽입 위치 상세**:
- 330줄 (`""");` — v_tag_summary 뷰 생성 종료) 바로 다음에 삽입
- `CREATE EXTENSION IF NOT EXISTS timescaledb` 이전
**변경 이유**:
- `MetaAttributes`에서 state descriptor가 제거되면 더 이상 갱신되지 않으나, 기존 데이터는 영구히 남음
- 테이블 크기와 불필요한 JOIN 결과 방지
**검증 기준**:
- [ ] 실행 시 기존 state descriptor 행이 삭제됨
- [ ] `SELECT COUNT(*) FROM tag_metadata WHERE attribute LIKE 'state%descriptor'` → 0 반환
- [ ] desc/area 행은 영향 없음
**참고**: 기존 데이터를 보존해야 한다면 이 스텝을 스킵 가능. 하지만 `v_tag_summary` 뷰에서 해당 컬럼이 제거되었으므로 조회 자체가 불가능해짐.
---
### STEP 5 — `ExperionDbContext.cs` 변경 후 빌드 검증
**목적**: STEP 4 변경이 컴파일 오류 없이 통과하는지 확인
**실행 명령**:
```bash
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
```
**검증 기준**:
- [ ] 빌드 성공 (exit code 0)
- [ ] SQL 문자열 문법 오류 없음 (raw string literal 안에서 SQL 구문 확인)
- [ ] 쉼표 누락/과잉 없음
**실패 시 대응**:
- SQL 구문 오류가 발생하면 SELECT 컬럼 목록의 쉼표 확인
- `area_md.value AS area`가 마지막 컬럼이므로 쉼표 제거했는지 확인
---
### STEP 6 — `app.js`: pv 값 파싱 헬퍼 함수 `parseEnumPv()` 추가
**파일**: [`app.js`](src/Web/wwwroot/js/app.js:2126)
**삽입 위치**: `fmtVal()` 함수 바로 아래 (2132줄 이후)
**추가할 코드**:
```javascript
/**
* OPC UA EnumValueType pv 값 파싱
* "{0 | L-STpv | }" → "L-STpv"
* "{0 | MID | }" → "MID"
* 일반 값은 그대로 반환
*/
function parseEnumPv(v) {
if (v == null) return v;
const s = String(v);
// "{코드 | DisplayName | }" 패턴 매칭
const m = s.match(/^\{\s*\d+\s*\|\s*([^|]+?)\s*\|\s*\}$/);
return m ? m[1].trim() : s;
}
```
**삽입 위치 상세**:
- 2132줄 (`}``fmtVal` 함수 종료) 바로 다음에 삽입
- 기존 `fmtVal` 함수는 변경하지 않음
**기능 설명**:
- 입력: pv 값 문자열 (예: `"{0 | L-STpv | }"`)
- 출력: DisplayName 부분만 (예: `"L-STpv"`)
- EnumValueType 형식이 아닌 일반 값은 그대로 반환
**정규식 설명**:
- `^\{``{`로 시작
- `\s*\d+\s*` — 정수 코드 (공백 허용)
- `\|\s*``|` 구분자
- `([^|]+?)` — DisplayName (첫 번째 캡처 그룹)
- `\s*\|\s*\}$``| }`로 끝남
**검증 기준**:
- [ ] `parseEnumPv("{0 | L-STpv | }")``"L-STpv"` 반환
- [ ] `parseEnumPv("{0 | MID | }")``"MID"` 반환
- [ ] `parseEnumPv("123.45")``"123.45"` 반환 (변경 없음)
- [ ] `parseEnumPv(null)``null` 반환
- [ ] 브라우저 콘솔에서 수동 테스트 가능
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.2: pv 값 파싱 방법 — `{코드 | 이름 | }`에서 DisplayName 추출
- 섹션 3.3: `app.js` — pv 값 표시 시 파싱 로직 추가
---
### STEP 6.5 — NL2SQL DB_SCHEMA 동기화 (`server.py` + `nl2sql_worker.py`)
**파일**: [`mcp-server/server.py`](mcp-server/server.py:447) / [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:78)
**변경 위치**: 두 파일의 `DB_SCHEMA` 문자열 내 `tag_metadata` / `v_tag_summary` 설명 부분
**변경 전 코드** (`server.py:447` + `462-464` + `470` + `474-475`):
```
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
...
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
...
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
...
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
```
**변경 후 코드**:
```
attribute TEXT - 속성명 ('desc', 'area')
...
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
...
- 메타데이터: desc (String), area (Enum)
...
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
```
**diff** (`server.py` 기준, `nl2sql_worker.py`는 동일):
```diff
- attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
+ attribute TEXT - 속성명 ('desc', 'area')
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
...
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
- state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
- state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
- state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double)
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
+ - 메타데이터: desc (String), area (Enum)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- - state0descriptor~7은 해당 비트의 의미 설명
- - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
+ - pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
```
**변경 이유**:
- `v_tag_summary` 뷰에서 `state0~2_descriptor` 컬럼이 제거되면 LLM이 해당 컬럼을 SELECT하는 SQL을 생성하면 실패함
- DB_SCHEMA는 LLM의 시스템 프롬프트로 사용되므로 실제 DB 스키마와 반드시 일치해야 함
**검증 기준**:
- [ ] `server.py` DB_SCHEMA에서 `state0_descriptor` / `state1_descriptor` / `state2_descriptor` 언급 없음
- [ ] `nl2sql_worker.py` DB_SCHEMA에서 동일하게 제거됨
- [ ] `attribute` 설명이 `'desc', 'area'`만 포함
- [ ] MCP 서버 재시작 후 NL2SQL 쿼리가 정상 동작 (state descriptor 없이)
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.1: pv 값이 EnumValueType 형식인 경우 DisplayName 파싱으로 상태 확인
---
### STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
**파일**: [`app.js`](src/Web/wwwroot/js/app.js:633)
**변경 위치**: `pbRender()` 함수 내 liveValue 표시 부분 (633줄)
**변경 전 코드** (633줄):
```javascript
<td class="val">${p?.liveValue != null ? esc(String(fmtVal(p.liveValue))) : '<span style="color:var(--t3)">—</span>'}</td>
```
**변경 후 코드**:
```javascript
<td class="val">${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : '<span style="color:var(--t3)">—</span>'}</td>
```
**diff**:
```diff
-<td class="val">${p?.liveValue != null ? esc(String(fmtVal(p.liveValue))) : '<span style="color:var(--t3)">—</span>'}</td>
+<td class="val">${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : '<span style="color:var(--t3)">—</span>'}</td>
```
**변경 이유**:
- `liveValue`가 EnumValueType 형식(`{0 | L-STpv | }`)이면 DisplayName만 표시
- 일반 숫자 값은 `parseEnumPv()`가 그대로 반환하므로 영향 없음
- `fmtVal()`은 숫자 포맷팅만 담당, `parseEnumPv()`는 EnumValueType 파싱만 담당 (단일 책임)
**다른 적용 위치 확인**:
- `fmtVal()`을 사용하는 모든 곳을 검색하여 pv 값 표시에 사용되는 곳에 `parseEnumPv()` 적용
- `t2sRenderTable()` (1606줄): `fmtVal(val)` — 시계열 데이터이므로 변경 불필요 (아날로그 값 중심)
- `histQuery()` 결과 렌더링 (956줄): `fmtVal(raw)` — 이력 데이터이므로 변경 불필요
**검증 기준**:
- [ ] 포인트빌더 테이블에서 xv-3202.pv 값이 `"{0 | L-MID | }"` 대신 `"L-MID"`로 표시됨
- [ ] 일반 숫자 값(예: ficq-6113.pv = "123.45")은 여전히 `"123.45"`로 표시됨
- [ ] 브라우저 콘솔에서 JS 오류 없음
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.2: 프론트엔드에서 pv 값 표시 시 `{``|`를 파싱하여 상태 이름만 표시
---
### STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트
**목적**: 모든 변경 사항이 함께 동작하는지 최종 확인
**실행 명령**:
```bash
# 1. 전체 솔루션 빌드
dotnet build ExperionCrawler.sln -v q
# 2. 애플리케이션 시작
dotnet run --project src/Web/ExperionCrawler.csproj
```
**검증 체크리스트**:
#### 빌드 검증
- [ ] `dotnet build` 성공 (exit code 0)
- [ ] 컴파일 오류 0개
- [ ] 경고 수 증가 없음
#### 백엔드 검증
- [ ] 애플리케이션 시작 시 DB 초기화 성공
- [ ] `v_tag_summary` 뷰 생성 성공 (state descriptor JOIN 없음)
- [ ] `tag_metadata` 고아 데이터 삭제 성공 (STEP 4.5)
- [ ] 메타데이터 로드 시 `desc`, `area`만 조회됨 (로그 확인)
- [ ] `tag_metadata` 테이블에 state0~7descriptor 행 없음
#### NL2SQL 검증
- [ ] MCP 서버 재시작 성공
- [ ] "xv-6124 상태 알려줘" 쿼리가 state descriptor 없이 정상 동작
- [ ] 생성된 SQL에서 `state0_descriptor` 컬럼 없음
#### 프론트엔드 검증
- [ ] 브라우저 콘솔 JS 오류 없음
- [ ] 포인트빌더 테이블에서 digital 태그 pv 값이 DisplayName만 표시됨
- 예: `xv-3202.pv``"L-MID"` (기존: `"{0 | L-MID | }"`)
- [ ] 아날로그 태그 pv 값은 정상 표시됨
- 예: `ficq-6113.pv``"123.45"`
- [ ] 메타데이터 조회 시 `desc`, `area`만 반환됨
#### 성능 검증
- [ ] 메타데이터 로드 시간 감소 확인 (기존 대비)
- [ ] `tag_metadata` 테이블 행 수 감소 확인 (기존 10행/태그 → 2행/태그)
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 4.3: 기대 효과 — OPC UA 읽기 요청 80% 감소, tag_metadata 행 80% 감소
- 섹션 4.4: 다음 단계 — 메타데이터 로드 테스트 실행
---
### STEP 9 — git 커밋 및 정리
**목적**: 변경 사항을 커밋하고 작업 기록 남기기
**커밋 전략**: 각 파일 변경을 별도 커밋으로 관리
```bash
# 1. MetadataLoaderService.cs 커밋
git add src/Infrastructure/OpcUa/MetadataLoaderService.cs
git commit -m "feat: MetaAttributes에서 state0~7descriptor 제거, 주석 동시 업데이트"
# 2. ExperionDbContext.cs 커밋
git add src/Infrastructure/Database/ExperionDbContext.cs
git commit -m "feat: v_tag_summary 뷰에서 state descriptor JOIN 제거, 고아 데이터 DELETE 추가"
# 3. app.js 커밋
git add src/Web/wwwroot/js/app.js
git commit -m "feat: pv 값 파싱 헬퍼 parseEnumPv() 추가, 포인트빌더 테이블 적용"
# 4. NL2SQL DB_SCHEMA 커밋
git add mcp-server/server.py mcp-server/worker/nl2sql_worker.py
git commit -m "feat: NL2SQL DB_SCHEMA에서 state0~2_descriptor 제거 (v_tag_summary 변경 반영)"
# 5. 계획 문서 커밋
git add plans/enum-metadata-optimize-coding-plan.md
git commit -m "docs: enum metadata 최적화 코딩 계획 작성"
```
**검증 기준**:
- [ ] 각 커밋 메시지가 변경 내용을 명확히 설명함
- [ ] `git log`에서 커밋 순서가 논리적임
- [ ] `.rooBackup/` 폴더에 gitignore 적용되어 있음 (백업 파일 커밋 제외)
---
## 부록: rollback 절차
작업 중 문제가 발생하면 백업 파일로 복원:
```bash
TIMESTAMP=$(ls -d .rooBackup/enum-opt-* | tail -1 | xargs basename)
cp .rooBackup/$TIMESTAMP/src/Infrastructure/OpcUa/MetadataLoaderService.cs src/Infrastructure/OpcUa/
cp .rooBackup/$TIMESTAMP/src/Infrastructure/Database/ExperionDbContext.cs src/Infrastructure/Database/
cp .rooBackup/$TIMESTAMP/src/Web/wwwroot/js/app.js src/Web/wwwroot/js/
cp .rooBackup/$TIMESTAMP/mcp-server/server.py mcp-server/
cp .rooBackup/$TIMESTAMP/mcp-server/worker/nl2sql_worker.py mcp-server/worker/
```
---
## 검증 요약
각 STEP의 검증 기준을 한눈에 확인:
| STEP | 파일 | 핵심 검증 항목 |
|------|------|----------------|
| 1 | — | 백업 파일 5개 생성됨 |
| 2 | MetadataLoaderService.cs | `MetaAttributes` = `["desc", "area"]` |
| 2.5 | MetadataLoaderService.cs | 클래스 주석 업데이트 |
| 3 | — | 빌드 성공 |
| 4 | ExperionDbContext.cs | state descriptor JOIN 3개 제거됨 |
| 4.5 | ExperionDbContext.cs | 고아 데이터 DELETE 쿼리 추가 |
| 5 | — | 빌드 성공 |
| 6 | app.js | `parseEnumPv()` 함수 추가됨 |
| 6.5 | server.py + nl2sql_worker.py | DB_SCHEMA에서 state descriptor 제거 |
| 7 | app.js | `pbRender()`에서 `parseEnumPv()` 적용됨 |
| 8 | 전체 | End-to-End + NL2SQL 테스트 통과 |
| 9 | — | git 커밋 완료 |

View File

@@ -1,169 +0,0 @@
# ExperionCrawler 코드 분석 및 수정 태스크
> Roo Code(GLM-4.7-Flash 모드)에 이 파일 내용을 그대로 붙여넣어 실행
---
## 지시사항
당신은 ExperionCrawler (.NET 8 C#, PostgreSQL/TimescaleDB, OPC UA) 프로젝트의
코드 품질 담당 엔지니어입니다.
아래 Phase 순서대로 작업하고, 각 단계 완료 시 `task_state.md`에 기록하세요.
---
## Phase 1 — 분석: issues.md 생성
### 1-1. 분석 대상 파일 (우선순위 순)
**[HIGH 우선순위]**
- `src/Infrastructure/Database/ExperionDbContext.cs`
- `src/Infrastructure/OpcUa/ExperionRealtimeService.cs`
- `src/Core/Application/Services/TextToSqlService.cs`
- `src/Infrastructure/OpcUa/ExperionOpcServerService.cs`
- `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs`
**[MED 우선순위]**
- `src/Web/Controllers/ExperionControllers.cs`
- `src/Web/Controllers/TextToSqlController.cs`
- `src/Core/Application/Services/SqlValidator.cs`
- `src/Core/Application/Services/KoreanTimeRangeExtractor.cs`
- `src/Infrastructure/OpcUa/ExperionOpcClient.cs`
**[LOW 우선순위]**
- `src/Core/Application/Interfaces/IExperionServices.cs`
- `src/Core/Application/DTOs/ExperionDtos.cs`
- `src/Core/Application/DTOs/TextToSqlDtos.cs`
- `src/Web/Program.cs`
- `src/Infrastructure/OpcUa/ExperionHistoryService.cs`
### 1-2. 각 파일에서 확인할 항목
```
□ null 참조 예외 가능성 (NullReferenceException)
□ async/await 오용 (deadlock, fire-and-forget 미처리)
□ IDisposable 미해제 (DbContext, HttpClient, Connection 등)
□ 예외 삼킴 (catch(Exception){} 빈 블록)
□ CancellationToken 미전파
□ SQL Injection 가능성 (raw string interpolation)
□ 경쟁 조건 (Race condition) — 특히 ConcurrentDictionary, lock 누락
□ 불필요한 await (Task.Result, .Wait() 블로킹)
□ 메모리 누수 (이벤트 핸들러 미구독 해제)
□ 하드코딩된 값 (IP, 포트, 문자열 상수)
□ 도메인 로직 오류 (KST/UTC 변환, OPC UA 상태 코드 처리)
```
### 1-3. MCP 도구 활용
각 파일 분석 시 다음을 활용하세요:
```
search_codebase("파일명 또는 핵심 패턴") → 관련 구현 컨텍스트 확인
ask_iiot_llm("OPC UA 관련 판단이 필요한 경우") → 도메인 전문 판단
```
### 1-4. 결과물
`issues.md` 파일을 프로젝트 루트에 생성하세요:
```markdown
# ExperionCrawler 코드 이슈 목록
> 생성일: YYYY-MM-DD | 분석 모델: GLM-4.7-Flash
## 요약
- HIGH: N건 / MED: N건 / LOW: N건
## 이슈 목록
| # | 파일 | 라인 | 심각도 | 분류 | 문제 설명 | 수정 방향 | 상태 |
|---|------|------|--------|------|-----------|-----------|------|
| 1 | src/.../파일.cs | 42 | HIGH | bug | 설명 | 수정 방향 | pending |
...
```
---
## Phase 2 — 수정: HIGH → MED → LOW 순서
### 2-1. 수정 규칙
1. **한 번에 이슈 1개씩** 수정
2. 수정 전: `read_file`로 현재 내용 확인
3. 수정 후: `dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q` 빌드 확인
4. 빌드 성공 시: `issues.md`에서 해당 이슈 상태를 `fixed`로 변경
5. 빌드 실패 시: 즉시 원인 분석 후 수정, 다음 이슈로 넘어가지 않음
### 2-2. 수정 불가 판단 기준
아래 경우 수정하지 말고 `issues.md``needs-review`로 표시하세요:
- 아키텍처 변경이 필요한 경우
- 비즈니스 로직 판단이 불명확한 경우
- 테스트 없이 검증 불가한 경우
### 2-3. 각 이슈 수정 후 커밋
```bash
git add [수정된 파일]
git commit -m "fix(#N): [이슈 요약]"
```
---
## Phase 3 — 검수 요청서 작성
모든 수정 완료 후 `REVIEW_REQUEST.md`를 생성하세요:
```markdown
# 클로드 코드 검수 요청
## 작업 요약
- 분석 파일: N개
- 발견 이슈: HIGH N / MED N / LOW N
- 수정 완료: N건
- 검수 필요(needs-review): N건
## 검수 항목
### ✅ 수정 완료 (확인 요청)
| # | 파일:라인 | 수정 내용 | 우려사항 |
|---|-----------|-----------|---------|
...
### ⚠️ 수정 보류 (판단 요청)
| # | 파일:라인 | 문제 | 보류 이유 |
|---|-----------|------|-----------|
...
## 빌드 상태
- 최종 빌드: ✅ 성공 / ❌ 실패
- 경고: N건
## 검수 방법
\`\`\`bash
git log --oneline # 수정 커밋 목록
git diff HEAD~N # 전체 변경사항
\`\`\`
```
---
## Phase 4 — task_state.md 최신화
작업 중 및 완료 시 `task_state.md`를 아래 형식으로 유지하세요:
```markdown
## 작업명: ExperionCrawler 코드 분석 및 수정
## 시작시각: YYYY-MM-DD HH:MM
## 진행 상태: Phase N / 4
### Phase 1 완료 파일
- [x] ExperionDbContext.cs → 이슈 N건 발견
- [x] ExperionRealtimeService.cs → 이슈 N건 발견
- [ ] TextToSqlService.cs
### Phase 2 수정 현황
- [x] #1 (HIGH) ExperionDbContext.cs:42 → fixed
- [ ] #2 (HIGH) ExperionRealtimeService.cs:156 → in-progress
### 발견된 이슈 누적
| # | 파일 | 심각도 | 내용 |
|---|------|--------|------|
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,110 +0,0 @@
# loop1.cde 파일 분석 보고서
## 1. 파일 기본 정보
| 항목 | 값 |
|------|-----|
| 파일 경로 | `plans/hc900/loop1.cde` |
| 파일 크기 | 593,557 바이트 (~580KB) |
| 파일 형식 | 바이너리 (Honeywell proprietary) |
| 추출 가능한 문자열 | 198 줄 |
## 2. 파일 형식 식별
`.cde` 파일은 **Honeywell Experion HS / Foxboro I/A Series**의 **Control Definition (제어 정의)** 파일입니다.
HC900 컨트롤러의 제어 루프 구성 정보를 포함하는 바이너리 포맷입니다.
### 헤더 구조 (초기 바이트 분석)
```
Offset 0x0000: 0202 2000 4800 0b00 ... (파일 마직 / 버전)
Offset 0x0018: 0001 006c (식별자: 0x6c01)
Offset 0x001C: 0200 532b (Unicode "S+")
Offset 0x0088: 69f7 21e4 69f7 2282 (타임스탬프 또는 체크섬)
Offset 0x0098: 0100 484a 0032 0063 (Unicode "HJ" + "2c")
```
## 3. 추출된 핵심 내용
### 3.1 제어 루프 정보
| 태그명 | 설명 |
|--------|------|
| `PID102` | PID 컨트롤러 블록 이름 |
| `PID102_WSP` | PID 컨트롤러 설정값 (Work Setpoint) |
### 3.2 컨트롤러 구성 요소
추출된 문자열에서 확인된 구성 영역:
| 영역 | 원문 | 설명 |
|------|------|------|
| CONFIG | CONFIG | 컨트롤러 일반 설정 |
| PROFIL | PROFIL | 프로파일 (운전 모드별 설정) |
| RECIPES | RECIPES | 레시피 관리 |
| SCHEDL | SCHEDL | 스케줄러 |
| SEQUEN | SEQUEN | 시퀀서 |
| STORAG | STORAG | 데이터 저장소 |
| FILE | FILE (x19) | 파일 관리 블록 |
### 3.3 기타 설정
| 항목 | 값 |
|------|-----|
| 컨트롤러 | CONTROLLER |
| 프로세스 구성 | Process Configuration#1 |
| 통신 | Custom Modbus Map |
| 파티션 | Undefined Partition |
| 신호 단위 | mA (4-20mA 아날로그 신호) |
## 4. 파일 구조 추정
```
loop1.cde (593,557 bytes)
├── Header (0x000 - 0x0FF) : 파일 마직, 버전, 메타데이터
├── Block Definitions (0x100 - 0x31F) : 블록 정의 영역
├── Tag Index (0x320 - 0x4FF) : 태그 인덱스 (e.(, e.) 등 순차적 인덱스)
├── Configuration Data : CONFIG, PROFIL, RECIPES 등 구성 데이터
├── Controller Properties : CONTROLLER, Process Configuration#1
├── Communication Settings : Custom Modbus Map
└── Padding/Reserved : 나머지 영역
```
## 5. 분석 가능성 평가
### 가능한 분석
- ✅ 파일 형식 식별 (Honeywell Control Definition 파일)
- ✅ 주요 태그명 추출 (PID102, PID102_WSP)
- ✅ 구성 영역 식별 (CONFIG, PROFIL, RECIPES, SCHEDL, SEQUEN, STORAG)
- ✅ 컨트롤러 정보 확인 (Process Configuration#1, Custom Modbus Map)
### 제한사항
-**완전한 구조 파싱 불가**: Honeywell proprietary 바이너리 포맷으로 공식 스펙이 공개되지 않음
-**PID 파라미터 추출 불가**: Kp, Ki, Kd 등 제어 게인 값은 바이너리 인코딩되어 있음
-**블록 연결 관계 파악 불가**: 신호 흐름/와이어링 정보는 구조화되어 있음
-**모든 태그 목록 추출 불가**: 198줄의 문자열만 추출 가능 (전체 데이터의 일부)
## 6. 완전 파싱을 위한 방안
### 옵션 1: Honeywell 공식 도구 사용
- **System Management Application (SMA)**: Honeywell Experion HS 표준 관리 도구
- **Experion PKS Builder**: 제어 루프 구성을 시각적으로 확인 가능
- **CDE Export 기능**: SMA에서 XML/CSV로 내보내기 가능
### 옵션 2: 바이너리 파서 개발
- 파일 구조를 reverse engineering 하여 파서 작성 필요
- 동일한 HC900 시스템에서 추출한 여러 CDE 파일을 비교 분석
- Honeywell 문서 (Experion HS R530 HTM) 에서 힌트 수집
### 옵션 3: OPC UA를 통한 실시간 접근
- 이미 구축된 ExperionCrawler를 통해 OPC UA로 실시간 태그 값 접근
- CDE 파일 대신 OPC UA Address Space에서 메타데이터 조회
- 더 신뢰성 있는 데이터 소스
## 7. 결론
`loop1.cde` 파일은 **Honeywell HC900 컨트롤러의 제어 루프 정의 파일**로, PID102 컨트롤러 블록과 관련 설정을 포함합니다.
바이너리 포맷의 특성상 `strings` 명령어로 일부 텍스트만 추출 가능하고, 완전한 파싱은 Honeywell 공식 도구가 필요합니다.
ExperionCrawler 프로젝트에서는 이 파일 대신 **OPC UA를 통한 실시간 데이터 접근**이 더 실용적인 접근 방식입니다.

Binary file not shown.

View File

@@ -1,78 +0,0 @@
# HC900 Modbus TCP 스캔 시간 분석
## 1. 측정 환경
| 항목 | 값 |
|------|-----|
| 대상 | HC900 C3 컨트롤러 (192.168.0.240) |
| 네트워크 | 100Mbps LAN |
| Ping RTT | 최소 0ms, 최대 1ms, 평균 0ms |
| 바이트 순서 | Big Endian (HC900 기본) |
| 통신 프로토콜 | Modbus TCP (FC03 Read Holding Registers) |
## 2. 데이터 양
| 항목 | 값 |
|------|-----|
| 총 태그 항목 | 2,554개 |
| float 32 (4바이트) | 2,320개 → 4,640 레지스터 |
| unsigned 16 (2바이트) | 234개 → 234 레지스터 |
| **총 레지스터** | **4,874개** |
| 총 바이트 | 9,748 bytes |
| 주소 범위 | 0x0001 ~ 0x7FFF (32,767 스팬) |
| 주소 갭 | 273개 (불연속) |
## 3. Modbus TCP 읽기 요청 수
- FC03 최대 125 레지스터/요청
- 불연속 주소로 인해 **최소 290회** 읽기 요청 필요
### Partition별 읽기 횟수
| Partition | 항목 수 | 주소 스팬 | 읽기 횟수 |
|-----------|---------|----------|----------|
| Loops 1-24 | 960 | 6,079 | 192회 |
| Loops 25-32 | 329 | 1,983 | 65회 |
| Signal Tags 1-1000 | 549 | 1,125 | 11회 |
| Signal Tags 1-4000 | 549 | 1,125 | 11회 |
| Variables 1-600 | 157 | 337 | 9회 |
| Time | 7 | 7 | 1회 |
| Misc. Parameters | 3 | 5 | 1회 |
## 4. 스캔 시간 계산
### 단일 스레드 순차 읽기
| 조건 | RTT | 전송/요청 | 총 시간 | 1초당 스캔 |
|------|-----|----------|---------|-----------|
| **최적** (Ping 그대로) | 0.2ms | 0.028ms | **0.066초** | 15.1회/s |
| **보수** (Ping+처리) | 0.5ms | 0.028ms | **0.153초** | 6.5회/s |
| **안전** (Ping 최대) | 1.0ms | 0.028ms | **0.298초** | 3.4회/s |
### 7개 Partition 병렬 읽기
| RTT | 총 시간 | 비고 |
|-----|---------|------|
| 0.2ms | 43.7ms | Loops 1-24 (192회)가 병목 |
| 0.5ms | 101.3ms | |
| 1.0ms | 197.3ms | |
## 5. 실시간 모니터링 가능 여부
| 샘플링 주기 | RTT=0.2ms | RTT=0.5ms | RTT=1.0ms |
|-------------|-----------|-----------|-----------|
| 10초 | ✓ 여유 99% | ✓ 여유 98% | ✓ 여유 97% |
| 5초 | ✓ 여유 99% | ✓ 여유 97% | ✓ 여유 94% |
| 2초 | ✓ 여유 97% | ✓ 여유 92% | ✓ 여유 85% |
| 1초 | ✓ 여유 93% | ✓ 여유 85% | ✓ 여유 70% |
| 0.5초 | ✓ 여유 87% | ✓ 여유 69% | ✓ 여유 40% |
| 100ms | ✓ 여유 34% | ✗ 불가 | ✗ 불가 |
## 6. 결론
- **290회 Modbus TCP 읽기**로 HC900 C3 컨트롤러의 전체 데이터 수집 가능
- 최적 조건에서 **0.066초**, 안전 조건에서 **0.298초**
- **1초 단위 실시간 모니터링 충분히 가능** (여유 70~93%)
- 100ms 단위는 최적 조건(RTT 0.2ms)에서만 가능
- Big Endian는 HC900 기본이므로 바이트 변환 불필요
- TCP keep-alive 필수 (handshake 오버헤드 제거)

View File

@@ -1,52 +0,0 @@
# 이력 조회 상태 표시기 구현
## 개요
이력 조회 페이지의 '▼ 옵션 불러오기' 버튼 오른쪽에 상태 표시기를 추가하여, 태그 목록 조회 상태를 시각적으로 표시합니다.
## 구현 내용
### 1. HTML 구조 ([`index.html`](src/Web/wwwroot/index.html:503))
```html
<div class="fg">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>태그 선택 <em>(최대 8개, OR 조건)</em></span>
<button class="btn-b btn-sm" onclick="histLoad()">▼ 옵션 불러오기</button>
<span id="hist-load-status" class="hist-status">대기 중</span>
</div>
<div class="pb-name-grid">
<!-- select 8개 -->
</div>
</div>
```
### 2. CSS 스타일 ([`style.css`](src/Web/wwwroot/css/style.css:815))
- `.hist-status`: 상태 표시기 컨테이너
- `.hist-status.loading`: 조회 중 (파란색)
- `.hist-status.success`: 조회 완료 (초록색)
- `.hist-status.error`: 조회 실패 (빨간색)
- 성능 최적화: `contain: layout style`, `transform: translateZ(0)`
### 3. JavaScript 로직 ([`app.js`](src/Web/wwwroot/js/app.js:688))
- `histUpdateStatus(state, message)`: 상태 표시기 업데이트
- `histLoad()`: 태그 목록 조회 및 상태 업데이트
- 조회 시작: `⏳ 조회 중...`
- 조회 완료: `✅ 조회 완료 (개수, 초)`
- 데이터 없음: `❌ 조회 데이터 없음 (0개)`
- 오류: `❌ 조회 실패: 메시지`
## 수정된 문제들
1. 초기 상태 "준비됨" → "대기 중"으로 변경
2. 레이아웃 구조 개선 (flex 컨테이너 사용)
3. CSS 애니메이션 제거 및 성능 최적화
4. `requestAnimationFrame`으로 DOM 업데이트 지연
## 남은 문제 (추가 작업 필요)
- 태그 선택 시 페이지 hang 현상 원인 파악 필요
- `pb-name-grid`의 CSS grid 레이아웃과 select 요소 8개의 상호작용
- 브라우저 호환성 문제 가능성
- 메모리 누락 가능성
## 테스트 방법
1. 이력 조회 탭 진입 → "대기 중" 표시 확인
2. '▼ 옵션 불러오기' 클릭 → 상태 변화 확인
3. 태그 선택 시 페이지 반응성 확인

View File

@@ -1,102 +0,0 @@
# 폐쇄 네트워크 로컬 LLM 채팅 웹페이지 제작 계획
## 1. 분석 요약
### Ollama (로컬 LLM 런타임)
- **기본 API 엔드포인트**: `http://localhost:11434/api`
- **OpenAI 호환 API**: `http://localhost:11434/v1/`
- **주요 API**:
- `POST /api/chat` - 채팅 (메시지 배열 기반)
- `POST /api/generate` - 텍스트 생성 (프롬프트 기반)
- `GET /api/tags` - 로컬 모델 목록 조회
- `POST /api/pull` - 모델 다운로드 (폐쇄 네트워크에서는 사전 다운로드 필요)
- **스트리밍 응답**: NDJSON 형식 (`application/x-ndjson`), `"stream": false`로 비활성화 가능
- **모델**: 폐쇄 네트워크에서는 `ollama pull`로 사전 다운로드 필요 (예: `llama3`, `gemma3`, `qwen3` 등)
### Open WebUI (참용 웹 UI)
- **기술 스택**: Svelte (프론트엔드) + FastAPI (백엔드)
- **설치**: Docker 또는 pip (`pip install open-webui`)
- **Ollama 연결**: `OLLAMA_BASE_URL` 환경변수
- **참고**: 폐쇄 네트워크에서 바로 사용 가능한 완성된 솔루션이지만, 커스텀 웹페이지 제작을 위한 참고용으로 활용
---
## 2. 필요한 정보 요약
### 필수 조건
1. **Ollama 설치 및 실행** (폐쇄 네트워크에 사전 설치)
2. **로컬 모델 다운로드** (사전 `ollama pull <model_name>` 실행)
3. **웹 서버** (정적 파일 서빙 + API 프록시, 또는 프론트엔드에서 직접 Ollama API 호출)
### API 요청/응답 형식
```json
// 채팅 요청 (POST /api/chat)
{
"model": "llama3",
"messages": [
{"role": "user", "content": "안녕하세요"}
],
"stream": false
}
// 채팅 응답
{
"model": "llama3",
"message": {"role": "assistant", "content": "안녕하세요! 어떻게 도와드릴까요?"}
}
```
### 스트리밍 응답 처리 (선택사항)
- NDJSON 형식으로 각 줄이独立的 JSON 객체
- `done: true`로 응답 종료 신호
- `ReadableStream` + `TextDecoder`로 처리 가능
---
## 3. Todo List
### Phase 1: 환경 준비
- [ ] 1.1 폐쇄 네트워크 서버에 Ollama 설치
- [ ] 1.2 필요한 LLM 모델 사전 다운로드 (`ollama pull`)
- [ ] 1.3 Ollama 서비스 실행 및 `localhost:11434` 접근 확인
### Phase 2: 프론트엔드 기본 구조
- [ ] 2.1 HTML/CSS/JavaScript 기반 채팅 UI 스키레션 작성
- [ ] 2.2 채팅 메시지 표시 영역 (사용자/보조 구분)
- [ ] 2.3 입력 필드 및 전송 버튼 구현
- [ ] 2.4 반응형 디자인 (모바일/데스크톱)
### Phase 3: Ollama API 연동
- [ ] 3.1 `fetch()`로 Ollama `/api/chat` 엔드포인트 호출 구현
- [ ] 3.2 메시지 히스토리 관리 (배열 유지)
- [ ] 3.3 모델 선택 기능 (`/api/tags`로 모델 목록 조회)
- [ ] 3.4 로딩 상태 및 에러 처리
### Phase 4: 스트리밍 응답 (선택사항)
- [ ] 4.1 NDJSON 스트리밍 파싱 구현
- [ ] 4.2 실시간 텍스트 표시 (타이핑 효과)
- [ ] 4.3 스트리밍 중단 기능
### Phase 5: 추가 기능
- [ ] 5.1 채팅 기록 저장 (localStorage)
- [ ] 5.2 새 채팅 시작 / 채팅 초기화
- [ ] 5.3 Markdown 렌더링 (코드 블록, 수식 등)
- [ ] 5.4 시스템 프롬프트 설정 기능
### Phase 6: 배포
- [ ] 6.1 정적 파일 빌드
- [ ] 6.2 폐쇄 네트워크 서버에 배포
- [ ] 6.3 CORS 설정 (Ollama `OLLAMA_HOST` 환경변수)
- [ ] 6.4 최종 테스트
---
## 4. 기술 선택 가이드
| 옵션 | 설명 | 추천도 |
|------|------|--------|
| 순수 HTML/JS | 의존성 없음, 폐쇄 네트워크에 적합 | ⭐⭐⭐ |
| Vue/React SPA | 빌드 필요, 하지만 풍부한 생태계 | ⭐⭐ |
| Open WebUI 그대로 사용 | 별도 개발 불필요, Docker로 배포 | ⭐⭐⭐ |
**폐쇄 네트워크 권장**: 순수 HTML/CSS/JavaScript 또는 Open WebUI Docker 배포

View File

@@ -1,680 +0,0 @@
# No-10 플랜트 P&ID 추출 데이터
**도면**: `No-10_Plant_PID.dxf` (DXF)
**추출 일자**: 2026-05-14
**데이터 소스**: PostgreSQL `pid_equipment` 테이블 (460건, is_active=true)
**용도**: RAG 검색 보조용 — 운전원 질의 시 P&ID 컨텍스트 자동 인용
---
## 통계
| 구분 | 수량 |
|---|---|
| **파이프 (LineNo)** | 247 |
| **장비** | 78 |
| **계기** | 110 |
| **미분류** | 25 |
| **Experion 매핑 완료** | 25 |
### 영역(area)별 분포
| area 추정 | 항목 수 | 비고 |
|---|---|---|
| `10` | 318 | 태그 prefix 숫자 기반 추정 |
| `91` | 19 | 태그 prefix 숫자 기반 추정 |
| `61` | 17 | 태그 prefix 숫자 기반 추정 |
| `94` | 16 | 태그 prefix 숫자 기반 추정 |
| `1` | 13 | 태그 prefix 숫자 기반 추정 |
| `21` | 13 | 태그 prefix 숫자 기반 추정 |
| `62` | 12 | 태그 prefix 숫자 기반 추정 |
| `92` | 9 | 태그 prefix 숫자 기반 추정 |
| `51` | 8 | 태그 prefix 숫자 기반 추정 |
| `9` | 7 | 태그 prefix 숫자 기반 추정 |
| `29` | 5 | 태그 prefix 숫자 기반 추정 |
| `2` | 4 | 태그 prefix 숫자 기반 추정 |
| `32` | 3 | 태그 prefix 숫자 기반 추정 |
| `34` | 3 | 태그 prefix 숫자 기반 추정 |
| `0` | 2 | 태그 prefix 숫자 기반 추정 |
| `27` | 2 | 태그 prefix 숫자 기반 추정 |
| `31` | 2 | 태그 prefix 숫자 기반 추정 |
| `81` | 2 | 태그 prefix 숫자 기반 추정 |
| `53` | 1 | 태그 prefix 숫자 기반 추정 |
| `66` | 1 | 태그 prefix 숫자 기반 추정 |
| `7` | 1 | 태그 prefix 숫자 기반 추정 |
| `90` | 1 | 태그 prefix 숫자 기반 추정 |
| `99` | 1 | 태그 prefix 숫자 기반 추정 |
### 파이프 서비스(fluid) 분포
| service 코드 | 의미 | 수량 |
|---|---|---|
| `P` | PROCESS FLUID | 90 |
| `VG` | VENT GAS | 46 |
| `CWS` | COOLING WATER SUPPLY | 24 |
| `CWR` | COOLING WATER RETURN | 22 |
| `IA` | INSTRUMENT AIR | 20 |
| `SW` | SOFT WATER | 12 |
| `SAM` | SAMPLE LINE | 9 |
| `CD` | STEAM CONDENSATE | 6 |
| `NBD` | NITROGEN BLOW DOWN | 5 |
| `ST` | STEAM | 3 |
| `WW` | WASTE WATER | 3 |
| `CHR` | CHILLED WATER RETURN | 2 |
| `CHS` | CHILLED WATER SUPPLY | 2 |
| `SC` | VENT GAS | 2 |
| `PW` | PW | 1 |
### 장비 종류 분포
| prefix | 의미 | 수량 |
|---|---|---|
| `T` | Tank | 20 |
| `E` | Heat Exchanger | 18 |
| `P` | Pump | 15 |
| `D` | Drum | 5 |
| `C` | Column | 4 |
| `F` | Filter | 4 |
| `K` | Compressor | 4 |
| `DP` | Drainage Point | 3 |
| `VP` | Vacuum Pump | 2 |
| `BT` | Buffer Tank | 1 |
| `CH` | Chiller | 1 |
| `CT` | Cooling Tower | 1 |
### 계기 종류 분포
| prefix | 의미 | 수량 |
|---|---|---|
| `FCV` | Flow Control Valve | 46 |
| `PSV` | Pressure Switch Valve | 27 |
| `PCV` | Pressure Control Valve | 11 |
| `LCV` | Level Control Valve | 10 |
| `TCV` | Temperature Control Valve | 9 |
| `TR` | Temperature Recorder | 3 |
| `PFD` | Pressure | 2 |
| `SP` | Speed | 2 |
---
## 장비 목록
### BT — Buffer Tank (1대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `BT-6200` | 62 | `—` | 0.950 |
### C — Column (4대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `C-10111` | 10 | `—` | 0.950 |
| `C-10211` | 10 | `—` | 0.950 |
| `C-9111` | 91 | `—` | 0.950 |
| `C-9128` | 91 | `—` | 0.950 |
### CH — Chiller (1대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `CH-6601` | 66 | `—` | 0.950 |
### CT — Cooling Tower (1대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `CT-10601` | 10 | `—` | 0.950 |
### D — Drum (5대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `D-10113` | 10 | `—` | 0.950 |
| `D-10213` | 10 | `—` | 0.950 |
| `D-10901` | 10 | `—` | 0.950 |
| `D-2901` | 29 | `—` | 0.950 |
| `D-901` | 9 | `—` | 0.950 |
### DP — Drainage Point (3대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `DP-10101` | 10 | `—` | 0.950 |
| `DP-10201` | 10 | `—` | 0.950 |
| `DP-3210` | 32 | `—` | 0.950 |
### E — Heat Exchanger (18대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `E-10103` | 10 | `—` | 0.950 |
| `E-10112` | 10 | `—` | 0.950 |
| `E-10115` | 10 | `—` | 0.950 |
| `E-10115A` | 10 | `—` | 0.950 |
| `E-10115B` | 10 | `—` | 0.950 |
| `E-10117` | 10 | `—` | 0.950 |
| `E-10119` | 10 | `—` | 0.950 |
| `E-10203` | 10 | `—` | 0.950 |
| `E-10212` | 10 | `—` | 0.950 |
| `E-10215` | 10 | `—` | 0.950 |
| `E-10217` | 10 | `—` | 0.950 |
| `E-10219` | 10 | `—` | 0.950 |
| `E-8115` | 81 | `—` | 0.950 |
| `E-9103` | 91 | `—` | 0.950 |
| `E-9112` | 91 | `—` | 0.950 |
| `E-9115` | 91 | `—` | 0.950 |
| `E-9203` | 92 | `—` | 0.950 |
| `E-9215` | 92 | `—` | 0.950 |
### F — Filter (4대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `F-10900` | 10 | `—` | 0.950 |
| `F-10952` | 10 | `—` | 0.950 |
| `F-2952` | 29 | `—` | 0.950 |
| `F-952` | 9 | `—` | 0.950 |
### K — Compressor (4대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `K-10901` | 10 | `—` | 0.500 |
| `K-2901` | 29 | `—` | 0.500 |
| `K-901A` | 9 | `—` | 0.500 |
| `K-901B` | 9 | `—` | 0.500 |
### P — Pump (15대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `P-10101` | 10 | `p-10101.pv` | 0.950 |
| `P-10114` | 10 | `p-10114.pv` | 0.950 |
| `P-10116` | 10 | `p-10116.pv` | 0.950 |
| `P-10118` | 10 | `p-10118.pv` | 0.950 |
| `P-10201` | 10 | `p-10201.pv` | 0.950 |
| `P-10214` | 10 | `p-10214.pv` | 0.950 |
| `P-10216` | 10 | `p-10216.pv` | 0.950 |
| `P-10218` | 10 | `p-10218.pv` | 0.950 |
| `P-10221` | 10 | `p-10221.pv` | 0.950 |
| `P-201` | 2 | `p-201.pv` | 0.950 |
| `P-202` | 2 | `p-202.pv` | 0.950 |
| `P-3101` | 31 | `—` | 0.950 |
| `P-5101` | 51 | `—` | 0.950 |
| `P-6101` | 61 | `p-6101.pv` | 0.950 |
| `P-9102` | 91 | `p-9102.pv` | 0.950 |
### T — Tank (20대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `T-10100` | 10 | `—` | 0.950 |
| `T-10101` | 10 | `—` | 0.950 |
| `T-10200` | 10 | `—` | 0.950 |
| `T-10201` | 10 | `—` | 0.950 |
| `T-10221` | 10 | `—` | 0.950 |
| `T-10800` | 10 | `—` | 0.950 |
| `T-201` | 2 | `—` | 0.950 |
| `T-202` | 2 | `—` | 0.950 |
| `T-2704` | 27 | `—` | 0.950 |
| `T-3101` | 31 | `—` | 0.950 |
| `T-3210` | 32 | `—` | 0.950 |
| `T-6121` | 61 | `—` | 0.950 |
| `T-6122` | 61 | `—` | 0.950 |
| `T-6125` | 61 | `—` | 0.950 |
| `T-6126` | 61 | `—` | 0.950 |
| `T-6222` | 62 | `—` | 0.950 |
| `T-8121` | 81 | `—` | 0.950 |
| `T-9123` | 91 | `—` | 0.950 |
| `T-9124` | 91 | `—` | 0.950 |
| `T-9125` | 91 | `—` | 0.950 |
### VP — Vacuum Pump (2대)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `VP-10117` | 10 | `vp-10117.pv` | 0.950 |
| `VP-10217` | 10 | `vp-10217.pv` | 0.950 |
---
## 계기 목록
### FCV — Flow Control Valve (46개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `FCV-10101` | 10 | `—` | 0.950 |
| `FCV-10113` | 10 | `—` | 0.950 |
| `FCV-10114A` | 10 | `—` | 0.950 |
| `FCV-10116` | 10 | `—` | 0.950 |
| `FCV-10118` | 10 | `—` | 0.950 |
| `FCV-10201` | 10 | `—` | 0.950 |
| `FCV-10213` | 10 | `—` | 0.950 |
| `FCV-10214` | 10 | `—` | 0.950 |
| `FCV-10216` | 10 | `—` | 0.950 |
| `FCV-10218` | 10 | `—` | 0.950 |
| `FCV-111` | 1 | `—` | 0.950 |
| `FCV-113` | 1 | `—` | 0.950 |
| `FCV-122` | 1 | `—` | 0.950 |
| `FCV-123` | 1 | `—` | 0.950 |
| `FCV-124` | 1 | `—` | 0.950 |
| `FCV-131` | 1 | `—` | 0.950 |
| `FCV-2111` | 21 | `—` | 0.950 |
| `FCV-2122` | 21 | `—` | 0.950 |
| `FCV-2123` | 21 | `—` | 0.950 |
| `FCV-2124` | 21 | `—` | 0.950 |
| `FCV-2131` | 21 | `—` | 0.950 |
| `FCV-5101` | 51 | `—` | 0.950 |
| `FCV-5113` | 51 | `—` | 0.950 |
| `FCV-5114` | 51 | `—` | 0.950 |
| `FCV-5116` | 51 | `—` | 0.950 |
| `FCV-5118` | 51 | `—` | 0.950 |
| `FCV-6101` | 61 | `—` | 0.950 |
| `FCV-6113` | 61 | `—` | 0.950 |
| `FCV-6114` | 61 | `—` | 0.950 |
| `FCV-6116` | 61 | `—` | 0.950 |
| `FCV-6118` | 61 | `—` | 0.950 |
| `FCV-6201` | 62 | `—` | 0.950 |
| `FCV-6213` | 62 | `—` | 0.950 |
| `FCV-6214` | 62 | `—` | 0.950 |
| `FCV-6216` | 62 | `—` | 0.950 |
| `FCV-6218` | 62 | `—` | 0.950 |
| `FCV-9101` | 91 | `—` | 0.950 |
| `FCV-9113` | 91 | `—` | 0.950 |
| `FCV-9114` | 91 | `—` | 0.950 |
| `FCV-9116` | 91 | `—` | 0.950 |
| `FCV-9118` | 91 | `—` | 0.950 |
| `FCV-9201` | 92 | `—` | 0.950 |
| `FCV-9213` | 92 | `—` | 0.950 |
| `FCV-9214` | 92 | `—` | 0.950 |
| `FCV-9216` | 92 | `—` | 0.950 |
| `FCV-9218` | 92 | `—` | 0.950 |
### LCV — Level Control Valve (10개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `LCV-111` | 1 | `—` | 0.950 |
| `LCV-113` | 1 | `—` | 0.950 |
| `LCV-121` | 1 | `—` | 0.950 |
| `LCV-131` | 1 | `—` | 0.950 |
| `LCV-2111` | 21 | `—` | 0.950 |
| `LCV-2113` | 21 | `—` | 0.950 |
| `LCV-2121` | 21 | `—` | 0.950 |
| `LCV-2131` | 21 | `—` | 0.950 |
| `LCV-2705` | 27 | `—` | 0.950 |
| `LCV-3402` | 34 | `—` | 0.950 |
### PCV — Pressure Control Valve (11개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `PCV-10111` | 10 | `—` | 0.950 |
| `PCV-10211` | 10 | `—` | 0.950 |
| `PCV-111` | 1 | `—` | 0.950 |
| `PCV-121` | 1 | `—` | 0.950 |
| `PCV-2111` | 21 | `—` | 0.950 |
| `PCV-2121` | 21 | `—` | 0.950 |
| `PCV-5111` | 51 | `—` | 0.950 |
| `PCV-6111` | 61 | `—` | 0.950 |
| `PCV-6211` | 62 | `—` | 0.950 |
| `PCV-9111` | 91 | `—` | 0.950 |
| `PCV-9211` | 92 | `—` | 0.950 |
### PFD — Pressure (2개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `PFD-001` | 0 | `—` | 0.950 |
| `PFD-002` | 0 | `—` | 0.950 |
### PSV — Pressure Switch Valve (27개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `PSV-10101` | 10 | `—` | 0.950 |
| `PSV-10103` | 10 | `—` | 0.950 |
| `PSV-10111` | 10 | `—` | 0.950 |
| `PSV-10112` | 10 | `—` | 0.950 |
| `PSV-10115` | 10 | `—` | 0.950 |
| `PSV-10117` | 10 | `—` | 0.950 |
| `PSV-10119A` | 10 | `—` | 0.950 |
| `PSV-10119B` | 10 | `—` | 0.950 |
| `PSV-10201` | 10 | `—` | 0.950 |
| `PSV-10203` | 10 | `—` | 0.950 |
| `PSV-10211` | 10 | `—` | 0.950 |
| `PSV-10212` | 10 | `—` | 0.950 |
| `PSV-10215` | 10 | `—` | 0.950 |
| `PSV-10217` | 10 | `—` | 0.950 |
| `PSV-10219A` | 10 | `—` | 0.950 |
| `PSV-10219B` | 10 | `—` | 0.950 |
| `PSV-10900` | 10 | `—` | 0.950 |
| `PSV-10900A` | 10 | `—` | 0.950 |
| `PSV-10900B` | 10 | `—` | 0.950 |
| `PSV-10901` | 10 | `—` | 0.950 |
| `PSV-10902` | 10 | `—` | 0.950 |
| `PSV-2900` | 29 | `—` | 0.950 |
| `PSV-2901` | 29 | `—` | 0.950 |
| `PSV-6203` | 62 | `—` | 0.950 |
| `PSV-900A` | 9 | `—` | 0.950 |
| `PSV-900B` | 9 | `—` | 0.950 |
| `PSV-901` | 9 | `—` | 0.950 |
### SP — Speed (2개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `SP-10601` | 10 | `—` | 0.950 |
| `SP-10602` | 10 | `—` | 0.950 |
### TCV — Temperature Control Valve (9개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `TCV-10111` | 10 | `—` | 0.950 |
| `TCV-10211` | 10 | `—` | 0.950 |
| `TCV-111` | 1 | `—` | 0.950 |
| `TCV-2111` | 21 | `—` | 0.950 |
| `TCV-5111` | 51 | `—` | 0.950 |
| `TCV-6111` | 61 | `—` | 0.950 |
| `TCV-6211` | 62 | `—` | 0.950 |
| `TCV-9111` | 91 | `—` | 0.950 |
| `TCV-9211` | 92 | `—` | 0.950 |
### TR — Temperature Recorder (3개)
| tag_no | area 추정 | Experion 매핑 | confidence |
|---|---|---|---|
| `TR-10115A` | 10 | `—` | 0.950 |
| `TR-10115B` | 10 | `—` | 0.950 |
| `TR-10215` | 10 | `—` | 0.950 |
---
## 파이프(LineNo) 목록
총 247개 파이프 라인. 각 라인은 `service-line_no-size-spec-insul` 형식으로 명명됨.
| tag_no | service (fluid) | line_no | size | material | flange | insul |
|---|---|---|---|---|---|---|
| `CD-10513-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10513 | 40A | S | 1 | AH50 |
| `CD-10514-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10514 | 40A | S | 1 | AH50 |
| `CD-10515-65A-S1A-H50` | CD (STEAM CONDENSATE) | 10515 | 65A | S | 1 | AH50 |
| `CD-10516-65A-S1A-H50` | CD (STEAM CONDENSATE) | 10516 | 65A | S | 1 | AH50 |
| `CD-10522-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10522 | 40A | S | 1 | AH50 |
| `CD-10523-40A-S1A-H50` | CD (STEAM CONDENSATE) | 10523 | 40A | S | 1 | AH50 |
| `CHR-10641-65A-F1A-C50` | CHR (CHILLED WATER RETURN) | 10641 | 65A | F | 1 | AC50 |
| `CHR-10642-50A-F1A-C50` | CHR (CHILLED WATER RETURN) | 10642 | 50A | F | 1 | AC50 |
| `CHS-10631-65A-F1A-C50` | CHS (CHILLED WATER SUPPLY) | 10631 | 65A | F | 1 | AC50 |
| `CHS-10632-50A-F1A-C50` | CHS (CHILLED WATER SUPPLY) | 10632 | 50A | F | 1 | AC50 |
| `CWR-10620-300A-S2A-N` | CWR (COOLING WATER RETURN) | 10620 | 300A | S | 2 | AN |
| `CWR-10621-80A-S2A-N` | CWR (COOLING WATER RETURN) | 10621 | 80A | S | 2 | AN |
| `CWR-10621-80A-S2A-n` | CWR (COOLING WATER RETURN) | 10621 | 80A | S | 2 | An |
| `CWR-10622-200A-S2A-N` | CWR (COOLING WATER RETURN) | 10622 | 200A | S | 2 | AN |
| `CWR-10622-200A-S2A-n` | CWR (COOLING WATER RETURN) | 10622 | 200A | S | 2 | An |
| `CWR-10623-50A-S2A-N` | CWR (COOLING WATER RETURN) | 10623 | 50A | S | 2 | AN |
| `CWR-10623-50A-S2A-n` | CWR (COOLING WATER RETURN) | 10623 | 50A | S | 2 | An |
| `CWR-10624-150A-S2A-N` | CWR (COOLING WATER RETURN) | 10624 | 150A | S | 2 | AN |
| `CWR-10624-150A-S2A-n` | CWR (COOLING WATER RETURN) | 10624 | 150A | S | 2 | An |
| `CWR-10625-50A-F1A-N` | CWR (COOLING WATER RETURN) | 10625 | 50A | F | 1 | AN |
| `CWR-10625-50A-S2A-N` | CWR (COOLING WATER RETURN) | 10625 | 50A | S | 2 | AN |
| `CWR-10625-50A-S2A-n` | CWR (COOLING WATER RETURN) | 10625 | 50A | S | 2 | An |
| `CWR-10626-25A-F1A-N` | CWR (COOLING WATER RETURN) | 10626 | 25A | F | 1 | AN |
| `CWR-10626-25A-F1A-n` | CWR (COOLING WATER RETURN) | 10626 | 25A | F | 1 | An |
| `CWR-10626-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10626 | 25A | S | 2 | AN |
| `CWR-10627-25A-F1A-N` | CWR (COOLING WATER RETURN) | 10627 | 25A | F | 1 | AN |
| `CWR-10627-25A-F1A-n` | CWR (COOLING WATER RETURN) | 10627 | 25A | F | 1 | An |
| `CWR-10627-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10627 | 25A | S | 2 | AN |
| `CWR-10628-15A-F1A-N` | CWR (COOLING WATER RETURN) | 10628 | 15A | F | 1 | AN |
| `CWR-10628-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10628 | 25A | S | 2 | AN |
| `CWR-10629-15A-F1A-N` | CWR (COOLING WATER RETURN) | 10629 | 15A | F | 1 | AN |
| `CWR-10629-25A-S2A-N` | CWR (COOLING WATER RETURN) | 10629 | 25A | S | 2 | AN |
| `CWS-10600-300A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10600 | 300A | S | 2 | AN |
| `CWS-10601-300A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10601 | 300A | S | 2 | AN |
| `CWS-10611-80A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10611 | 80A | S | 2 | AN |
| `CWS-10611-80A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10611 | 80A | S | 2 | An |
| `CWS-10612-200A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10612 | 200A | S | 2 | AN |
| `CWS-10612-200A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10612 | 200A | S | 2 | An |
| `CWS-10613-50A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10613 | 50A | S | 2 | AN |
| `CWS-10613-50A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10613 | 50A | S | 2 | An |
| `CWS-10614-150A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10614 | 150A | S | 2 | AN |
| `CWS-10614-150A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10614 | 150A | S | 2 | An |
| `CWS-10615-50A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10615 | 50A | F | 1 | AN |
| `CWS-10615-50A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10615 | 50A | S | 2 | AN |
| `CWS-10615-50A-S2A-n` | CWS (COOLING WATER SUPPLY) | 10615 | 50A | S | 2 | An |
| `CWS-10616-25A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10616 | 25A | F | 1 | AN |
| `CWS-10616-25A-F1A-n` | CWS (COOLING WATER SUPPLY) | 10616 | 25A | F | 1 | An |
| `CWS-10616-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10616 | 25A | S | 2 | AN |
| `CWS-10617-25A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10617 | 25A | F | 1 | AN |
| `CWS-10617-25A-F1A-n` | CWS (COOLING WATER SUPPLY) | 10617 | 25A | F | 1 | An |
| `CWS-10617-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10617 | 25A | S | 2 | AN |
| `CWS-10618-15A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10618 | 15A | F | 1 | AN |
| `CWS-10618-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10618 | 25A | S | 2 | AN |
| `CWS-10619-15A-F1A-N` | CWS (COOLING WATER SUPPLY) | 10619 | 15A | F | 1 | AN |
| `CWS-10619-25A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10619 | 25A | S | 2 | AN |
| `CWS-10620-300A-S2A-N` | CWS (COOLING WATER SUPPLY) | 10620 | 300A | S | 2 | AN |
| `IA-10900-25A-F1A-n` | IA (INSTRUMENT AIR) | 10900 | 25A | F | 1 | An |
| `IA-10901-15A-F1A-n` | IA (INSTRUMENT AIR) | 10901 | 15A | F | 1 | An |
| `IA-10902-15A-F1A-n` | IA (INSTRUMENT AIR) | 10902 | 15A | F | 1 | An |
| `IA-10903-15A-F1A-n` | IA (INSTRUMENT AIR) | 10903 | 15A | F | 1 | An |
| `IA-10904-15A-F1A-n` | IA (INSTRUMENT AIR) | 10904 | 15A | F | 1 | An |
| `IA-10905-15A-F1A-n` | IA (INSTRUMENT AIR) | 10905 | 15A | F | 1 | An |
| `IA-10906-15A-F1A-n` | IA (INSTRUMENT AIR) | 10906 | 15A | F | 1 | An |
| `IA-10907-15A-F1A-n` | IA (INSTRUMENT AIR) | 10907 | 15A | F | 1 | An |
| `IA-10908-15A-F1A-n` | IA (INSTRUMENT AIR) | 10908 | 15A | F | 1 | An |
| `IA-10909-15A-F1A-n` | IA (INSTRUMENT AIR) | 10909 | 15A | F | 1 | An |
| `IA-10910-15A-F1A-n` | IA (INSTRUMENT AIR) | 10910 | 15A | F | 1 | An |
| `IA-10912-15A-F1A-n` | IA (INSTRUMENT AIR) | 10912 | 15A | F | 1 | An |
| `IA-10913-25A-F1A-n` | IA (INSTRUMENT AIR) | 10913 | 25A | F | 1 | An |
| `IA-10914-25A-F1A-n` | IA (INSTRUMENT AIR) | 10914 | 25A | F | 1 | An |
| `IA-10915-15A-F1A-n` | IA (INSTRUMENT AIR) | 10915 | 15A | F | 1 | An |
| `IA-10918-15A-F1A-n` | IA (INSTRUMENT AIR) | 10918 | 15A | F | 1 | An |
| `IA-10919-15A-F1A-n` | IA (INSTRUMENT AIR) | 10919 | 15A | F | 1 | An |
| `IA-10920-15A-F1A-n` | IA (INSTRUMENT AIR) | 10920 | 15A | F | 1 | An |
| `IA-10921-15A-F1A-n` | IA (INSTRUMENT AIR) | 10921 | 15A | F | 1 | An |
| `IA-10922-25A-F1A-n` | IA (INSTRUMENT AIR) | 10922 | 25A | F | 1 | An |
| `NBD-10100` | NBD (NITROGEN BLOW DOWN) | 10100 | — | — | — | — |
| `NBD-10101` | NBD (NITROGEN BLOW DOWN) | 10101 | — | — | — | — |
| `NBD-10200` | NBD (NITROGEN BLOW DOWN) | 10200 | — | — | — | — |
| `NBD-10201` | NBD (NITROGEN BLOW DOWN) | 10201 | — | — | — | — |
| `NBD-10221` | NBD (NITROGEN BLOW DOWN) | 10221 | — | — | — | — |
| `P-10101-25A-F1A-n` | P (PROCESS FLUID) | 10101 | 25A | F | 1 | An |
| `P-10102-25A-F1A-n` | P (PROCESS FLUID) | 10102 | 25A | F | 1 | An |
| `P-10103-25A-F1A-n` | P (PROCESS FLUID) | 10103 | 25A | F | 1 | An |
| `P-10104-25A-F1A-n` | P (PROCESS FLUID) | 10104 | 25A | F | 1 | An |
| `P-10106-25A-F1A-n` | P (PROCESS FLUID) | 10106 | 25A | F | 1 | An |
| `P-10107-40A-F1A-n` | P (PROCESS FLUID) | 10107 | 40A | F | 1 | An |
| `P-10109-40A-F1A-n` | P (PROCESS FLUID) | 10109 | 40A | F | 1 | An |
| `P-10110-40A-F1A-n` | P (PROCESS FLUID) | 10110 | 40A | F | 1 | An |
| `P-10111-25A-F1A-n` | P (PROCESS FLUID) | 10111 | 25A | F | 1 | An |
| `P-10112-25A-F1A-n` | P (PROCESS FLUID) | 10112 | 25A | F | 1 | An |
| `P-10113-25A-F1A-H50` | P (PROCESS FLUID) | 10113 | 25A | F | 1 | AH50 |
| `P-10114-250A-F2A-H50` | P (PROCESS FLUID) | 10114 | 250A | F | 2 | AH50 |
| `P-10115-200A-F2A-H50` | P (PROCESS FLUID) | 10115 | 200A | F | 2 | AH50 |
| `P-10116-200A-F2A-H50` | P (PROCESS FLUID) | 10116 | 200A | F | 2 | AH50 |
| `P-10117-300A-F2A-H50` | P (PROCESS FLUID) | 10117 | 300A | F | 2 | AH50 |
| `P-10117-500A-F2A-H50` | P (PROCESS FLUID) | 10117 | 500A | F | 2 | AH50 |
| `P-10118-300A-F2A-H50` | P (PROCESS FLUID) | 10118 | 300A | F | 2 | AH50 |
| `P-10119-32A-F1A-H50` | P (PROCESS FLUID) | 10119 | 32A | F | 1 | AH50 |
| `P-10120-25A-F1A-H50` | P (PROCESS FLUID) | 10120 | 25A | F | 1 | AH50 |
| `P-10121-25A-F1A-H50` | P (PROCESS FLUID) | 10121 | 25A | F | 1 | AH50 |
| `P-10122-20A-F1A-n` | P (PROCESS FLUID) | 10122 | 20A | F | 1 | An |
| `P-10122-25A-F1A-N` | P (PROCESS FLUID) | 10122 | 25A | F | 1 | AN |
| `P-10123-20A-F1A-n` | P (PROCESS FLUID) | 10123 | 20A | F | 1 | An |
| `P-10124-20A-F1A-n` | P (PROCESS FLUID) | 10124 | 20A | F | 1 | An |
| `P-10125-20A-F1A-n` | P (PROCESS FLUID) | 10125 | 20A | F | 1 | An |
| `P-10126-20A-F1A-n` | P (PROCESS FLUID) | 10126 | 20A | F | 1 | An |
| `P-10127-65A-F2A-n` | P (PROCESS FLUID) | 10127 | 65A | F | 2 | An |
| `P-10128-50A-F2A-n` | P (PROCESS FLUID) | 10128 | 50A | F | 2 | An |
| `P-10129-25A-F2A-n` | P (PROCESS FLUID) | 10129 | 25A | F | 2 | An |
| `P-10130-25A-F2A-n` | P (PROCESS FLUID) | 10130 | 25A | F | 2 | An |
| `P-10132-25A-F2A-n` | P (PROCESS FLUID) | 10132 | 25A | F | 2 | An |
| `P-10133-25A-F2A-n` | P (PROCESS FLUID) | 10133 | 25A | F | 2 | An |
| `P-10134-25A-F2A-n` | P (PROCESS FLUID) | 10134 | 25A | F | 2 | An |
| `P-10135-25A-F2A-n` | P (PROCESS FLUID) | 10135 | 25A | F | 2 | An |
| `P-10136-25A-F2A-n` | P (PROCESS FLUID) | 10136 | 25A | F | 2 | An |
| `P-10137-25A-F1A-n` | P (PROCESS FLUID) | 10137 | 25A | F | 1 | An |
| `P-10138-600A-F2A-H100` | P (PROCESS FLUID) | 10138 | 600A | F | 2 | AH100 |
| `P-10139-600A-F2A-n` | P (PROCESS FLUID) | 10139 | 600A | F | 2 | An |
| `P-10140-40A-F2A-n` | P (PROCESS FLUID) | 10140 | 40A | F | 2 | An |
| `P-10141-32A-F2A-n` | P (PROCESS FLUID) | 10141 | 32A | F | 2 | An |
| `P-10142-25A-F2A-n` | P (PROCESS FLUID) | 10142 | 25A | F | 2 | An |
| `P-10143-32A-F2A-n` | P (PROCESS FLUID) | 10143 | 32A | F | 2 | An |
| `P-10144-25A-F1A-n` | P (PROCESS FLUID) | 10144 | 25A | F | 1 | An |
| `P-10146-20A-F1A-n` | P (PROCESS FLUID) | 10146 | 20A | F | 1 | An |
| `P-10147-25A-F1A-n` | P (PROCESS FLUID) | 10147 | 25A | F | 1 | An |
| `P-10148-25A-F1A-n` | P (PROCESS FLUID) | 10148 | 25A | F | 1 | An |
| `P-10149-40A-F1A-n` | P (PROCESS FLUID) | 10149 | 40A | F | 1 | An |
| `P-10150-25A-F1A-n` | P (PROCESS FLUID) | 10150 | 25A | F | 1 | An |
| `P-10151-15A-F1A-n` | P (PROCESS FLUID) | 10151 | 15A | F | 1 | An |
| `P-10201-25A-F1A-n` | P (PROCESS FLUID) | 10201 | 25A | F | 1 | An |
| `P-10202-40A-F1A-n` | P (PROCESS FLUID) | 10202 | 40A | F | 1 | An |
| `P-10203-40A-F1A-n` | P (PROCESS FLUID) | 10203 | 40A | F | 1 | An |
| `P-10204-25A-F1A-n` | P (PROCESS FLUID) | 10204 | 25A | F | 1 | An |
| `P-10205-25A-F1A-n` | P (PROCESS FLUID) | 10205 | 25A | F | 1 | An |
| `P-10207-25A-F1A-H50` | P (PROCESS FLUID) | 10207 | 25A | F | 1 | AH50 |
| `P-10208-400A-F2A-H50` | P (PROCESS FLUID) | 10208 | 400A | F | 2 | AH50 |
| `P-10209-200A-F2A-H50` | P (PROCESS FLUID) | 10209 | 200A | F | 2 | AH50 |
| `P-10210-25A-F1A-H50` | P (PROCESS FLUID) | 10210 | 25A | F | 1 | AH50 |
| `P-10211-20A-F1A-H50` | P (PROCESS FLUID) | 10211 | 20A | F | 1 | AH50 |
| `P-10212-20A-F1A-H50` | P (PROCESS FLUID) | 10212 | 20A | F | 1 | AH50 |
| `P-10213-20A-F1A-n` | P (PROCESS FLUID) | 10213 | 20A | F | 1 | An |
| `P-10214-20A-F1A-n` | P (PROCESS FLUID) | 10214 | 20A | F | 1 | An |
| `P-10215-20A-F1A-n` | P (PROCESS FLUID) | 10215 | 20A | F | 1 | An |
| `P-10216-20A-F1A-n` | P (PROCESS FLUID) | 10216 | 20A | F | 1 | An |
| `P-10217-65A-F2A-n` | P (PROCESS FLUID) | 10217 | 65A | F | 2 | An |
| `P-10218-40A-F2A-n` | P (PROCESS FLUID) | 10218 | 40A | F | 2 | An |
| `P-10219-25A-F2A-n` | P (PROCESS FLUID) | 10219 | 25A | F | 2 | An |
| `P-10220-25A-F1A-n` | P (PROCESS FLUID) | 10220 | 25A | F | 1 | An |
| `P-10221-25A-F2A-n` | P (PROCESS FLUID) | 10221 | 25A | F | 2 | An |
| `P-10222-25A-F2A-n` | P (PROCESS FLUID) | 10222 | 25A | F | 2 | An |
| `P-10223-25A-F2A-n` | P (PROCESS FLUID) | 10223 | 25A | F | 2 | An |
| `P-10224-40A-F2A-n` | P (PROCESS FLUID) | 10224 | 40A | F | 2 | An |
| `P-10225-25A-F2A-n` | P (PROCESS FLUID) | 10225 | 25A | F | 2 | An |
| `P-10226-25A-F2A-n` | P (PROCESS FLUID) | 10226 | 25A | F | 2 | An |
| `P-10227-25A-F2A-n` | P (PROCESS FLUID) | 10227 | 25A | F | 2 | An |
| `P-10228-500A-F2A-H100` | P (PROCESS FLUID) | 10228 | 500A | F | 2 | AH100 |
| `P-10228-500A-F2A-n` | P (PROCESS FLUID) | 10228 | 500A | F | 2 | An |
| `P-10229-40A-F2A-n` | P (PROCESS FLUID) | 10229 | 40A | F | 2 | An |
| `P-10230-25A-F2A-n` | P (PROCESS FLUID) | 10230 | 25A | F | 2 | An |
| `P-10231-15A-F2A-n` | P (PROCESS FLUID) | 10231 | 15A | F | 2 | An |
| `P-10232-25A-F1A-n` | P (PROCESS FLUID) | 10232 | 25A | F | 1 | An |
| `P-10233-25A-F2A-n` | P (PROCESS FLUID) | 10233 | 25A | F | 2 | An |
| `P-10234-15A-F1A-n` | P (PROCESS FLUID) | 10234 | 15A | F | 1 | An |
| `P-10235-15A-F1A-n` | P (PROCESS FLUID) | 10235 | 15A | F | 1 | An |
| `P-10236-40A-F1A-n` | P (PROCESS FLUID) | 10236 | 40A | F | 1 | An |
| `P-10237-40A-F1A-n` | P (PROCESS FLUID) | 10237 | 40A | F | 1 | An |
| `P-10311-125A-F1A-n` | P (PROCESS FLUID) | 10311 | 125A | F | 1 | An |
| `P-10312-100A-F1A-n` | P (PROCESS FLUID) | 10312 | 100A | F | 1 | An |
| `P-10313-100A-F1A-n` | P (PROCESS FLUID) | 10313 | 100A | F | 1 | An |
| `P-10314-80A-F1A-n` | P (PROCESS FLUID) | 10314 | 80A | F | 1 | An |
| `PW-10903-25A-F1A-E40` | PW (PW) | 10903 | 25A | F | 1 | AE40 |
| `SAM-10951-10A-F2A-n` | SAM (SAMPLE LINE) | 10951 | 10A | F | 2 | An |
| `SAM-10952-10A-F2A-n` | SAM (SAMPLE LINE) | 10952 | 10A | F | 2 | An |
| `SAM-10953-10A-F2A-n` | SAM (SAMPLE LINE) | 10953 | 10A | F | 2 | An |
| `SAM-10954-10A-F2A-n` | SAM (SAMPLE LINE) | 10954 | 10A | F | 2 | An |
| `SAM-10955-10A-F2A-n` | SAM (SAMPLE LINE) | 10955 | 10A | F | 2 | An |
| `SAM-10956-10A-F2A-n` | SAM (SAMPLE LINE) | 10956 | 10A | F | 2 | An |
| `SAM-10957-10A-F2A-n` | SAM (SAMPLE LINE) | 10957 | 10A | F | 2 | An |
| `SAM-10958-10A-F2A-n` | SAM (SAMPLE LINE) | 10958 | 10A | F | 2 | An |
| `SAM-9954-10A-F2A-n` | SAM (SAMPLE LINE) | 9954 | 10A | F | 2 | An |
| `SC-10128` | SC (VENT GAS) | 10128 | — | — | — | — |
| `SC-9128` | SC (VENT GAS) | 9128 | — | — | — | — |
| `ST-10511-100A-S1A-H50` | ST (STEAM) | 10511 | 100A | S | 1 | AH50 |
| `ST-10512-100A-S1A-H50` | ST (STEAM) | 10512 | 100A | S | 1 | AH50 |
| `ST-10521-65A-S1A-H50` | ST (STEAM) | 10521 | 65A | S | 1 | AH50 |
| `SW-10801-50A-F1A-E50` | SW (SOFT WATER) | 10801 | 50A | F | 1 | AE50 |
| `SW-10802-25A-F1A-E50` | SW (SOFT WATER) | 10802 | 25A | F | 1 | AE50 |
| `SW-10803-20A-F1A-E50` | SW (SOFT WATER) | 10803 | 20A | F | 1 | AE50 |
| `SW-10804-25A-F1A-E50` | SW (SOFT WATER) | 10804 | 25A | F | 1 | AE50 |
| `SW-10805-25A-F1A-E50` | SW (SOFT WATER) | 10805 | 25A | F | 1 | AE50 |
| `SW-10806-25A-F1A-E50` | SW (SOFT WATER) | 10806 | 25A | F | 1 | AE50 |
| `SW-10807-25A-F1A-E50` | SW (SOFT WATER) | 10807 | 25A | F | 1 | AE50 |
| `SW-10808-25A-F1A-E50` | SW (SOFT WATER) | 10808 | 25A | F | 1 | AE50 |
| `SW-10809-15A-F1A-E50` | SW (SOFT WATER) | 10809 | 15A | F | 1 | AE50 |
| `SW-10810-25A-F1A-E50` | SW (SOFT WATER) | 10810 | 25A | F | 1 | AE50 |
| `SW-10821-50A-F1A-E50` | SW (SOFT WATER) | 10821 | 50A | F | 1 | AE50 |
| `SW-10822-32A-F1A-E50` | SW (SOFT WATER) | 10822 | 32A | F | 1 | AE50 |
| `VG-10401-150A-F1A-N` | VG (VENT GAS) | 10401 | 150A | F | 1 | AN |
| `VG-10411-65A-F1A-N` | VG (VENT GAS) | 10411 | 65A | F | 1 | AN |
| `VG-10411-65A-F1A-n` | VG (VENT GAS) | 10411 | 65A | F | 1 | An |
| `VG-10412-50A-F1A-N` | VG (VENT GAS) | 10412 | 50A | F | 1 | AN |
| `VG-10412-50A-F1A-n` | VG (VENT GAS) | 10412 | 50A | F | 1 | An |
| `VG-10421-50A-F1A-N` | VG (VENT GAS) | 10421 | 50A | F | 1 | AN |
| `VG-10421-50A-F1A-n` | VG (VENT GAS) | 10421 | 50A | F | 1 | An |
| `VG-10422-50A-F1A-N` | VG (VENT GAS) | 10422 | 50A | F | 1 | AN |
| `VG-10422-50A-F1A-n` | VG (VENT GAS) | 10422 | 50A | F | 1 | An |
| `VG-10423-50A-F1A-N` | VG (VENT GAS) | 10423 | 50A | F | 1 | AN |
| `VG-10424-50A-F1A-N` | VG (VENT GAS) | 10424 | 50A | F | 1 | AN |
| `VG-10425-50A-F1A-N` | VG (VENT GAS) | 10425 | 50A | F | 1 | AN |
| `VG-10426-50A-F1A-N` | VG (VENT GAS) | 10426 | 50A | F | 1 | AN |
| `VG-10431-50A-F1A-N` | VG (VENT GAS) | 10431 | 50A | F | 1 | AN |
| `VG-10431-50A-F1A-n` | VG (VENT GAS) | 10431 | 50A | F | 1 | An |
| `VG-10432-50A-F1A-N` | VG (VENT GAS) | 10432 | 50A | F | 1 | AN |
| `VG-10432-50A-F1A-n` | VG (VENT GAS) | 10432 | 50A | F | 1 | An |
| `VG-10433-50A-F1A-N` | VG (VENT GAS) | 10433 | 50A | F | 1 | AN |
| `VG-10433-50A-F1A-n` | VG (VENT GAS) | 10433 | 50A | F | 1 | An |
| `VG-10440-300A-F1A-N` | VG (VENT GAS) | 10440 | 300A | F | 1 | AN |
| `VG-10441-125A-F1A-N` | VG (VENT GAS) | 10441 | 125A | F | 1 | AN |
| `VG-10441-200A-F1A-N` | VG (VENT GAS) | 10441 | 200A | F | 1 | AN |
| `VG-10441-200A-F1A-n` | VG (VENT GAS) | 10441 | 200A | F | 1 | An |
| `VG-10442-100A-F1A-N` | VG (VENT GAS) | 10442 | 100A | F | 1 | AN |
| `VG-10442-150A-F1A-N` | VG (VENT GAS) | 10442 | 150A | F | 1 | AN |
| `VG-10443-25A-F1A-N` | VG (VENT GAS) | 10443 | 25A | F | 1 | AN |
| `VG-10443-25A-F1A-n` | VG (VENT GAS) | 10443 | 25A | F | 1 | An |
| `VG-10444-25A-F1A-N` | VG (VENT GAS) | 10444 | 25A | F | 1 | AN |
| `VG-10444-25A-F1A-n` | VG (VENT GAS) | 10444 | 25A | F | 1 | An |
| `VG-6203-15A-F1A-n` | VG (VENT GAS) | 6203 | 15A | F | 1 | An |
| `VG-9400-150A-F1A-N` | VG (VENT GAS) | 9400 | 150A | F | 1 | AN |
| `VG-9411-50A-F1A-N` | VG (VENT GAS) | 9411 | 50A | F | 1 | AN |
| `VG-9412-50A-F1A-N` | VG (VENT GAS) | 9412 | 50A | F | 1 | AN |
| `VG-9421-50A-F1A-N` | VG (VENT GAS) | 9421 | 50A | F | 1 | AN |
| `VG-9423-50A-F1A-N` | VG (VENT GAS) | 9423 | 50A | F | 1 | AN |
| `VG-9424-50A-F1A-N` | VG (VENT GAS) | 9424 | 50A | F | 1 | AN |
| `VG-9425-50A-F1A-N` | VG (VENT GAS) | 9425 | 50A | F | 1 | AN |
| `VG-9426-50A-F1A-N` | VG (VENT GAS) | 9426 | 50A | F | 1 | AN |
| `VG-9431-50A-F1A-N` | VG (VENT GAS) | 9431 | 50A | F | 1 | AN |
| `VG-9432-50A-F1A-N` | VG (VENT GAS) | 9432 | 50A | F | 1 | AN |
| `VG-9433-50A-F1A-N` | VG (VENT GAS) | 9433 | 50A | F | 1 | AN |
| `VG-9434-50A-F1A-N` | VG (VENT GAS) | 9434 | 50A | F | 1 | AN |
| `VG-9440-300A-F1A-N` | VG (VENT GAS) | 9440 | 300A | F | 1 | AN |
| `VG-9441-125A-F1A-N` | VG (VENT GAS) | 9441 | 125A | F | 1 | AN |
| `VG-9441-150A-F1A-n` | VG (VENT GAS) | 9441 | 150A | F | 1 | An |
| `VG-9442-65A-F1A-N` | VG (VENT GAS) | 9442 | 65A | F | 1 | AN |
| `WW-10191-25A-F1A-E50` | WW (WASTE WATER) | 10191 | 25A | F | 1 | AE50 |
| `WW-10193-25A-F1A-N` | WW (WASTE WATER) | 10193 | 25A | F | 1 | AN |
| `WW-9193-25A-F1A-N` | WW (WASTE WATER) | 9193 | 25A | F | 1 | AN |
---
## 미분류 항목 (25건)
| tag_no | instrument_type | line_number | confidence |
|---|---|---|---|
| `BV-10100` | BV | — | 0.500 |
| `BV-10101` | BV | — | 0.500 |
| `BV-10200` | BV | — | 0.500 |
| `BV-10201` | BV | — | 0.500 |
| `BV-10221` | BV | — | 0.500 |
| `KA-10901` | KA | — | 0.500 |
| `KD-10901` | KD | — | 0.500 |
| `KF-10901A` | KF | — | 0.500 |
| `KF-10901B` | KF | — | 0.500 |
| `UFD-9005` | UFD | — | 0.500 |
| `XV-10111` | XV | — | 0.500 |
| `XV-10211` | XV | — | 0.500 |
| `XV-2136` | XV | — | 0.500 |
| `XV-3208` | XV | — | 0.500 |
| `XV-3402` | XV | — | 0.500 |
| `XV-3470` | XV | — | 0.500 |
| `XV-5320` | XV | — | 0.500 |
| `XV-6120` | XV | — | 0.500 |
| `XV-6121` | XV | — | 0.500 |
| `XV-6122` | XV | — | 0.500 |
| `XV-6125` | XV | — | 0.500 |
| `XV-6126` | XV | — | 0.500 |
| `XV-6220` | XV | — | 0.500 |
| `XV-705` | XV | — | 0.500 |
| `XV-9125` | XV | — | 0.500 |

View File

@@ -1,464 +0,0 @@
# 실시간 태그 확장 설계안
## 1. 배경
현재 ExperionCrawler는 아날로그 태그의 `.pv`, `.sp`, `.op`, `.qv.value``realtime_table`에 저장하고 있음.
디지털 장비(Pump, XV)의 상태 정보와 모든 태그의 `.desc`, `.area` 메타데이터를 추가하고자 함.
## 2. 현재 시스템 아키텍처
```
Experion HS R530 OPC Server
↓ (1. 크롤링 - 전체 노드 스캔)
CSV 파일 저장
↓ (2. CSV 로드)
node_map_master (530,080 행)
↓ (3. UI에서 태그+속성 선택 - 최대 8개)
realtime_table (등록된 태그만)
↓ (4. OPC UA 구독)
livevalue 실시간 업데이트
↓ (5. 히스토리 저장)
history_table
```
### node_map_master 구조 특징
- `name`: 속성명만 (`pv`, `sp`, `desc`, `area`, `instate0` 등)
- `node_id`: 전체 경로 (`ns=1;s=sinamserver:xv-6124.pv`)
- 상위 Object (`xv-6124`)와 하위 속성 (`pv`)이 별도 행으로 존재
## 3. 추가할 태그
### 3.1 아날로그 태그 (기존 확장)
| 태그 | 데이터 타입 | 설명 |
|------|------------|------|
| `ficq-6101.area` | Enum(i=7594) | 소속 플랜트/Asset |
> 참고: ficq-6101은 `.desc` 속성이 없음 (DB 확인 결과)
### 3.2 디지털 태그 (신규 추가) (0~7까지 모두 추가)
| 태그 | 데이터 타입 | 설명 |
|------|------------|------|
| `xv-6124.pv` | Int32 (BCD 조합값) | 프로세스 값 |
| `xv-6124.op` | Int32 | 출력값 |
| `xv-6124.desc` | String | 장비 설명 |
| `xv-6124.area` | Enum(i=7594) | 소속 플랜트/Asset |
| `xv-6124.instate0` | Boolean | 상태 비트 0 (현재 값) |
| `xv-6124.instate1` | Boolean | 상태 비트 1 (현재 값) |
| `xv-6124.instate2` | Boolean | 상태 비트 2 (현재 값) |
| `xv-6124.state0descriptor` | String | 비트 0 의미 (예: "Run/Stop") |
| `xv-6124.state1descriptor` | String | 비트 1 의미 (예: "Remote/Local") |
| `xv-6124.state2descriptor` | String | 비트 2 의미 (예: "Trip/Normal") |
> **핵심 발견:** Experion HS에 `state0descriptor`~`state7descriptor`가 이미 존재합니다.
> 이는 사용자가 정의한 BCD 상태 의미를 String으로 저장한 태그로,
> OPC UA에서 구독하면 `realtime_table`에 저장 가능하므로 **별도 테이블 불필요**.
### 3.3 BCD 상태값 해석
`pv`는 HC900 컨트롤러에서 Digital Input을 BCD로 조합하여 32bit Float로 전송.
**직접 `pv` 값을 파싱하지 않고 `instate0~7` Boolean 태그를 사용.**
| 사용 비트 | 현재 프로젝트 예시 (Pump) | 현재 프로젝트 예시 (Valve) |
|-----------|---------------------------|----------------------------|
| instate0 | Run(1)/Stop(0) | Open(1)/Close(0) |
| instate0+1 | Run/Stop + R/L | Open/Close + R/L |
| instate0+1+2 | Run/Stop + R/L + Trip/Normal | Open/Close + R/L + Fault/Normal |
> **중요**: 위 값은 현재 프로젝트에서만 알려진 사용자 정의 값입니다.
> 다른 프로젝트에서는 BCD 상태 의미가 다를 수 있으므로 정형화 불가능합니다.
> 각 장비의 실제 비트 사용 수와 의미는Experion HS에서 사용자 정의됩니다.
## 4. 설계 결정
### 4.1 DB 스키마 변경: **tag_metadata 테이블 추가**
메타데이터(desc, area, state0descriptor~7)는 실시간으로 자주 변경되지 않으므로,
별도 테이블에 저장하고 OPC UA 통신 부담을 줄입니다.
```sql
CREATE TABLE tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL, -- ficq-6101, xv-6124
attribute TEXT NOT NULL, -- desc, area, state0descriptor, ...
value TEXT, -- 실제 값
node_id TEXT, -- OPC UA node_id (재로드용)
loaded_at TIMESTAMP DEFAULT NOW(), -- 마지막 로드 시간
UNIQUE(base_tag, attribute)
);
CREATE INDEX idx_tag_metadata_base ON tag_metadata(base_tag);
```
**데이터 분리 전략:**
| 테이블 | 저장 내용 | OPC UA 구독 |
|--------|----------|-------------|
| `realtime_table` | pv, sp, op, instate0~7 등 **실시간 데이터** | ✅ 지속 구독 |
| `tag_metadata` | desc, area, state0descriptor~7 등 **메타데이터** | ❌ 한 번 로드 |
**장점:**
- OPC UA 통신 부담 대폭 감소 (메타데이터는 한 번만 읽음)
- 수천 개 태그에 대해 desc/area를 실시간 구독하지 않음
- UI에서 "재로드" 버튼으로 필요시 갱신 가능
### 4.2 메타데이터 로드/재로드 시나리오
**초기 로드:**
1. UI에서 태그 선택 후 등록 시, 실시간 태그(pv, sp, op, instate0~7)는 `realtime_table`에 등록
2. 동시에 메타데이터(desc, area, state0descriptor~7)는 OPC UA에서 **한 번 읽어서** `tag_metadata` 테이블 저장
3. 읽은 메타데이터 정보로 `node_map_master`도 동시 갱신
**재로드 (UI 버튼):**
1. UI에서 "메타데이터 재로드" 버튼 클릭
2. Backend가 OPC UA 서버에 직접 연결
3. 선택된 태그(또는 전체 등록 태그)의 메타데이터 재조회
4. `tag_metadata` 테이블 UPDATE + `node_map_master` 갱신
```
UI 재로드 버튼
Backend API: POST /api/tags/metadata/reload
OPC UA Read (desc, area, state0descriptor~7)
tag_metadata UPDATE
node_map_master UPDATE (UPSERT)
Response: 갱신된 태그 수
```
### 4.3 SQL 뷰 추가
`realtime_table` + `tag_metadata`를 JOIN하여 태그별 속성을 한눈에 보기 위한 뷰:
```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,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
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 s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor';
```
### 4.4 UI 변경
| 항목 | 현재 | 변경 |
|------|------|------|
| 최대 선택 태그 수 | 8개 | 10개 |
| 태그 검색 | name 기준 | name + node_id 기준 |
| 메타데이터 재로드 | 없음 | "메타데이터 재로드" 버튼 추가 |
| 속성 미리보기 | 없음 | 선택 시 하위 속성 표시 (선택사항) |
## 5. 변경 사항 요약
### 5.1 Database
- **신규 테이블**: `tag_metadata` (base_tag, attribute, value, node_id, loaded_at)
- **신규 뷰**: `v_tag_summary` (realtime_table + tag_metadata JOIN)
### 5.2 Backend (`src/Infrastructure/OpcUa/`)
- **신규 서비스**: `MetadataLoaderService`
- `LoadMetadataAsync(base_tags)` : OPC UA에서 desc, area, state0descriptor~7 읽어서 `tag_metadata` 저장
- `ReloadMetadataAsync(base_tags)` : 재로드 시 OPC UA 재조회 + `tag_metadata` UPDATE + `node_map_master` UPSERT
- **신규 API**: `POST /api/tags/metadata/reload`
### 5.3 Frontend (`src/Web/wwwroot/js/app.js`)
- 태그 선택 최대 수 8 → 10개로 변경
- "메타데이터 재로드" 버튼 추가
- (선택사항) 상위 Object 선택 시 하위 속성 자동 표시
### 5.4 Entity/DbContext
- **신규 Entity**: `TagMetadata` class
- **DbContext**: `DbSet<TagMetadata>` 추가, `OnModelCreating` 설정 추가
### 5.5 사용자 작업
- UI에서 새로운 태그(Pump, XV)와 속성(pv, op, instate0~2) 선택하여 등록
- 메타데이터(desc, area, state descriptor)는 자동 로드
- 필요시 "메타데이터 재로드" 버튼으로 갱신
## 6. 대규모 리팩토링이 필요한 시나리오 (향후 고려사항)
현재 설계안은 기존 `realtime_table` 구조를 유지하므로 저위험입니다.
하지만 아래 기능들을 추가하려면 **DB 스키마 변경 + 코드 리팩토링**이 필요합니다.
### 6.1 자동 속성 제안 기능
**문제:** 현재 UI에서 각 태그의 속성(pv, sp, op, desc, area, instate0~2)을 수동으로 선택해야 함.
Pump/XV 같은 디지털 장비는 7개 이상의 속성을 선택해야 하므로 번거로움.
**해결 방안:**
- 상위 Object (예: `xv-6124`) 선택 시, node_map_master에서 하위 속성을 자동으로 조회하여 제안
- 장비 타입에 따라 기본 속성 조합 제안:
- 아날로그 (ficq-6101): `.pv`, `.sp`, `.op`, `.area`
- 디지털 XV (xv-6124): `.pv`, `.op`, `.desc`, `.area`, `.instate0`, `.instate1`, `.instate2`
- Pump (p-6102): `.pv`, `.op`, `.desc`, `.area`, `.instate0`, `.instate1`, `.instate2`
**변경 범위:**
- Frontend: 상위 Object 선택 시 하위 속성 자동 로딩 UI
- Backend: `/api/nodemap/children?parent=xv-6124` API 추가
- node_map_master에서 `node_id LIKE '%xv-6124.%'`로 하위 속성 조회
**리팩토링 위험:** 낮음 (기존 API 추가만 필요)
### 6.2 tag_registry 테이블 도입
**문제:** BCD 상태 의미(instate0=Run/Stop, instate1=R/L 등)는 사용자 정의이므로
시스템에서 자동으로 해석할 수 없음. Text-to-SQL에서 "pump-6102 상태 알려줘" 같은
자연어 질문에 의미 있는 답변을 제공하려면 상태 의미 정보가 필요함.
**해결 방안:**
```sql
CREATE TABLE tag_registry (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL UNIQUE, -- xv-6124, p-6102
equipment_type TEXT, -- pump, xv, ai, ao, fi, ti
bit_count INT DEFAULT 1, -- 사용하는 instate 비트 수 (1~3)
state0_meaning TEXT, -- Run/Stop, Open/Close
state1_meaning TEXT, -- Remote/Local
state2_meaning TEXT, -- Trip/Normal, Fault/Normal
area TEXT, -- 소속 플랜트/Asset
description TEXT, -- 장비 설명
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
**활용 시나리오:**
- BCD 상태 의미 저장: 사용자가 UI에서 상태 의미 정의 → DB 저장
- 자동 속성 제안: `equipment_type`에 따라 기본 속성 조합 제안
- Text-to-SQL: "pump-6102 현재 상태" → instate0~2를 Boolean으로 읽어서 상태 의미와 매핑하여 "Run, Remote, Normal" 표시
- P&ID 매핑: P&ID에서 추출한 장비와 Experion 태그를 `tag_registry`를 통해 연결
**리팩토링 위험:** 중간 (새 테이블 + Entity + Service 추가)
### 6.3 장비 타입별 다른 처리 로직
**문제:** 현재 `realtime_table`은 모든 태그를 동일하게 처리.
하지만 장비 타입에 따라 다른 처리가 필요할 수 있음:
- 아날로그: pv/sp/op를 숫자로 표시, 단위 표시
- 디지털 XV: instate0~2를 Boolean으로 읽어서 "Open/Close", "R/L"로 표시
- Pump: instate0~2를 Boolean으로 읽어서 "Run/Stop", "R/L", "Trip/Normal"로 표시
- 알람: alarmflags, alarmvalue를 파싱해서 알람 상태 표시
**해결 방안:**
- `tag_registry.equipment_type`에 따라 UI에서 다른 컴포넌트 렌더링
- Backend에서 장비 타입별 상태 해석 서비스 추가
- BCD 상태값을 Boolean 배열로 파싱하는 유틸리티 함수
**변경 범위:**
- Frontend: 장비 타입별 상태 표시 컴포넌트
- Backend: `EquipmentStateService` 추가 (BCD → Boolean 배열 → 상태 문자열)
- `tag_registry` 테이블과 연동
**리팩토링 위험:** 높음 (Frontend 컴포넌트 + Backend 서비스 + DB 연동)
### 6.4 desc/area 별도 테이블
**문제:** desc/area는 자주 변경되지 않지만, 현재는 `realtime_table`에 실시간 데이터로 저장.
수천 개의 태그에 대해 desc/area를 OPC UA에서 계속 구독하면 불필요한 트래픽 발생.
**해결 방안:**
```sql
CREATE TABLE tag_metadata (
tagname TEXT PRIMARY KEY, -- ficq-6101.area, xv-6124.desc
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
);
```
- 초기 로드 시 OPC UA에서 desc/area 한 번 읽어서 저장
- 변경 감지: 주기적 폴링 또는 이벤트 기반 업데이트
- UI 조회 시 `tag_metadata`에서 읽되, 변경 의심 시 OPC UA에서 재조회
**리팩토링 위험:** 중간 (새 테이블 + 폴링 서비스 추가)
## 7. 권장 진행 순서
### Phase 1: 기본 확장 (현재 설계안)
1. **UI 변경**: 태그 선택 최대 수 8 → 10개
2. **테스트**: xv-6124의 pv, op, desc, area, instate0~2 등록 및 OPC UA 구독 확인
3. **SQL 뷰**: `v_tag_summary` 생성 (선택사항)
### Phase 2: 자동화 (향후)
4. **자동 속성 제안**: 상위 Object 선택 시 하위 속성 자동 제안
5. **tag_registry 테이블**: BCD 상태 의미 저장, 장비 타입 관리
### Phase 3: 지능형 처리 (장기)
6. **장비 타입별 처리**: BCD 상태 해석, UI 컴포넌트별 렌더링
7. **desc/area 최적화**: 별도 테이블 + 변경 감지 폴링
8. **Text-to-SQL 연동**: 자연어 질문 → BCD 상태 해석 → 의미 있는 답변
## 8. NL2SQL 변경 사항
현재 NL2SQL worker(`mcp-server/worker/nl2sql_worker.py`)는 `history_table``realtime_table`만 인식합니다.
새로운 테이블과 뷰, 태그 타입을 인식하도록 `DB_SCHEMA`를 업데이트해야 합니다.
### 8.1 DB_SCHEMA 업데이트 필요 항목
**추가할 테이블 정의:**
```
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간 + 메타데이터 통합)
base_tag TEXT - 기본 태그명
pv TEXT - 현재 프로세스 값
sp TEXT - 설정값
op TEXT - 출력값
instate0 TEXT - 상태 비트 0 (true/false)
instate1 TEXT - 상태 비트 1 (true/false)
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
```
### 8.2 NL2SQL 사용 예시 (새로운 시나리오)
| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "ficq-6101의 현재 값과 설명은?" | `SELECT pv, description FROM v_tag_summary WHERE base_tag = 'ficq-6101'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |
| "p-6102의 3개 상태 비트와 의미를 보여줘" | `SELECT instate0, instate1, instate2, state0_descriptor, state1_descriptor, state2_descriptor FROM v_tag_summary WHERE base_tag = 'p-6102'` |
### 8.3 변경 파일 목록
| 파일 | 변경 내용 |
|------|----------|
| `mcp-server/worker/nl2sql_worker.py` | `DB_SCHEMA``tag_metadata`, `v_tag_summary` 추가 |
| `mcp-server/server.py` | `_DB_SCHEMA`에 동일하게 추가 (동기용) |
### 8.4 DB_SCHEMA 추가 내용 예시
```
테이블: tag_metadata (태그 메타데이터 - 변경 드묾, OPC UA 폴링으로 갱신)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor'~'state7descriptor')
value TEXT - 메타데이터 값
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
base_tag, pv, sp, op, instate0~2, description, area, state0~2_descriptor
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double), ficq-6101.area
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명 (예: "Run/Stop")
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
```
## 9. Event & Alarm 테이블 (지능형 가동 상태 보고용)
### 9.1 필요성
"어제 18시부터 6차 플랜트의 가동상태 알려줘" 같은 지능형 보고를 하려면:
- 펌프/밸브 상태 변화 이력
- 알람 발생/해제 시점
- 비상정지(ESHUTDOWN) 발생/해제 시점
- 유량계 적산값(qv.value)으로 생산량 계산
이 정보들은 **실시간 값이 아닌 이벤트 이력**이므로 별도 테이블 필요.
### 9.2 테이블 구조
```sql
CREATE TABLE event_alarm_log (
id BIGSERIAL PRIMARY KEY,
base_tag TEXT NOT NULL, -- xv-6124, p-6102
event_type TEXT NOT NULL, -- state_change, alarm, shutdown
bit_index INT, -- instate0~7 중 어떤 비트 (state_change용)
old_value TEXT, -- 이전 값
new_value TEXT, -- 새 값
state_descriptor TEXT, -- state0descriptor 등 의미
alarm_priority TEXT, -- Urgent/High/Low/Journal
alarm_message TEXT, -- 알람 메시지
occurred_at TIMESTAMPTZ NOT NULL, -- 발생 시각
acknowledged_at TIMESTAMPTZ, -- 확인 시각
source TEXT DEFAULT 'opcua' -- opcua, manual
);
CREATE INDEX idx_eal_base_tag ON event_alarm_log(base_tag);
CREATE INDEX idx_eal_occurred ON event_alarm_log(occurred_at);
CREATE INDEX idx_eal_type ON event_alarm_log(event_type);
```
### 9.3 수집 방법
**방법 1: OPC UA Event Subscription (권장)**
- Experion HS의 OPC UA Event Mechanism 구독
- 알람 발생 시 실시간으로 이벤트 수신 → DB 저장
**방법 2: 폴링 기반 상태 변화 감지**
- instate0~7 값을 주기적 폴링
- 이전 값과 비교하여 변화 감지 → event_alarm_log INSERT
- alarmflags, alarmvalue 변화도 함께 감지
### 9.4 활용 시나리오
| 질문 | 필요한 데이터 |
|------|--------------|
| "p6 플랜트 가동상태 알려줘" | pump instate0 변화 이력 + shutdown 이벤트 |
| "00시에 왜 정지됐어?" | event_alarm_log WHERE event_type='shutdown' |
| "오늘 생산량?" | qv.value 적산 (history_table) |
| "xv-6124 알람 이력" | event_alarm_log WHERE base_tag='xv-6124' |
### 9.5 NL2SQL DB_SCHEMA 추가 내용
```
테이블: event_alarm_log (이벤트/알람 이력)
base_tag TEXT - 기본 태그명
event_type TEXT - state_change, alarm, shutdown
bit_index INT - instate 비트 인덱스 (0~7)
old_value TEXT - 이전 값
new_value TEXT - 새 값
state_descriptor TEXT - 상태 의미 (예: "Trip, Fault")
alarm_priority TEXT - Urgent/High/Low/Journal
occurred_at TIMESTAMPTZ - 발생 시각
area - 그룹명 (플랜트 'p1~10' 에 그룹 알람 정보 찾기 쉬움)
예시: "오늘 p6 플랜트 알람 목록"
SELECT base_tag, event_type, new_value, state_descriptor, alarm_priority, occurred_at
FROM event_alarm_log
WHERE base_tag IN (SELECT base_tag FROM tag_metadata WHERE attribute='area' AND value='p6')
AND occurred_at >= date_trunc('day', NOW())
ORDER BY occurred_at
```
##### future Plan 항목
이 디자인 플랜의 후순위
- 알람 관련 작업 :Experion HS 알람 구성 파악, 알람 테이블 긁어올 가능성 있는지 체크
- WebUI 도입관련 : 진정한 AX도입의 FINAL WORK가 될 것이며, 위의 모든 기능은 최종 AX화를 위한 걸 기본으로 설계되어야 한다.

View File

@@ -1,110 +0,0 @@
# Roo 작업 지시: 노드맵 대시보드 undefined 필드 수정
## 배경 및 원인
`src/Web/Program.cs` 에 다음 설정이 있음:
```csharp
opt.JsonSerializerOptions.PropertyNamingPolicy = null; // PascalCase 직렬화
```
이로 인해 C# 익명 객체 shorthand `new { x.Id, x.NodeId }` 등은 PascalCase로 직렬화됨.
프론트엔드(`app.js`)는 camelCase(`r.id`, `r.nodeId`)로 접근 → **모든 값이 `undefined`로 표시됨**.
같은 문제를 Browse 엔드포인트에서도 확인했으며, 명시적 camelCase 익명 객체로 수정 완료:
```csharp
// 수정 전
return Ok(new { success = r.Success, nodes = r.Nodes, error = r.ErrorMessage });
// 수정 후
return Ok(new {
success = r.Success,
error = r.ErrorMessage,
nodes = r.Nodes.Select(n => new {
nodeId = n.NodeId,
displayName = n.DisplayName,
nodeClass = n.NodeClass,
hasChildren = n.HasChildren
})
});
```
---
## 수정 대상 파일
**`src/Web/Controllers/ExperionControllers.cs`**
클래스: `ExperionNodeMapController`
메서드: `Query()` (약 571번째 줄)
---
## 현재 코드 (문제)
```csharp
return Ok(new
{
total = r.Total,
items = r.Items.Select(x => new
{
x.Id, x.Level, x.Class, x.Name, x.NodeId, x.DataType
})
});
```
`x.Id` → JSON `"Id"` (PascalCase) → JS `r.id` = undefined
---
## 수정 후 코드 (목표)
```csharp
return Ok(new
{
total = r.Total,
items = r.Items.Select(x => new
{
id = x.Id,
level = x.Level,
@class = x.Class,
name = x.Name,
nodeId = x.NodeId,
dataType = x.DataType
})
});
```
`@class` 는 C# 예약어 회피용이며, JSON 직렬화 시 `"class"` 로 정상 출력됨.
---
## 추가 확인 사항 (같은 패턴이 있는지 전수 검사)
`ExperionControllers.cs` 전체에서 `PropertyNamingPolicy = null` 환경에서 PascalCase로 직렬화될 수 있는 패턴을 모두 찾아 수정:
1. `new { x.PropertyName }` 형태의 shorthand 익명 객체
2. 직접 typed record/class 인스턴스를 `Ok(...)` 에 넣는 경우
단, 다음은 이미 lowercase이므로 수정 불필요:
- `new { success = ..., nodes = ... }` — 명시적 소문자 키
- `new { total = ..., names = ... }` — 명시적 소문자 키
---
## 빌드 검증
수정 후 반드시:
```bash
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
```
- `Build succeeded` 확인
- 에러 0건 확인
---
## 클로드 코드 검수 항목
수정 완료 후 아래 내용을 검수 요청:
1. `ExperionNodeMapController.Query()` 응답 필드가 모두 camelCase인지
2. 같은 패턴(`{ x.Prop }`)이 다른 컨트롤러에도 있는지 확인 여부
3. 빌드 성공 여부
4. `@class` → JSON `"class"` 직렬화 정상 작동 여부

View File

@@ -1,416 +0,0 @@
# Text-to-SQL (PostgreSQL + TimeScaleDB) 추가 계획
## 1. 개요
ExperionCrawler 프로젝트에 **TimeScaleDB 하이퍼테이블**을 적용하여 시계열 데이터(`realtime_table`, `history_table`)의 성능과 확장성을 대폭 개선합니다. 또한 **Text-to-SQL 기능**을 추가하여 API를 통해 자연어/쿼리 입력으로 시계열 데이터를 조회·분석할 수 있도록 합니다.
---
## 2. 현재 아키텍처 분석
```mermaid
graph TB
subgraph WebLayer
Controller[Controllers]
Program[Program.cs]
end
subgraph Infrastructure
DbContext[ExperionDbContext]
DbService[ExperionDbService]
end
subgraph Core
CrawlService[ExperionCrawlService]
RealtimeSvc[ExperionRealtimeService]
HistorySvc[ExperionHistoryService]
end
subgraph Data
PostgreSQL[(PostgreSQL)]
CSV[CSV Files]
end
Controller --> DbContext
Controller --> DbService
DbContext --> PostgreSQL
RealtimeSvc --> DbService
HistorySvc --> DbService
DbService --> CSV
```
### 현재 사용 테이블
| 테이블명 | 용도 | 현재 방식 |
|----------|------|-----------|
| `realtime_table` | 실시간 모니터링 포인트 | 일반 PostgreSQL 테이블 |
| `history_table` | 시계열 이력 스냅샷 | 일반 PostgreSQL 테이블 |
| `raw_node_map` | OPC UA 노드맵 원시 데이터 | 일반 PostgreSQL 테이블 |
| `node_map_master` | 빌드된 노드맵 | 일반 PostgreSQL 테이블 |
| `experion_records` | 크롤링 기록 | EF Core DbSet |
---
## 3. 목표 아키텍처
```mermaid
graph TB
subgraph WebLayer
Controller[Controllers]
TextToSql[Text-to-SQL Service]
Program[Program.cs]
end
subgraph Infrastructure
DbContext[ExperionDbContext]
DbService[ExperionDbService]
TimeSeriesSvc[TimeSeries Service]
end
subgraph Core
CrawlService[ExperionCrawlService]
RealtimeSvc[ExperionRealtimeService]
HistorySvc[ExperionHistoryService]
end
subgraph Data
TimeScaleDB[(TimeScaleDB<br/>HyperTables)]
CSV[CSV Files]
end
Controller --> TextToSql
TextToSql --> DbService
Controller --> DbContext
Controller --> DbService
DbContext --> TimeScaleDB
RealtimeSvc --> TimeSeriesSvc
HistorySvc --> TimeSeriesSvc
TimeSeriesSvc --> DbService
DbService --> CSV
```
---
## 4. 구현 단계별 계획
### 단계 1: TimeScaleDB 패키지 추가 및 초기화
**목표**: TimeScaleDB 확장 활성화 및 하이퍼테이블 생성
#### 4.1.1 NuGet 패키지 추가
- `Npgsql.EntityFrameworkCore.PostgreSQL` (이미 존재)
- `Npgsql.TimeScaleDB` (새로 추가)
#### 4.1.2 데이터베이스 초기화 수정
- [`ExperionDbContext`](src/Infrastructure/Database/ExperionDbContext.cs:1)에 TimeScaleDB 확장 활성화 코드 추가
- `history_table``history_hypertable` (하이퍼테이블)로 마이그레이션
- `realtime_table``realtime_hypertable` (하이퍼테이블)로 마이그레이션
#### 4.1.3 마이그레이션 스크립트
```sql
-- TimeScaleDB 확장 활성화
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- 기존 history_table 데이터를 백업
CREATE TABLE history_table_backup AS SELECT * FROM history_table;
-- 기존 테이블 삭제 후 하이퍼테이블 생성
DROP TABLE history_table;
SELECT create_hypertable('history_hypertable', 'recorded_at',
chunk_time_interval => INTERVAL '1 day',
create_default_indexes => true);
-- 데이터 복원
INSERT INTO history_hypertable (id, tagname, node_id, value, recorded_at)
SELECT id, tagname, node_id, value, recorded_at FROM history_table_backup;
DROP TABLE history_table_backup;
```
---
### 단계 2: Text-to-SQL 서비스 구현
**목표**: 자연어/쿼리 입력으로 시계열 데이터를 조회하는 서비스 계층 구현
#### 4.2.1 인터페이스 정의 (`ITextToSqlService`)
```
namespace ExperionCrawler.Core.Application.Interfaces;
public interface ITextToSqlService
{
/// <summary>자연어 질의를 SQL로 변환</summary>
Task<string> ParseNaturalLanguageAsync(string input);
/// <summary>SQL 쿼리 실행 및 결과 반환</summary>
Task<SqlQueryResult> ExecuteQueryAsync(string sql);
/// <summary>쿼리 제안 (자동 완성)</summary>
Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput);
/// <summary>시계열 분석 (평균, 최대, 최소, 추세)</summary>
Task<TimeSeriesAnalysisResult> AnalyzeAsync(string tagName, DateTime? from, DateTime? to);
}
```
#### 4.2.2 구현체 (`TextToSqlService`)
- **자연어 파싱**: 키워드 기반 파서 (tag name, 시간 범위, 집계 함수)
- **SQL 생성**: TimeScaleDB 함수 (`time_bucket`, `avg`, `max`, `min`, `last`) 활용
- **결과 매핑**: 동적 SQL → DTO 매핑
#### 4.2.3 자연어 파싱 예시
| 입력 | 생성된 SQL |
|------|-----------|
| "PV001 온도 최근 1시간 평균" | `SELECT time_bucket('5 min', recorded_at) AS bucket, AVG(value::float) FROM history_hypertable WHERE tagname = 'PV001' AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY bucket ORDER BY bucket` |
| "전체 태그 현재 값" | `SELECT DISTINCT ON (tagname) tagname, livevalue, timestamp FROM realtime_hypertable ORDER BY tagname, timestamp DESC` |
| "PV001, PV002 최근 24시간 최대값" | `SELECT time_bucket('1 hour', recorded_at) AS bucket, tagname, MAX(value::float) FROM history_hypertable WHERE tagname IN ('PV001', 'PV002') AND recorded_at > NOW() - INTERVAL '24 hour' GROUP BY bucket, tagname ORDER BY bucket, tagname` |
---
### 단계 3: API 엔드포인트 추가
**목표**: Text-to-SQL 기능을 REST API로 노출
#### 4.3.1 컨트롤러 (`TextToSqlController`)
```
[ApiController]
[Route("api/text-to-sql")]
public class TextToSqlController : ControllerBase
{
private readonly ITextToSqlService _service;
[HttpPost("parse")]
public Task<IActionResult> Parse([FromBody] NaturalLanguageQueryDto dto);
[HttpPost("execute")]
public Task<IActionResult> Execute([FromBody] SqlQueryDto dto);
[HttpGet("suggest")]
public Task<IActionResult> Suggest([FromQuery] string input);
[HttpPost("analyze")]
public Task<IActionResult> Analyze([FromBody] AnalysisRequestDto dto);
}
```
#### 4.3.2 DTO 정의
```
public class NaturalLanguageQueryDto
{
public string Query { get; set; } = string.Empty;
public string Language { get; set; } = "ko"; // "ko" or "en"
}
public class SqlQueryDto
{
public string Sql { get; set; } = string.Empty;
public int? Limit { get; set; } = 1000;
}
public class AnalysisRequestDto
{
public List<string> TagNames { get; set; } = new();
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string Interval { get; set; } = "5 min"; // time_bucket interval
}
```
---
### 단계 4: TimeSeries 서비스 개선
**목표**: TimeScaleDB 특화 함수 활용하여 성능 최적화
#### 4.4.1 기존 `ExperionDbService` 수정
- `history_table``history_hypertable` 참조로 변경
- `realtime_table``realtime_hypertable` 참조로 변경
- TimeScaleDB 함수 (`time_bucket`, `continuous aggregates`) 활용
#### 4.4.2 연속 집계 (Continuous Aggregates) 생성
```sql
-- 5분 단위 집계 뷰 생성
CREATE MATERIALIZED VIEW history_5min_agg
WITH (timescaledb.continuous) AS
SELECT
time_bucket('5 min', recorded_at) AS bucket,
tagname,
AVG(value::float) AS avg_value,
MIN(value::float) AS min_value,
MAX(value::float) AS max_value,
FIRST(value::float, recorded_at) AS open_value,
LAST(value::float, recorded_at) AS close_value,
COUNT(*) AS point_count
FROM history_hypertable
GROUP BY bucket, tagname;
-- 기존 데이터 리프레시
REFRESH MATERIALIZED VIEW history_5min_agg;
```
---
### 단계 5: Program.cs 설정 업데이트
**목표**: 서비스 등록 및 초기화 플로우 수정
#### 4.5.1 서비스 등록
```csharp
// Text-to-SQL 서비스
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
// TimeSeries 서비스
builder.Services.AddScoped<ITimeSeriesService, TimeSeriesService>();
```
#### 4.5.2 연결 문자열 확인
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=experion_crawler;Username=postgres;Password=postgres"
}
}
```
---
## 5. 파일 구조 변경
```
src/
├── Core/
│ ├── Application/
│ │ ├── DTOs/
│ │ │ ├── ExperionDtos.cs (수정: Text-to-SQL DTO 추가)
│ │ │ └── TextToSqlDtos.cs (신규)
│ │ ├── Interfaces/
│ │ │ ├── IExperionServices.cs (수정: ITextToSqlService, ITimeSeriesService 추가)
│ │ │ └── ITextToSqlService.cs (신규)
│ │ └── Services/
│ │ ├── ExperionCrawlService.cs (변경 없음)
│ │ └── TextToSqlService.cs (신규)
│ └── Domain/
│ └── Entities/
│ ├── ExperionEntities.cs (수정: TimeScaleDB 엔티티 추가)
│ └── TimeSeriesEntities.cs (신규)
├── Infrastructure/
│ ├── Database/
│ │ ├── ExperionDbContext.cs (수정: 하이퍼테이블 설정)
│ │ ├── TimeSeriesService.cs (신규)
│ │ └── Migrations/ (신규: TimeScaleDB 마이그레이션)
│ └── ... (기존 파일 유지)
└── Web/
├── Controllers/
│ ├── ExperionControllers.cs (수정: Text-to-SQL 엔드포인트 추가)
│ └── TextToSqlController.cs (신규)
├── appsettings.json (수정: 연결 문자열)
└── ExperionCrawler.csproj (수정: NuGet 패키지 추가)
```
---
## 6. TimeScaleDB 특화 기능
### 6.1 체킹 간격 설정
| 테이블 | chunk_time_interval | 설명 |
|--------|-------------------|------|
| `history_hypertable` | 1일 | 이력 데이터는 1일 단위 청크 |
| `realtime_hypertable` | 1시간 | 실시간 데이터는 1시간 단위 청크 |
### 6.2 데이터 보존 정책
```sql
-- 90일 이전 데이터 자동 삭제
SELECT add_drop_chunks_policy('history_hypertable', INTERVAL '90 days');
```
### 6.3 압축 설정
```sql
-- 1일 이전 데이터 자동 압축
SELECT add_compression_policy('history_hypertable', INTERVAL '1 day');
```
---
## 7. NuGet 패키지 변경
### 추가 패키지
| 패키지 | 버전 | 용도 |
|--------|------|------|
| `Npgsql.TimeScaleDB` | 최신 | TimeScaleDB 전용 확장 |
### 기존 패키지 (변경 없음)
| 패키지 | 현재 버전 |
|--------|----------|
| `Npgsql.EntityFrameworkCore.PostgreSQL` | 8.0.11 |
| `Microsoft.EntityFrameworkCore.Design` | 8.0.13 |
---
## 8. 구현 체크리스트
- [ ] **단계 1**: TimeScaleDB 패키지 추가 및 초기화
- [ ] `Npgsql.TimeScaleDB` NuGet 패키지 추가
- [ ] `ExperionDbContext`에 TimeScaleDB 확장 코드 추가
- [ ] `history_table``history_hypertable` 마이그레이션
- [ ] `realtime_table``realtime_hypertable` 마이그레이션
- [ ] **단계 2**: Text-to-SQL 서비스 구현
- [ ] `ITextToSqlService` 인터페이스 정의
- [ ] `TextToSqlService` 구현체 작성
- [ ] 자연어 파서 (한국어/영어 지원)
- [ ] SQL 생성 로직 (TimeScaleDB 함수 활용)
- [ ] 시계열 분석 기능 (평균, 최대, 최소, 추세)
- [ ] **단계 3**: API 엔드포인트 추가
- [ ] `TextToSqlController` 생성
- [ ] `/api/text-to-sql/parse` 엔드포인트
- [ ] `/api/text-to-sql/execute` 엔드포인트
- [ ] `/api/text-to-sql/suggest` 엔드포인트
- [ ] `/api/text-to-sql/analyze` 엔드포인트
- [ ] DTO 정의
- [ ] **단계 4**: TimeSeries 서비스 개선
- [ ] `ExperionDbService` 수정 (하이퍼테이블 참조)
- [ ] 연속 집계 (Continuous Aggregates) 생성
- [ ] TimeScaleDB 함수 활용 쿼리 최적화
- [ ] **단계 5**: Program.cs 설정 업데이트
- [ ] 서비스 등록
- [ ] 연결 문자열 확인
- [ ] 초기화 플로우 수정
- [ ] **단계 6**: 테스트 및 문서화
- [ ] API 테스트 (Swagger)
- [ ] 자연어 파싱 테스트
- [ ] TimeScaleDB 성능 벤치마크
- [ ] README.md 업데이트
---
## 9. 예상 효과
| 항목 | 기존 | 개선 후 |
|------|------|--------|
| 시계열 데이터 삽입 속도 | 일반 테이블 | TimeScaleDB 청킹으로 10x 이상 향상 |
| 시간 범위 쿼리 | 전체 테이블 스캔 | 청크 제거로 빠른 응답 |
| 데이터 압축 | 없음 | 자동 압축 (5-10x 저장공간 절약) |
| 자동 보존 | 수동 삭제 | 정책 기반 자동 삭제 |
| Text-to-SQL | 없음 | 자연어 기반 시계열 쿼리 |
---
## 10. 주의사항
1. **TimeScaleDB 설치 prerequisite**: 대상 서버에 TimeScaleDB가 설치되어 있어야 함
```bash
# Ubuntu/Debian
sudo apt-get install timescaledb-postgresql-16
```
2. **기존 데이터 마이그레이션**: `history_table`과 `realtime_table`의 기존 데이터를 새 하이퍼테이블로 이동해야 함
3. **연결 문자열**: `Trust Server Certificate=true`는 TimeScaleDB에서 필요하지 않을 수 있음
4. **EF Core 제한**: EF Core는 TimeScaleDB 하이퍼테이블을 직접 지원하지 않음
- Raw SQL 쿼리 활용 필요
- `ExecuteSqlRawAsync`, `FromSqlRaw` 사용
5. **백호환성**: 기존 API 엔드포인트는 변경되지 않도록 유지

View File

@@ -1,145 +0,0 @@
# 결정 보류 2건 — 사용자 액션 가이드
> 작성일: 2026-05-14
> 대상: `plans/빅피클-잔여작업-코딩계획.md` 7번(현장 재고), 8번(BGE-M3)
> 상태: 코드 작업 불필요. **사용자 의사 결정과 운영 액션이 필요**.
---
## 1. 현장 재고 데이터 출처 결정
### 현황
Phase 0 설계서(G3)에서 "현장 재고 데이터 자체가 시스템에 없음"으로 식별됨.
운전원이 "P-6201 펌프 예비품 재고", "교체용 PT100 센서 수량" 같은 질문을 했을 때
참조할 데이터 소스가 없어 답변 불가.
### 결정해야 할 것
| 옵션 | 설명 | 사용자 액션 |
|------|------|------------|
| **A. KB 업로드 (권장)** | 엑셀/CSV/PDF 재고 대장을 `plant_operation` 또는 `report` 컬렉션에 업로드 → 즉시 RAG 검색 가능 | 1) 재고 대장 파일 확보 (관리부/구매팀 협조)<br>2) 컬럼 구조 정리 (품목·규격·수량·위치·재발주점)<br>3) 14번 탭 "RAG 관리" → 업로드 |
| **B. ERP/MES API 연동** | 외부 시스템에서 실시간 재고 조회 | 1) ERP 운영팀과 API 협의<br>2) 인증/네트워크 방화벽 정책<br>3) 별도 개발 필요 (Phase 8) |
| **C. 수동 입력 테이블** | PostgreSQL에 `inventory_table` 신설, 관리자 UI에서 직접 입력 | 1) 누가 어떻게 최신화할지 거버넌스 결정<br>2) 별도 개발 필요 |
| **D. 보류 유지** | 운전원 질문 시 "재고 정보는 별도 시스템 참조" 안내 | 운전원 교육 |
### 권장: 옵션 A
**근거**:
- 현재 KB 시스템(Qdrant 5종 컬렉션)이 이미 운영 중
- xlsx/pdf 자동 파싱 + 임베딩 + 한국어 검색 모두 동작
- 코드 추가 0줄, 즉시 시도 가능
- 부족하다면 그때 B 또는 C로 확장
### 사용자가 해야 할 일 (옵션 A 채택 시)
1. **재고 대장 파일 확보**
- [ ] 관리부/구매팀에서 최신 재고 엑셀 입수
- [ ] 민감정보(가격·공급사 단가 등) 제외 또는 가공 결정
2. **업로드**
- [ ] 앱 접속 → 14번 "RAG 관리" 탭
- [ ] 관리자 비밀번호 로그인
- [ ] 컬렉션 = `plant_operation` (또는 신규 `inventory` 컬렉션을 원하면 DB 시드 확장 요청)
- [ ] 태그 = `inventory`, `예비품` 등 일관된 태그 부착
3. **검증**
- [ ] 채팅(#13)에서 "PT100 센서 재고 알려줘" 같은 질문으로 검색 결과 확인
- [ ] 결과가 부정확하면 청크 미리보기(🔍 버튼)로 인덱싱 품질 확인
4. **운영 룰**
- [ ] 월 1회 or 분기 1회 최신 파일 재업로드 (이전 버전은 "🚫 비활성화" 후 "🗑 정리")
- [ ] 갱신 주체 1명 지정
### 사용자가 해야 할 일 (옵션 B/C로 갈 경우)
**이 문서 범위 밖**. 별도 요건 정의서가 필요. 본 항목은 옵션 결정만 요청.
---
## 2. 임베딩 모델 BGE-M3 마이그레이션 검토
### 현황
- 현재 사용 중: **`nomic-embed-text`** (768-dim, Ollama 호스트)
- 후보: **`bge-m3`** (1024-dim, 다국어 SOTA, 특히 한국어 성능 우수)
- 5개 Qdrant 컬렉션이 768-dim으로 생성된 상태
### 결정해야 할 것
전환 시 **이득**과 **비용**을 비교 후 GO/NO-GO/POSTPONE 결정.
| 이득 | 비용/위험 |
|------|----------|
| 한국어 검색 품질 향상 (대시기관 평가 기준 +5~15%) | Qdrant 컬렉션 5개 전부 재생성 → 기존 인덱스 일시 소실 |
| 멀티링구얼(영문 매뉴얼+한국어 SOP 혼합)에 강함 | 1024-dim → Qdrant 디스크 사용량 +33% |
| Dense + Sparse + ColBERT 통합 모델 | 임베딩 속도 ~30% 느려짐 (GPU 점유율 ↑) |
| 청크 크기 8K 토큰까지 지원 (nomic은 2K) | Ollama가 bge-m3 미지원 시 다른 호스트 필요 (HF transformers 등) |
### 마이그레이션 절차 (실행 시)
1. **사전 평가** ⚠️ (사용자 액션)
- [ ] BGE-M3가 Ollama Library에 있는지 확인 (`ollama pull bge-m3`)
- [ ] 없으면 대안 결정: HF transformers, sentence-transformers 또는 별도 임베딩 서버
- [ ] 현재 KB 검색 품질에 실질 문제가 있는지 — 운전원 피드백 수집 (1~2주)
2. **샘플 비교 (선택)** (사용자 액션)
- [ ] 동일 한국어 쿼리 10건을 두 모델로 비교
- [ ] 한국어 정밀도에서 명백한 개선이 보일 때만 GO
3. **백업** (사용자 액션)
- [ ] `storage/kb/` 전체 백업
- [ ] `pg_dump``kb_*` 테이블 백업
4. **코드 작업** (개발자 액션 — 본 문서 범위 아님)
- `appsettings.json``Kb:EmbeddingModel`, `Kb:VectorSize` 설정 추가
- `KbEmbeddingClient.cs` 에서 모델명/차원 환경변수 참조
- `KbStartupService.cs` 에서 컬렉션 차원 mismatch 감지 시 재생성
5. **컷오버** (사용자 액션)
- [ ] 운영 외 시간대 선택 (KB 검색 일시 중단)
- [ ] Qdrant 컬렉션 5개 삭제 → 새 차원으로 재생성
- [ ] 모든 KB 문서 일괄 `재인덱싱(↻)` 트리거
- [ ] 완료까지 모니터링 (문서 수 × 청크 수 × 임베딩 시간)
### 권장: POSTPONE (당분간 보류)
**근거**:
- 현재 nomic-embed-text 검색 품질이 운영에 임계 미달이라는 정량 증거가 없음
- BGE-M3 전환은 비가역적 비용(재인덱싱 시간, 디스크 +33%)이 큰 결정
- Phase 7 신규 기능(채팅 통합·청크 미리보기·요약 등)이 운영에서 안정화된 후
사용자 피드백으로 검색 품질 이슈가 누적되면 그때 검토
### 사용자가 해야 할 일
- [ ] 1~2개월간 운영하면서 **KB 검색 결과가 부정확했던 사례를 메모**
- 질문 / 기대 답변 / 실제 검색된 문서 / 점수
- 메모 위치: 별도 텍스트 파일 또는 본 문서에 추가
- [ ] 사례가 **5건 이상 누적**되고 한국어 매칭 실패가 패턴화되면 마이그레이션 재검토 회의
- [ ] 회의 시점에 다시 본 문서를 갱신 (GO/NO-GO 결정 기록)
### 즉시 가능한 대안 (개발자 액션 불필요)
검색 품질이 부족할 때 BGE-M3 전환 없이 시도해 볼 수 있는 것:
| 시도 | 방법 |
|------|------|
| 더 좋은 청킹 | 업로드 시 `chunking_policy` 옵션 조정 (현재는 자동) |
| 태그 보강 | KB 업로드 시 태그를 풍부하게 부착 → `search_kb`의 tags 필터로 정확도 ↑ |
| 컬렉션 분리 | doc_type별 5개 컬렉션을 더 세분화 |
| `top_k` 조정 | 채팅에서 검색 결과 수를 늘려 LLM이 더 많은 단서로 답하게 |
---
## 체크리스트 요약 (사용자가 해야 할 일)
### 단기 (이번 주 ~ 2주 내)
- [ ] **재고 데이터**: 옵션 A(KB 업로드) 채택 여부 결정 → 채택 시 관리부에 파일 요청
- [ ] **BGE-M3**: 마이그레이션은 보류, 검색 품질 모니터링 시작
### 중기 (1~3개월)
- [ ] 재고 KB 파일 1차 업로드 + 검증
- [ ] KB 검색 부정확 사례 누적 (5건 임계)
### 장기 (3개월+)
- [ ] BGE-M3 마이그레이션 재검토 회의 (사례 누적 시)
- [ ] 재고 데이터를 ERP 연동으로 격상할지 검토 (옵션 A로 한계 도달 시)
---
## 본 문서 갱신 규칙
이 문서는 결정이 진행됨에 따라 사용자가 직접 갱신합니다.
- 결정 완료 시: 해당 섹션에 "결정 결과", "결정자", "결정일" 추가
- 보류 유지: "재검토 예정일" 추가
- 옵션 변경: 새 옵션 행 추가 + 이전 결정 사유 보존

View File

@@ -1,14 +0,0 @@
qwen 역할
1) todo.md 읽고,
2) diagnosis-checklist.md 규칙에 따라서 스텝별 실행계획 만들기
3) 스텝 미션을 gemma4 에게 전달
5) 끝났다고 보고 받으면, 검증 시작
6) 검증완료면 다음 스텝 미션 전달
gemma4는
1) 스텝 미션 받아서,
2) 코딩하고,
3) diagnosis-checklist.md에 따라서 자기 검증
4) 문제있으면 loop 돌림
5) 끝나면 끝났다고 보고

View File

@@ -1,301 +0,0 @@
# 배포 테스트 — Qwen2.5-7B-Instruct-FP8 모델 전환 검토
**날짜**: 2026-05-15
**환경**: NVIDIA GB10 (DGX Spark) / 통합 메모리 121 GiB / vLLM in Docker `vllm_node`
**결론**: **7B 운영 부적합. 27B 유지 결정.**
---
## TL;DR
- 동기: 27B Qwen3.6-FP8이 48 tok/s로 느리게 느껴져, 더 가벼운 7B FP8로 교체 시 속도/메모리 이득을 볼 수 있는지 검토.
- 실측: 7B FP8(`RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic`)이 29.4 tok/s로 오히려 **느림** (27B는 `qwen3_next_mtp` speculative decoding 효과).
- 결정타: 7B는 **도구 호출을 회피하고 가짜 데이터를 만들어냄** — 운전원 안전 위험. 도구 토글 ON·에이전트 모드 ON에도 동작 안 함.
- 조치: 27B로 즉시 복귀. 검증 중 만든 시스템 프롬프트 강화는 그대로 유지(27B에도 도움).
---
## 1. 환경 정보
### 하드웨어
| 항목 | 값 |
|---|---|
| GPU/CPU | NVIDIA GB10 (Grace Blackwell, unified memory) |
| 통합 메모리 | 121 GiB (시스템·GPU 공유) |
| Swap | 15 GiB |
### 27B 운영 구성 (baseline)
| 항목 | 값 |
|---|---|
| 모델 | `Qwen/Qwen3.6-27B-FP8` |
| 메모리 점유 | ~97 GiB |
| `max-model-len` | 262,144 (256K) |
| `gpu-memory-utilization` | 0.80 |
| `speculative-config` | `qwen3_next_mtp`, 2 tokens |
| `tool-call-parser` | `qwen3_coder` |
| 측정 속도 | **48 tok/s** |
| GB10 튜닝 mod | `mods/vllm-tune-qwen--qwen3.6-27b-fp8-tp1` 적용 |
---
## 2. 7B FP8 띄우기 레시피 (재현용)
### 모델 선택
HF에서 `neuralmagic/Qwen2.5-7B-Instruct-FP8`**존재하지 않음** (404). neuralmagic이 RedHatAI로 흡수됨.
올바른 repo:
-`RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic` (다운로드 2378회, 공식 후속)
- 그 외: `CalamitousFelicitousness/...`, `llmcompressor-quants/...` 등 community 변형
### 27B 종료 + 7B 기동 절차
```bash
# 1. 현재 27B vllm 프로세스만 종료 (컨테이너는 sleep infinity로 유지)
docker exec vllm_node pkill -f "vllm serve"
sleep 5
# 2. 7B FP8 기동 (launch-cluster.sh exec 패턴)
cd /home/windpacer/ai-models/spark-vllm-docker
./launch-cluster.sh -t vllm-node-tf5 --solo -d \
exec vllm serve RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic \
--served-model-name Qwen2.5-7B-Instruct-FP8 \
--max-model-len 32768 \
--max-num-seqs 4 \
--enable-prefix-caching \
--gpu-memory-utilization 0.30 \
--port 8000 --host 0.0.0.0 \
--enable-chunked-prefill \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--trust-remote-code \
-tp 1
```
### 27B 명령에서 제거한 인자 (Qwen3 전용)
| 인자 | 이유 |
|---|---|
| `--apply-mod mods/vllm-tune-qwen--qwen3.6-27b-fp8-tp1` | Qwen3.6-27B 전용 GEMM kernel config |
| `--load-format instanttensor` | Qwen3 instant tensor 최적화 |
| `--reasoning-parser qwen3` | Qwen3 reasoning trace (Qwen2.5 미지원) |
| `--default-chat-template-kwargs '{"preserve_thinking":true}'` | Qwen3 thinking block |
| `--speculative-config '{"method":"qwen3_next_mtp",...}'` | Qwen3 MTP — Qwen2.5에 적용 불가 |
| `--tool-call-parser qwen3_coder` | → `hermes`로 교체 (Qwen2.5는 hermes 포맷) |
| `--max-model-len 262144` | → 32768로 축소 (KV 캐시 절약) |
| `--gpu-memory-utilization 0.80` | → 0.30 (7B는 메모리 적게 필요) |
| `--max-num-batched-tokens 32768` | 32K로 충분 |
| `--override-generation-config` | 7B 기본값 사용 |
### 동기화 작업
```bash
# vllm_model 표시명을 UI에 맞게 변경
echo '{"vllm_model":"Qwen2.5-7B-Instruct-FP8"}' > mcp-server/llm-model.json
```
`appsettings.json`의 vLLM endpoint, `OllamaController.cs`, `KbEmbeddingClient.cs` 등은 **변경 불필요** — 포트 8000 동일.
### 기동 시간
- 가중치 다운로드: 152초 (~8 GiB, 첫 회만)
- 가중치 로드: 45초
- torch.compile 캐시: ~30초
- **합계 약 4분** (캐시 후 재기동은 ~75초)
---
## 3. 검증 결과
### 3.1 메모리 점유
| 모델 | 점유 | 가용 |
|---|---|---|
| 27B FP8 (max-len 256K, util 0.80) | 97 GiB | 11 GiB |
| 7B FP8 (max-len 32K, util 0.30) | ~25 GiB | ~85 GiB |
### 3.2 순수 채팅 속도 (3회 평균)
```
27B FP8 + qwen3_next_mtp : 48 tok/s
7B FP8 (vanilla) : 29.4 tok/s
```
**역설**: 7B가 더 느림. 이유:
1. 27B는 MTP(Multi-Token Prediction) speculative decoding으로 한 step에 2~3토큰 생성
2. 27B는 GB10 전용 GEMM kernel mod 적용
3. 7B는 default kernel + vanilla autoregressive
### 3.3 도구 호출 능력 — 결정적 검증
#### 테스트 1: 일반 KB 질의
> "5월12일 6차플랜트 이상 상황 보고해줘"
- 7B 응답: `summarize_events(since=2026-05-12T00:00:00Z, area=P6)` ⭕ 도구 선택·area 매핑 정확
- 단 KST→UTC 변환 일부 생략 (27B는 `2026-05-11T15:00:00Z`로 정확)
#### 테스트 2: 시간 인자 정확도
> "지난 30분 동안 ficq-6101.pv의 값을 표시해줘"
- 7B 응답: `time_from: 2026-05-15T14:00:00, time_to: 2026-05-15T14:30:00` (**현재로부터 +16시간 미래!**)
- 결과 `count: 0` → "데이터 없습니다" 답변 (사실은 84건 존재)
- 원인: 7B가 "지난 30분"의 절대 시각 계산 못 함 → 학습 데이터에서 흔한 KST 14:00 차용 후 KST→UTC 변환 생략
**시스템 프롬프트 강화 후 재시도** (`BuildDateContextKo`에 시:분 + 상대시간 표 + 미래 금지):
- 결과: 시간은 그럴듯하게 표기되었으나 **도구 호출 자체를 skip**하고 가짜 값(50.2 → 51.1 단조 증가) 생성
#### 테스트 3: 결정적 검증 프롬프트
요청:
```
다음 3가지를 정확히 알려줘. 도구 결과만 인용, 추측 금지.
1) ficq-6101.pv 가장 최근 1건 (값 + UTC 시각)
2) ficq-6101.sp 가장 최근 1건 (값 + UTC 시각)
3) event_history_table 전체 이벤트 수
```
비교:
| 항목 | 7B 답변 | 실제 DB | 판정 |
|---|---|---|---|
| 1) ficq-6101.pv | `50.2` @ `22:19:00Z` | `47.4666...` @ `22:23:03.055Z` | ❌ |
| 2) ficq-6101.sp | `50.5` @ `22:18:00Z` | `34.4000...` @ `22:23:03.055Z` | ❌ |
| 3) event_history_table count | **`12345`** | **`53`** | ❌ |
**3가지 모두 fabrication**. 특히 `12345`는 챗봇 demo placeholder. "도구 결과 그대로 인용"이라고 7B가 표기까지 했음에도 실제 도구 호출 없음.
#### UI 측 도구 호출 카드
- 도구 토글: ON ✅
- 에이전트 모드(ReAct 강제): ON ✅
- **실제 도구 카드 표시: 없음** ❌
7B가 도구 호출 자체를 회피. ReAct 프롬프트도 무시.
### 3.4 일반 채팅 품질
- 한국어 자연스러움: ⭕ 양호 (시스템 프롬프트 한자 금지 강화 후)
- 프로젝트 설명 등 paraphrase: ⭕ 양호
- 시스템 프롬프트 내용 활용: ⭕ 양호
---
## 4. 결론
| 사용 유형 | 7B FP8 평가 |
|---|---|
| 일반 한국어 Q&A | ⭕ 사용 가능 |
| 시스템/문서 내용 paraphrase | ⭕ 사용 가능 |
| **실시간 도구 호출** | ❌ **결정적 실패** |
| **데이터 값 정확 인용** | ❌ **fabrication** |
| 속도 | ❌ 27B보다 느림 |
| 메모리 절약 | ⭕ 유일한 이점 (~70 GiB 절약) |
**판단 기준**: 운전원이 "최근 PV값"을 물었을 때 가짜 수치(50.2 vs 실제 47.47)를 사실로 받는 것은 산업 안전 위험. 12,345 같은 명백한 placeholder를 53건으로 보고하면 운영 판단 완전 왜곡.
**7B 운영 부적합 확정**.
---
## 5. 27B 롤백 절차
검증 종료 시 즉시 복귀.
### 롤백 스크립트 (`/tmp/restore-27b.sh`)
```bash
#!/bin/bash
# 27B vLLM 복귀
set -e
cd /home/windpacer/ai-models/spark-vllm-docker
# 현재 vllm 프로세스 종료
docker exec vllm_node pkill -f "vllm serve" 2>/dev/null || true
sleep 3
./launch-cluster.sh -t vllm-node-tf5 --solo -d \
--apply-mod mods/vllm-tune-qwen--qwen3.6-27b-fp8-tp1 \
exec vllm serve Qwen/Qwen3.6-27B-FP8 \
--served-model-name Qwen3.6-27B-FP8 \
--max-model-len 262144 \
--max-num-seqs 4 \
--enable-prefix-caching \
--gpu-memory-utilization 0.80 \
--port 8000 --host 0.0.0.0 \
--load-format instanttensor \
--enable-chunked-prefill \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--reasoning-parser qwen3 \
--max-num-batched-tokens 32768 \
--trust-remote-code \
--default-chat-template-kwargs '{"preserve_thinking": true}' \
--speculative-config '{"method": "qwen3_next_mtp", "num_speculative_tokens": 2}' \
--generation-config auto \
--override-generation-config '{"temperature": 0.6, "top_p": 0.95, "top_k": 20, "min_p": 0.0, "presence_penalty": 0.0, "repetition_penalty": 1.0}' \
-tp 1
```
### 함께 복원
```bash
echo '{"vllm_model":"Qwen3.6-27B-FP8"}' > mcp-server/llm-model.json
```
C# 앱은 사용자 재시작 (`dotnet run --project src/Web/ExperionCrawler.csproj`).
### 로딩 시간 (캐시 hit)
- 약 75초 (가중치는 이미 `~/.cache/huggingface/`에 있음)
---
## 6. 향후 재시도 시 점검 항목
다른 모델/구성으로 재검토할 경우 반드시 통과해야 할 체크리스트:
### 필수 (이 중 하나라도 실패하면 운영 불가)
- [ ] **결정 프롬프트** (Section 3.3 테스트 3) 3개 항목 모두 도구 결과 정확 인용
- [ ] UI에서 `query_pv_history` / `run_sql` 도구 카드가 시각적으로 표시됨
- [ ] `event_history_table` 전체 카운트가 실제값과 일치 (추측 불가 숫자)
- [ ] 시각 인자에 미래 시각(NOW 이후) 절대 안 나옴
- [ ] 데이터 값이 단조 증가 demo 패턴(`50.2 → 51.1` 등)이 아님
### 우대 (있으면 좋음)
- [ ] 한자(중국어) 글자 응답에 섞이지 않음
- [ ] KST/UTC 라벨 정확
- [ ] 27B 대비 동등 또는 더 빠른 tok/s
- [ ] `plant_context.md` 의 area 매핑(`P1~P10`) 따라 인자 생성
### 검토 후보 (참고)
| 모델 | 비고 |
|---|---|
| **`gemma4-quantized` (사용자 우선순위)** | **다음 검토 1순위.** 70~80 GiB 점유 예상 (27B와 유사 위치). 배포 환경에서도 이걸 쓰기로 계획됨. 검증 시 GB10 호환 양자화 종류(AWQ/GPTQ/FP8/MXFP4 등) 확인 필요. tool-call-parser는 Gemma 전용 파서(`gemma`/`hermes` 호환 여부) 점검 |
| `RedHatAI/Qwen2.5-72B-Instruct-FP8` | 72B FP8 ~72 GiB. 도구 호출 신뢰성 27B에 근접 예상 |
| `exaone3.5:7.8b` (LG) | 한국어 특화 7.8B. 함수 호출 미지원일 수 있어 사전 확인 필요 |
| `Qwen3-30B-A3B-Instruct-2507` | Qwen3 차세대 MoE 30B (3B active). 출시되면 검토 가치 |
### Gemma4 검토 시 사전 준비 (TODO)
- [ ] HF에서 GB10·FP8 호환 정확한 repo 식별 (예: `google/gemma-4-27b-it`의 FP8/AWQ 변형, RedHatAI/neuralmagic 양자화 후속)
- [ ] vLLM의 Gemma 지원 버전 확인 — `--tool-call-parser` 옵션 (`gemma`, `pythonic`, `hermes` 중 어느 것)
- [ ] 기존 mod 디렉토리에 Gemma 관련 자료 유무 점검 (`/home/windpacer/ai-models/spark-vllm-docker/mods/fix-gemma4-tool-parser`가 이미 있음 — Gemma4 도구 파서 패치 시사)
- [ ] speculative decoding 가능 여부 (Gemma3는 미지원, Gemma4는 별도 확인)
- [ ] 27B FP8 (97 GiB)을 종료한 뒤에만 충분한 여유. 동시 운영은 메모리 부족 가능성 (배포 환경 기준 80GB로 가능)
---
## 7. 부산물 — 이 검증 과정에서 추가된 코드 개선
7B 검증 실패와 무관하게 시스템 전반에 도움이 되는 변경:
| 파일 | 개선 내용 |
|---|---|
| `src/Web/Controllers/OllamaController.cs` | `BuildDateContextKo`에 KST/UTC 현재 시:분 + 상대시간 변환 표(지난 30분/1시간/24시간/오늘/어제) + 미래 시각 금지 명시 |
| `src/Web/Controllers/OllamaController.cs` | `BaseSystemPromptKo`에 한자(중국어) 금지 강화 — 자주 출현하는 9글자(`请·您·是·了·我·会·什么·可以·需要`) 명시 |
| `src/Web/Controllers/OllamaController.cs` | `GetModels`에 Ollama `/api/show` 호출 → `capabilities=['embedding']` 모델(bge-m3, nomic-embed-text) 채팅 셀렉터에서 자동 제외 |
| `prompts/plant_context.md` | P1~P10/UTIL/PACKING area 매핑 + `event_history_table` vs `v_tag_summary` area 형식 차이 안내 + 의도별 권장 도구 표 |
| `mcp-server/server.py` | `_CLASSIFY_RULES``이상.*상황\|상황.*보고\|이상.*보고\|비정상.*상황\|abnormal``summarize_events` 추가 |
27B에도 도움 — 그대로 유지.
---
## 8. 메모
- `/tmp/restore-27b.sh`는 임시 파일. 재기동 후 사라짐. 영구 보관하려면 `scripts/restore-27b.sh`로 옮기는 것 권장.
- 7B 가중치는 `~/.cache/huggingface/hub/models--RedHatAI--Qwen2.5-7B-Instruct-FP8-dynamic/`에 캐시됨 (~8 GiB). 디스크 정리 시 삭제 가능.
- 다음에 7B를 다시 시도할 경우 **결정 프롬프트(Section 3.3 테스트 3)부터** 30분 안에 통과 여부 판정 가능. 시간 낭비 방지.

View File

@@ -1,517 +0,0 @@
# 빅피클 — 잔여 작업 상세 코딩 계획
> 기준: `CLAUDE.md` 잔여 작업 항목 (2026-05-14)
> 상태: Phase 0~6 완료, Phase 7 + Phase 5 후순위 + 결정 보류 항목이 대상
---
## 현황 요약
| 영역 | 상태 | 비고 |
|------|------|------|
| Phase 0~5 (RAG + 채팅) | ✅ 완료 | Ollama/vLLM 이중 백엔드, SSE 스트리밍, MCP 툴콜 루프(10라운드), KB 업로드/인덱싱 5종 컬렉션 |
| Phase 5 핫픽스 | ✅ 완료 | nl2sql 버그, KB DDL `String.Format` 이슈, 비번 로그 마스킹, `IHttpClientFactory`, plant_context 캐시 |
| Phase 6 (run_sql 가드) | ✅ 완료 | `_validate_sql`, `_apply_sql_guards`, keyword 차단, auto-LIMIT, statement_timeout |
| Phase 6 (보강 도구 5종) | ✅ 완료 | `find_tags`, `query_events`, `active_alarms`, `summarize_events`, `generate_status_report` |
| **Phase 7 (옵션)** | **⏳ 잔여** | NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 |
| **Phase 5 후순위** | **⏳ 잔여** | 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존 |
| **결정 보류** | **⏳ 분석** | 현장 재고 데이터 출처, BGE-M3 마이그레이션 |
---
## 1. NL2SQL 의도 라우터 (Phase 7.1)
### 배경
현재 `query_with_nl`은 모든 자연어 질문을 무조건 LLM에 보내 SQL 생성을 시도한다.
"활성 알람 보여줘", "이벤트 요약" 같은 질문도 SQL 경로로 가므로 불필요한 LLM 호출 + SQL
실패 위험이 있다. 의도 라우터가 질문을 분류하여 적절한 MCP 도구로 직접 라우팅한다.
### 설계
```
사용자 질문
[_classify_intent()] ← 키워드/Trie 기반 (ML 불필요)
├── "알람/트립" → active_alarms 도구
├── "이벤트/요약/보고서" → summarize_events 또는 generate_status_report
├── "태그 찾기/검색" → find_tags 도구
├── "SQL/조회/데이터" → query_with_nl (기존)
└── "기타/모름" → query_with_nl (fallback)
```
### 수정 파일
**mcp-server/server.py**
| 위치 | 변경 |
|------|------|
| `_CLASSIFY_RULES` 상수 (신규) | 키워드→도구 매핑 규칙 맵 (예: `{"alarm|트립|경보|trip": "active_alarms", ...}`) |
| `_classify_intent(query) → str` (신규) | 정규식 + 키워드 Trie로 의도 분류, fallback은 `"query_with_nl"` |
| `query_with_nl` 수정 | `query_with_nl` 내부 첫 줄에서 `_classify_intent` 호출, 매칭되면 해당 도구로 위임 |
| `@mcp.tool() classify_intent(question)` 검토 | MCP 도구로 별도 노출할지 결정 (OllamaController의 JSON 폴백 경로에 활용 가능) |
**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `ToolGuideKo` | (필요시) `classify_intent` 도구 항목 추가 |
### 분류 규칙 (예시)
```python
_CLASSIFY_RULES = [
(r'alarm|트립|경보|trip|경보|비상', 'active_alarms'),
(r'요약|보고서|리포트|summary|report', 'generate_status_report'),
(r'태그.*찾|검색|찾아줘|찾기|find.*tag', 'find_tags'),
(r'이벤트|event|로그|기록', 'query_events'),
(r'SQL|조회|데이터|select|값.*보여줘|수치', 'query_with_nl'),
]
```
### 동작 흐름 (query_with_nl 변경 후)
```
query_with_nl("지금 알람 상황 알려줘")
→ _classify_intent → "active_alarms"
→ active_alarms(area=None) 호출 → 결과 반환
→ (SQL 생성 생략)
```
### 검증
- `python3 -c "import server; print(server._classify_intent('지금 알람 알려줘'))"``"active_alarms"`
- `python3 -c "import server; print(server._classify_intent('FIC-6113.PV 값 보여줘'))"``"query_with_nl"`
- `python3 -c "import server; print(server._classify_intent('안녕'))"``"query_with_nl"` (fallback)
---
## 2. 대화 요약 (Phase 7.2)
### 배경
현재 매 턴 전체 messages 배열을 LLM에 전송한다. 대화가 길어질수록 컨텍스트 윈도우를
초과하거나 토큰 비용이 증가한다. N messages 이상이면 이전 메시지를 요약하여
system prompt에 "지금까지의 대화 요약: ..." 형태로 압축한다.
### 설계
```
messages 길이 > MAX_HISTORY (기본 20)
llmSend() 전에 PreprocessMessages()
├── 오래된 메시지를 LLM에 요약 요청
├── 요약 텍스트를 system prompt에 "대화 요약: ..." 형태로 삽입
└── 요약된 메시지 제거
```
### 수정 파일
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `LLM_MAX_HISTORY = 20` (신규 상수) | 요약 트리거 기준 messages 수 |
| `llmPreprocessMessages(messages)` (신규) | messages 길이 체크 → 초과 시 요약 API 호출 |
| `llmSend()` (line 3725) | `llmPreprocessMessages` 호출 추가 (line 3748 앞) |
| session 저장 구조 확장 | `sess.summary` 필드 추가 (요약 텍스트 보관) |
| `llmRenderMessages()` | summary 표시 (접힌 카드) |
**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `POST /api/ollama/summarize` (신규) | messages 배열 받아 LLM으로 요약 + 반환 |
| `VllmChatStreamWithTools` | `maxToolRounds` 카운트 유지, 필요시 요약 |
**src/Web/wwwroot/css/style.css**
| `.llm-summary-card` | 요약 표시용 스타일 (접힘/펼침) |
### UI 동작
```
def llmPreprocessMessages(messages):
if len(messages) <= LLM_MAX_HISTORY: return messages
# 최근 절반 유지, 오래된 절반 요약 요청
old_msgs = messages[:-LLM_MAX_HISTORY//2]
new_msgs = messages[-LLM_MAX_HISTORY//2:]
summary = await api('POST', '/api/ollama/summarize', {messages: old_msgs})
sess.summary = summary
# system prompt에 요약 주입
systemPrompt = f"[대화 요약]\n{summary}\n\n[최근 대화]"
return new_msgs
```
### 검증
- 25개 메시지 → 20개 초과 → 요약 트리거
- 요약 이후 처음 메시지에 system prompt에 `[대화 요약]` 포함 확인
- 요약 카드가 UI에 정상 렌더링
---
## 3. 에이전트 모드 (Phase 7.3)
### 배경
현재 툴콜 루프(10라운드)는 LLM이 도구를 호출하면 실행하고 결과를 다시 LLM에 주입한다.
하지만 "이 공정의 문제점을 분석해줘" 같은 복합 태스크는: (1) 활성 알람 조회 →
(2) 관련 태그 이력 조회 → (3) 보고서 생성 순서로 여러 도구를 자율적으로 호출해야 한다.
### 설계
"에이전트 모드" 토글을 채팅 UI에 추가:
- OFF (현행): 단순 툴콜 루프 (LLM이 요청한 도구만 실행)
- ON: 계획→실행→관찰→반복 사이클 (ReAct 패턴)
### 수정 파일
**src/Web/wwwroot/index.html**
| 위치 | 변경 |
|------|------|
| `#llm-agent-mode` 체크박스 (line 1269 옆) | "에이전트 모드" 토글 추가 (MCP 도구 체크박스 옆) |
| 설명 툴팁 | "복합 태스크를 단계별로 계획하고 실행합니다" |
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `llmAgentMode` 변수 (line 3297 옆) | localStorage persist |
| `llmToggleAgentMode()` (신규) | 토글 변경 시 상태 저장 |
| `llmSend()` | agentMode ON이면 system prompt에 ReAct 프롬프트 주입 |
| `llmRenderMessages()` | agent planning 단계 시각화 |
**src/Web/Controllers/OllamaController.cs**
| 위치 | 변경 |
|------|------|
| `ComposeSystemPrompt()` | agentMode 인자 추가 → ReAct 가이드 포함 |
| `ToolGuideKo` | agent 모드용 tool 사용 설명 추가 |
**ReAct 시스템 프롬프트 (ToolGuideKo에 추가)**
```
[에이전트 모드]
복잡한 질문은 다음 단계로 분해하여 도구를 호출하세요:
1. Thought: 현재 상황과 필요한 정보 파악
2. Action: 적절한 도구 호출 (active_alarms, query_events, query_pv_history 등)
3. Observation: 도구 결과 분석
4. Thought: 다음 단계 결정
5. ...반복...
6. Final Answer: 모든 정보를 종합하여 답변
```
### 동작 흐름
```
사용자: "지금 공장 상황을 분석해줘"
Round 1: Thought → active_alarms() → 결과 분석
Round 2: Thought → find_tags("pump") → 결과 분석
Round 3: Thought → query_events(area="A") → 결과 분석
Round 4: Thought → generate_status_report() → 최종 보고서 생성
최종: 모든 정보를 종합한 한국어 보고서 출력
```
### 검증
- 에이전트 ON/OFF 토글 localStorage 저장 확인
- ON 상태에서 복합 질문 시 2라운드 이상 도구 호출
- 최종 응답에 모든 단계의 정보가 통합됨
---
## 4. KB 청크 미리보기 UI (Phase 7.4)
### 배경
현재 KB 관리 탭에서 문서 목록은 보이지만, 각 문서의 청크 내용을 볼 수 없다.
인덱싱 결과를 눈으로 확인할 수 없어 디버깅과 품질 검증이 어렵다.
### 설계
Qdrant에서 `doc_id`로 청크를 조회하는 API → 프론트엔드에서 접이식 카드로 표시.
### 수정 파일
**src/Infrastructure/Kb/KbQdrantClient.cs**
| 위치 | 변경 |
|------|------|
| `GetChunksByDocIdAsync(docId, collection)` (신규) | Qdrant Scroll API로 doc_id 필터, payload(text, chunk_kind, locator) 반환 |
**src/Web/Controllers/KbController.cs**
| 위치 | 변경 |
|------|------|
| `GET /api/kb/documents/{id}/chunks` (신규) | admin 인증 필요, `KbQdrantClient.GetChunksByDocIdAsync` 호출 |
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `kbShowChunks(docId)` (신규) | `/api/kb/documents/{id}/chunks` 호출 → 모달에 렌더 |
| `kbRenderChunks(chunks)` (신규) | 청크 목록을 접이식 카드로 표시 (chunk_kind 배지, text 미리보기 200자, locator 표시) |
| `kbRenderDocs` | "청크 미리보기" 버튼 추가 (청크 수 > 0인 경우) |
**src/Web/wwwroot/index.html**
| 위치 | 변경 |
|------|------|
| 청크 미리보기 모달 (kb-upload-modal 다음) | #kb-chunk-modal: 모달 내 청크 리스트 + 닫기 버튼 |
**src/Web/wwwroot/css/style.css**
| `.kb-chunk-card` | 청크 카드 (테두리, 접힘/펼침) |
| `.kb-chunk-badge` | chunk_kind 배지 (table/page/text 등) |
| `.kb-chunk-locator` | locator 표시 (파일 내 위치) |
### API 응답 형식
```json
GET /api/kb/documents/{id}/chunks
{
"success": true,
"docId": "uuid",
"collection": "plant_operation",
"count": 12,
"chunks": [
{
"text": "청크 내용...",
"chunk_kind": "table",
"locator": "Sheet1, Row 5-10",
"score": null
}
]
}
```
### UI 동작
```
[d832a1f2] 온도센서 교체 매뉴얼.pdf | plant_operation | indexed | 12청크 | [청크보기]
↓ 클릭
┌─ 청크 미리보기 ─────────────────────────────────┐
│ [table] Sheet1, Row 5-10 │
│ ┌───────┬──────┬──────┐ │
│ │ 온도 │ 범위 │ 오차 │ │
│ ├───────┼──────┼──────┤ │
│ │ PT100 │ -200…│ ±0.1 │ │
│ └───────┴──────┴──────┘ │
│ │
│ [text] 페이지 2, 3번째 문단 │
│ RTD 센서는 저항 온도 센서로... [펼치기] │
└──────────────────────────────────────────────────┘
```
### 검증
- 청크 0건 문서 → 버튼 미표시
- 청크 12건 문서 → 버튼 표시 → 클릭 시 모달 오픈
- 모달 내 청크 카드 접기/펼치기 동작
---
## 5. 시계열 미니 스파클라인 (Phase 5 후순위)
### 배경
Phase 5 설계서(C5)에서 "표 자동 렌더링"은 완료했으나 "시계열 스파클라인"은 보류됨.
uPlot은 이미 fastRecord 탭에서 사용 중 (`index.html:1535` 로드 완료).
### 설계
`query_pv_history` 또는 `run_sql` 결과에서 timestamp + numeric value 컬럼 감지 시
uPlot 미니 차트(스파클라인)를 자동 렌더링.
### 수정 파일
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| `llmRenderSparkline(containerId, data)` (신규) | uPlot 미니차트 생성 (height: 80px, grid 없음, tooltip만) |
| `llmRenderToolPayload()` (line 3587) | JSON 응답에 timestamp+value 패턴 감지 시 스파클라인 렌더링 옵션 제공 |
| `llmDetectTimeSeries(data)` (신규) | 데이터가 `[{timestamp, value}]` 또는 `[{recorded_at, pv}]` 형태인지 감지 |
**src/Web/wwwroot/css/style.css**
| `.llm-sparkline-box` | 스파클라인 컨테이너 (padding, border, max-width) |
| `.llm-sparkline-toggle` | "📈 추세 보기" 토글 버튼 |
### 스파클라인 생성 코드 (예시)
```javascript
function llmRenderSparkline(containerId, data, valueKey, timeKey) {
const times = data.map(r => new Date(r[timeKey || 'timestamp']).getTime() / 1000);
const vals = data.map(r => parseFloat(r[valueKey || 'value']));
const opts = {
width: 280, height: 64,
cursor: { show: true },
select: { show: false },
axes: [{ show: false }, { show: false }],
series: [
{ label: '' },
{ label: '', stroke: 'var(--accent)', width: 1, points: { show: false } }
]
};
new uPlot(opts, [times, vals], document.getElementById(containerId));
}
```
### 동작 흐름
```
query_pv_history 결과
→ llmRenderToolPayload에서 {success, data:[{tag_name, timestamp, value}]} 감지
→ "📈 추세 보기" 버튼 표시
→ 클릭 시 llmRenderSparkline() 실행
→ uPlot 미니차트가 툴 카드 내에 렌더링
```
### 검증
- 시계열 데이터 2건 미만 → 차트 미표시
- 3건 이상 → "📈 추세 보기" 버튼 → 클릭 시 uPlot 차트 렌더링
- 툴 카드 접기/펼치기와 호환
---
## 6. 툴 카드 메시지 영구 보존 (Phase 5 후순위)
### 배경
현재 툴 카드(`llm-tool-card`)는 SSE `tool_start`/`tool_result` 이벤트로 DOM에 직접
삽입되지만, `llmRenderMessages()``sess.messages`만으로 전체 메시지 영역을
innerHTML=...로 재생성하므로 툴 카드가 사라진다.
즉, 페이지 새로고침이나 탭 전환 후 이전 대화를 열면 툴 카드가 모두消失.
### 설계
`sess.messages``tool_call` 타입 메시지를 저장하고, `llmRenderMessages()`에서
툴 카드를 재생성할 수 있도록 구조화.
### 수정 파일
**src/Web/wwwroot/js/app.js**
| 위치 | 변경 |
|------|------|
| **데이터 모델** | `sess.messages` item에 type: `"text"` \| `"tool_call"` 속성 추가 |
| `llmSend()` (line 3758) | assistant 메시지에 `toolCalls: []` 배열 추가 (초기 빈 배열) |
| SSE `tool_start` 처리 (line 3838) | `assistantMsg.toolCalls.push({id, name, args, ok:null, payload:null})` |
| SSE `tool_result` 처리 (line 3846) | 해당 toolCalls 항목 업데이트 (`ok`, `payload`, `preview`, `length`) |
| `llmRenderMessages()` (line 3418) | 메시지에 `toolCalls` 배열 있으면 툴 카드 렌더링 |
| `llmRenderToolCards(toolCalls)` (신규) | toolCalls 배열 → 툴 카드 HTML 생성 |
| `llmSaveSessions()` | 변경 없음 (messages 배열에 포함되어 자동 저장) |
### 데이터 구조 (localStorage)
```javascript
sess.messages = [
{ role: 'user', content: '지금 알람 보여줘' },
{
role: 'assistant',
content: '현재 3개의 활성 알람이 있습니다...',
toolCalls: [
{ id: 'tc_1_xxx', name: 'active_alarms', args: '{}', ok: true, payload: '{...}' }
]
}
]
```
### 수정 상세
**llmSend() - toolCalls 배열 초기화 (line 3758)**
```javascript
// 변경 전
const assistantMsg = { role: 'assistant', content: '' };
// 변경 후
const assistantMsg = { role: 'assistant', content: '', toolCalls: [] };
```
**SSE `tool_start` 핸들러 (line 3838)**
```javascript
// 추가
assistantMsg.toolCalls.push({ id: t.id, name: t.name, args: t.args, ok: null, payload: null });
```
**SSE `tool_result` 핸들러 (line 3846)**
```javascript
// 추가
const tc = assistantMsg.toolCalls.find(x => x.id === t.id);
if (tc) { tc.ok = t.ok; tc.payload = t.payload; }
```
**llmRenderMessages() - toolCalls 렌더링 (line 3449 이후)**
```javascript
// 각 메시지의 content 다음에 toolCalls 렌더링
if (m.toolCalls && m.toolCalls.length > 0) {
const cardsHtml = m.toolCalls.map(tc => {
const statusClass = tc.ok === null ? 'running' : (tc.ok ? 'ok' : 'err');
const statusText = tc.ok === null ? '실행 중…' : (tc.ok ? '완료' : '실패');
// ... 툴 카드 HTML 생성
}).join('');
html += `<div class="llm-tool-cards">${cardsHtml}</div>`;
}
```
### 마이그레이션 (기존 세션 호환)
기존 localStorage `llmSessions`에는 `toolCalls` 필드가 없음. `llmRenderMessages()`에서
`toolCalls`가 undefined/null이면 툴 카드 렌더링 생략 (기존처럼 동작).
### 검증
1. 새 채팅에서 도구 호출 → SSE 완료 후 localStorage 확인 → `toolCalls` 배열 저장됨
2. F5 새로고침 → 이전 대화 열기 → 툴 카드가 정상 렌더링
3. 기존 세션(툴 카드 없는 메시지) → 오류 없음
4. 저장/로드 시 JSON 용량 증가 확인 (toolCalls 1개당 약 200바이트)
---
## 7. 현장 재고 데이터 출처 (결정 보류 — 분석만)
### 현황
Phase 0 설계서(G3)에서 "현장 재고 데이터 자체 없음"으로 식별됨. 코드 작업 없음.
### 분석 방향
| 측면 | 검토 사항 |
|------|----------|
| 데이터 성격 | 예비품 재고, 소모품, 교체 이력, 위치 정보 |
| 가능한 출처 | 별도 엑셀 관리 → KB 업로드, ERP 연동 API, 수동 입력 |
| KB 활용 | plant_operation 또는 report 컬렉션에 엑셀 업로드로 즉시 해결 가능 |
| 우선순위 | 낮음 (Phase 0~6 안정화 후 검토) |
### 권장
현장 재고 데이터는 기존 KB 시스템에 엑셀/CSV를 `plant_operation` 컬렉션으로
업로드하여 즉시 RAG 검색 가능. 별도 개발 불필요.
---
## 8. 임베딩 모델 BGE-M3 마이그레이션 (결정 보류 — 계획만)
### 현황
현재 `nomic-embed-text` (768차원, Ollama) 사용 중. BGE-M3 (1024차원)는
다국어(한국어) 성능이 더 우수하나 Qdrant 컬렉션 재생성이 필요.
### 마이그레이션 계획 (향후 실행 시)
| 단계 | 작업 | 영향 |
|------|------|------|
| 1 | BGE-M3 Ollama에 pull (`ollama pull bge-m3`) | 서버 리소스 추가 사용 |
| 2 | `KbEmbeddingClient.cs` 모델명 변경 (settings.json) | 임베딩 차원 768→1024 |
| 3 | Qdrant 컬렉션 5개 재생성 (기존 삭제 + vector_size=1024로 recreate) | 기존 인덱스 전부 소멸 |
| 4 | 전체 문서 재인덱싱 | 시간 소요 (문서 수에 비례) |
| 5 | `_embed()` consistency check | 1024차원 정상 출력 확인 |
### 위험
- 기존 Qdrant 컬렉션 삭제 시 모든 KB 검색 불가 (재인덱싱 완료까지)
- 1024차원으로 변경 시 메모리 사용량 증가 (약 33%)
- BGE-M3의 한국어 성능 향상이 임계값 이상인지 사전 평가 필요
### 권장
긴급하지 않음. nomic-embed-text로 Phase 7 운영 후, BGE-M3 안정성 확인 후
점진적 마이그레이션. `KbEmbeddingClient.cs`에 dimension을 환경변수/설정에서
읽도록 개선하는 선행 작업 권장.
---
## 실행 우선순위
| 순위 | 작업 | 예상 시간 | 영향도 |
|------|------|-----------|--------|
| 1 | **툴 카드 영구 보존** | 2~3h | HIGH — UX 품질, 데이터 손실 방지 |
| 2 | **KB 청크 미리보기 UI** | 2~3h | MED — 관리자 디버깅 편의 |
| 3 | **시계열 스파클라인** | 1~2h | MED — 데이터 가시성 향상 |
| 4 | **NL2SQL 의도 라우터** | 1~2h | MED — 불필요한 SQL 호출 감소 |
| 5 | **대화 요약** | 1~2h | LOW — 장기 대화 안정성 |
| 6 | **에이전트 모드** | 2~3h | LOW — 고급 기능, Phase 7 후순위 |
| 7 | **BGE-M3 분석/계획 수립** | — | 보류 |
| 8 | **현장 재고 데이터 출처** | — | 보류 |
---
## 빌드/검증 명령
```bash
# .NET 빌드
dotnet build src/Web/ExperionCrawler.csproj
# Python syntax check
python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py
# Python import check
cd mcp-server && python3 -c "import server"
```
## 런타임 셋업 (코드 외)
- mcp-server 재시작 (의도 라우터 추가 시)
- 브라우저 캐시 무효화 (Ctrl+F5)

View File

@@ -1,453 +0,0 @@
# 옵시디언-식 그래프 구조 적용 플랜
> 옵시디언(Obsidian) 앱을 도입하는 것이 아니라, 옵시디언이 사용하는 **데이터 패턴**(마크다운 노트 + YAML frontmatter + `[[wikilinks]]` + `#tags` + 그래프 탐색)을 현재 RAG 파이프라인 안에 흡수해 P&ID/DXF/PDF만으로 부족한 지식 표현을 보강하는 것이 목적이다.
작성: 2026-05-14 | 대상 스택: vLLM Qwen3.6-27B-FP8 + Ollama nomic-embed-text(768) + Qdrant + PostgreSQL + KbIngestWorker(C#) + MCP(Python)
---
## 1. 배경 — 왜 지금 그래프 구조가 필요한가
### 1.1 현재 한계
| 영역 | 현재 동작 | 한계 |
|------|----------|------|
| P&ID / DXF | `parse_pid_dxf`, `parse_pid_pdf`로 태그/심볼/선 추출 → PostgreSQL `pid_*` 테이블 적재 | 추출 정확도가 도면 품질에 종속. LLM이 그림을 "읽지" 못해 27B로도 흐름/인과 추론이 약함. 누락된 관계는 영원히 누락 |
| PDF/Excel KB | `parse_document`로 텍스트 청크 → 벡터 임베딩 → Qdrant 5개 컬렉션 | 청크는 의미 단위가 아닌 페이지/시트/표 단위. 청크 간 관계(이 절차는 저 알람과 연결됨)는 모델이 매번 "발견"해야 함 |
| 채팅 RAG | `search_kb`/`rag_query`로 top-k 청크 retrieval | 의미적으로 가까운 청크는 잘 찾지만 **명시적 연결**이 없는 항목(같은 루프의 다른 태그, 같은 area의 작년 이벤트)은 안 따라옴 |
| 현장 지식 | 운전원 머릿속 / Excel / Word | 검색·연결·LLM 주입이 불가 |
### 1.2 27B 모델의 현실
`Qwen3.6-27B-FP8` 단일 GPU 서빙. 한 답변에 토큰 46k 정도가 안정 구간. 따라서:
- **검색기가 똑똑할수록 LLM 부담이 줄어든다** — 청크 100개 던지고 LLM에게 정리시키면 토큰·지연 폭증. 청크 510개 + **관계 그래프 1-hop**을 던지는 게 훨씬 싸다.
- **그래프 탐색은 LLM 추론이 아니라 인덱스 조회로 해결**해야 한다. GraphRAG처럼 LLM으로 엔티티/관계를 추출하는 무거운 방식은 우리 GPU에서 비용이 크다.
### 1.3 옵시디언 패턴이 잘 맞는 이유
- **마크다운 = LLM 친화** — 토큰화 효율, 헤딩 기반 청킹 자연스러움
- **`[[wikilinks]]` = 사람이 손으로 만든 명시적 엣지** — LLM이 추출할 필요 없음. 운전원/엔지니어가 노트 쓰면서 자동으로 그래프가 자란다
- **YAML frontmatter = 구조화 메타데이터** — `area`, `loop`, `vendor`, `range` 같은 필드가 노트 수준에서 일관됨
- **`#tags` = 가로 분류** — 폴더 계층과 무관한 횡적 묶음
- 모두 **로컬 텍스트 파일**로 표현 가능 → 백업/git/diff 자유롭고 KB 파이프라인에 그대로 흘려 넣을 수 있음
---
## 2. 채택할 것 / 채택하지 않을 것 (스코프 선)
### 채택
- 노트 = 마크다운 1개 파일 + YAML frontmatter
- `[[note-id]]` 위키링크 문법 — id는 안정적이고 URL-safe
- `#tag` 인라인 태그 + frontmatter `tags:` 둘 다 인정
- 노트 간 **유향 그래프** (`source_note``target_note`, `link_type`)
- 그래프 13 hop 탐색 도구 + 벡터 검색과의 하이브리드
### 채택하지 않음
- ❌ Obsidian 앱 실행 / `.obsidian/` 설정 / plugin 호환
- ❌ Obsidian Sync · Publish
- ❌ Canvas, Excalidraw 등 GUI 부속
- ❌ "Vault 폴더 → 자동 동기화" 패턴 (파일 워처) — 현재 KB는 업로드형 큐 모델이고 일관성 유지에 유리. 동기화는 단방향 임포트 도구로 충분
- ❌ LLM으로 자동 엔티티/관계 추출 (GraphRAG 식) — 27B 처리량 부담. 대신 **결정론적 추출**(정규식·frontmatter·기존 P&ID 파서 결과) + 사용자 작성을 1차로 한다
→ 옵시디언으로 노트 편집하고 싶은 사용자는 `.md` 파일을 외부에서 옵시디언으로 열어도 호환된다. 그것은 **부수효과**이지 의존 관계가 아니다.
---
## 3. 데이터 모델
### 3.1 노트 ID 규칙
`{kind}/{slug}` 형식. 예:
```
tag/fic-6113
loop/compression-a
area/unit-a
drawing/pid-105
procedure/start-up-a
vendor/honeywell-c300
event-pattern/surge-recovery
```
- 모두 소문자, kebab-case
- `tag/`, `loop/`, `area/`, `drawing/`, `procedure/`, `vendor/`, `event-pattern/`, `term/`, `kpi/` 9종 (시드, 확장 가능)
- 위키링크 본문에선 `[[tag/fic-6113]]` 또는 별칭 `[[tag/fic-6113|FIC-6113]]`
### 3.2 Frontmatter 스키마 (공통 + kind별)
공통:
```yaml
---
id: tag/fic-6113 # 필수, 위 ID 규칙
title: FIC-6113 (압축단 1열 입구 유량)
kind: tag # 필수, 9종 중 하나
tags: [flow, area-a, critical]
aliases: [FIC6113, fic-6113.pv]
created: 2026-05-14
updated: 2026-05-14
sources: # 선택 — 이 노트의 근거 문서
- kb_doc_id: 1f3a... # 기존 kb_documents UUID
locator: "p.12 §3.2"
---
```
kind별 추가 필드 (선택, frontmatter라 유연):
| kind | 권장 필드 |
|------|----------|
| `tag` | `pv_tag`, `sp_tag`, `op_tag`, `unit`, `range_min`, `range_max`, `eu`, `area`, `loop` |
| `loop` | `area`, `controllers`, `actuators`, `transmitters`, `setpoint_strategy` |
| `area` | `unit`, `manager`, `process_description` |
| `drawing` | `drawing_number`, `revision`, `pid_doc_id` (기존 pid_drawings.id 연결) |
| `procedure` | `applicable_loops`, `last_review`, `owner` |
| `vendor` | `manufacturer`, `model`, `firmware` |
| `event-pattern` | `event_types`, `area`, `mitigation` |
### 3.3 본문 컨벤션
```markdown
# FIC-6113
압축기 A열 입구 유량 제어. [[loop/compression-a]] 의 1차 제어 변수.
## 정상 운전
- SP: 120 t/h (보통 115125 범위)
- 알람 상한: 135 t/h → 트립: 145 t/h
- 상위 cascade: [[tag/pic-6101]]
## 관련 이벤트 패턴
- [[event-pattern/surge-recovery]] — 빠르게 떨어지면 ESV 확인
- 정기점검 후 첫 기동 시 잦은 흔들림 → [[procedure/start-up-a]] 단계 7 참조
## 참고
- 도면: [[drawing/pid-105]]
- 벤더: [[vendor/honeywell-c300]]
```
- 헤딩 ≤ H3 권장 → 청킹 단위
- 위키링크 `[[id]]` 또는 `[[id|alias]]`
- 인라인 태그 `#flow #critical` 도 허용 (frontmatter `tags:`에 자동 병합)
### 3.4 PostgreSQL 스키마 (신규 5개 테이블)
기존 KB 파이프라인과 **분리된 네임스페이스**(`vault_*` prefix). `kb_documents`는 원본 파일(PDF/Excel)용 그대로 유지, vault는 별도 트랙.
```sql
-- 노트 본체
CREATE TABLE vault_notes (
id TEXT PRIMARY KEY, -- "tag/fic-6113"
kind TEXT NOT NULL, -- 9종 중 하나
title TEXT NOT NULL,
body_md TEXT NOT NULL, -- 원본 마크다운
frontmatter JSONB NOT NULL DEFAULT '{}',
content_hash TEXT NOT NULL, -- SHA256(body_md+frontmatter)
status TEXT NOT NULL DEFAULT 'pending',-- pending/embedding/indexed/failed
error_message TEXT,
source_doc_id UUID REFERENCES kb_documents(id),-- 임포트된 경우
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON vault_notes (kind);
CREATE INDEX ON vault_notes USING gin (frontmatter);
-- 별칭 (id ↔ alias 양방향)
CREATE TABLE vault_aliases (
alias TEXT PRIMARY KEY, -- 소문자 정규화
note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE
);
-- 태그 (frontmatter + 인라인 #tag 병합 결과)
CREATE TABLE vault_tags (
note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (note_id, tag)
);
CREATE INDEX ON vault_tags (tag);
-- 위키링크 (유향 엣지)
CREATE TABLE vault_links (
src_note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE,
dst_note_id TEXT NOT NULL, -- 미존재 노트도 허용(빨간 링크)
dst_resolved BOOLEAN NOT NULL DEFAULT FALSE, -- 타겟 노트가 실제 존재?
link_type TEXT NOT NULL DEFAULT 'wikilink', -- 향후 'cascade','vendor-of' 등 확장
occurrences INT NOT NULL DEFAULT 1, -- 같은 노트에서 N회 등장
PRIMARY KEY (src_note_id, dst_note_id, link_type)
);
CREATE INDEX ON vault_links (dst_note_id);
-- 청크 (Qdrant point_id ↔ 노트/헤딩 매핑)
CREATE TABLE vault_chunks (
point_id UUID PRIMARY KEY, -- Qdrant point_id
note_id TEXT NOT NULL REFERENCES vault_notes(id) ON DELETE CASCADE,
heading_path TEXT, -- "## 정상 운전 > ### 알람"
chunk_text TEXT NOT NULL,
token_count INT
);
CREATE INDEX ON vault_chunks (note_id);
```
`dst_resolved=false`인 링크는 "아직 노트가 안 만들어진 빨간 링크" — 자라야 할 그래프의 빈자리를 가시화한다.
### 3.5 Qdrant 컬렉션
신규 1개 — `vault` (768-dim cosine, payload만 인덱스: `note_id`, `kind`, `tags`, `area`, `loop`).
기존 `kb_*` 5개와 **공존**. `search_kb`는 그대로, `search_vault`는 신규(아래 §6).
---
## 4. 인덱싱 파이프라인
### 4.1 입력 경로 3가지
| 경로 | 동작 |
|------|------|
| **A. UI에서 마크다운 직접 작성/수정** | 14번 탭 확장. 저장 시 `vault_notes` UPSERT + 인덱싱 큐 적재 |
| **B. zip/폴더 임포트** | `.md` 파일들을 zip으로 업로드 → 일괄 파싱·검증·노트 생성 |
| **C. 기존 데이터 자동 시드** | `node_map_master`/`pid_tags`/`pid_drawings`/`kb_collections` 등에서 자동으로 노트 스켈레톤 생성 (§7.2 별도 절) |
세 경로 모두 동일한 **MarkdownNoteWorker**(C# `BackgroundService`, 기존 `KbIngestWorker` 패턴 차용)로 수렴.
### 4.2 워커 단계 (단일 패스)
```
[큐: vault_notes WHERE status='pending']
1. 파싱 (server.py 또는 C# Markdig)
- frontmatter 추출 → JSON 검증
- 본문에서 [[id]] / [[id|alias]] / #tag 추출
- 헤딩 트리 추출 → 청크 분할 (≤512 토큰, 헤딩 경계 우선)
2. 정규화
- aliases 소문자화, 중복 제거
- 위키링크 id의 alias 해석 (vault_aliases 조회)
- 자기 자신 링크 제거
3. 그래프 갱신 (트랜잭션)
- vault_links 기존 행 삭제 후 재삽입
- dst_resolved 계산: dst가 vault_notes에 존재 OR alias에 존재
- 신규 노트가 들어오면 기존의 'unresolved' 링크들을 다시 resolve 시도
- vault_tags 동기화
4. 임베딩 (Ollama /api/embeddings, 청크별)
- 실패 청크는 skip + error_message에 누적, attempts<3
5. Qdrant upsert
- 기존 doc의 point들 먼저 delete(filter: note_id)
- 신규 청크 일괄 upsert
6. status='indexed' or 'failed'
```
부분 실패 정책은 기존 KbIngestWorker와 동일(전부 실패 시 failed, 일부 실패 시 indexed + error_message).
### 4.3 파싱 위치 선택
- **C# 측 Markdig + 자체 정규식** — 의존성 단순. frontmatter는 `YamlDotNet`.
- 또는 **MCP server.py에 `parse_markdown_note` 도구 추가** — Python `markdown-it-py` + `python-frontmatter` 사용, 기존 parsers/ 구조와 일관.
권장: **server.py에 추가**. 이유: 기존 `parse_document`/`parse_pid_*`와 같은 파이프라인이고, 위키링크·태그 추출 같은 텍스트 처리는 Python이 더 쉬움. C# Worker는 큐 관리·Qdrant 호출만 담당.
---
## 5. 하이브리드 검색 (핵심 차별점)
### 5.1 단계
```
질의 q
A. 벡터 검색 (Qdrant vault collection, top_k=10)
→ 시드 노트 집합 S = { note_id of top hits }
B. 그래프 확장 (PostgreSQL, 1-hop, 옵션 2-hop)
→ for each s in S:
out_links = vault_links WHERE src=s AND dst_resolved
in_links = vault_links WHERE dst=s
→ 확장 집합 E = S neighbors
C. 재순위 (rule-based, LLM 호출 없음)
- 벡터 점수 + boost(같은 area), boost(같은 loop)
- kind 우선순위 가중 (질의에 "절차" 포함 시 procedure 가중)
D. 청크 선택 + 컨텍스트 패킹
- 시드 노트는 top-2 청크
- 이웃 노트는 frontmatter + 첫 문단(요약)만
- 총 토큰 ≤ 3000 budget
LLM에 주입
```
핵심: **그래프 확장은 PostgreSQL JOIN 한두 번**. LLM 호출 0회. 27B GPU 부담 없음.
### 5.2 옵시디언의 "백링크"가 주는 가치
특정 태그 노트(`tag/fic-6113`)에 대한 백링크 목록 = 그 태그를 언급한 모든 절차/이벤트패턴/도면. 운전원이 "FIC-6113 이상해" 라고 물으면:
1. 벡터 검색이 `tag/fic-6113` 노트를 시드로 잡고
2. 백링크로 `[[procedure/start-up-a]]`, `[[event-pattern/surge-recovery]]` 가 따라붙고
3. cascade frontmatter로 `[[tag/pic-6101]]`도 자동 추가
→ 27B에게 던지는 컨텍스트가 **의미 검색만으로는 절대 못 모을 조합**으로 채워진다.
---
## 6. MCP 도구 (신규)
`server.py`에 추가. 모두 PostgreSQL/Qdrant 조회 — LLM 호출 없음.
| 도구 | 시그니처 | 동작 |
|------|---------|------|
| `vault_search` | `(query, kinds?, tags?, top_k=8, expand_hops=1)` | §5의 하이브리드 검색. 시드 + 확장 노트 목록 + 청크 반환 |
| `vault_get_note` | `(note_id, include_body=True)` | 단일 노트 전체. 헤딩 트리·frontmatter·in/out 링크 카운트 |
| `vault_backlinks` | `(note_id, limit=50)` | 이 노트를 가리키는 모든 src 노트 (`vault_links` WHERE dst) |
| `vault_neighbors` | `(note_id, hops=1, kinds?)` | 13 hop 이웃. 시각화·운전원 질문 "이 루프에 뭐 있어" |
| `vault_by_tag` | `(tag, kind?, limit=50)` | 태그 매칭 노트 목록 |
| `vault_unresolved` | `(limit=50)` | dst_resolved=false 링크 리스트 — "비어있는 노트" 진단 |
| `vault_update_note` | `(note_id, body_md, frontmatter)` | (admin) 노트 upsert. 큐 적재. 운전원이 채팅 중 "이거 노트로 저장" 흐름 위해 |
채팅 의도 라우터(`_classify_intent`)에도 규칙 추가:
```
"노트|이 태그.*정보|관련.*뭐|연결" → vault_search
"백링크|어디서.*언급" → vault_backlinks
```
`ToolGuideKo`에 위 도구들 추가, `rag_query` 확장 옵션 `use_vault=True` (벡터-only 모드와 토글 가능).
---
## 7. UI (14번 탭 확장 + 신규 카드 / 신규 탭은 보류)
> 신규 탭(15번)을 또 만들기보다 **14번 "RAG 관리" 탭 안에 두 번째 모드 토글**을 두는 게 코드/사용자 인지부하 면에서 깔끔하다.
### 7.1 14번 탭 — 모드 전환
```
[원본 문서] | [Vault 노트] ← 토글
```
### 7.2 Vault 노트 모드 화면
- 좌측: kind 필터(tag/loop/area/...) + 태그 facet + 빨간 링크 카운트
- 중앙: 노트 목록 (검색·정렬·일괄 선택)
- 우측: 단일 노트 뷰
- 상단: frontmatter 폼 (수정 가능, 저장 시 큐 적재)
- 가운데: 마크다운 편집기 (textarea + 프리뷰 토글, [[ 자동완성)
- 하단: "백링크 (N)" / "이웃 그래프 미니뷰" (SVG 간단한 force-layout — 100 노드 미만, 그 이상은 리스트로 fallback)
### 7.3 채팅 통합
- `vault_search` 결과 카드: 노트 제목 클릭 시 14번 탭의 해당 노트로 점프
- `[[id]]` 토큰이 답변에 등장하면 자동으로 다운로드/노트뷰 링크 치환 (현재 `llmLinkKbCitations`와 동일 패턴 확장)
신규 라이브러리는 도입하지 않는다 — 마크다운 렌더는 기존이 있으면 재사용, 없으면 textarea + 단순 `marked` CDN 한 줄.
---
## 8. P&ID/DXF 한계 해결 — 자동 시드 + 운전원 보강
### 8.1 자동 시드 (1회성 마이그레이션 잡)
기존 PostgreSQL 데이터에서 결정론적으로 노트를 만든다 (LLM 0회):
| 소스 | 생성될 노트 | frontmatter 자동 채움 |
|------|------------|-----------------------|
| `node_map_master` | `tag/{base-tag}` 각 1개 | `pv_tag`, `sp_tag`, `op_tag`, `unit`, `range_*`, `eu` |
| `pid_drawings` | `drawing/{drawing_number}` | `revision`, `pid_doc_id` |
| `pid_tags` (drawing 안의 태그) | drawing 노트 본문에 `[[tag/...]]` 위키링크로 자동 삽입 | — |
| `kb_collections` 시드 5종 | `term/system-instrument` 등 | — |
| `event_history_table` 빈도 상위 패턴 | (보류 — LLM 없이 군집화 어려움) | — |
→ 첫 임포트로 **수천 개 노트가 빨간 링크 없는 상태**로 깔린다. P&ID에서 못 잡은 관계는 운전원이 노트 본문에 `[[tag/...]]` 한 줄 추가로 채운다. 채울수록 그래프가 풍부해진다.
### 8.2 DXF/PDF는 그대로 두되 결과를 노트로 발행
`parse_pid_dxf` 결과는 지금처럼 `pid_*` 테이블에 그대로. 거기에 더해:
- drawing 1개 = 노트 1개 (자동 생성, 본문은 태그 리스트 + 도면 파일 임베드 링크)
- DXF 파싱이 인식한 태그 = 해당 drawing 노트 본문에 위키링크
- 인식 못 한 영역은 노트 본문 하단에 `## TODO` 섹션 자동 생성 → 운전원이 채움
**LLM이 도면을 "읽을" 필요가 없어진다.** 운전원이 노트로 옮긴 정보만 LLM이 본다.
---
## 9. 하드웨어/성능 예측
가정: 자동 시드 후 노트 ~3,000개, 평균 본문 1.5KB, 청크 평균 3개/노트 → 청크 ~9,000개.
| 자원 | 추정 | 비고 |
|------|------|------|
| PostgreSQL 디스크 | < 50 MB | vault_notes(본문) + 인덱스 |
| Qdrant 메모리 | 9,000 × 768 × 4byte ≈ 28 MB + HNSW 오버헤드 ≈ **< 100 MB** | 기존 ws-/experion-opc-docs와 별도 |
| 임베딩 초기 비용 | 9,000 청크 × nomic-embed-text | CPU/GPU에 따라 다르지만 단발성. 이후 변경된 노트만 |
| 검색 (벡터+1hop) | Qdrant 10ms + PG 5ms ≈ **< 50ms** | LLM 호출 없음 |
| 검색 (벡터+2hop) | PG가 노드 수 폭발 가능 → **2-hop은 옵션 + 노드 cap=200** | |
| LLM 컨텍스트 증가 | 시드 청크 5 + 이웃 메타 10개 ≈ **+1.5k 토큰** | 27B 컨텍스트 32k 한계 내 안전 |
| 동시성 | 워커 1개 단일 패스 (기존 정책 유지) | 임베딩 큐가 LLM과 GPU 충돌 시: **임베딩은 Ollama(별도 프로세스/CPU 가능)** 이므로 vLLM과 분리 |
**하드웨어 추가 부담 거의 없음**. 가장 큰 비용은 초기 임베딩 1회.
---
## 10. Phase 분할 (현실적 단위)
### Phase A — 데이터 모델 + 워커 골격 (변경 최소, 채팅 영향 없음)
- `vault_*` 테이블 5개 DDL (ExperionDbContext.InitializeAsync에 추가, 기존 KB DDL 패턴 답습 — `NpgsqlConnection.ExecuteNonQueryAsync` 사용해 중괄호 이슈 회피)
- `VaultNote` / `VaultLink` / `VaultTag` / `VaultChunk` 엔티티
- `IVaultService` 인터페이스 + 구현 (CRUD + 큐 적재)
- `MarkdownNoteWorker` BackgroundService (parse → embed → index)
- `server.py``parse_markdown_note` MCP 도구
- 검증: 손으로 `.md` 1개 업로드 → `indexed` 도달 + Qdrant 점 생성 확인
### Phase B — MCP 도구 + 채팅 연결
- `vault_search` / `vault_get_note` / `vault_backlinks` / `vault_neighbors` / `vault_by_tag` / `vault_unresolved`
- `ToolGuideKo` 갱신
- 의도 라우터 규칙 추가
- 채팅 결과 카드에 노트 인용 렌더링 (`llmLinkVaultCitations`)
- 검증: 채팅에서 "FIC-6113 관련 노트 보여줘" → 도구 호출 + 카드 표시
### Phase C — UI (14번 탭 확장)
- 모드 토글, 노트 목록, 노트 뷰/편집, frontmatter 폼
- 빨간 링크 진단 패널
- 이웃 그래프 미니뷰 (간단 SVG)
- zip 임포트 엔드포인트
### Phase D — 자동 시드 마이그레이션
- `node_map_master` → tag 노트 생성 잡
- `pid_drawings` + `pid_tags` → drawing 노트 + 위키링크
- 1회성 admin-only 엔드포인트 `POST /api/vault/seed` (dry-run 옵션 포함)
### Phase E (선택) — 점진적 자동화
- 채팅에서 LLM이 답변하면서 새 fact를 발견하면 "이걸 [[tag/...]] 노트에 추가할까요?" 제안 → 운전원 확인 시 `vault_update_note` 호출
- 운영자가 일정 누르면 "지난주 자주 등장한 unresolved 링크 N개 — 노트 만들기" 작업 큐
Phase AB는 12일, C는 1일, D는 0.5일, E는 보류 가능.
---
## 11. 잔여 결정사항 (사용자 확인 필요)
| 항목 | 옵션 | 코멘트 |
|------|------|--------|
| 노트 ID 충돌 시 | (1) 거부 (2) 버전 suffix | 기본 (1) 권장 |
| `.md` 외부 편집 허용 여부 | (1) UI 전용 (2) 파일 export/import 양방향 | 양방향은 동기화 충돌 비용 큼. (1) 권장. 필요 시 read-only export만 |
| 옵시디언 앱과 호환 (부수효과) | (1) frontmatter 키 호환 (2) Obsidian 전용 키(`cssclasses` 등) 무시 | (1) — 우리가 정의한 키 외엔 그냥 frontmatter에 보존만 하면 됨 |
| 이웃 그래프 시각화 라이브러리 | (1) 직접 SVG (2) Cytoscape.js (3) vis-network | (1) 노드 ≤ 100 가정. 그 이상은 리스트 fallback |
| 자동 시드 후 첫 화면 | (1) 모든 태그 노트가 "빈 본문" (2) 자동으로 한 줄 요약 LLM 생성 | (1) 권장 — LLM 비용 회피 + 운전원이 손으로 채우면서 도메인 정합 |
| 백업/git | `storage/vault-export/` 매일 1회 `.md` 덤프 | DB 백업과 별개로 사람이 읽을 수 있는 형태 보존 |
---
## 12. 핵심 메시지
- **옵시디언 도입이 아니다.** 옵시디언이 검증한 4개 패턴(마크다운 + frontmatter + 위키링크 + 태그)만 빌려와 기존 KB 위에 **그래프 레이어**를 얹는다.
- **27B로 그래프를 "추론"시키지 않는다.** 그래프는 사람·규칙·기존 DB로 만든다. LLM은 만들어진 그래프를 **탐색**한 결과를 받아쓴다.
- **P&ID 한계는 노트로 우회한다.** 도면이 표현 못 한 관계를 운전원이 위키링크 한 줄로 채운다. 채울수록 RAG 품질이 자란다.
- **하드웨어 부담은 임베딩 초기 1회 + Qdrant < 100 MB.** 검색 경로에 LLM 추가 호출이 없다.
- **기존 KB와 공존**한다. PDF/Excel은 그대로, vault는 별도 트랙. 채팅은 두 채널 결과를 모두 본다.

View File

@@ -1,604 +0,0 @@
# 운전판정 고도화 플랜 — 유량계·진공압 교차검증(Corroboration) 도입
> **문서 상태**: 초안 + 감리 진단 반영 (2026-05-24) — **§0이 §1~§8 초안보다 우선**
> **작성일**: 2026-05-24
> **관련 시스템**: ExperionCrawler v_plant_running_state, MCP server, pid_equipment
---
## 0. 감리 진단 결과 (2026-05-24, 실 DB·pid_equipment·코드 검증)
> 초안(§1~§8)을 실제 데이터로 검증한 결과다. 본문과 충돌 시 **이 절의 정정/보완이 우선**한다.
### 0.1 검증된 사실 (초안 대비 정정)
| 항목 | 초안 주장 | 실측 결과 | 판정 |
|---|---|---|---|
| 유량 데이터 성격 | "dummy 20.27 고정" (risk #1) | `ficq-6113`이 하루 동안 **38~55로 실측 변동**(history_table). 단 **수집기 stall 시 전 태그 frozen**(2026-05-24 09:58 KST 실제 발생, 별도 수정 완료) | 라이브 ✅ / **신선도 게이트 필수** |
| 펌프번호=유량번호 | "일관됨" (obs #1) | P-6102→ficq-**6101**, P-6114→ficq-6113+**6114**(1:N) — 번호 불일치·1:N | ❌ 오류 |
| `pica-6111.pv` 값 | "20.8" (§2.1) | 쿼리마다 변동(5.87/20.8…) — 고정값 아님 | ❌ 값 오류 |
| VP↔진공압 매핑 | "pid_equipment 기반" (§4.1.1 Step3) | VP-6117: from=D-6113, to=SC-6128 → **압력계와 토폴로지 연결 없음**. Step3는 사실상 하드코딩 manual | ⚠️ 명칭 정정 |
| 진공압 태그 의미 | "PICA.pv<50=진공유지" | `pi-6111`="VACUUM PRESSURE", `pica-6111`은 PT-6111→PCV-6111 **압력제어 루프**(다른 점일 수). 센서 sense(절대압/진공계)·단위 미확정 | ⚠️ 임계 근거 부족 |
| 실시간 유량 태그 | FT 기준 매핑 (§4.1.1) | realtime에 `ft-6113` **없음**(count=0). 값은 **FICQ 컨트롤러**(`ficq-6113.pv`)에 존재 | ❌ 매핑 대상 오류 |
### 0.2 치명적 문제 — Phase 1 SQL 그대로면 동작하지 않음
1. **[높음] 하이픈 제거 버그**: `LOWER(REPLACE(ft.tag_no,'-',''))``'ft6113'`. realtime base_tag는 하이픈 유지(`ficq-6113`) → **매칭 0건** → 전부 INDETERMINATE.
2. **[높음] FT vs FICQ 대상 오류**: 유량값은 `ft-*`가 아니라 `ficq-*.pv`. FT 태그로는 realtime 조인 불가.
3. **[높음] heuristic 오매핑**: `'ficq-'||SUBSTRING(base_tag FROM 3)` → P-6102는 `ficq-6102`(없음, 실제 6101), vp-6117은 `ficq--6117`(깨짐). 번호 가정 자체가 틀림.
4. **[중간] 1:N 손실**: flow_tag 단일 컬럼 → P-6114(6113+6114) 한쪽만 저장.
5. **[중간] forward JOIN 실패**: P-6102.to_tag=필터, P-6116.to_tag=C-6111/FCV → to_tag에 FT 없음. 역방향(FT.from_tag=pump)만 일부 성립.
### 0.3 설계 공백
6. **[최우선] 데이터 신선도 게이트 부재** — realtime 값이 stale/frozen이어도 임계 비교 → frozen `20.2667`을 "유량 정상=CONFIRMED"로 **오판**. (이번 수집기 stall이 정확히 이 시나리오.) 판정 전 `NOW()-timestamp` 확인, stale면 판정 보류.
7. **[중간] 임계 0.5 절대값** — 계기별 Full Scale 무시(작은 레인지 과대·큰 레인지 과소).
8. **[중간] 집계가 1대 의심에 전체 오염** — suspicious 1대로 area가 RUNNING_WITH_SUSPICIOUS. 정상 standby/kickback(sp=0) 펌프 상시 의심 위험. corroborated_pct 분모에 INDETERMINATE 혼입.
9. **[중간] active_alarms 주입 위험** — 미검증 휴리스틱(+데이터 품질 이슈)을 운전원 알람화 → 알람 피로. 검증 전 advisory만.
10. **[낮음] VP 신호 선택** — `pica-6111`(압력제어)보다 `pi-6111b`("VACUUM PRESSURE", C-6111 직결)가 진공 판정에 적합해 보임. sense/단위 확인 후 결정.
### 0.4 권장 보완
**(a) 매핑 — 토폴로지 규칙(번호 heuristic 폐기)**
- 1차: `FT.from_tag`가 펌프를 참조하는 행 수집(역방향, 1:N 자연 지원). 값 태그 = 같은 번호 **FICQ 컨트롤러** `ficq-<FT번호>.pv`. 하이픈 **유지**(`lower(tag_no)`만, REPLACE 금지).
- 2차: 펌프-FT 사이 중간설비(P-6102→F-6101A/B→FT-6101)는 토폴로지 2-hop 또는 **manual** 항목.
- map 테이블은 1:N 허용(펌프당 flow_tag 복수 행) + `mapping_source ∈ {topology, manual}`.
**(b) 신선도 게이트 (신규·최우선)**
```sql
-- 값 신뢰 조건: 수집 후 N초 이내(예: 120s). realtime_table.timestamp 기준.
(NOW() - rt.timestamp) < interval '120 seconds'
```
신선하지 않으면 `STALE`(운전 여부 판정 보류). 수집기 측은 supervisor가 stall을 30초 내 자동 복구하고 `RealtimeServiceStatus.Stalled`로도 노출(2026-05-24 적용).
**(c) 판정 상태에 STALE 추가**
| 판정 | 조건 |
|---|---|
| CONFIRMED_RUNNING | RUN + 유량 **신선** + PV>임계 |
| SUSPICIOUS_RUNNING | RUN + 유량 **신선** + PV≤임계 |
| **STALE** (신규) | RUN + 유량 **stale/frozen** (수집 지연·stall) |
| INDETERMINATE_RUNNING | RUN + 유량 매핑/데이터 없음 |
| STOPPED / TRIPPED | enum 기준 |
**(d) 임계** — Phase 1은 절대 0.5 단독 대신 "신선 AND PV<계기군 기본임계"로 무유량/frozen만 포착. Full Scale 5%는 Phase 2 `instrument_range`로.
**(e) 집계·알람** — overall_status는 CONFIRMED 기준으로 RUNNING 유지하고 suspicious/stale는 **부가 카운트**로 노출(전체 상태 오염 금지). active_alarms 주입은 **운전원 검증(§6.4) 통과 후**로 보류.
### 0.5 갱신된 의사결정 체크리스트
- [x] 데이터 성격: **라이브**(dummy 아님), 단 stale 가능 → 신선도 게이트 필수
- [ ] 매핑: FT.from_tag 역방향 + FICQ 값태그 + manual 보강 (번호 heuristic 폐기) ← **재결정**
- [ ] 임계: 계기군 기본값 + 신선도 동반 (절대 0.5 단독 폐기) ← **재결정**
- [ ] VP 신호: `pi-6111b` vs `pica-6111` + sense/단위 확인 ← **미결**
- [ ] STALE 상태 도입 ← **신규 결정 필요**
- [ ] active_alarms 주입: 검증 전 보류 ← **재결정**
### 0.6 구현 현황 (2026-05-24)
-**DB 뷰 계층 구현·검증** (`ExperionDbContext` boot DDL):
- `pump_corroboration_manual` (수동 매핑 테이블, P6 예외 시드: p-6102→ficq-6101, vp-6117→pica-6111, vp-6217→pica-6211)
- `v_pump_signal_map` (토폴로지 `FT.from_tag=펌프`→FICQ 1:N + 수동 UNION)
- `v_plant_running_state_corroborated` (신선도 게이트 120s + STALE + 유량 > 0.5 kg/hr · 진공 < 300 torr)
- `v_plant_running_state_agg` (overall은 CONFIRMED 기준 RUNNING, suspicious/stale는 부가 카운트)
- 빌드 0/0. 라이브 검증: 현재 frozen 데이터가 전부 `STALE`로 분류됨 확인(게이트 정상 작동).
-**plant_context.md** 교차검증 사용법 추가.
-**단위/레인지 메타데이터화** (별도 테이블 X — 복잡도 최소화 결정): `MetadataLoaderService.MetaAttributes``euhi/eulo/units` 추가 → `tag_metadata`(EAV) 재사용. node_map_master에 점 레벨 `euhi`(Double FS-Hi)·`eulo`·`units`(String) 노드 존재 확인. **PointBuilder 작성·수동 메타갱신 트리거에 자동 편승**(스코프=구독 아날로그 ⓐ). 타입 접근은 `v_instrument_range` 뷰(피벗+캐스트). 값은 단위 torr / 유량 kg/hr.
-**유량 임계 FS 5%**: corroborated가 `flow > COALESCE(eu_hi*0.05, 0.5 kg/hr)` — 레인지 적재되면 자동 FS 5% 승급, 없으면 절대 fallback. 진공은 300 torr 절대(실 레인지 확인 후 보정).
-**OPC 복구 후 실값 적재**: 현재 수집 stall이라 `euhi/eulo/units` 값 미적재 → 복구 후 메타갱신 1회 시 채워지며 FS 5% 자동 적용.
-**MCP 연동 보류**: `generate_status_report``v_plant_running_state_agg` 노출은 후속. `active_alarms` 주입은 운전원 검증 후(§0.4e).
---
## 1. 배경 및 문제 정의
### 1.1 현재 상황
현재 공장 운전 판정(`v_plant_running_state` 뷰)은 **펌프의 상태 워드(enum 값)만**으로 이루어짐:
```sql
-- 현재 로직 (의사코드)
CASE
WHEN pv ~ '[LR]-RUN' THEN 'RUNNING'
WHEN pv ~ '[LR]-TRIP' THEN 'TRIPPED'
ELSE 'STOPPED'
END AS status
```
예: P-6102의 PV = `{5 | R-RUN | }`**RUNNING**
### 1.2 문제점 — 허위 운전 미검출
펌프 상태 워드가 `R-RUN`이어도 **실질적 운전**이 아닌 경우가 있음:
| 상황 | 펌프 상태 | 유량계 | 실질 운전? |
|------|----------|--------|-----------|
| 정상 운전 | R-RUN | > 0 | ✅ |
| 밸브 닫힘/Deadhead | R-RUN | ≈ 0 | ❌ (기계 손상 위험) |
| 커플링 파손 | R-RUN | ≈ 0 | ❌ (무부하 운전) |
| 센서 오류 | R-RUN | ≈ 0 | ❌ (신호 끊김) |
| Kickback 순환 | R-RUN | 0 (메인) | ⚠️ (의도된 운전, main line은 닫힘) |
**현재는 이 4가지 케이스를 모두 동일하게 `RUNNING`으로 판정 → 허위 정보 제공**
### 1.3 진공 펌프의 특수성
진공 펌프(VP)는 유량계가 없고 **진공압(PI/PICA)** 으로 운전 상태를 검증:
| 상황 | VP 상태 | 진공압 | 실질 운전? |
|------|---------|--------|-----------|
| 정상 진공 유지 | R-RUN | 목표압 도달 | ✅ |
| 펌프 고장/RUN 신호 오류 | R-RUN | 대기압 (≈0) | ❌ |
| 계통 누설 | R-RUN | 대기압 (≈0) | ❌ |
---
## 2. 조사 결과 — P6 데이터 기반 분석
### 2.1 Pump↔Flow Meter 매핑 (pid_equipment 기반)
**P6-1 (C-6111 증류탑):**
| Pump | 상태 | P&ID 연결 | Experion 유량계 | SETPOINT |
|------|------|----------|----------------|----------|
| P-6101 | L-STOP | (미매핑, 번호 일치) | ficq-6101.pv=20.3 | sp=36.0 |
| P-6102 | **R-RUN** | →F-6101A/B→FT-6101→FCV-6101 | ficq-6101.pv=20.3 | sp=36.0 |
| P-6114 | **R-RUN** | →FT-6113( reflux) + FT-6114(light ends) | ficq-6113.pv=20.3, ficq-6114.pv=20.3 | sp=36.4 / 0 |
| P-6116 | **R-RUN** | →FT-6116→FCV-6116 | ficq-6116.pv=20.3 | sp=0 |
| P-6118 | **R-RUN** | →FT-6118→FCV-6118 | ficq-6118.pv=20.3 | sp=0 |
| VP-6117 | **R-RUN** | C-6111 진공 유지 | pi-6111.pv=0, pica-6111.pv=20.8 | — |
| P-6120 | OFF | (미매핑) | fiq-6120.pv=0 | — |
| P-6123 | L-STOP | (미매핑) | — | — |
| P-6128a/b | L-STOP | (미매핑) | — | — |
**P6-2 (C-6211 증류탑):**
| Pump | 상태 | Experion 유량계 | 비고 |
|------|------|----------------|------|
| P-6201 | L-STOP | ficq-6201.pv=20.8 | P6-1/P6-2 공용 |
| P-6202~6223 | 전부 STOP | ficq-62XX.pv=20.3 | — |
| VP-6217 | L-STOP | pi-6211.pv=0, pica-6211.pv=20.8 | — |
### 2.2 key observations
1. ⚠️ **(정정 §0.1) 펌프 번호 ≠ 유량계 번호**: P-6102→ficq-6101, P-6114→ficq-6113+6114(1:N). 유량 번호는 stream/line 번호 → pid_equipment 토폴로지(`FT.from_tag=pump`)로 매핑해야 함
2. **pid_equipment.from_tag/to_tag 토폴로지**로 1:N 매핑 추적 가능 (예: P-6114→FT-6113 + FT-6114)
3. **Setpoint(SP) 데이터 존재**: ficq-XXXX.sp 사용 가능 — SP=0은 밸브 닫힘(킥백) 신호
4. **VP는 유량계 없음**: 대신 pica-6111/sp, pi-6111로 진공압 교차검증 필요
5. **FCV-XXXX.op(밸브 위치) 데이터 없음**: 현재 realtime_table에 미등록
---
## 3. 설계 결정
### 3.1 매핑 전략: pid_equipment 기반 + 번호 heuristic fallback
> ⚠️ **§0.4(a) 우선** — 번호 heuristic은 오매핑(P-6102→6101 어긋남, vp- 깨짐)이라 폐기. `FT.from_tag` 역방향(1:N) + FICQ 값태그 + manual 보강으로 대체.
```
[1차] pid_equipment.from_tag/to_tag 정방향/역방향 조회
└─ P-6114의 to_tag = FT-6113, FT-6114 → ficq-6113, ficq-6114 매핑
[2차] 번호 heuristic fallback (pid_equipment 커버 안 되는 경우)
└─ P-6101 (pid_equipment 미존재) → ficq-6101 (번호 일치)
```
**pump_corroboration_map 테이블 (신규):**
```sql
CREATE TABLE pump_corroboration_map (
pump_base_tag TEXT PRIMARY KEY,
flow_tag TEXT, -- ficq-XXXX 또는 fiq-XXXX
flow_sp_tag TEXT, -- ficq-XXXX.sp (threshold 계산용)
vacuum_tag TEXT, -- VP용: pica-XXXX.pv 또는 pi-XXXX.pv
vacuum_sp_tag TEXT, -- VP용: pica-XXXX.sp
mapping_source TEXT DEFAULT 'auto', -- 'pid_equipment' | 'heuristic' | 'manual'
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
> **MetadataLoaderService가 건드리지 않음** — sub_area와 동일한 원칙 (자동 덮어쓰기 금지)
### 3.2 Threshold 기준
| 구분 | Phase 1 기준 | Phase 2 기준 |
|------|-------------|-------------|
| **유량계 (FICQ/FIQ)** | PV > **0.5** (절대값) | PV > **5% of Full Scale** (instrument_range 테이블 도입 시) |
| **진공압 (PICA)** | PV < **50** (mmHg 절대압 가정) | PV < **5% of Full Scale** |
| **진공압 (PI)** | PV ≈ **0** (≈대기압이면 의심) | 동일 |
**Phase 1에서 SP(Setpoint)를 threshold 기준으로 사용하지 않는 이유:**
운전원은 잦은 수동 밸브 조작으로 SP를 변경하지 않고 Control Valve만 조작함.
예: ficq-6101.sp=36으로 설정되어 있지만, PV가 1.2로 수동 조작 중 → SP*5%=1.8 > PV=1.2 → false SUSPICIOUS.
절대값 0.5를 기준으로 하면 PV=1.2는 정상 판정 → **false positive 방지**.
### 3.3 3단계 판정 로직
> ⚠️ **§0.4(c): `STALE` 상태 추가로 4단계+STALE. 신선도 미확인 시 frozen 데이터 오판(CONFIRMED) 차단.**
킥백 라인 상황(펌프 RUN + 메인 밸브 닫힘 + kickback만 순환) 고려:
| 판정 | 조건 | 의미 |
|------|------|------|
| **CONFIRMED_RUNNING** | pump RUN + flow PV > threshold | 유량 있음, 실질 운전 중 |
| **SUSPICIOUS_RUNNING** | pump RUN + flow PV ≤ threshold | RUN인데 유량 없음 (deadhead / 센서오류 / 커플링파손) |
| **INDETERMINATE_RUNNING** | pump RUN + flow 데이터 없음 | kickback 가능성, 추가 정보 필요 |
| **STOPPED** | pump STOP/TRIP/OFF | 정지 또는 트립 |
킥백 상황(P-6114 RUN + ficq-6114.sp=0 → valve closed)은 유량 PV=0이어도 PV=0이라 SUSPICIOUS 대상이지만, **이건 의도된 운전이므로 사용자가 해석 시 고려**. Phase 2에서 FCV-XXXX.op 데이터 추가 시 자동 구분 가능.
### 3.4 진공 펌프(VP) 교차검증
VP는 유량계가 없으므로 **진공압(PICA/PI)** 으로 검증:
- **VP R-RUN** + PICA.pv < threshold (진공 유지 중) → CONFIRMED
- **VP R-RUN** + PI.pv ≈ 0 (대기압, 진공 안 잡힘) → SUSPICIOUS
- **VP STOP/TRIP** → STOPPED
---
## 4. 구현 계획 — 3 Phase
### Phase 1: SQL View 확장 (즉시)
#### 4.1.1 pump_corroboration_map 생성 및 시딩
> ⚠️ **아래 시딩 SQL은 현 상태로 동작하지 않음** (§0.2: ① 하이픈 제거 `REPLACE(...,'-','')` → 매칭 0건, ② FT/FICQ 대상 혼동, ③ 번호 heuristic 오류, ④ 1:N 손실). **§0.4(a)의 토폴로지 규칙으로 대체할 것.**
```sql
-- Step 1: pid_equipment 기반 pump→FT 매핑 (from_tag/to_tag 정/역방향)
INSERT INTO pump_corroboration_map (pump_base_tag, flow_tag, flow_sp_tag, mapping_source)
SELECT DISTINCT
LOWER(REPLACE(p.tag_no, '-', '')) AS pump_base_tag,
LOWER(REPLACE(ft.tag_no, '-', '')) || '.pv' AS flow_tag,
LOWER(REPLACE(ft.tag_no, '-', '')) || '.sp' AS flow_sp_tag,
'pid_equipment'
FROM pid_equipment p
JOIN pid_equipment ft ON (
-- 정방향: pump.to_tag = FT
p.to_tag LIKE '%' || ft.tag_no || '%'
-- 역방향: FT.from_tag = pump
OR ft.from_tag LIKE '%' || p.tag_no || '%'
)
WHERE p.category = '펌프'
AND ft.category IN ('계기', '제어')
AND ft.tag_no ~ '^FT-|^FIC-';
-- Step 2: 번호 heuristic fallback (pid_equipment에 없는 pump)
INSERT INTO pump_corroboration_map (pump_base_tag, flow_tag, flow_sp_tag, mapping_source)
SELECT DISTINCT
v.base_tag,
'ficq-' || SUBSTRING(v.base_tag FROM 3) || '.pv',
'ficq-' || SUBSTRING(v.base_tag FROM 3) || '.sp',
'heuristic'
FROM v_tag_summary v
WHERE (v.base_tag LIKE 'p-6%' OR v.base_tag LIKE 'vp-6%')
AND v.pv ~ '[LR]-RUN|L-STOP|R-STOP|OFF'
AND NOT EXISTS (
SELECT 1 FROM pump_corroboration_map m WHERE m.pump_base_tag = v.base_tag
);
-- Step 3: VP 전용 vacuum 태그 매핑
INSERT INTO pump_corroboration_map (pump_base_tag, vacuum_tag, vacuum_sp_tag, mapping_source)
SELECT
LOWER(REPLACE(vp.tag_no, '-', '')),
LOWER(REPLACE(pi.tag_no, '-', '')) || '.pv',
LOWER(REPLACE(pi.tag_no, '-', '')) || '.sp',
'pid_equipment'
FROM pid_equipment vp
CROSS JOIN pid_equipment pi
WHERE vp.tag_no LIKE 'VP-%'
AND pi.tag_no IN ('PICA-6111', 'PICA-6211', 'PI-6111B', 'PI-6211B');
```
#### 4.1.2 v_plant_running_state_corroborated 뷰
> ⚠️ **신선도 게이트(§0.4b)·STALE 분기(§0.4c) 미반영.** 아래 뷰에 `(NOW() - rt.timestamp) < interval '120 seconds'` 조건과 `STALE` 분기를 추가하고, flow 값은 `ft-*`가 아니라 `ficq-*.pv`(컨트롤러)에서 조인할 것.
```sql
CREATE OR REPLACE VIEW v_plant_running_state_corroborated AS
WITH pump_base AS (
-- 기존 pump_state 로직 + corroboration 매핑 LEFT JOIN
SELECT
trim(split_part(v.area, '|', 2)) AS area_code,
v.area AS area_raw,
v.base_tag,
v.pv,
v.description,
v.sub_area,
m.flow_tag,
m.flow_sp_tag,
m.vacuum_tag,
m.vacuum_sp_tag,
m.mapping_source,
-- 유량계 PV/SP 값 조회 (realtime_table에서)
flow_rt.livevalue AS flow_pv,
flow_sp_rt.livevalue AS flow_sp,
vac_rt.livevalue AS vacuum_pv,
vac_sp_rt.livevalue AS vacuum_sp
FROM v_tag_summary v
LEFT JOIN pump_corroboration_map m ON m.pump_base_tag = v.base_tag
LEFT JOIN realtime_table flow_rt ON flow_rt.tagname = m.flow_tag
LEFT JOIN realtime_table flow_sp_rt ON flow_sp_rt.tagname = m.flow_sp_tag
LEFT JOIN realtime_table vac_rt ON vac_rt.tagname = m.vacuum_tag
LEFT JOIN realtime_table vac_sp_rt ON vac_sp_rt.tagname = m.vacuum_sp_tag
WHERE v.area IS NOT NULL
AND (v.base_tag LIKE 'p-%' OR v.base_tag LIKE 'vp-%')
AND v.pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
),
pump_with_corroboration AS (
SELECT *,
CASE
-- pump STOP/TRIP/OFF
WHEN pv ~ '\|\s*[LR]-TRIP\s*\|' THEN 'TRIPPED'
WHEN pv ~ '\|\s*(L-STOP|R-STOP|OFF|STOP)\s*\|' THEN 'STOPPED'
-- pump RUN - vacuum pump (VP)
WHEN base_tag LIKE 'vp-%' THEN
CASE
WHEN vacuum_pv IS NOT NULL
AND vacuum_pv ~ '^\d+\.?\d*$'
AND CAST(vacuum_pv AS DOUBLE PRECISION) < 50
THEN 'CONFIRMED_RUNNING'
WHEN vacuum_pv IS NOT NULL
AND (vacuum_pv ~ '^\{' OR CAST(vacuum_pv AS DOUBLE PRECISION) >= 50)
THEN 'SUSPICIOUS_RUNNING'
ELSE 'INDETERMINATE_RUNNING'
END
-- pump RUN - 유량계 있음
WHEN flow_pv IS NOT NULL AND flow_pv ~ '^\d+\.?\d*$' THEN
CASE
WHEN CAST(flow_pv AS DOUBLE PRECISION) > 0.5 THEN 'CONFIRMED_RUNNING'
ELSE 'SUSPICIOUS_RUNNING'
END
-- pump RUN - 유량계 없음
ELSE 'INDETERMINATE_RUNNING'
END AS corroborated_status
FROM pump_base
)
SELECT
area_code,
area_raw,
base_tag,
pv AS raw_pv,
description,
sub_area,
flow_tag,
flow_pv,
flow_sp,
vacuum_tag,
vacuum_pv,
vacuum_sp,
mapping_source,
corroborated_status,
CASE
WHEN corroborated_status = 'CONFIRMED_RUNNING' THEN TRUE
ELSE FALSE
END AS is_corroborated_running,
CASE
WHEN corroborated_status = 'SUSPICIOUS_RUNNING' THEN TRUE
ELSE FALSE
END AS is_suspicious_running,
CASE
WHEN corroborated_status = 'INDETERMINATE_RUNNING' THEN TRUE
ELSE FALSE
END AS is_indeterminate_running
FROM pump_with_corroboration
WHERE area_code IS NOT NULL AND area_code <> '';
```
#### 4.1.3 v_plant_running_state_agg 뷰 (area별 집계)
```sql
CREATE OR REPLACE VIEW v_plant_running_state_agg AS
SELECT
area_code,
MAX(area_raw) AS area_raw,
COUNT(*) AS total_pumps,
COUNT(*) FILTER (WHERE corroborated_status = 'CONFIRMED_RUNNING') AS confirmed_running,
COUNT(*) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') AS suspicious_running,
COUNT(*) FILTER (WHERE corroborated_status = 'INDETERMINATE_RUNNING') AS indeterminate_running,
COUNT(*) FILTER (WHERE corroborated_status = 'TRIPPED') AS tripped_pumps,
COUNT(*) FILTER (WHERE corroborated_status = 'STOPPED') AS stopped_pumps,
CASE
WHEN COUNT(*) FILTER (WHERE corroborated_status IN ('CONFIRMED_RUNNING', 'SUSPICIOUS_RUNNING', 'INDETERMINATE_RUNNING')) > 0
AND COUNT(*) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') = 0
THEN 'RUNNING'
WHEN COUNT(*) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') > 0
THEN 'RUNNING_WITH_SUSPICIOUS'
WHEN COUNT(*) FILTER (WHERE corroborated_status = 'TRIPPED') > 0
THEN 'TRIPPED'
ELSE 'STOPPED'
END AS overall_status,
-- corroborated_rate: 전체 RUN 펌프 중 확인된 비율
CASE
WHEN COUNT(*) FILTER (WHERE corroborated_status IN ('CONFIRMED_RUNNING', 'SUSPICIOUS_RUNNING', 'INDETERMINATE_RUNNING')) > 0
THEN ROUND(
COUNT(*) FILTER (WHERE corroborated_status = 'CONFIRMED_RUNNING')::NUMERIC
/ COUNT(*) FILTER (WHERE corroborated_status IN ('CONFIRMED_RUNNING', 'SUSPICIOUS_RUNNING', 'INDETERMINATE_RUNNING'))
* 100, 1
)
ELSE NULL
END AS corroborated_pct,
array_agg(base_tag) FILTER (WHERE corroborated_status = 'SUSPICIOUS_RUNNING') AS suspicious_pump_tags,
array_agg(base_tag) FILTER (WHERE corroborated_status = 'CONFIRMED_RUNNING') AS confirmed_running_tags
FROM v_plant_running_state_corroborated
WHERE area_code IS NOT NULL AND area_code <> ''
GROUP BY area_code
ORDER BY area_code;
```
### Phase 1: MCP Server 통합
#### 4.1.4 server.py — 새 뷰 조회 추가
`generate_status_report` 함수 내에서 `v_plant_running_state_agg` 조회:
```python
# server.py — generate_status_report 내부
cur.execute("""
SELECT area_code, overall_status, total_pumps, confirmed_running,
suspicious_running, suspicious_pump_tags, corroborated_pct
FROM v_plant_running_state_agg
WHERE (%s IS NULL OR area_code = %s)
ORDER BY area_code
""", (area, area))
```
응답 JSON에 추가:
```python
{
"active_alarms": [...],
"recent_events": [...],
"by_type": {...},
"pump_corroboration": {
"by_area": [
{
"area": "P6",
"status": "RUNNING_WITH_SUSPICIOUS",
"total_pumps": 22,
"confirmed_running": 4,
"suspicious_running": 1,
"corroborated_pct": 80.0,
"suspicious_pumps": ["p-6114"]
}
]
}
}
```
#### 4.1.5 active_alarms — SUSPICIOUS_RUNNING 의심 알람 추가
```sql
-- active_alarms에 suspicious pump 추가
SELECT base_tag AS tag_name, 'SUSPICIOUS_RUNNING' AS event_type,
'펌프 RUN 상태이나 유량 없음' AS description,
area_code
FROM v_plant_running_state_corroborated
WHERE is_suspicious_running = TRUE;
```
#### 4.1.6 trace_connections — flow_pv/run_status 노출
```python
# 각 path 노드에 flow_pv, corroborated_status 추가
# pid_equipment tag_no → base_tag 변환 후 v_plant_running_state_corroborated 조회
```
### Phase 1: plant_context.md 업데이트
프롬프트에 교차검증 관련 컨텍스트 추가:
```
## 운전 판정 교차검증 (Corroboration)
펌프의 상태 워드(R-RUN/L-RUN)만으로 운전을 판정하지 않고,
연결된 유량계(FICQ/FIQ)의 PV 값을 교차검증하여 3단계 판정:
| 판정 | 의미 |
|------|------|
| CONFIRMED_RUNNING | 펌프 RUN + 유량계 PV > 0.5 (실질 운전) |
| SUSPICIOUS_RUNNING | 펌프 RUN + 유량계 PV ≤ 0.5 (의심: deadhead, 센서오류) |
| INDETERMINATE_RUNNING | 펌프 RUN + 유량계 데이터 없음 (킥백 가능성) |
진공펌프(VP)는 유량계 대신 진공압(PICA.pv < 50)으로 판정.
- `v_plant_running_state_corroborated`: 태그별 상세 판정
- `v_plant_running_state_agg`: area별 집계 (corroborated_pct 포함)
```
---
### Phase 2: 정밀화 (Phase 1 검증 후)
| 항목 | 내용 | 우선순위 |
|------|------|---------|
| **instrument_range 테이블** | 각 유량계의 Full Scale / Unit 저장 → 5% threshold 계산 | 높음 |
| **Plant Load Rate 기반 검증** | 원료투입량(FICQ-6101.pv) 대비 각 유량계 비율 계산 → 수율(Throughput) 추정 | 중간 |
| **FCV-XXXX.op 태그 추가** | Control Valve position 실시간 감시 → kickback 자동 인식 | 중간 |
| **AI/통계 threshold** | 정상 운전 기간의 평균±3σ로 이상 감지 | 낮음 |
| **PumpCorroborationService** | C# BackgroundService로 주기적 검증 → pump_corroboration_history 테이블에 이벤트 기록 | 낮음 |
#### instrument_range 테이블 설계 (Phase 2)
```sql
CREATE TABLE instrument_range (
base_tag TEXT PRIMARY KEY,
full_scale DOUBLE PRECISION NOT NULL,
unit TEXT,
source TEXT DEFAULT 'manual', -- 'pid_equipment' | 'opc_ua' | 'manual'
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Phase 2 threshold: PV > 0.05 * full_scale (5% of Full Scale)
```
Phase 2에서 기존 view의 threshold만 변경:
```sql
-- Phase 2 수정안
WHEN CAST(flow_pv AS DOUBLE PRECISION) > 0.05 * ir.full_scale THEN 'CONFIRMED_RUNNING'
```
---
### Phase 3: 프론트엔드 대시보드
- Area Overview에 `corroborated_pct` 게이지 표시
- SUSPICIOUS_RUNNING 펌프 빨간색 하이라이트
- 클릭 시 flow_pv / vacuum_pv 상세 표시
---
## 5. 리스크 분석
| 리스크 | 영향 | 대응 |
|--------|------|------|
| ~~Flow PV가 dummy 값~~**(정정 §0.1) 라이브 데이터** (ficq-6113 38~55 변동 확인). 단 **수집기 stall 시 frozen** | frozen을 정상으로 오판(CONFIRMED) | **신선도 게이트(§0.4b)** + 수집기 supervisor 수정(2026-05-24 적용) |
| **pid_equipment 미완성** | 일부 pump 교차검증 불가 | 번호 heuristic fallback으로 커버. 이후 수동 보강 |
| **킥백 상황 오판** | SUSPICIOUS_RUNNING 과다 | 0.5 threshold로 PV=0 케이스만 포착. Phase 2에서 FCV-XXXX.op 추가 |
| **진공압 범위/단위 불명** | threshold 값 부정확 | 현재 PICA.pv=20.8(임의값). 실제 단위 확인 필요 (mmHg / kPa / bar) |
| **성능: realtime_table LEFT JOIN** | 뷰 조회 속도 저하 | pump_corroboration_map에 인덱스. 실운영 모니터링 |
## 6. 검증 계획
1. **유닛 테스트**: 각 판정 CASE별 샘플 데이터 생성 → 예상 결과와 일치 확인
2. **DB 뷰 검증**: production replica에서 `v_plant_running_state_corroborated` 조회, 수동 확인
3. **MCP 응답 체크**: `generate_status_report`에 suspicious 필드 정상 포함 확인
4. **운전원 피드백**: SUSPICIOUS_RUNNING 케이스 실제 상황과 일치하는지 확인
---
## 7. 일정 (예상)
| Phase | 작업 | 예상 기간 | 비고 |
|-------|------|----------|------|
| **Phase 1** | pump_corroboration_map 시딩 스크립트 | 1일 | |
| | v_plant_running_state_corroborated 뷰 | 0.5일 | |
| | v_plant_running_state_agg 뷰 | 0.5일 | |
| | MCP server.py 통합 | 1일 | |
| | plant_context.md 업데이트 | 0.5일 | |
| | **소계** | **3.5일** | |
| **Phase 2** | instrument_range 테이블 + 시딩 | 1일 | Phase 1 검증 후 |
| | Plant Load Rate 계산 로직 | 2일 | |
| | FCV-XXXX.op 추가 | 0.5일 | |
| | 통계 threshold | 2일 | |
| | **소계** | **5.5일** | |
| **Phase 3** | 프론트엔드 대시보드 | 2일 | |
| | **총계** | **11일** | |
---
## 8. 결론
현재 펌프 상태 워드 단일 판정을 유량계·진공압 교차검증으로 고도화하여 **허위 운전 정보 제공을 방지**하고, **실질 운전 여부를 정확히 판정**할 수 있음.
Phase 1은 SQL view 확장만으로 즉시 적용 가능하며, OPC UA 실제 데이터 연결 후 검증 즉시 가동 가능.
**핵심 의사결정 사항:**
- [ ] Pump↔유량계 매핑 방식 → pid_equipment 기반 + 번호 heuristic fallback (✅ 결정)
- [ ] Threshold 기준 → 절대값 0.5 (Phase 1), Full Scale 5% (Phase 2) (✅ 결정)
- [ ] 킥백 처리 → Phase 1에서 별도 미처리, Phase 2에서 valve position 추가 (✅ 결정)
- [ ] VP 교차검증 → PICA.pv < 50 기준 (단위 확인 필요)

View File

@@ -1,644 +0,0 @@
# 이벤트 히스토리 UI 신설 — 상세 코딩 플랜
## 진단 보고서 (2026-05-11)
diagnosis-checklist.md 8단계 순서대로 진단한 결과 **실행 가능한 플랜**임. 중대한 오진은 없음. 아래는 단계별 확인 사항과 발견된 소규모 이슈.
### STEP 1 — 맥락 파악
- **역할**: 백엔드 API 3개는 구현·동작 중인데 UI 전무 → 접근 불가 문제 해결
- **레이어**: 순수 프론트엔드 (HTML/JS/CSS) — 백엔드 수정 없음
- **관련 문서**: AGENTS.md (camelCase 규칙), CODING_CONVENTIONS.md
### STEP 2 — 구조 탐색
| 항목 | 실제 값 | 플랜 가정 | 일치 |
|------|---------|-----------|------|
| `style.css` 총 줄수 | 1489 | 1489 | ✅ |
| `index.html` 총 줄수 | 1205 | — | — |
| `index.html` `</main>` 위치 | 1119 | 파일 말미 | ⚠️ |
| `app.js` 총 줄수 | 3075 | — | — |
| `histReset()` 위치 | 1158~1169 | ~1175 | ⚠️ |
| `data-tab="pid"` 위치 | 75-78 | 75-78 | ✅ |
### STEP 3 — 코드 읽기 (핵심 검증)
| 검증 대상 | 실제 상태 | 플랜 가정 | 일치 |
|-----------|-----------|-----------|------|
| `api()` 함수 존재 | `app.js:40` | 존재 | ✅ |
| `esc()` 함수 존재 | `app.js:22` | 존재 | ✅ |
| `dtOpen(target)` 패턴 | `hf-${target}`, `dtp-${target}-display` 참조 | 동일 | ✅ |
| `dtClearField(target)` 패턴 | 동일 패턴 | 동일 | ✅ |
| 탭 핸들러 자동 호출 없음 | `app.js:5-18` — evt 케이스 없음 | 불필요 | ✅ |
| CSS 변수 (`--s2`, `--bd`, `--r`, `--fm`, `--t0`, `--t2`) | 모두 정의됨 | 사용 | ✅ |
| `.hidden` 클래스 | `style.css:1274` | 재사용 | ✅ |
| `.nm-cls` 클래스 | `style.css:469` | 재사용 | ✅ |
| `.hist-status` 클래스 | `style.css:1216` | 재사용 | ✅ |
| `.nm-result-info` 클래스 | `style.css:454` | 재사용 | ✅ |
| 백엔드 camelCase 응답 | `ExperionControllers.cs:1192-1207` | 일치 | ✅ |
| 백엔드 digital-tags 응답 | `{ tagName: "..." }` | 일치 | ✅ |
### STEP 4 — 호출 계층 지도
```
사용자 클릭 (이벤트 조회)
→ evtQuery()
→ api('GET', '/api/event-history?params')
→ EventHistoryController.Query()
→ _db.QueryEventHistoryAsync()
→ DB (external I/O)
→ _evtBuildTable(d.data) ← 순수 함수, side effect 없음
→ DOM innerHTML 업데이트
```
에러 처리: Controller 레벨 try-catch 전체 포착 → `{ success: false, error: "..." }` 반환. JS 측 `if (!d.success) throw`로 캐치. **계층 적절**.
### STEP 5 — 패턴 매칭 결과
| 체크 | 항목 | 결과 |
|------|------|------|
| 미정의 변수 참조 | `api`, `esc`, `dtClearField` 모두 정의됨 | ✅ |
| XSS (내부 HTML) | `esc()`로 모든 서버 응답 문자열 이스케이프 | ✅ |
| `.innerHTML` + 서버 데이터 | `_evtBuildTable`에서 `esc()` 적용済み | ✅ |
| 날짜 변환 | `new Date(fromRaw).toISOString()` — 기존 hist 탭과 동일 패턴 | ✅ |
### STEP 6 — 교차 검증
| 의심 항목 | Q1-Q4 결과 | 결론 |
|-----------|-----------|------|
| `histReset()` 줄번호 불일치 (1175 vs 1169) | Q3: 의도적 아님, 단순 추정치 차이 | LOW, 수정 제안만 |
| `</main>`가 "파일 말미"가 아님 (1119/1205) | Q3: 설명상 불명확하나 삽입 로직은 정확 | LOW, 명시화 제안 |
### 발견 사항
### 1. `histReset()` 종료 줄번호 불일치 (LOW)
**문제**: 플랜 439줄 "histReset() 함수 끝 (현재 ~1175줄) 뒤"라고 했으나 실제 `histReset()``app.js:1158-1169`에서 종료됨. 1171줄부터 "07-2 하이퍼테이블 관리" 섹션 시작.
**근거**: `app.js:1158-1173`
**영향**: 동작에는 영향 없음. 삽입 위치를 "histReset() 뒤"로 이해하면 정확함. 줄번호만 불일치.
**수정**: "~1175줄" → "1169줄"로 정정
### 2. `</main>`가 파일 말미가 아님 (LOW)
**문제**: 플랜 181줄 "#pane-pid 섹션 (현재 파일 말미) 바로 뒤, </main> 닫는 태그 앞"이라고 했으나 `</main>``index.html:1119`에 위치하고 파일은 1205줄. 1119~1205줄 사이에 fastRecord 모달, dt-popup 등 외부 요소가 있음.
**근거**: `index.html:1117-1205`
**영향**: 삽입 위치 자체는 정확함 (`</main>` 앞). "파일 말미"라는 표현이 혼동을 줄 수 있음.
**수정**: "파일 말미" → "1117줄 (#pane-pid 종료)"로 정정
---
**종합**: 플랜 실행 가능. CSS 변수, 헬퍼 함수, 클래스, 백엔드 응답 형식 등 모든 전제가 검증됨. 발견된 2개 이슈 모두 LOW (표현 명확화 수준).
---
## 0. 현황 진단
### UI 반영 여부
| 항목 | 상태 |
|------|------|
| 사이드바 탭 (`data-tab="evt"`) | ❌ 없음 |
| `#pane-evt` 섹션 | ❌ 없음 |
| JS 함수 (`evtQuery` 등) | ❌ 없음 |
| CSS 이벤트 배지 스타일 | ❌ 없음 |
**결론**: 백엔드 API 3개(`GET /api/event-history`, `/summary`, `/digital-tags`)는 완전 구현·동작 중이나 **UI가 전무**하여 접근 불가.
### 기존 API 엔드포인트 (구현 완료)
| 엔드포인트 | 설명 | 주요 파라미터 |
|-----------|------|--------------|
| `GET /api/event-history` | 이벤트 조회 | tagName, area, section, eventType, from, to, limit |
| `GET /api/event-history/summary` | 구간별 집계 | area, section, from, to |
| `GET /api/event-history/digital-tags` | 디지털 태그 목록 | — |
### 응답 형식
```json
// GET /api/event-history
{
"success": true,
"count": 42,
"data": [
{
"id": 1,
"tagName": "P6-FIC101.instate0",
"nodeId": "...",
"prevValue": "RUN",
"currValue": "L-STOP",
"eventType": "TRIP",
"eventTime": "2026-05-11T03:00:00Z",
"area": "P6",
"section": "1-2차",
"durationSeconds": 3600,
"metadata": null
}
]
}
// GET /api/event-history/summary
{
"success": true,
"count": 3,
"data": [
{
"section": "1-2차",
"totalEvents": 10,
"tripCount": 3,
"runCount": 3,
"alarmCount": 2,
"changeCount": 2
}
]
}
```
---
## 1. 구현 대상 파일
| 파일 | 수정 내용 |
|------|----------|
| `src/Web/wwwroot/index.html` | 탭 항목(12번) + `#pane-evt` 섹션 추가 |
| `src/Web/wwwroot/js/app.js` | `evtLoadTags`, `evtQuery`, `evtSummary`, `evtBuildTable`, `evtBuildSummary`, `evtReset` 함수 추가 |
| `src/Web/wwwroot/css/style.css` | `.evt-badge`, `.evt-summary-*` 스타일 추가 |
---
## 2. 구현 순서 (Dependency Graph)
```
Step 1: CSS — .evt-badge, .evt-summary-grid, .evt-summary-item
Step 2: HTML — 사이드바 탭 항목(12번) 추가
Step 3: HTML — #pane-evt 섹션 전체 구조
Step 4: JS — evtLoadTags(), evtQuery(), evtSummary()
Step 5: JS — evtBuildTable(), evtBuildSummary(), evtReset()
Step 6: 검증
```
---
## 3. Step-by-Step 코딩 계획
### Step 1: CSS 추가
**파일**: `src/Web/wwwroot/css/style.css`
**위치**: 파일 말미 (현재 1489줄 끝)
```css
/* ── Event History ─────────────────────────────────────────── */
.evt-badge {
display: inline-block;
font-family: var(--fm); font-size: 10px; font-weight: 700;
letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
text-transform: uppercase; white-space: nowrap;
}
.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
.evt-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 12px;
}
.evt-summary-item {
background: var(--s2);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
}
.evt-summary-section {
font-family: var(--fm); font-size: 13px; font-weight: 700;
color: var(--t0); margin-bottom: 10px;
}
.evt-summary-counts {
display: flex; gap: 10px; flex-wrap: wrap;
}
.evt-count {
display: flex; align-items: center; gap: 5px;
font-family: var(--fm); font-size: 11px; color: var(--t2);
}
.evt-count strong { font-size: 14px; color: var(--t0); }
.evt-total {
font-family: var(--fm); font-size: 11px; color: var(--t2);
margin-top: 8px; padding-top: 8px;
border-top: 1px solid var(--bd);
}
.evt-total strong { color: var(--t0); }
```
**검증**:
- [ ] `.evt-badge.trip` 의 색상이 `#f87171` (red-400) 계열인지 확인
- [ ] `.evt-summary-grid`가 반응형으로 동작하는지 확인 (`auto-fill, minmax`)
---
### Step 2: HTML — 사이드바 탭 항목 추가
**파일**: `src/Web/wwwroot/index.html`
**위치**: `<li class="nav-item" data-tab="pid">` 블록 (현재 75-78줄) **뒤**
```html
<li class="nav-item" data-tab="evt">
<span class="ni">12</span>
<span class="nl">이벤트 히스토리</span>
</li>
```
**검증**:
- [ ] 탭 번호가 기존 11번(P&ID 추출) 다음인지 확인
- [ ] `data-tab="evt"``#pane-evt`와 매핑되는지 확인 (탭 클릭 핸들러가 `pane-${tab}` 패턴 사용)
---
### Step 3: HTML — #pane-evt 섹션 추가
**파일**: `src/Web/wwwroot/index.html`
**위치**: `#pane-pid` 섹션 (현재 파일 말미) 바로 뒤, `</main>` 닫는 태그 앞
```html
<!-- ══════════════════════════════════════════════════════
12 이벤트 히스토리
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-evt">
<header class="pane-hdr">
<div>
<h1>이벤트 히스토리</h1>
<p>디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)</p>
</div>
<div class="pane-tag">EVENT / DIGITAL</div>
</header>
<!-- 조회 조건 카드 -->
<div class="card">
<div class="card-cap">조회 조건</div>
<!-- 태그 필터 -->
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:13px">
<span>태그 필터</span>
<button class="btn-b btn-sm" onclick="evtLoadTags()">▼ 태그 목록 불러오기</button>
<span id="evt-tag-status" class="hist-status"></span>
</div>
<div class="fg">
<select id="ef-tag" class="inp">
<option value="">— 전체 태그 —</option>
</select>
</div>
<!-- 필터 4열 -->
<div class="cols-4">
<div class="fg">
<label>이벤트 타입</label>
<select id="ef-event-type" class="inp">
<option value="">전체</option>
<option value="TRIP">TRIP</option>
<option value="RUN">RUN</option>
<option value="ALARM">ALARM</option>
<option value="NORMAL">NORMAL</option>
<option value="CHANGE">CHANGE</option>
</select>
</div>
<div class="fg">
<label>Area <em>(예: P6)</em></label>
<input id="ef-area" class="inp" type="text" placeholder="비워두면 전체"/>
</div>
<div class="fg">
<label>Section <em>(예: 1-2차)</em></label>
<input id="ef-section" class="inp" type="text" placeholder="비워두면 전체"/>
</div>
<div class="fg">
<label>최대 행 수</label>
<input id="ef-limit" class="inp" type="number" value="500" min="10" max="5000"/>
</div>
</div>
<!-- 시간 범위 -->
<div class="cols-2">
<div class="fg">
<label>시작 시간</label>
<input type="hidden" id="hf-evt-from"/>
<div class="dt-display inp" id="dtp-evt-from-display" onclick="dtOpen('evt-from')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>종료 시간</label>
<input type="hidden" id="hf-evt-to"/>
<div class="dt-display inp" id="dtp-evt-to-display" onclick="dtOpen('evt-to')">— 선택 안 함 —</div>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="evtQuery()">🔍 이벤트 조회</button>
<button class="btn-b" onclick="evtSummary()">📊 구간 요약</button>
<button class="btn-b" onclick="evtReset()">초기화</button>
</div>
</div>
<!-- 요약 결과 카드 (처음에는 숨김) -->
<div id="evt-summary-card" class="card hidden">
<div class="card-cap">구간별 이벤트 요약</div>
<div id="evt-summary-content"></div>
</div>
<!-- 조회 결과 -->
<div id="evt-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
<div id="evt-table" class="tbl-wrap hidden"></div>
</section>
```
**설계 결정**:
- 날짜 피커: 기존 `dtOpen(target)` 재사용. target이 `'evt-from'`이면 `hf-evt-from`, `dtp-evt-from-display` ID를 자동으로 참조 — **기존 코드 수정 없음**
- `hist-status` 클래스 재사용 (기존 스타일 이미 존재)
- `.hidden` 클래스 재사용 (기존 스타일 이미 존재)
- `nm-result-info` 클래스 재사용 (조회 결과 건수 표시용)
**검증**:
- [ ] `dtOpen('evt-from')` 호출 시 `hf-evt-from`, `dtp-evt-from-display` 를 정확히 참조하는지 확인
- [ ] `class="pane"` (active 없음) — 탭 클릭 시 JS가 active 추가하므로 정상
- [ ] `</main>` 닫는 태그 앞에 삽입했는지 확인
---
### Step 4+5: JS 함수 추가
**파일**: `src/Web/wwwroot/js/app.js`
**위치**: `histReset()` 함수 (현재 1158줄) 블록 **뒤**에 추가
```javascript
// ── Event History ─────────────────────────────────────────────────────────────
async function evtLoadTags() {
const statusEl = document.getElementById('evt-tag-status');
statusEl.textContent = '⏳ 조회 중...';
try {
const d = await api('GET', '/api/event-history/digital-tags');
const tags = d.data || [];
const sel = document.getElementById('ef-tag');
sel.innerHTML = '<option value="">— 전체 태그 —</option>' +
tags.map(t => `<option value="${esc(t.tagName)}">${esc(t.tagName)}</option>`).join('');
statusEl.textContent = `${tags.length}`;
} catch (e) {
statusEl.textContent = `${e.message}`;
}
}
async function evtQuery() {
const tag = document.getElementById('ef-tag').value;
const eventType = document.getElementById('ef-event-type').value;
const area = document.getElementById('ef-area').value.trim();
const section = document.getElementById('ef-section').value.trim();
const limit = document.getElementById('ef-limit').value || 500;
const fromRaw = document.getElementById('hf-evt-from').value;
const toRaw = document.getElementById('hf-evt-to').value;
const params = new URLSearchParams();
if (tag) params.set('tagName', tag);
if (eventType) params.set('eventType', eventType);
if (area) params.set('area', area);
if (section) params.set('section', section);
params.set('limit', limit);
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
if (toRaw) params.set('to', new Date(toRaw).toISOString());
const infoEl = document.getElementById('evt-result-info');
const tableEl = document.getElementById('evt-table');
infoEl.textContent = '⏳ 조회 중...';
infoEl.classList.remove('hidden');
tableEl.classList.add('hidden');
try {
const d = await api('GET', `/api/event-history?${params}`);
if (!d.success) throw new Error(d.error || '조회 실패');
infoEl.textContent = `${d.count}`;
tableEl.innerHTML = _evtBuildTable(d.data);
tableEl.classList.remove('hidden');
} catch (e) {
infoEl.textContent = `${e.message}`;
}
}
async function evtSummary() {
const area = document.getElementById('ef-area').value.trim();
const section = document.getElementById('ef-section').value.trim();
const fromRaw = document.getElementById('hf-evt-from').value;
const toRaw = document.getElementById('hf-evt-to').value;
const params = new URLSearchParams();
if (area) params.set('area', area);
if (section) params.set('section', section);
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
if (toRaw) params.set('to', new Date(toRaw).toISOString());
const card = document.getElementById('evt-summary-card');
const content = document.getElementById('evt-summary-content');
content.textContent = '⏳ 집계 중...';
card.classList.remove('hidden');
try {
const d = await api('GET', `/api/event-history/summary?${params}`);
if (!d.success) throw new Error(d.error || '조회 실패');
content.innerHTML = _evtBuildSummary(d.data);
} catch (e) {
content.textContent = `${e.message}`;
}
}
function _evtBadge(t) {
const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change';
return `<span class="evt-badge ${cls}">${esc(t)}</span>`;
}
function _evtFmtTime(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleString('ko-KR', {
year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
});
}
function _evtBuildTable(rows) {
if (!rows || !rows.length)
return '<div style="padding:24px;text-align:center;color:var(--t2)">데이터 없음</div>';
const html = rows.map(r => `
<tr>
<td style="white-space:nowrap;color:var(--t2);font-family:var(--fm);font-size:11px">${_evtFmtTime(r.eventTime)}</td>
<td><code style="font-size:11px;color:var(--blu)">${esc(r.tagName)}</code></td>
<td>${_evtBadge(r.eventType)}</td>
<td style="color:var(--t2)">${esc(r.prevValue ?? '—')}</td>
<td style="color:var(--t0);font-weight:600">${esc(r.currValue)}</td>
<td>${r.area ? `<span class="nm-cls">${esc(r.area)}</span>` : '—'}</td>
<td>${r.section ? `<span class="nm-cls">${esc(r.section)}</span>` : '—'}</td>
<td style="font-family:var(--fm);font-size:11px;color:var(--t2)">${r.durationSeconds != null ? r.durationSeconds + 's' : '—'}</td>
</tr>`).join('');
return `
<table>
<thead><tr>
<th>시간</th>
<th>태그명</th>
<th>이벤트</th>
<th>이전값</th>
<th>현재값</th>
<th>Area</th>
<th>Section</th>
<th>지속(초)</th>
</tr></thead>
<tbody>${html}</tbody>
</table>`;
}
function _evtBuildSummary(data) {
if (!data || !data.length)
return '<div style="padding:12px;color:var(--t2)">데이터 없음</div>';
return `<div class="evt-summary-grid">${data.map(s => `
<div class="evt-summary-item">
<div class="evt-summary-section">${esc(s.section)}</div>
<div class="evt-summary-counts">
<div class="evt-count">${_evtBadge('TRIP')} <strong>${s.tripCount}</strong></div>
<div class="evt-count">${_evtBadge('RUN')} <strong>${s.runCount}</strong></div>
<div class="evt-count">${_evtBadge('ALARM')} <strong>${s.alarmCount}</strong></div>
<div class="evt-count">${_evtBadge('CHANGE')} <strong>${s.changeCount}</strong></div>
</div>
<div class="evt-total">합계 <strong>${s.totalEvents}</strong>건</div>
</div>`).join('')}</div>`;
}
function evtReset() {
document.getElementById('ef-tag').value = '';
document.getElementById('ef-event-type').value = '';
document.getElementById('ef-area').value = '';
document.getElementById('ef-section').value = '';
document.getElementById('ef-limit').value = '500';
dtClearField('evt-from');
dtClearField('evt-to');
document.getElementById('evt-result-info').classList.add('hidden');
document.getElementById('evt-table').classList.add('hidden');
document.getElementById('evt-summary-card').classList.add('hidden');
document.getElementById('evt-tag-status').textContent = '';
}
```
**설계 결정**:
- `_evtBuildTable`, `_evtBuildSummary`, `_evtBadge`, `_evtFmtTime``_` 접두사 → 내부 헬퍼임을 명시
- `dtClearField('evt-from')` 재사용 — 기존 함수가 `hf-${target}`, `dtp-${target}-display` 패턴으로 동작하므로 수정 없음
- 탭 진입 시 자동 API 호출 없음 (탭 핸들러에 `if (tab === 'evt')` 추가 불필요)
- `api()` 헬퍼 함수 재사용 (기존 코드에 이미 정의됨)
**검증**:
- [ ] `api('GET', '/api/event-history/digital-tags')` 응답의 `d.data` 배열 안에 `{ tagName: "..." }` 형태인지 확인
- [ ] `new Date(fromRaw).toISOString()``fromRaw``"2026-05-11T03:00"` 형태 → ISO 8601 UTC로 변환됨 (기존 hist 탭과 동일 패턴)
- [ ] `_evtBuildTable(d.data)``d.data`가 빈 배열이면 "데이터 없음" 메시지 표시
- [ ] `evtReset()`이 summary 카드도 숨기는지 확인
---
## 4. 파일별 추가 위치 요약
### `style.css`
```
파일 말미 (1489줄 끝) → .evt-badge ~ .evt-total 블록 추가
```
### `index.html`
```
[위치 1] 75-78줄 <li data-tab="pid"> 블록 뒤:
→ <li data-tab="evt"> 탭 항목 추가
[위치 2] </main> 닫는 태그 앞 (현재 파일 말미):
→ #pane-evt 섹션 전체 추가
```
### `app.js`
```
histReset() 함수 끝 (현재 ~1175줄) 뒤:
→ evtLoadTags ~ evtReset 전체 블록 추가
```
**탭 핸들러 수정 없음**: `evt` 탭은 진입 시 API 자동 호출 없음 (사용자가 버튼 클릭 시에만 실행).
---
## 5. 검증 절차
### Stage A — 빌드/렌더링
- [ ] 브라우저에서 사이드바에 "12 이벤트 히스토리" 탭이 보이는지 확인
- [ ] 탭 클릭 시 `#pane-evt`가 활성화되는지 확인
### Stage B — 태그 목록 로드
```
▼ 태그 목록 불러오기 버튼 클릭
→ GET /api/event-history/digital-tags
→ ef-tag 드롭다운에 디지털 태그 목록 채워짐
→ 태그 상태에 "✅ N개" 표시
```
### Stage C — 이벤트 조회
```
시작/종료 시간 선택 → 🔍 이벤트 조회 버튼
→ GET /api/event-history?from=...&to=...&limit=500
→ 결과 테이블 렌더링 (시간, 태그, 배지, 이전값/현재값, Area, Section, 지속시간)
→ 이벤트 타입별 배지 색상 확인 (TRIP:red, RUN:green, ALARM:amber)
```
### Stage D — 구간 요약
```
📊 구간 요약 버튼
→ GET /api/event-history/summary?from=...&to=...
→ evt-summary-card 표시
→ Section별 카드 그리드 렌더링 (TRIP/RUN/ALARM/CHANGE 각 건수)
```
### Stage E — 필터 조합
```
- eventType=TRIP 선택 후 조회 → TRIP 이벤트만 표시
- area=P6 입력 후 조회 → P6 area만 표시
- tagName 선택 후 조회 → 해당 태그만 표시
```
### Stage F — 초기화
```
초기화 버튼 → 모든 필터 리셋, 결과 테이블 숨김, 요약 카드 숨김
```
---
## 6. 주의사항
1. **날짜 피커 재사용**: `dtOpen('evt-from')` 호출 시 `hf-evt-from`(hidden input), `dtp-evt-from-display`(표시 div) ID를 자동 참조. **기존 dt-picker 코드 수정 불필요**.
2. **탭 진입 시 API 없음**: 탭 핸들러 (`app.js` 7-18줄)에 `evt` 케이스 추가 불필요. 사이드 이펙트 없음.
3. **from/to 기본값**: from/to 미입력 시 백엔드에서 자동으로 최근 1일 (`DateTime.UtcNow.AddDays(-1)` ~ `DateTime.UtcNow`) 적용. 빈 파라미터 전송 시에도 정상 동작.
4. **`esc()` 필수**: XSS 방지를 위해 서버 응답의 모든 문자열 필드에 `esc()` 적용. 특히 tagName, currValue, prevValue.
5. **`nm-cls` 재사용**: Area/Section 표시에 기존 `.nm-cls` 배지 스타일 재사용 (별도 CSS 불필요).
6. **`hist-status` 재사용**: 태그 로드 상태 표시에 기존 `.hist-status` 클래스 재사용.
---
## 7. Todo List
| # | 작업 | 파일 | 상태 | 검증 방법 |
|---|------|------|------|-----------|
| 1 | `.evt-badge` ~ `.evt-total` CSS 추가 | `style.css` | ⬜ | 브라우저 개발자도구 CSS 확인 |
| 2 | `<li data-tab="evt">` 탭 항목 추가 | `index.html` | ⬜ | 사이드바 탭 노출 확인 |
| 3 | `#pane-evt` 섹션 전체 추가 | `index.html` | ⬜ | 탭 클릭 시 pane 활성화 확인 |
| 4 | `evtLoadTags()` ~ `evtReset()` 함수 추가 | `app.js` | ⬜ | 버튼 클릭 → API 호출 확인 |
| 5 | 태그 목록 로드 테스트 | — | ⬜ | 드롭다운에 디지털 태그 표시 |
| 6 | 이벤트 조회 테스트 | — | ⬜ | 결과 테이블 + 배지 렌더링 확인 |
| 7 | 구간 요약 테스트 | — | ⬜ | 요약 카드 그리드 렌더링 확인 |
| 8 | 필터 조합 테스트 | — | ⬜ | TRIP 전용, Area 전용 등 확인 |
| 9 | 초기화 테스트 | — | ⬜ | 모든 필드 리셋 확인 |

File diff suppressed because it is too large Load Diff

View File

@@ -1,603 +0,0 @@
# 이벤트 기반 디지털 포인트 히스토리 테이블 플랜
## 1. 개요
### 목적
- 기존 periodic snapshot (60초마다)에서 **event-driven** 방식으로 변경
- 디지털 포인트의 상태 변경 時만 기록하여 스토리지 절약
### 현재 상태
- `ExperionHistoryService`가 60초마다 `realtime_table` 전체를 `history_table`에 스냅샷
- 디지털/아날로그 구분 없이 모든 포인트가 Periodic으로 기록
---
## 2. Phase 1: 기존 히스토리 루틴에서 디지털 포인트 제외
### 구현 위치
`src/Infrastructure/Database/ExperionDbContext.cs:730` - `SnapshotToHistoryAsync()`
### 변경 내용
1. 디지털 태그 식별 기준 정의
2. 기존 SnapshotToHistoryAsync 메서드에 Where 조건 추가
### 예상 코드 변경
```csharp
// 메서드 시그니처에 디지털 필터 옵션 추가
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
var now = DateTime.UtcNow;
var query = _ctx.RealtimePoints.AsQueryable();
// 디지털 제외 (기본값)
if (!includeDigital)
{
var digitalTagNames = await GetDigitalTagNamesAsync();
query = query.Where(p => !digitalTagNames.Contains(p.TagName));
}
var points = await query.ToListAsync();
if (points.Count == 0) return 0;
var rows = points.Select(p => new HistoryRecord
{
TagName = p.TagName,
NodeId = p.NodeId,
Value = p.LiveValue,
RecordedAt = now
}).ToList();
await _ctx.HistoryRecords.AddRangeAsync(rows);
var saved = await _ctx.SaveChangesAsync();
_logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now);
return saved;
}
// 디지털 태그 목록 조회 (캐싱 고려)
private async Task<HashSet<string>> GetDigitalTagNamesAsync()
{
// node_map_master에서 data_type = i=7594 인 태그 조회
// 또는 tag_metadata에서 value 패턴이 {X | STATE | } 형태인 태그
}
```
---
## 3. Phase 2: 이벤트 히스토리 테이블 설계
### 테이블: event_history_table
```sql
-- 이벤트 히스토리 테이블 생성
CREATE TABLE event_history_table (
id BIGSERIAL PRIMARY KEY,
tagname TEXT NOT NULL,
node_id TEXT NOT NULL,
prev_value TEXT, -- 이전 상태 (예: "{0 | L-STOP | }")
curr_value TEXT, -- 현재 상태 (예: "{0 | RUN | }")
event_type TEXT NOT NULL, -- CHANGE, TRIP, RUN, ALARM, NORMAL, INTERLOCK
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
area TEXT, -- P1, P6, P8 등 (tag_metadata에서 조회)
section TEXT, -- 6-1차, 6-2차 (태그 번호 기반: 61xx=6-1차, 62xx=6-2차)
duration_seconds INT, -- 이전 상태 지속 시간 (초)
metadata JSONB, -- 추가 정보 (선택): {"alarm_priority": 3, "interlock_tag": "lica-6113-trip"}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 인덱스 (쿼리 성능 최적화)
CREATE INDEX idx_event_history_tagname_time ON event_history_table(tagname, event_time DESC);
CREATE INDEX idx_event_history_area_time ON event_history_table(area, event_time DESC);
CREATE INDEX idx_event_history_section_time ON event_history_table(section, event_time DESC);
CREATE INDEX idx_event_history_event_type ON event_history_table(event_type, event_time DESC);
CREATE INDEX idx_event_history_tagname_event_type ON event_history_table(tagname, event_type, event_time DESC);
-- 테이블 설명 주석
COMMENT ON TABLE event_history_table IS '디지털 포인트 상태 변경 이벤트 히스토리';
COMMENT ON COLUMN event_history_table.event_type IS 'CHANGE: 일반 변경, TRIP: 정지, RUN: 가동, ALARM: 알람, NORMAL: 정상복귀, INTERLOCK: 인터록';
```
### event_type 정의
| event_type | 설명 | 트리거 조건 |
|------------|------|-------------|
| `CHANGE` | 일반 상태 변경 | prev_value != curr_value |
| `TRIP` | 장치 정지 | curr_value에 "L-STOP", "STOP", "TRIP" 포함 |
| `RUN` | 장치 가동 | curr_value에 "RUN", "START" 포함 |
| `ALARM` | 알람 발생 | alarm 상태 감지 |
| `NORMAL` | 정상 복귀 | alarm clear |
| `INTERLOCK` | 인터록 발생 | 인터록 관련 태그 (-il-rst, -trip) |
| `SHUTDOWN` | 계획정지 | 명시적 shutdown 신호 |
| `STARTUP` | 계획가동 | 명시적 startup 신호 |
---
## 4. Phase 3: 디지털 포인트 상태변화 감지 및 기록
### 4.1 새 서비스: DigitalEventDetectorService
**파일**: `src/Infrastructure/OpcUa/DigitalEventDetectorService.cs`
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
/// </summary>
public class DigitalEventDetectorService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DigitalEventDetectorService> _logger;
private readonly Dictionary<string, DigitalPointState> _previousStates = new();
private readonly HashSet<string> _digitalTagNames = new();
private readonly ConcurrentDictionary<string, string> _areaCache = new();
private readonly int _checkIntervalMs = 1000;
private readonly int _debounceSeconds = 5; // 동일 상태 반복 방지
private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);
public DigitalEventDetectorService(
IServiceScopeFactory scopeFactory,
ILogger<DigitalEventDetectorService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);
// 초기 디지털 태그 목록 로드
await LoadDigitalTagNamesAsync();
// 현재 상태 로드 (서비스 재시작 시 상태 손실 방지)
await LoadCurrentStatesAsync();
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_checkIntervalMs, stoppingToken);
await DetectAndRecordChangesAsync();
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "[DigitalEventDetector] 감지 오류");
}
}
_logger.LogInformation("[DigitalEventDetector] 종료");
}
private async Task LoadDigitalTagNamesAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var digitalTags = await db.GetDigitalTagNamesAsync();
_digitalTagNames.UnionWith(digitalTags);
_logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", _digitalTagNames.Count);
}
private async Task LoadCurrentStatesAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var points = await db.GetDigitalPointsAsync();
foreach (var p in points)
_previousStates[p.TagName] = new DigitalPointState(p.LiveValue, DateTime.UtcNow, null);
_logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
}
private async Task DetectAndRecordChangesAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
// 디지털 포인트만 조회
var currentPoints = await db.GetDigitalPointsAsync();
foreach (var point in currentPoints)
{
var tagName = point.TagName;
var currValue = point.LiveValue;
// 첫 등장 시 이전 값 초기화 (이벤트 기록 안 함)
if (!_previousStates.TryGetValue(tagName, out var prevState))
{
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
continue;
}
// 값 변경 감지
if (prevState.Value != currValue)
{
var eventType = DetermineEventType(prevState.Value, currValue);
// Debounce: 동일 상태로의 반복만 방지, 상태 전환(TRIP→RUN 등)은 항상 기록
if (prevState.EventType == eventType && prevState.Value == currValue &&
(DateTime.UtcNow - prevState.Timestamp).TotalSeconds < _debounceSeconds)
{
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType);
continue;
}
// 이벤트 기록
var duration = (int)(DateTime.UtcNow - prevState.Timestamp).TotalSeconds;
await db.RecordDigitalEventAsync(new DigitalEventRecord
{
TagName = tagName,
NodeId = point.NodeId,
PrevValue = prevState.Value,
CurrValue = currValue,
EventType = eventType,
EventTime = DateTime.UtcNow,
DurationSeconds = duration,
Area = ExtractArea(tagName),
Section = ExtractSection(tagName),
Metadata = BuildMetadata(tagName, eventType, currValue)
});
_logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr})",
tagName, eventType, prevState.Value, currValue);
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType);
}
}
}
private string DetermineEventType(string? prevValue, string currValue)
{
if (currValue.Contains("L-STOP") || currValue.Contains("STOP") || currValue.Contains("TRIP"))
return "TRIP";
if (currValue.Contains("RUN") || currValue.Contains("START"))
return "RUN";
if (currValue.Contains("ALARM"))
return "ALARM";
if (prevValue?.Contains("ALARM") == true && !currValue.Contains("ALARM"))
return "NORMAL";
return "CHANGE";
}
private string? ExtractArea(string tagName)
{
if (_areaCache.TryGetValue(tagName, out var area)) return area;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
area = db.GetAreaByTagNameAsync(tagName).GetAwaiter().GetResult();
if (area != null)
_areaCache[tagName] = area;
return area;
}
private string? ExtractSection(string tagName)
{
// 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차
// 첫 번째 숫자-두 번째 숫자 패턴 (일반화)
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
return "기타";
}
private string? BuildMetadata(string tagName, string eventType, string currValue)
{
// 인터록 태그인 경우 메타데이터 추가
if (tagName.Contains("-il-") || tagName.Contains("-trip"))
{
return System.Text.Json.JsonSerializer.Serialize(new
{
interlock_tag = tagName,
event_type = eventType,
raw_value = currValue
});
}
return null;
}
}
```
### 4.2 IExperionServices 인터페이스 확장
**참고**: `DigitalEventRecord`는 서비스 계층 DTO, `EventHistoryRecord`는 EF Core Entity 클래스로 별도 정의 필요
```csharp
// src/Core/Application/Interfaces/IExperionServices.cs에 추가
public interface IExperionDbService
{
// ... 기존 메서드 ...
/// <summary>디지털 태그 이름 목록 조회</summary>
Task<IEnumerable<string>> GetDigitalTagNamesAsync();
/// <summary>디지털 포인트 현재 값 조회</summary>
Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();
/// <summary>디지털 이벤트 기록</summary>
Task<int> RecordDigitalEventAsync(DigitalEventRecord record);
/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
Task<string?> GetAreaByTagNameAsync(string tagName);
}
// 새로운 모델 클래스 (서비스 계층 DTO)
public class DigitalEventRecord
{
public string TagName { get; set; } = "";
public string NodeId { get; set; } = "";
public string? PrevValue { get; set; }
public string CurrValue { get; set; } = "";
public string EventType { get; set; } = "";
public DateTime EventTime { get; set; }
public int? DurationSeconds { get; set; }
public string? Area { get; set; }
public string? Section { get; set; }
public string? Metadata { get; set; }
}
```
### 4.2.1 EventHistoryRecord Entity 클래스 정의
```csharp
// src/Core/Domain/Entities/ExperionEntities.cs에 추가
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
[Table("event_history_table")]
public class EventHistoryRecord
{
[Column("id")] public int Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("prev_value")] public string? PrevValue { get; set; }
[Column("curr_value")] public string CurrValue { get; set; } = string.Empty;
[Column("event_type")] public string EventType { get; set; } = string.Empty;
[Column("event_time")] public DateTime EventTime { get; set; } = DateTime.UtcNow;
[Column("area")] public string? Area { get; set; }
[Column("section")] public string? Section { get; set; }
[Column("duration_seconds")] public int? DurationSeconds { get; set; }
[Column("metadata")] public string? Metadata { get; set; }
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
```
### 4.3 ExperionDbContext 구현 추가
```csharp
// src/Infrastructure/Database/ExperionDbContext.cs에 추가
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
{
// node_map_master에서 data_type = 'i=7594' 인 태그 조회
// 또는 정규식으로 value 패턴이 {X | STATE | } 형태인 태그
return await _ctx.RealtimePoints
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
.Select(p => p.TagName)
.ToListAsync();
}
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
{
return await _ctx.RealtimePoints
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
.ToListAsync();
}
public async Task<string?> GetAreaByTagNameAsync(string tagName)
{
// tag_metadata에서 base_tag=tagName, attribute='area' 조회
// value = "{12 | P6 | }" → "P6" 파싱
var meta = await _ctx.TagMetadata
.Where(m => m.BaseTag == tagName && m.Attribute == "area")
.Select(m => m.Value)
.FirstOrDefaultAsync();
if (string.IsNullOrEmpty(meta)) return null;
// "{12 | P6 | }" 패턴에서 area 코드 추출
var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
return match.Success ? match.Groups[1].Value : null;
}
public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
{
var row = new EventHistoryRecord
{
TagName = record.TagName,
NodeId = record.NodeId,
PrevValue = record.PrevValue,
CurrValue = record.CurrValue,
EventType = record.EventType,
EventTime = record.EventTime,
DurationSeconds = record.DurationSeconds,
Area = record.Area,
Section = record.Section,
Metadata = record.Metadata
};
await _ctx.EventHistoryRecords.AddAsync(row);
return await _ctx.SaveChangesAsync();
}
// DbSet 추가
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
```
### 4.4 Program.cs 등록
```csharp
// src/Web/Program.cs에 추가
builder.Services.AddHostedService<DigitalEventDetectorService>();
```
---
## 5. Phase 4: 보고서 기능 설계
### 5.1 요구사항 분석
**입력 예시**: "지난밤 6차 플랜트의 현황 보고"
**출력 형식**:
```
[제목] 2026-05-10 6차 플랜트 현황 보고
[시간] 2026-05-09 00:00:00 ~ 2026-05-10 00:00:00
[6-1차 (태그명: %-61%)]
- 00:00:00 p-6102 Trip 정지 → 00:05:30 가동 (정지 시간: 5분 30초)
- 알람 발생: 3회
- ficq-6101.qv.value (투입량): 12,500 kg
- ficq-6118.qv.value (생산량): 11,200 kg
[6-2차 (태그명: %-62%)]
- 02:30:00 p-6201 Trip 정지 → 02:45:00 가동 (정지 시간: 15분)
- 알람 발생: 1회
```
### 5.2 필요한 데이터
| 데이터 | 출처 | 설명 |
|--------|------|------|
| 구간별 이벤트 | event_history_table | 6-1차 (61xx), 6-2차 (62xx) |
| Trip/Run 쌍 | event_history_table | 정지~가동 시간 계산 |
| 알람 횟수 | event_history_table | event_type = 'ALARM' count |
| 투입량 | history_table 또는 별도 테이블 | ficq-6101, ficq-6201 |
| 생산량 | history_table 또는 별도 테이블 | ficq-6118, ficq-6218 |
### 5.3 구간(세션) 구분 로직
**참고**: Phase 3의 `ExtractSection`과 동일한 로직으로 통일 (진단 HIGH #1 반영)
```csharp
private string? ExtractSection(string tagName)
{
// 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차
// 첫 번째 숫자-두 번째 숫자 패턴 (일반화)
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
return "기타";
}
```
### 5.4 적산값 처리
**문제**: history_table의 periodic snapshot에서 차이를 계산하면 노이즈가 많음
**해결방안**: event_history_table에 적산 정보 기록
```csharp
// DigitalEventDetectorService에 추가
// TRIP 발생 시 현재 적산값 저장
// RUN 발생 시 (TRIP 시점 적산값 - 현재 적산값) = 정지 시간 동안의 미투입량
// 또는 별도 테이블
CREATE TABLE accumulated_events (
id BIGSERIAL PRIMARY KEY,
tagname TEXT NOT NULL,
event_type TEXT NOT NULL, -- TRIP_START, RUN_START
value_at_event DOUBLE,
event_time TIMESTAMPTZ NOT NULL
);
```
### 5.5 보고서 쿼리 예시
```sql
-- 6차 플랜트 6-1차 구간 이벤트 요약
SELECT
section,
event_type,
COUNT(*) as count,
MIN(event_time) as first_occurrence,
MAX(event_time) as last_occurrence
FROM event_history_table
WHERE area = 'P6'
AND section = '6-1차'
AND event_time BETWEEN '2026-05-09 00:00:00' AND '2026-05-10 00:00:00'
GROUP BY section, event_type
ORDER BY event_type;
-- Trip별 정지 시간 계산 (TRIP → RUN 쌍 찾기)
WITH trip_events AS (
SELECT
tagname,
event_time as trip_time,
LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time) as run_time,
LEAD(event_type) OVER (PARTITION BY tagname ORDER BY event_time) as next_event_type
FROM event_history_table
WHERE event_type = 'TRIP' AND area = 'P6'
)
SELECT
tagname,
trip_time,
run_time,
EXTRACT(EPOCH FROM (run_time - trip_time)) / 60 as duration_minutes
FROM trip_events
WHERE next_event_type = 'RUN';
```
---
## 6. 구현 로드맵
| Phase | 작업 | 우선순위 | 예상 시간 |
|-------|------|----------|-----------|
| 1 | 디지털 태그 식별 로직 추가 | 높음 | 1일 |
| 1 | SnapshotToHistoryAsync 수정 | 높음 | 0.5일 |
| 2 | event_history_table 생성 | 높음 | 0.5일 |
| 3 | DigitalEventDetectorService 구현 | 높음 | 2일 |
| 3 | IExperionServices 확장 | 높음 | 1일 |
| 4 | 보고서 쿼리 작성 | 중간 | 2일 |
| 4 | API 엔드포인트 추가 | 중간 | 1일 |
---
## 7. 고려사항
### 7.1 중복 이벤트 방지
- Debounce 로직 적용 (기본 5초)
- 동일 상태로의 반복 발생 시 무시
### 7.2 마이그레이션
- 기존 history_table의 디지털 데이터 보존 또는 별도 아카이브
- 새로운 event_history_table로의 데이터 이전 (선택)
### 7.3 성능
- 디지털 태그 수: ~500개
- 감지 주기: 1초
- 예상 INSERT: 초당 수십 건 (변경 시)
### 7.4 확장성
- 알람 우선순위 정보 메타데이터에 추가
- 인터록 체인 추적 (상위/interlock 원인 태그 기록)
---
## 8. 부록: 테스트 계획
### 8.1 단위 테스트
- DetermineEventType 메서드 테스트
- ExtractSection 메서드 테스트
### 8.2 통합 테스트
- 실제 OPC UA 연결 없이 모의 데이터로 동작 확인
- event_history_table에 올바른 기록 확인
### 8.3 성능 테스트
- 500개 디지털 태그 처리 시 CPU/메모리 사용량
- DB INSERT 성능
---
## 9. 참고 자료
- OPC UA LocalizedText (i=7594): 로컬라이즈된 텍스트 타입, `{locale | text | }` 형태
- Experion 태그 명명 규칙: {type}-{unit}{number}.{attribute}
- P6 플랜트 구조: 61xx = 6-1차, 62xx = 6-2차

File diff suppressed because it is too large Load Diff

View File

@@ -1,196 +0,0 @@
# 트렌드 워크스페이스 P2 — 작업지시서 (LLM 구현용)
> 이 문서 하나로 cold-start 착수 가능. P1은 커밋 `c7e2250`로 완료됨. 같은 단일 ECharts 차트에 **레이어만 추가**하는 구조라 P1 코드 거의 안 건드림.
---
## 0. 전제 — 반드시 먼저 읽기
- **차트 1개에 레이어를 쌓는 구조**다. 새 시각요소 = `① 레이어 함수 + ② TR_LAYERS 등록 1줄 + ③ 토글 버튼 + ④ (필요시) 데이터 로더/엔드포인트`. **차트 본체(`trRender`/`trInitChart`)는 수정 금지.**
- 이미 구현된(참고/복사 대상) 레이어: `layerMinMax`(perSeries), `layerEvents`(global, 트립/이벤트 세로 플래그). **운전음영·알람선은 이걸 그대로 베껴서** 만든다.
- **JSON 직렬화 주의**: 이 프로젝트 MVC는 기본 PascalCase 패스스루다. 새 DTO 반환 시 **반드시 속성마다 `[JsonPropertyName("camelCase")]`** 를 단다(`TrendDtos.cs`가 선례). 안 그러면 프론트(camelCase)와 안 맞음.
- **DB 조회**: EF 미사용, **raw `NpgsqlCommand`** 패턴. `TrendService.cs``GetLiveAsync`/`GetAnalogPointsAsync`를 복사해서 쓴다(`NewConn()` 헬퍼 있음).
- **날짜 입력 id**: 트렌드는 `hf-trfrom`/`hf-trto`(hist의 `hf-from/to`와 충돌 회피). 보이는 윈도우는 `trVisibleWindow()`로 얻는다.
- 빌드: `dotnet build src/Web/ExperionCrawler.csproj -clp:ErrorsOnly` (경고/에러 0 유지). JS: `node -c src/Web/wwwroot/js/trend.js`.
- 정적 파일(html/css/js)은 서버 재기동 없이 새로고침으로 반영. C# 변경은 재빌드+재기동 필요.
---
## 1. 파일 맵
| 영역 | 파일 |
|---|---|
| 프론트 로직 | `src/Web/wwwroot/js/trend.js` |
| 프론트 마크업 | `src/Web/wwwroot/panes/trend.html` (타임바의 `.tr-layers` 안에 토글 추가) |
| 프론트 스타일 | `src/Web/wwwroot/css/trend.css` |
| 백엔드 서비스 | `src/Infrastructure/Trend/TrendService.cs` + `ITrendService.cs`(Core/Application/Interfaces) |
| 백엔드 API | `src/Web/Controllers/TrendController.cs` (`[Route("api/trend")]`) |
| DTO | `src/Core/Application/DTOs/TrendDtos.cs` |
| DB 뷰/테이블 DDL | `src/Infrastructure/Database/ExperionDbContext.cs` (`InitializeAsync` 부팅 DDL) |
## 2. 현재 `trState` / `TR_LAYERS` (trend.js, 그대로 존재)
```js
const trState = {
group, members:[{tag,color,axis,desc,unit,euLo,euHi,hidden}],
seriesData:{tag:[[ms,val],...]}, window:{t0,t1}, selected, live, euAxis, yZoom, normalize,
liveNow:{tag:val},
layers:{ minmax:false, events:false }, // ← P2 토글 키 추가
cache:{ events:[] } // ← P2 캐시 키 추가
};
const TR_LAYERS = [
{ id:'minmax', perSeries: layerMinMax, when: s=>s.layers.minmax },
{ id:'events', global: layerEvents, when: s=>s.layers.events },
// ← 여기에 P2 레이어 push
];
```
- `perSeries(member, state)` → 멤버 시리즈에 머지될 조각 반환(`{markPoint|markLine|markArea|lineStyle...}`).
- `global(state)` → 전역 helper series 1개 반환(markArea/markLine 운반용, `data:[]`). 이름은 `__`로 시작(툴팁 formatter가 `__` 시리즈를 자동 제외함).
- `trRender()`가 켜진 레이어를 모아 1회 `setOption`. 토글은 `trToggleLayer(id)`(이미 있음, events 처리 패턴 참고).
---
## 3. P2 작업 항목 (4개)
### P2-1. 알람 한계선 (HI/LO/SP 수평선) — markLine perSeries
**목적**: 멤버별 EU 상/하한(euhi/eulo)과 현재 SP를 수평 점선으로.
**백엔드**
- DTO (`TrendDtos.cs`), camelCase:
```csharp
public class TrendLimitDto {
[JsonPropertyName("tag")] public string Tag {get;set;}="";
[JsonPropertyName("hi")] public double? Hi {get;set;}
[JsonPropertyName("lo")] public double? Lo {get;set;}
[JsonPropertyName("sp")] public double? Sp {get;set;}
[JsonPropertyName("unit")]public string? Unit {get;set;}
}
```
- `ITrendService` + `TrendService.GetLimitsAsync(IReadOnlyList<string> tags, ct)`:
- 입력 tags는 멤버 태그(예 `ficq-6113.pv`). base_tag = `split_part(tag,'.',1)`.
- SQL(파라미터 배열 `@bases` = base_tag 목록): `v_instrument_range`에서 `unit, eu_lo, eu_hi` + (선택) `realtime_table`에서 `base||'.sp'` 숫자 현재값.
- 컬럼: `v_instrument_range(base_tag, unit, eu_lo, eu_hi)`. SP는 `realtime_table` 에서 `tagname = base||'.sp' AND livevalue ~ '^-?[0-9.]+$'`.
- 반환은 입력 **tag 기준**으로 매핑(hi=eu_hi, lo=eu_lo).
- `TrendController`: `[HttpGet("limits")] Limits([FromQuery] string? tags, ct)` → `GetLiveAsync` 컨트롤러 메서드 복사해서 `tags` split 후 호출.
**프론트 (trend.js)**
- `trState.layers.limits=false`, `trState.cache.limits={}` 추가.
- 로더:
```js
async function trLoadLimits(){
const tags=trState.members.map(m=>m.tag); if(!tags.length)return;
const d=await api('GET',`/api/trend/limits?tags=${encodeURIComponent(tags.join(','))}`);
trState.cache.limits={}; for(const l of (d.items||[])) trState.cache.limits[l.tag]=l;
}
```
- 레이어(perSeries) — **100%환산 시 좌표 환산 필요**(`trMemberSpan` 사용; `layerMinMax` 방식 동일):
```js
function layerLimits(m,s){
const lim=s.cache.limits[m.tag]; if(!lim) return {};
let toY=v=>v; if(s.normalize){const sp=trMemberSpan(m); if(sp) toY=v=>(v-sp[0])/sp[1]*100;}
const mk=(v,txt,style)=> v==null?null:{yAxis:toY(v),lineStyle:style,label:{formatter:txt,position:'insideEndTop',fontSize:9}};
const data=[mk(lim.hi,'HI',{color:'#ef4444',type:'dashed'}),
mk(lim.lo,'LO',{color:'#ef4444',type:'dashed'}),
mk(lim.sp,'SP',{color:m.color,type:'dotted'})].filter(Boolean);
return { markLine:{ silent:true, symbol:'none', data } };
}
```
- `TR_LAYERS`에 `{ id:'limits', perSeries:layerLimits, when:s=>s.layers.limits }` 추가.
- `trToggleLayer`의 events 분기처럼: `if(id==='limits'&&trState.layers.limits) await trLoadLimits();` 후 `trRender()`.
- `trQuery()` 끝부분(events 로딩 옆)에 `if(trState.layers.limits) await trLoadLimits();` 추가.
- 토글 버튼(trend.html `.tr-layers`): `<button class="tr-tog" data-layer="limits" onclick="trToggleLayer('limits')">⚠ 알람선</button>`
> 주의: markLine은 `yAxisIndex` 다른 멤버(우축)면 그 축 기준으로 그려진다. perSeries라 멤버 시리즈에 붙으니 자동으로 해당 멤버 축을 따른다.
### P2-2. 운전상태 음영 (RUN 구간 markArea) — global
**목적**: 시간축 배경에 펌프 RUN 구간 초록, TRIP 빨강 음영.
**데이터 출처**: `event_history_table`(컬럼: `tagname, node_id, prev_value, curr_value, event_type{ALARM|TRIP|NORMAL|RUN|CHANGE}, event_time, area, sub_area, duration_seconds, metadata`). RUN 구간 = `event_type='RUN'` 시작 ~ 다음 상태변경(또는 `duration_seconds`).
**백엔드**
- DTO: `TrendBandDto { [tag], [t0], [t1], [state] }` (camelCase, t0/t1은 ISO string 또는 DateTime).
- `GetRunBandsAsync(from,to,area?,ct)`:
- 한 펌프 태그의 시간순 이벤트에서 `RUN`→다음 이벤트 시각까지를 한 밴드로. SQL은 `LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time)`로 다음 시각 산출 후 `event_type IN ('RUN','TRIP')` 행만 밴드화. 윈도우 [from,to]로 클리핑.
- `area` 필터(선택): `area ILIKE '%'||@area||'%'`.
- 반환 `[{tag,t0,t1,state}]` (state='RUN'|'TRIP').
- `TrendController [HttpGet("runbands")]`.
**프론트**
- `trState.layers.runstate=false`, `trState.cache.runbands=[]`.
- 로더 `trLoadRunbands()`: `/api/trend/runbands?from&to` (from/to = `hf-trfrom/hf-trto`), 결과를 `{t0:ms,t1:ms,state}`로 변환 저장.
- 레이어(global):
```js
function layerRunState(s){
if(!s.cache.runbands.length) return null;
const COL={RUN:'rgba(16,185,129,.10)',TRIP:'rgba(239,68,68,.12)'};
const data=s.cache.runbands.map(b=>[{xAxis:b.t0,itemStyle:{color:COL[b.state]||'rgba(148,163,184,.1)'}},{xAxis:b.t1}]);
return { name:'__runstate', type:'line', data:[], silent:true, showSymbol:false, markArea:{ silent:true, data } };
}
```
- `TR_LAYERS` 등록 + `trToggleLayer` 분기(`runstate` 켜질 때 `trLoadRunbands()`) + `trQuery` 끝에 조건 로딩 + 토글 버튼 `▒ 운전음영`.
### P2-3. 듀얼 커서 Δ (두 점 클릭 → Δt·Δy·기울기)
**프론트 only**(백엔드 없음).
- 상태: `trState.cursor={a:null,b:null}` (각 `{ms,val,tag}` 또는 좌표).
- ECharts zr 클릭 사용:
```js
trChart.getZr().on('click', e=>{
if(!trState.cursorMode) return;
const pt=trChart.convertFromPixel({xAxisIndex:0,yAxisIndex:0},[e.offsetX,e.offsetY]);
// pt=[ms,val]; a→b 순으로 채우고 두 점 채워지면 graphic 으로 Δ 표시
});
```
- 토글 버튼 `📐 Δ커서` → `trState.cursorMode` on/off. 두 점 찍히면 `graphic`(또는 별도 markLine 2개 + 텍스트)으로 `Δt=…s, Δy=…, 기울기=Δy/Δt /min` 표시. 세 번째 클릭은 리셋.
- 보이는 윈도우/통계와 독립. 구현 자유.
### P2-4. 자동 집계 + LTTB 다운샘플
**현재 상태**: `trQuery()`가 raw `/api/history/query`(limit 5000)만 쓴다(P1에서 단순화됨). 긴 구간은 5000행에서 잘림.
- **자동 집계 복구**: 타임바 `#tr-interval` 셀렉트(`auto/1 minute/5 minutes/...`)는 trend.html에 이미 있다. `trQuery`에서:
```js
let interval=document.getElementById('tr-interval').value;
if(interval==='auto') interval = trAutoInterval(from,to); // 구현 추가
if(interval==='1 minute'){ /* 기존 raw 경로 */ }
else { /* POST /api/text-to-sql/query-history-interval {tagNames,from,to,interval,limit:5000} → rows[].timeBucket */ }
```
- `trAutoInterval(from,to)`: 창 길이 h시간 → `h<=6?'1 minute':h<=48?'5 minutes':h<=24*14?'1 hour':'1 day'`.
- 집계 응답은 `rows[].timeBucket`(원시는 `recordedAt`), 값은 `rows[].values[tag]`. (기존 hist.js와 동일 계약)
- **LTTB**: ECharts line series에 `sampling:'lttb'` 추가. `layerBaseSeries` 반환에 `sampling:'lttb'` 한 줄. (대용량 렌더 경량화, 형태 보존)
> 백엔드 `/api/text-to-sql/query-history-interval`는 이미 존재(재사용). 신규 백엔드 불필요.
---
## 4. 공통 통합 체크 (각 레이어 추가 시)
1. `trState.layers.<id>` 기본 false, 필요시 `trState.cache.<id>` 초기화.
2. `TR_LAYERS`에 1줄 등록(`perSeries` 또는 `global` + `when`).
3. `trToggleLayer(<id>)`에서 켜질 때 데이터 로더 await 후 `trRender()`.
4. `trQuery()` 말미에 `if(trState.layers.<id>) await trLoad…()` (새 조회 시 갱신).
5. trend.html `.tr-layers`에 토글 버튼(`data-layer="<id>"`), css `.tr-tog.on` 스타일 이미 있음.
6. 100%환산(`trState.normalize`) 호환: y값 좌표 쓰는 레이어는 `trMemberSpan(m)`로 환산(P2-1 참고).
## 5. 검증
- `dotnet build … -clp:ErrorsOnly` 0/0, `node -c trend.js` OK.
- 새 엔드포인트 스모크(서버 :5000): `curl -s 'http://localhost:5000/api/trend/limits?tags=ficq-6113.pv'` → camelCase JSON.
- 브라우저(Ctrl+F5): 각 토글 ON/OFF, 100%환산·Y줌·라이브와 동시 동작, 토글 OFF 시 잔상 없음.
- 회귀: 기존 P1(그룹/dataZoom/범례/minmax/events/라이브/normalize) 정상.
## 6. 커밋
- 메시지 스타일(한국어): `feat: 트렌드 P2 — 알람선·운전음영·듀얼커서·자동집계`.
- 끝에 `Co-Authored-By: Claude <noreply@anthropic.com>` 트레일러.
- 트렌드 관련 파일만 스테이징(무관 산출물 `opencode.json`·`*.xlsx`·`*.bak`·`*.md` 잡파일 제외).
## 7. 참고 (DB 스키마 요약)
- `v_analog_points(tagname, base_tag, value, timestamp, unit, eu_lo, eu_hi, description, area)` — 숫자 livevalue=아날로그.
- `v_instrument_range(base_tag, unit, eu_lo, eu_hi)`.
- `v_tag_summary(base_tag, pv, sp, op, …, description, area, sub_area)`.
- `event_history_table(tagname, prev_value, curr_value, event_type, event_time, area, sub_area, duration_seconds, metadata)` — event_type ∈ ALARM/TRIP/NORMAL/RUN/CHANGE.
- `realtime_table(tagname, node_id, livevalue, timestamp)` — 현재값(enum은 `{n | LABEL | }`, 숫자는 그대로).
- 기존 이력 API: `GET /api/history/query?tagNames=&from=&to=&limit=`(rows[].recordedAt), `POST /api/text-to-sql/query-history-interval`(rows[].timeBucket).

View File

@@ -1,440 +0,0 @@
# 포인트빌더 개선 방안 - 코딩 Todo List
## 현황
이전 세션에서 부분적 수정이 적용됨 (pb-n9/n10 제거, custom input 2개 추가 등).
이번 작업은 **전면 재구성**으로, 기존 코드를 완전히 대체합니다.
---
## Step 1: HTML - 포인트빌더 pane 전면 재구성
**변경 파일:** `src/Web/wwwroot/index.html`
### 작업 내용
1. 기존 "조건으로 테이블 작성" card 내부 완전 교체
2. 5개 그룹 카드 생성:
- 컨트롤러 포인트 #1 (`pb-group-controller1`)
- 아날로그 모니터링 포인트 #2 (`pb-group-analogmon1`)
- 디지털 포인트 #1 (`pb-group-digital1`)
- 디지털 포인트 #2 (`pb-group-digital2`)
- 사용자 정의 (`pb-group-custom`)
3. 각 그룹 카드 구성:
- 그룹명 제목 (`card-sub-cap`)
- 태그명 패턴 input (placeholder 포함)
- 속성 체크박스: pv, op, sp, md
- 사용자 정의 속성 input 2개 (작은 크기)
- 데이터 타입 select (전체, Double, i=7594, Boolean, String, Int16, Int32, UInt16, UInt32, Float, DateTime)
4. 수동 포인트 추가, 실시간 구독 제어, 메타데이터 관리 → 유지
5. 포인트 목록 → 유지
### 완성 기준
- [x] 기존 `pb-n1`~`pb-n8` select 드롭다운 제거됨 — **이미 완료** (이전 세션)
- [x] 기존 `pb-dt1`, `pb-dt2` input 제거됨 — **이미 완료**
- [x] 기존 `pb-custom1`, `pb-custom2` input 제거됨 — **이미 완료**
- [x] `pbLoad()` 버튼 제거됨 — **이미 완료**
- [x] 5개 그룹 카드 HTML 생성됨 — **이미 완료** (`index.html:421-598`)
- [x] 각 그룹에 태그명 패턴 input 1개 존재 — **이미 완료**
- [x] 각 그룹에 pv/op/sp/md 체크박스 4개 존재 — **이미 완료**
- [x] 각 그룹에 사용자 속성 input 2개 존재 — **이미 완료**
- [x] 각 그룹에 데이터 타입 select 존재 — **이미 완료**
- [x] 수동 포인트 추가 pane 유지됨 — **이미 완료**
- [x] 실시간 구독 제어 pane 유지됨 — **이미 완료**
- [x] 메타데이터 관리 pane 유지됨 — **이미 완료**
- [x] 포인트 목록 pane 유지됨 — **이미 완료**
- [x] HTML 유효성 검사 통과 (브라우저에서 렌더링 오류 없음) — **이미 완료**
---
## Step 2: CSS - 그룹 카드, 체크박스, 입력창 스타일
**변경 파일:** `src/Web/wwwroot/css/style.css`
### 작업 내용
1. `.pb-group-card` — 그룹 카드 컨테이너 스타일
2. `.pb-group-header` — 그룹명 + 활성화 체크박스
3. `.pb-pattern-input` — 태그명 패턴 input (전체 너비)
4. `.pb-attr-checkboxes` — 속성 체크박스 행 (flex)
5. `.pb-custom-attr-inputs` — 사용자 속성 input 2개 (작은 크기)
6. `.pb-datatype-select` — 데이터 타입 select
### 완성 기준
- [ ] CSS 클래스 정의됨
- [ ] 체크박스 라벨 정렬 정상
- [ ] 작은 input 2개는 inline 배치
- [ ] 반응형 레이아웃 유지 (900px 이하)
- [ ] 기존 스타일과 충돌 없음
---
## Step 3: JS - pbBuild() 그룹 기반 전송 로직
**변경 파일:** `src/Web/wwwroot/js/app.js`
### 작업 내용
1. **중복 `pbBuild()` 함수 삭제 (L671-686)** — 🔴 치명적 버그
- 두 번째 `pbBuild()``PB_NAME_IDS`(미정의 상수)를 참조하여 `ReferenceError` 발생
- JS에서同名 function declaration은 후자가 전자를 덮어씀 → 첫 번째(정상) `pbBuild()`가 동작하지 않음
2. 새 그룹 ID 상수 정의:
- `PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom']`
3. `pbCollectGroupData(groupKey)` 함수:
- 해당 그룹의 태그명 패턴 input 읽기 (쉼표 분할)
- 체크된 속성 체크박스 읽기
- 사용자 속성 input 2개 읽기
- 데이터 타입 select 읽기
- `{ tagPatterns: [], attributes: [], dataType: string|null }` 반환
4. `pbBuild()` 함수 재작성 (첫 번째 정의만 유지, L599-630):
- 5개 그룹 데이터 수집
- 빈 그룹(태그명 패턴 없음)은 제외
- `POST /api/pointbuilder/build` 전송
- 응답 처리 동일 유지
5. `pbRefresh()`, `pbRender()`, `pbAddManual()`, `pbDelete()` → 유지
### 완성 기준
- [x] `PB_GROUPS` 상수 정의됨 — **이미 완료** (`app.js:575`)
- [x] `pbCollectGroupData()` 함수 정의됨 — **이미 완료** (`app.js:577-597`)
- [x] `pbBuild()` 함수 정의됨 — **이미 완료** (`app.js:599-630`)
- [x] 쉼표 구분 패턴 분할 로직 작동 — **이미 완료**
- [x] 체크박스 상태 정확히 읽기 — **이미 완료**
- [x] JSON 구조: `{ controller1: {...}, analogmon1: {...}, ... }` (groups 래퍼 없음) — **이미 완료**
- [x] 빈 그룹은 전송 제외 — **이미 완료**
- [x] **중복 `pbBuild()` (L671-686) 삭제****완료** (2026-05-10)
- [x] 콘솔 에러 없음
---
## Step 4: DTO - PointBuilderGroupDto, PointBuilderBuildDto 재정의
**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`
### 작업 내용
1. 기존 `PointBuilderBuildDto` 클래스 제거
2.`PointBuilderGroupDto` 클래스 추가:
```csharp
public class PointBuilderGroupDto
{
public List<string> TagPatterns { get; set; } = new();
public List<string> Attributes { get; set; } = new();
public string? DataType { get; set; }
}
```
3. 새 `PointBuilderBuildDto` 클래스:
```csharp
public class PointBuilderBuildDto
{
public PointBuilderGroupDto Controller1 { get; set; } = new();
[JsonPropertyName("analogmon1")]
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
public PointBuilderGroupDto Digital1 { get; set; } = new();
public PointBuilderGroupDto Digital2 { get; set; } = new();
public PointBuilderGroupDto Custom { get; set; } = new();
}
```
### 완성 기준
- [x] 기존 `PointBuilderBuildDto` (Names, DataTypes, CustomPatterns) 제거됨 — **이미 완료**
- [x] `PointBuilderGroupDto` 클래스 정의됨 — **이미 완료** (`ExperionDtos.cs:54-59`)
- [x] 새 `PointBuilderBuildDto` 클래스 정의됨 — **이미 완료** (`ExperionDtos.cs:61-69`)
- [x] JSON camelCase 속성: `tagPatterns`, `attributes`, `dataType` — **이미 완료**
- [x] `[JsonPropertyName("analogmon1")]` 속성 적용 — **이미 완료** (`ExperionDtos.cs:64`)
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 5: Interface - BuildRealtimeTableAsync 시그니처 변경
**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`
### 작업 내용
1. 기존 시그니처:
```csharp
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes, IEnumerable<string> customPatterns);
```
2. 새 시그니처:
```csharp
Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups);
```
### 완성 기준
- [x] 시그니처 변경됨 — **이미 완료** (구현체 `ExperionDbContext.cs:514`가 이미 해당 시그니처 사용)
- [x] `PointBuilderGroupDto` 참조 가능 — **이미 완료**
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 6: DB - BuildRealtimeTableAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
### ⚠️ 진단 결과: 기존 EF Core LINQ 구현 유지
Plan이 UNION + Raw SQL 방식을 제안했으나, 진단 결과:
1. 기존 EF Core LINQ 구현(`ExperionDbContext.cs:514-573`)이 이미 정상 동작 중
2. 제안된 Raw SQL 코드는 `.Join()` 컴파일 에러 포함
3. Raw SQL 방식은 SQL Injection 위험 (parameterized query 아님)
4. 기존 LINQ 방식이 type-safe + EF Core 추적 + 테스트 커버리지 보유
**결정: 기존 코드 유지, 변경 없음.**
### 현재 구현 (이미 완료)
```csharp
public async Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return 0;
var allSources = new List<NodeMapMaster>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
allSources.AddRange(sources);
}
var distinctSources = allSources
.GroupBy(s => s.NodeId)
.Select(g => g.First())
.ToList();
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = distinctSources.Select(s => new RealtimePoint
{
TagName = ExtractTagName(s.NodeId),
NodeId = s.NodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
var saved = await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 빌드: {Count}건", saved);
return saved;
}
```
### 완성 기준
- [x] 그룹별 EF.Core LINQ 쿼리 구현됨 — **이미 완료** (`ExperionDbContext.cs:514-573`)
- [x] level = 3 필터 고정 적용 — **이미 완료**
- [x] TagPatterns → EF.Functions.Like OR 조건 — **이미 완료**
- [x] Attributes → Contains (name IN) 조건 — **이미 완료**
- [x] DataType → 등가 조건 (null 생략) — **이미 완료**
- [x] 빈 그룹은 쿼리에서 제외 — **이미 완료**
- [x] 중복 제거 (GroupBy nodeId) — **이미 완료**
- [x] TRUNCATE 후 INSERT 로직 — **이미 완료**
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 7: Controller - DTO 매핑
**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`
### 작업 내용
1. `Build` 액션 수정:
- 기존: `await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes, dto.CustomPatterns)`
- 새: `await _dbSvc.BuildRealtimeTableAsync([dto.Controller1, dto.AnalogMonitor1, dto.Digital1, dto.Digital2, dto.Custom])`
### 완성 기준
- [x] Build 액션에서 DTO 그룹 배열 전달 — **이미 완료** (`ExperionControllers.cs:290-293`)
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 8: 빌드 및 검증
### 작업 내용
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 확인 (0 warning, 0 error)
3. HTML 파일 브라우저에서 렌더링 확인 (선택)
### 완성 기준
- [ ] 빌드 성공
- [ ] 0 warning
- [ ] 0 error
- [ ] 모든 변경 파일 반영됨
---
## 진행 상태 추적
| Step | 상태 | 완료 기준 충족 | 비고 |
|------|------|---------------|------|
| 1. HTML | ✅ 완료 | 전체 체크 | 이전 세션에서 완료 |
| 2. CSS | ✅ 완료 | - | `width: auto` 이미 적용 |
| 3. JS | ✅ 완료 | 전체 체크 | 중복 pbBuild() 삭제 완료 |
| 4. DTO | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 5. Interface | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 6. DB | ✅ 완료 | 전체 체크 | 기존 EF Core LINQ 유지 |
| 7. Controller | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 8. 빌드 | ✅ 완료 | 전체 체크 | 0 Warning, 0 Error |
---
## 진단 결과 (2026-05-10 진단 체크리스트 8단계 적용)
> diagnosis-checklist.md STEP 1~8 순서대로 실행. STEP 6 교차 검증(Q1~Q4) 적용.
---
### 🔴 1. JS 중복 `pbBuild()` — 치명적 버그, 즉시 처리 필요 (HIGH)
**문제**: `app.js`에 `pbBuild()` 함수가 2개 정의되어 있음 (L599, L671). JS에서同名 function declaration은 후자가 전자를 덮어씁니다. L671의 `pbBuild()`는 `PB_NAME_IDS`, `PB_DT_IDS`, `PB_CUSTOM_IDS`를 참조하는데 이 상수들은 어디에도 정의되어 있지 않습니다. 즉 "테이블 작성하기" 버튼 클릭 시 즉시 `ReferenceError: PB_NAME_IDS is not defined` 발생.
**근거**: `app.js:671-686` — 두 번째 `pbBuild()` 정의; `PB_NAME_IDS` 미정의
**영향**: "테이블 작성하기" 버튼 클릭 시 즉시 JS 에러, 포인트 빌드 불가
**수정**: Step 3에 "중복 `pbBuild()` (L671-686) 삭제" 작업 항목 추가 — **본 문서에 반영 완료**
---
### ✅ 2. JS JSON 구조와 C# DTO 불일치 — 오진, 실제 코드에서는 문제 없음 (Q4 탈락)
**문제**: 이전 진단에서 "JS가 `{ groups: {...} }` 래퍼를 전송하여 C# 역직렬화 실패"라고 지적함.
하지만 실제 코드(`app.js:599-618`)는 `groups` 객체를 직접 전송합니다:
```js
const groups = {};
for (const gk of PB_GROUPS) { groups[gk] = gd; }
await api('POST', '/api/pointbuilder/build', groups);
```
C# DTO(`ExperionDtos.cs:64`)는 `[JsonPropertyName("analogmon1")]`으로 JS 키와 일치하므로 실제로 문제 없음.
**근거**: `app.js:599-618` — groups 래퍼 없이 직접 전송; `ExperionDtos.cs:64` — `[JsonPropertyName("analogmon1")]` 존재
**교차 검증**: STEP 6 Q4("실제 장애 시나리오가 있는가?") 탈락 — 재현 불가
**결론**: 이전 진단 삭제, 문서에서 제거
---
### ✅ 3. `.Join()` 컴파일 에러 — Plan 제안 코드의 문제, 실제 코드는 아님 (Q1 탈락)
**문제**: 이전 진단에서 `.Join(separator)` 미존재 메서드를 지적함. 그러나 이 코드는 `ExperionDbContext.cs`의 실제 구현이 아닙니다. 실제 코드는 EF Core LINQ를 사용하며 `Join()`을 전혀 호출하지 않음.
**근거**: `ExperionDbContext.cs:514-573` — 실제 코드는 EF Core LINQ 사용; Plan Step 6 제안 코드에만 `.Join()` 존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락 — 실제 코드에 존재하지 않음
**결론**: Plan Step 6에서 UNION+Raw SQL 제안 삭제, 기존 EF Core LINQ 유지 — **본 문서에 반영 완료**
---
### ✅ 4. Union Raw SQL 변경 — 불필요한 변경 (Q3 탈락)
**문제**: Plan Step 6이 기존 EF Core LINQ를 Raw SQL UNION으로 대체하라고 제안함. 그러나:
1. 기존 LINQ 방식이 이미 정상 동작 중
2. 제안된 Raw SQL 방식은 SQL Injection 위험
3. 제안 코드는 컴파일 에러 포함
**근거**: `ExperionDbContext.cs:514-573` — 기존 EF Core LINQ 구현 정상 동작 중
**교차 검증**: STEP 6 Q3("의도적 설계인가?") — EF Core LINQ가 의도적 설계
**결론**: Step 6에서 Raw SQL 제안 삭제 — **본 문서에 반영 완료**
---
### ✅ 5. `PB_NAME_IDS` 상수 제거 — 이미 존재하지 않음 (Q1 탈락)
**문제**: Plan Step 3에서 "기존 `PB_NAME_IDS`, `PB_DT_IDS`, `PB_CUSTOM_IDS` 상수 제거"라고 함. 그러나 이 상수들은 현재 코드베이스에 존재하지 않음.
**근거**: `app.js` 전체 grep 결과 `PB_NAME_IDS` 미존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 3 작업 내용에서 해당 문구 제거 — **본 문서에 반영 완료**
---
### ✅ 6. `pbLoad()` 함수 제거 — 이미 존재하지 않음 (Q1 탈락)
**문제**: Plan Step 3에서 "기존 `pbLoad()` 함수 제거"라고 함. 그러나 `app.js`에 `pbLoad()` 함수는 존재하지 않음.
**근거**: `app.js` 전체 grep 결과 `pbLoad` 미존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 3 작업 내용에서 해당 문구 제거 — **본 문서에 반영 완료**
---
### ✅ 7. Step 1 완성 기준 — 이미 완료됨 (Q1 탈락)
**문제**: `pb-n1`~`pb-n8` select, `pb-dt1`, `pb-dt2`, `pb-custom1`, `pb-custom2` 제거를 검증 항목으로 포함함. 그러나 현재 HTML에는 이 요소들이 이미 존재하지 않음.
**근거**: `index.html:400-600` — 해당 ID의 요소 없음
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: 완성 기준에서 "[x] 이미 완료"로 표시 — **본 문서에 반영 완료**
---
### ✅ 8. Step 5 Interface 시그니처 — 이미 완료됨 (Q1 탈락)
**문제**: Plan Step 5에서 시그니처 변경을 미완료로 표시함. 그러나 구현체(`ExperionDbContext.cs:514`)가 이미 해당 시그니처를 사용하고 있음.
**근거**: `ExperionDbContext.cs:514` — 이미 `IEnumerable<PointBuilderGroupDto>` 시그니처
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 5에서 "[x] 이미 완료"로 표시 — **본 문서에 반영 완료**
---
### 🟡 참고 — LIKE 패턴 내 `%`, `_` 메타문자
**문제**: EF.Functions.Like에서 `_`가 LIKE 와일드카드(임의 1문자)로 동작. `p-602` 대신 `p602`도 매칭될 수 있음.
**판단**: 사용자가 `%`를 직접 입력하는 방식으로 결정. `_` 문제는 현장에서 실용적으로 문제없음 → **수용**
---
### ✅ 이상 없는 항목
- Step 5 인터페이스 시그니처 변경: 구현체·컨트롤러와 일관됨 — **이미 완료**
- Step 7 컨트롤러 배열 전달: C# 12 컬렉션 표현식 `[...]` 문법 — .NET 8 이상에서 정상 컴파일 — **이미 완료**
- Step 6 TRUNCATE 후 INSERT 흐름: 정상 — **이미 완료**
- Step 3 빈 그룹 제외 로직: Step 6의 `activeGroups.Count == 0` 가드와 일관됨 — **이미 완료**
---
## 최종 요약
| 등급 | 수 | 내용 |
|------|---|------|
| 🔴 HIGH | 1 | 중복 `pbBuild()` (L671-686) 삭제 필요 — 즉시 처리 |
| ✅ 오진 | 7 | 이미 완료됨, 존재하지 않음, 재현 불가 → 문서 반영 완료 |
| 🟡 참고 | 1 | LIKE `_` 메타문자 — 수용 |
**현재 남은 작업:**
1. 🔴 `app.js` L671-686 중복 `pbBuild()` 삭제
2. Step 2 CSS: `.pb-custom-attr-inputs .inp`에 `width: auto` 추가 (이미 완료됨)
3. 빌드 검증 (`dotnet build`)

View File

@@ -1,689 +0,0 @@
# 포인트빌더 개선 방안 - 코딩 2
## 1. 문제 정의
현재 "테이블 작성하기" 버튼은 조건에 맞는 포인트를 즉시 `realtime_table`에 TRUNCATE + INSERT 합니다.
**문제:**
- 빌드 결과가 의도와 다른지 확인할 수 없음
- 잘못된 조건으로 빌드 시 기존 liveValue 데이터 손실
- 521,958개 node_map_master 레코드 중 실수로 전체 선택 시 대규모 데이터 삽입 가능
**목표:** 빌드 전에 결과 미리보기 → 개별 체크 → 원하는 것만 선택하여 적용
---
## 2. 효율적 UI 구성 방안
### 2.1 왜 모달(팝업)이 아닌 인라인 확장인가?
| 고려사항 | 모달 | 인라인 확장 |
|----------|------|-------------|
| 포인트 수 | 수백~수천 개 | 동일 |
| 스크롤 | 모달 내부 스크롤 + 페이지 스크롤 충돌 | 자연스러운 페이지 흐름 |
| 체크 상태 유지 | 모달 닫으면 초기화 | 유지 가능 |
| 테이블 비교 | 기존 테이블과 별도 창 | 바로 아래에 표시, 비교 용이 |
| 모바일 | 화면 절반 가림 | 자연스럽게 스크롤 |
**결정: 인라인 확장** — "테이블 작성하기" 옆에 "미리보기" 버튼 추가, 결과를 기존 포인트 목록 위에 인라인으로 표시.
### 2.2 UI 플로우
```
┌─────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
│ ┌─ 컨트롤러 포인트 #1 ──────────────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌─ 아날로그 모니터링 포인트 #2 ────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ (나머지 그룹...) │
│ │
│ [🔍 미리보기] [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
│ ▼ (미리보기 클릭 후 인라인 표시) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 미리보기 결과 (127개 포인트) │ │
│ │ [전체 선택] [전체 해제] [역전] 선택: 127/127 │ │
│ │ ┌──┬ ID ┬ TagName ┬ NodeType ┬ DataType┐ │ │
│ │ │☑ │ 1 │ FICQ-2113.PV │ pv │ Double │ │ │
│ │ │☑ │ 2 │ FICQ-2113.OP │ op │ Double │ │ │
│ │ │☐ │ 3 │ FICQ-2113.SP │ sp │ Double │ │ │
│ │ │☑ │ 4 │ TIC-2101.PV │ pv │ Double │ │ │
│ │ │ │ ... │ ... │ ... │ ... │ │ │
│ │ └──┴─────┴──────────────────┴──────────┴─────────┘ │ │
│ │ [취소] [선택된 126개 적용하기] │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.3 핵심 UX 결정
1. **미리보기는 READ-ONLY**: DB를 변경하지 않음. node_map_master에서 조건에 맞는 레코드만 조회
2. **기본 전체 체크**: 미리보기 시 모든 포인트가 체크된 상태로 표시 (대부분의 경우 전체 적용)
3. **그룹별 색상 라벨**: 어떤 그룹 조건에서 매칭되었는지 표시
4. **검색/필터**: 미리보기 테이블에 태그명 검색 입력창 제공 (수천 개 중 찾기)
5. **"테이블 작성하기"는 유지**: 기존처럼 조건 → 즉시 빌드 (미리보기 없이 빠른 빌드)
---
## 3. 아키텍처 변경
### 3.1 변경 파일
| 파일 | 변경 내용 |
|------|----------|
| `src/Web/wwwroot/index.html` | 미리보기 버튼 + 인라인 미리보기 영역 추가 |
| `src/Web/wwwroot/js/app.js` | `pbPreview()`, `pbCancelPreview()`, `pbApplySelected()`, `pbRenderPreview()` 추가 |
| `src/Web/wwwroot/css/style.css` | `.pb-preview` 스타일 추가 |
| `src/Core/Application/DTOs/ExperionDtos.cs` | `PointBuilderPreviewResultDto` 추가 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `PreviewRealtimeBuildAsync()` 인터페이스 추가 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `PreviewRealtimeBuildAsync()` 구현 |
| `src/Web/Controllers/ExperionControllers.cs` | `POST /api/pointbuilder/preview`, `POST /api/pointbuilder/apply` 추가 |
### 3.2 API 엔드포인트
```
GET /api/pointbuilder/points — 기존 유지 (realtime_table 조회)
POST /api/pointbuilder/build — 기존 유지 (즉시 빌드)
POST /api/pointbuilder/preview — NEW: 조건에 맞는 포인트 조회 (DB 변경 없음)
POST /api/pointbuilder/apply — NEW: 선택된 포인트만 realtime_table에 적용
POST /api/pointbuilder/add — 기존 유지 (수동 추가)
DELETE /api/pointbuilder/{id} — 기존 유지 (삭제)
```
### 3.3 데이터 흐름
```
┌────────────────────────────────────────────────────────────────────┐
│ [미리보기] 클릭 │
│ POST /api/pointbuilder/preview ← groups (기존 build와 동일) │
│ ↓ │
│ C#: PreviewRealtimeBuildAsync() │
│ → 각 그룹별 node_map_master 쿼리 (기존 BuildRealtimeTableAsync와 │
│ 동일한 쿼리 로직, 하지만 TRUNCATE/INSERT 없이 결과만 반환) │
│ → 중복 제거 (GroupBy nodeId) │
│ → { count, items: [{ nodeId, tagName, name, dataType, group }] } │
│ ↓ │
│ JS: pbRenderPreview() — 체크박스 테이블 렌더링 │
│ ↓ │
│ 사용자가 체크/해제 → [선택된 N개 적용하기] 클릭 │
│ ↓ │
│ POST /api/pointbuilder/apply ← { selectedNodeIds: [...] } │
│ ↓ │
│ C#: ApplySelectedPointsAsync() │
│ → TRUNCATE realtime_table │
│ → selectedNodeIds만 INSERT │
│ → pbRefresh() → 포인트 목록 갱신 │
└────────────────────────────────────────────────────────────────────┘
```
---
## 4. 코딩 Todo List
### Step 1: C# DTO — Preview 결과 DTO 추가
**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`
```csharp
public class PointBuilderPreviewItem
{
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; // pv, op, sp, md 등
public string DataType { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty; // 어떤 그룹에서 매칭
}
public class PointBuilderPreviewResult
{
public int Count { get; set; }
public List<PointBuilderPreviewItem> Items { get; set; } = new();
}
```
**완성 기준:**
- [ ] 두 클래스 추가됨
- [ ] 빌드 컴파일 성공
---
### Step 2: C# Interface — Preview 메서드 추가
**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`
```csharp
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
```
**참고:** `(string GroupKey, PointBuilderGroupDto Group)` 튜플 사용 — Step 3에서 그룹명 태그에 필요.
**완성 기준:**
- [ ] 두 메서드 시그니처 추가됨
- [ ] 빌드 컴파일 성공 (구현체는 다음 Step)
---
### Step 3: C# DB — PreviewRealtimeBuildAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
**구현 로직:**
- 기존 `BuildRealtimeTableAsync`와 동일한 쿼리 로직 재사용
- 각 그룹별로 쿼리 실행 → 결과에 `Group` 속성 태그
- `GroupBy(NodeId)` 중복 제거
- TRUNCATE/INSERT 없이 `PointBuilderPreviewResult` 반환
```csharp
public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return new PointBuilderPreviewResult();
var allSources = new List<(NodeMapMaster Node, string Group)>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
var groupName = GetGroupName(g); // Controller1, AnalogMonitor1 등
foreach (var s in sources)
allSources.Add((s, groupName));
}
var distinct = allSources
.GroupBy(x => x.Node.NodeId)
.Select(g => g.First())
.ToList();
var items = distinct.Select(x => new PointBuilderPreviewItem
{
NodeId = x.Node.NodeId,
TagName = ExtractTagName(x.Node.NodeId),
Name = x.Node.Name,
DataType = x.Node.DataType,
Group = x.Group
}).ToList();
return new PointBuilderPreviewResult { Count = items.Count, Items = items };
}
```
**문제:** `GetGroupName(g)``PointBuilderGroupDto`에는 그룹명이 없음. 해결: 컨트롤러에서 그룹 키와 함께 전달하도록 변경.
**수정안:** 인터페이스 시그니처를 `IEnumerable<(string GroupKey, PointBuilderGroupDto Group)>`로 변경.
**완성 기준:**
- [ ] 쿼리 로직 구현됨 (기존 BuildRealtimeTableAsync와 동일)
- [ ] TRUNCATE/INSERT 없음 (READ-ONLY)
- [ ] 중복 제거 (GroupBy nodeId)
- [ ] 그룹 정보 포함
- [ ] 빌드 컴파일 성공
---
### Step 4: C# DB — ApplySelectedPointsAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
```csharp
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
{
var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (nodeIds.Count == 0) return 0;
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = nodeIds.Select(nodeId => new RealtimePoint
{
TagName = ExtractTagName(nodeId),
NodeId = nodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
return points.Count;
}
```
**완성 기준:**
- [ ] TRUNCATE 후 선택된 nodeId만 INSERT
- [ ] 빌드 컴파일 성공
---
### Step 5: C# Controller — preview + apply 엔드포인트 추가
**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`
```csharp
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
{
var groups = new[]
{
("controller1", dto.Controller1),
("analogmon1", dto.AnalogMonitor1),
("digital1", dto.Digital1),
("digital2", dto.Digital2),
("custom", dto.Custom)
};
var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);
return Ok(new
{
count = result.Count,
items = result.Items.Select(i => new
{
nodeId = i.NodeId,
tagName = i.TagName,
name = i.Name,
dataType = i.DataType,
group = i.Group
})
});
}
[HttpPost("apply")]
public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
{
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);
return Ok(new { success = true, count, message = $"{count}개 포인트 적용 완료" });
}
```
**추가 DTO:** `ExperionDtos.cs`
```csharp
public class PointBuilderApplyDto
{
public List<string> SelectedNodeIds { get; set; } = new();
}
```
**완성 기준:**
- [ ] 두 엔드포인트 추가됨
- [ ] camelCase JSON 응답
- [ ] PointBuilderApplyDto 추가됨
- [ ] 빌드 컴파일 성공
---
### Step 6: HTML — 미리보기 버튼 + 영역 추가
**변경 파일:** `src/Web/wwwroot/index.html`
**6a. 버튼 행 수정 (기존 L600-603):**
```html
<div class="btn-row">
<button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
</div>
```
**6b. 미리보기 영역 추가 (조건으로 테이블 작성 card 내부, 버튼 행 바로 아래):**
```html
<div id="pb-preview" class="pb-preview hidden">
<div class="pb-preview-header">
<span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
<div class="pb-preview-actions">
<button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
<button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
<button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
<span id="pb-preview-selected" class="mut">선택: 0/0</span>
</div>
</div>
<div class="fg" style="margin-bottom:8px">
<input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
</div>
<div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
</div>
</div>
```
**완성 기준:**
- [ ] 미리보기 버튼 추가됨
- [ ] 미리보기 영역 HTML 추가됨 (hidden 기본)
- [ ] 전체 선택/해제/역전 버튼 포함
- [ ] 검색 입력창 포함
- [ ] 취소 + 적용 버튼 포함
- [ ] HTML 유효성 검사 통과
---
### Step 7: CSS — 미리보기 스타일 추가
**변경 파일:** `src/Web/wwwroot/css/style.css`
```css
.pb-preview {
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin-top: 10px;
}
.pb-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
font-size: 13px;
}
.pb-preview-actions {
display: flex;
gap: 6px;
align-items: center;
}
.pb-preview table th:first-child,
.pb-preview table td:first-child {
width: 36px;
text-align: center;
}
.pb-preview table input[type="checkbox"] {
cursor: pointer;
width: 15px;
height: 15px;
}
.pb-preview .group-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--ag);
color: var(--a);
font-weight: 600;
}
@media (max-width: 900px) {
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
.pb-preview-actions { flex-wrap: wrap; }
}
```
**완성 기준:**
- [ ] `.pb-preview` 스타일 정의됨
- [ ] 체크박스 컬럼 너비 조정
- [ ] 그룹 배지 스타일
- [ ] 반응형 대응
- [ ] 기존 스타일과 충돌 없음
---
### Step 8: JS — 미리보기 로직 추가
**변경 파일:** `src/Web/wwwroot/js/app.js`
**8a. 전역 변수 (L575 근처):**
```javascript
let pbPreviewData = []; // 미리보기 원본 데이터
```
**8b. 새 함수 추가:**
```javascript
async function pbPreview() {
const groups = {};
for (const gk of PB_GROUPS) {
const gd = pbCollectGroupData(gk);
if (gd.tagPatterns.length > 0) {
groups[gk] = gd;
}
}
const activeKeys = Object.keys(groups);
if (activeKeys.length === 0) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
return;
}
setGlobal('busy', '미리보기 조회 중');
try {
const d = await api('POST', '/api/pointbuilder/preview', groups);
pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
document.getElementById('pb-preview').classList.remove('hidden');
pbRenderPreview(pbPreviewData);
setGlobal('ok', `미리보기: ${d.count}개 포인트`);
} catch (e) {
setGlobal('err', '미리보기 실패');
}
}
function pbRenderPreview(data) {
const el = document.getElementById('pb-preview-table');
const filtered = pbGetFilteredPreview();
const pts = filtered.length > 0 ? filtered : data;
if (pts.length === 0) {
el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
pbUpdatePreviewCount();
return;
}
el.innerHTML = `
<table>
<thead>
<tr>
<th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
<th>ID</th>
<th>TagName</th>
<th>NodeType</th>
<th>DataType</th>
<th>Group</th>
</tr>
</thead>
<tbody>
${pts.map((p, i) => `
<tr style="${!p.selected ? 'opacity:0.5' : ''}">
<td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
<td class="mut">${i + 1}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<td>${esc(p?.name || '')}</td>
<td class="mut">${esc(p?.dataType || '')}</td>
<td><span class="group-badge">${esc(p?.group || '')}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
pbUpdatePreviewCount();
}
function pbPreviewToggleItem(idx) {
pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
pbUpdatePreviewCount();
}
function pbPreviewToggleAll(checked) {
pbPreviewData.forEach((item, idx) => {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (searchVal) {
const filtered = pbGetFilteredPreview();
if (filtered.includes(item)) {
item.selected = checked;
}
} else {
item.selected = checked;
}
});
pbRenderPreview(pbPreviewData);
}
function pbPreviewSelectAll() {
pbPreviewData.forEach(p => p.selected = true);
pbRenderPreview(pbPreviewData);
}
function pbPreviewDeselectAll() {
pbPreviewData.forEach(p => p.selected = false);
pbRenderPreview(pbPreviewData);
}
function pbPreviewInvert() {
pbPreviewData.forEach(p => p.selected = !p.selected);
pbRenderPreview(pbPreviewData);
}
function pbGetFilteredPreview() {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (!searchVal) return [];
return pbPreviewData.filter(p =>
(p.tagName || '').toLowerCase().includes(searchVal) ||
(p.nodeId || '').toLowerCase().includes(searchVal) ||
(p.name || '').toLowerCase().includes(searchVal)
);
}
function pbPreviewFilter() {
pbRenderPreview(pbPreviewData);
}
function pbUpdatePreviewCount() {
const selected = pbPreviewData.filter(p => p.selected).length;
const total = pbPreviewData.length;
document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
}
function pbCancelPreview() {
document.getElementById('pb-preview').classList.add('hidden');
pbPreviewData = [];
}
async function pbApplySelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '적용할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 적용 중`);
try {
const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '적용 오류');
}
}
```
**완성 기준:**
- [ ] `pbPreviewData` 전역 변수 추가됨
- [ ] `pbPreview()` 함수 추가됨
- [ ] `pbRenderPreview()` 함수 추가됨
- [ ] `pbPreviewToggleItem()`, `pbPreviewToggleAll()` 추가됨
- [ ] `pbPreviewSelectAll()`, `pbPreviewDeselectAll()`, `pbPreviewInvert()` 추가됨
- [ ] `pbGetFilteredPreview()`, `pbPreviewFilter()` 검색 기능 추가됨
- [ ] `pbUpdatePreviewCount()` 카운트 업데이트
- [ ] `pbCancelPreview()` 취소
- [ ] `pbApplySelected()` 선택된 것만 적용
- [ ] `pbPreviewData``idx` 속성 부여 (indexOf 버그 방지)
- [ ] 콘솔 에러 없음
---
### Step 9: 빌드 및 검증
**작업 내용:**
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 (0 error, 0 warning)
3. 브라우저에서 테스트:
- 조건 입력 → 미리보기 → 결과 확인
- 체크/해제/역전 동작
- 검색 필터 동작
- 선택된 것만 적용 → 포인트 목록 갱신
- 기존 "테이블 작성하기" 버튼도 정상 동작
**완성 기준:**
- [ ] 빌드 성공 (0 error, 0 warning)
- [ ] 미리보기 버튼 클릭 시 결과 표시
- [ ] 전체 선택/해제/역전 정상
- [ ] 검색 필터 정상
- [ ] 선택 적용 후 포인트 목록 갱신
- [ ] 기존 빌드 버튼 영향 없음
- [ ] 기존 pbRefresh/pbDelete 영향 없음
---
## 5. 진행 상태 추적
| Step | 상태 | 비고 |
|------|------|------|
| 1. DTO | ⬜ 미완료 | PointBuilderPreviewItem, PointBuilderPreviewResult, PointBuilderApplyDto |
| 2. Interface | ⬜ 미완료 | PreviewRealtimeBuildAsync, ApplySelectedPointsAsync |
| 3. DB Preview | ⬜ 미완료 | READ-ONLY 쿼리 |
| 4. DB Apply | ⬜ 미완료 | 선택된 nodeId만 TRUNCATE + INSERT |
| 5. Controller | ⬜ 미완료 | preview + apply 엔드포인트 |
| 6. HTML | ⬜ 미완료 | 미리보기 버튼 + 영역 |
| 7. CSS | ⬜ 미완료 | .pb-preview 스타일 |
| 8. JS | ⬜ 미완료 | 미리보기 로직 (중복 pbBuild 이미 삭제 완료) |
| 9. 빌드 | ⬜ 미완료 | 검증 |
---
## 6. 부가 사항
### 6.1 기존 버그 수정 (이미 완료)
- **중복 `pbBuild()` 함수 (app.js L671-686):** ✅ 이미 삭제 완료 (코딩.md 반영 시)
- **`pbRender()` 테이블 컬럼 불일치 (app.js L660-662):** ✅ 컬럼 순서 확인 완료
### 6.2 향후 개선 (별도 작업)
- 그룹별 색상 배지 (컨트롤러=파랑, 아날로그=초록, 디지털=주황)
- 미리보기 결과 Excel/PDF 내보내기
- 최근 미리보기 조건 저장 (localStorage)
- 페이지네이션 (1000개 이상 시)

View File

@@ -1,242 +0,0 @@
# 포인트빌더 개선 방안
## 1. 현재 문제점
### 1.1 구조적 문제
`node_map_master` 테이블의 level 3 레코드 구조:
| name | node_id | data_type | level |
|------|---------|-----------|-------|
| `pv` | `ns=1;s=sinamserver:ficq-2113.pv` | `Double` | 3 |
| `op` | `ns=1;s=sinamserver:ficq-2113.op` | `Double` | 3 |
| `sp` | `ns=1;s=sinamserver:ficq-2113.sp` | `Double` | 3 |
| `md` | `ns=1;s=sinamserver:ficq-2113.md` | `i=7594` | 3 |
| `pv` | `ns=1;s=sinamserver:p-602.pv` | `i=7594` | 3 |
| `op` | `ns=1;s=sinamserver:xv-402.op` | `i=7594` | 3 |
- `name`은 속성명만 (`pv`, `op`, `sp`, `md` 등) — 태그명 없음
- 태그명은 `node_id` 안에 포함됨
- **521,958개**의 level 3 레코드가 존재
- 현재 드롭다운이 `name` 전체를 로드 → `pv` 선택 시 **모든** 컨트롤러의 pv全选됨
### 1.2 속성별 데이터 타입 분포
| 속성 | data_type | 설명 |
|------|-----------|------|
| `pv` | Double / i=7594 | 아날로그/디지털 |
| `op` | Double / i=7594 | 아날로그/디지털 |
| `sp` | Double / Boolean | 아날로그/디지털 |
| `md` | i=7594 | 모드 표시 (StatusCode) |
| `mode` | i=7594 | 모드 (md와 중복 → 제외) |
| `a1~a4` | Boolean / Double | Aux parameter (Alarm 아님 → 제외) |
| `qv.value` | Double | 품질 값 |
### 1.3 태그명 규칙의 다양성
- 회사마다, 프로젝트마다 태그명 규칙이 다름
- 정형화된 드롭다운/조건으로는 모든 케이스 커버 불가
- 사용자 자유도가 필수
---
## 2. 개선 방안
### 2.1 UI 구성
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 컨트롤러 포인트 #1 ────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %fic%, %lic%, %tic%, %pic% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [Double ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 아날로그 모니터링 포인트 #2 ───────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %fi%, %ti%, %li%, %pia% ] │ │
│ │ ☑ pv ☐ op ☐ sp ☐ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [Double ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 디지털 포인트 #1 ──────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %p-%, %xv-%, %vp-% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [i=7594 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 디지털 포인트 #2 ──────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [i=7594 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 사용자 정의 ───────────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %ficq-6113%, %ti-6101% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [전체 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 각 그룹의 동작
**태그명 패턴 입력:**
- 쉼표(`,`)로 구분하여 여러 패턴 입력
- 각 패턴은 `node_id`의 태그명 부분에 LIKE 매칭
- `%` 자동 추가 (선택): 사용자가 `%fic%` 입력 시 `%fic%`로, `fic` 입력 시 `%fic%`로 변환
- 예: `fic, tic, lica``%fic%` OR `%tic%` OR `%lica%`
**속성 체크박스:**
- `pv`, `op`, `sp`, `md` 기본 체크박스
- 오른쪽 작은 입력창 2개: 사용자 정의 속성 (예: `qv.value`, `tagname`)
- 체크된 속성 + 입력된 사용자 속성이 AND 조건으로 필터
**데이터 타입 풀다운:**
- `전체` (필터 없음)
- `Double` (아날로그)
- `i=7594` (디지털, StatusCode)
- `Boolean`
- `String`
- `Int16`, `Int32`, `UInt16`, `UInt32`
- `Float`, `DateTime`
### 2.3 백엔드 쿼리 로직
각 그룹별로 독립적인 쿼리를 생성하고 UNION DISTINCT:
```sql
-- 그룹 1: 컨트롤러 포인트
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%fic%' OR node_id LIKE '%tic%' OR node_id LIKE '%lica%')
AND name IN ('pv', 'op', 'sp', 'md')
AND data_type = 'Double'
UNION
-- 그룹 2: 아날로그 모니터링
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%fi%' OR node_id LIKE '%ti%' OR node_id LIKE '%li%')
AND name IN ('pv')
AND data_type = 'Double'
UNION
-- 그룹 3: 디지털 포인트 #1
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%p-%' OR node_id LIKE '%xv-%' OR node_id LIKE '%vp-%')
AND name IN ('pv', 'op', 'sp', 'md')
AND data_type = 'i=7594'
UNION
-- 그룹 4: 디지털 포인트 #2
-- (그룹 3과 동일 구조, 다른 패턴)
UNION
-- 그룹 5: 사용자 정의
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%custom1%' OR node_id LIKE '%custom2%')
AND name IN ('pv', 'op', 'sp', 'md', 'customAttr1', 'customAttr2')
-- data_type 필터 없음 (전체)
```
### 2.4 DTO 구조
```csharp
public class PointBuilderGroupDto
{
public List<string> TagPatterns { get; set; } = new(); // 쉼표 구분 → 분할
public List<string> Attributes { get; set; } = new(); // 체크박스 + 사용자 속성
public string? DataType { get; set; } // null = 전체
}
public class PointBuilderBuildDto
{
public PointBuilderGroupDto Controller1 { get; set; } = new();
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
public PointBuilderGroupDto Digital1 { get; set; } = new();
public PointBuilderGroupDto Digital2 { get; set; } = new();
public PointBuilderGroupDto Custom { get; set; } = new();
}
```
---
## 3. 추가 제안
### 3.1 미리보기 기능
테이블 작성 전에 조건에 맞는 레코드 수와 샘플을 표시:
```
[🔍 미리보기] → "조건에 맞는 레코드: 1,247개 (샘플: ficq-2113.pv, ficq-2113.op, ...)"
```
### 3.2 그룹 활성화 토글
각 그룹에 체크박스 추가하여 비활성화 가능한 그룹 표시:
```
☑ 컨트롤러 포인트 #1
☐ 아날로그 모니터링 포인트 #2 ← 비활성화 (쿼리에서 제외)
```
### 3.3 최근 조건 저장/로드
사용자가 자주 사용하는 조건 조합을 로컬 스토리지에 저장:
```
[💾 조건 저장] [📂 조건 로드] [삭제]
```
### 3.4 태그명 패턴 자동완성
level 2 name (`ficq-2113`, `ficq-2114` 등)을 기반으로 자동완성 제공:
```
사용자 입력: "fic" → 제안: fic, ficq-111, ficq-113, ficq-122, ficq-124, ...
```
### 3.5 중복 제거
UNION으로 결합 시 중복 레코드 자동 제거 (UNION DISTINCT).
---
## 4. 변경 범위
| 파일 | 변경 내용 |
|------|----------|
| `wwwroot/index.html` | 포인트빌더 pane 전면 재구성 |
| `wwwroot/js/app.js` | `pbLoad()` 제거, `pbBuild()` 그룹 기반 전송 |
| `wwwroot/css/style.css` | 그룹 카드, 체크박스, 입력창 스타일 |
| `Core/DTOs/ExperionDtos.cs` | `PointBuilderGroupDto`, `PointBuilderBuildDto` 재정의 |
| `Core/Interfaces/IExperionServices.cs` | `BuildRealtimeTableAsync` 시그니처 변경 |
| `Infrastructure/Database/ExperionDbContext.cs` | UNION 기반 쿼리 로직 |
| `Web/Controllers/ExperionControllers.cs` | DTO 매핑 |
---
## 5. 데이터 타입 참고
| data_type | 설명 | 포인트 유형 |
|-----------|------|------------|
| `Double` | 부동소수점 | 아날로그 (pv/op/sp) |
| `i=7594` | StatusCode | 디지털 (pv/op/sp/md) |
| `Boolean` | 참/거짓 | sp (디지털), a1~a4 |
| `String` | 문자열 | desc, name, area |
| `Int16` | 정수 16bit | eulo, euhi, pvperiod |
| `Int32` | 정수 32bit | numberofparents |
| `DateTime` | 일시 | lastscannedtime |

View File

@@ -1,595 +0,0 @@
# 통합 트렌드 워크스페이스 (Apache ECharts) — 구현 플랜 (P1→P2→P3, 단일 차트)
> 작성일 2026-05-25 · 대상: ExperionCrawler Web UI · 엔진: **Apache ECharts 5.x (Apache-2.0, 로컬 번들)**
> 설계 원칙: **하나의 차트에 기능을 층층이 쌓는다.** P1/P2/P3는 별도 화면이 아니라 *같은 Trend Workspace*에 더해지는 토글형 레이어다.
---
## 0. 핵심 원칙 — "하나의 차트, 슬롯 구조"
```
┌──────────── 단일 Trend Workspace (하나의 ECharts 인스턴스) ──────────┐
P3 지능 │ 🤖LLM Insights · 스마트그룹(P&ID trace) · 상관(X-Y)모드 · 공유링크 │ ← 같은 차트의 window를 읽음
├────────────────────────────────────────────────────────────────────┤
P2 분석 │ 알람한계선(markLine) · 운전음영(markArea) · 이벤트플래그 · │ ← 같은 인스턴스에 오버레이
│ 보이는구간 통계패널 · 듀얼커서Δ · 자동집계/다운샘플 │
├────────────────────────────────────────────────────────────────────┤
P1 기반 │ 멀티시리즈 · dataZoom(좌우날짜) · 범례표(색/강조) · minmax · 라이브토글 │ ← 토대 + 빈 슬롯
└────────────────────────────────────────────────────────────────────┘
같은 데이터 · 같은 시간축 · 같은 그룹 · 같은 window 공유
```
**왜 하나로 합쳐지나** — P1에서 *중앙 상태(`trState`) + 레이어 레지스트리(`TR_LAYERS`) + 합성 렌더(`trRender`)*를 세워두기 때문. 모든 시각요소는 "레이어 함수 → ECharts 옵션 조각"으로 표현되고, `trRender`**켜진 레이어만 모아 한 번에 `setOption`**. P2·P3 기능 추가 = `layerXxx()` 함수 + 레지스트리 1줄 + 토글 1개. **기존 코드 리팩터 없음.**
> 유일한 "모드 전환"은 상관(X-Y)뿐 — 선↔산점도를 **같은 컴포넌트**에서 토글. 새 차트가 아니다.
---
## 1. 결정 요약
| 항목 | 결정 |
|------|------|
| 엔진 | ECharts 5.x (Apache-2.0). `dataZoom`·`emphasis`·`markPoint`·`markLine`·`markArea` 내장 |
| 번들 | 로컬 `wwwroot/lib/echarts.min.js` (uPlot/marked/mermaid와 동일 방식) |
| 배치 | **신규 전용 탭 "트렌드"** (단일 워크스페이스 페이지) |
| 그룹 저장 | 서버 DB `trend_group` (공유·영속). localStorage 대체 가능(§5.1 주석) |
| 아날로그 한정 | `v_analog_points` (숫자 livevalue만 = 아날로그; enum `{n\|RUN\|}` 자동 제외) |
| 합성 구조 | `trState` + `TR_LAYERS` + `trRender` — P2/P3 확장의 핵심(§6.3) |
| 데이터원 | history(원시/집계)·realtime(live)·event_history(이벤트/운전상태)·tag_metadata(한계)·trace(연관)·vLLM(분석) |
---
## 2. 기능 → 단계 매핑 (전부 같은 차트)
| 기능 | 단계 | 메커니즘 | 활용 자산 |
|------|------|----------|----------|
| 멀티시리즈 + 이중/멀티 Y축 | P1 | `series[].yAxisIndex` | history API |
| 좌·우 날짜(`dataZoom`)↔from/to 동기 | P1 | `dataZoom` slider ↔ dt피커 | core.js dt |
| 범례표(엑셀식)·색상편집·클릭 강조 | P1 | `lineStyle.color`/`width`+`emphasis.focus` | — |
| 보이는 범위 최대/최소 마커 | P1 | `markPoint` 윈도우 재계산 | §7 |
| 실시간 폴링 시작/정지 | P1 | `setInterval`+`setOption`/`appendData` | `/api/trend/live` |
| **알람 한계선 HI/LO/SP** | P2 | `markLine{yAxis}` | `v_instrument_range`(euhi/eulo)+`.sp` |
| **운전상태 음영(RUN/STOP/TRIP)** | P2 | `markArea{xAxis구간}` | `event_history_table`(상태변경+duration) |
| **알람/이벤트 세로 플래그** | P2 | `markLine{xAxis}` | `event_history_table`/`active_alarms` |
| **보이는 구간 통계(min/max/avg/σ)** | P2 | 클라 계산(window) | minmax 로직 재사용 |
| **듀얼 커서 Δt·Δy·기울기** | P2 | `graphic`+클릭 | — |
| **자동 집계/LTTB 다운샘플** | P2 | interval API + `sampling:'lttb'` | TimescaleDB 연속집계 |
| **🤖 보이는 구간 LLM 분석** | P3 | window 통계+이벤트→프롬프트 | `ask_iiot_llm`/MCP |
| **스마트그룹(연관 태그 추천)** | P3 | 상·하류 trace | `trace_connections`/`v_pump_signal_map` |
| **상관(X-Y) 모드** | P3 | line↔scatter 토글+회귀 | 클라 |
| **공유 링크/PNG·XLSX 내보내기** | P3 | URL state + toolbox | — |
---
## 3. 아키텍처
### 3.1 데이터 흐름 (단계별로 같은 차트에 합류)
```
realtime_table ─┬─(숫자)→ v_analog_points → /api/trend/analog-points [P1 그룹빌더]
└─(현재값)→ /api/trend/live [P1 실시간 tail]
history_table ──→ /api/history/query · /query-history-interval [P1 시리즈]
tag_metadata ──→ v_instrument_range → /api/trend/limits [P2 알람선]
event_history ──→ /api/trend/events · /api/trend/runbands [P2 이벤트/운전음영]
pid graph ──→ /api/trend/related (trace_connections) [P3 스마트그룹]
vLLM/MCP ──→ /api/trend/analyze (ask_iiot_llm) [P3 분석]
trend_group(DB)──→ /api/trend/groups CRUD [그룹 영속]
trState (중앙 상태) ── TR_LAYERS(레이어 레지스트리) ── trRender() → 단일 ECharts
```
### 3.2 ⭐ 프론트 합성 구조 — "슬롯"의 정체 (§6.3 코드)
- **`trState`**: 멤버·시리즈데이터·보이는 window·라이브·모드·**레이어 토글**·레이어 캐시. 모든 기능이 읽는 단일 진실원.
- **`TR_LAYERS`**: 각 레이어 = `{ id, when(state), perSeries(member,state)?, global(state)? }`. `when`이 false면 합성에서 빠짐(= 토글 OFF).
- **`trRender()`**: 멤버별로 활성 레이어의 `perSeries` 조각을 병합 + 전역 레이어의 `global` 조각을 모아 **한 번** `setOption`. → 새 기능은 레이어 추가만.
### 3.3 백엔드 엔드포인트 맵
| Phase | 엔드포인트 | 비고 |
|------|-----------|------|
| P1 | `GET /api/trend/analog-points` · `GET/POST/PUT/DELETE /api/trend/groups` · `GET /api/trend/live` | 신규 |
| P1 | `GET /api/history/query` · `POST /api/text-to-sql/query-history-interval` | **기존 재사용** |
| P2 | `GET /api/trend/limits?tags=` · `GET /api/trend/events?from&to&tags=` · `GET /api/trend/runbands?from&to&area=` | 신규(뷰/테이블 조회) |
| P3 | `POST /api/trend/analyze` · `GET /api/trend/related?tag=` | LLM/trace 위임 |
---
## 4. 설치 — ECharts 로컬 번들
```bash
cd src/Web/wwwroot/lib
curl -L -o echarts.min.js https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js
ls -la echarts.min.js # ~1MB UMD
```
- 버전 5.5.1(안정) 권장. 폐쇄망은 파일 복사. 용량 이슈 시 echarts 빌더로 line+dataZoom+markLine/Area/Point+tooltip+toolbox만 ~400KB 커스텀 빌드.
- index.html에 `<script src="/lib/echarts.min.js"></script>` 추가(§6.1).
---
## 5. 백엔드
### 5.1 P1 DDL — `trend_group` + `v_analog_points`
`src/Infrastructure/Database/ExperionDbContext.cs` 부팅 DDL(현 `fast_session`·`v_instrument_range` 블록)에 멱등 추가:
```csharp
// 트렌드 그룹 (members JSONB: [{tag,color,axis}])
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS trend_group (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
members JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""");
// 아날로그 포인트(숫자 livevalue) + 단위/레인지 + 설명 조인
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_analog_points");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE VIEW v_analog_points AS
SELECT rt.tagname,
split_part(rt.tagname,'.',1) AS base_tag,
rt.livevalue::double precision AS value,
rt.timestamp,
ir.unit, ir.eu_lo, ir.eu_hi,
ts.description, ts.area, ts.sub_area
FROM realtime_table rt
LEFT JOIN v_instrument_range ir ON ir.base_tag = split_part(rt.tagname,'.',1)
LEFT JOIN v_tag_summary ts ON ts.base_tag = split_part(rt.tagname,'.',1)
WHERE rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
ORDER BY rt.tagname
""");
```
> **아날로그 판별**: enum/디지털은 `{n | LABEL | }`라 숫자 정규식 불일치 → 자동 제외. 메타 적재 여부와 무관.
> **localStorage 대체 시**: `trend_group` DDL + §5.2 그룹 CRUD 생략, `trState.group`을 `localStorage['trendGroups']`로. analog-points/live는 유지.
### 5.2 P1 DTO / Service / Controller / DI
DTO (`TrendDtos.cs`): `TrendMemberDto{Tag,Color,Axis}`, `TrendGroupDto{Id,Name,Description,Members[],CreatedAt,UpdatedAt}`, `TrendGroupUpsertDto`, `AnalogPointDto{TagName,BaseTag,Value,Timestamp,Unit,EuLo,EuHi,Description,Area}`, `TrendLivePointDto{Tag,Value,Ts}`.
Service (`ITrendService`/`TrendService`, `IDbContextFactory<ExperionDbContext>` 주입 — 124행에 이미 등록):
```csharp
public interface ITrendService {
Task<List<AnalogPointDto>> GetAnalogPointsAsync(string? q, CancellationToken ct);
Task<List<TrendGroupDto>> GetGroupsAsync(CancellationToken ct);
Task<TrendGroupDto?> GetGroupAsync(int id, CancellationToken ct);
Task<TrendGroupDto> CreateGroupAsync(TrendGroupUpsertDto d, CancellationToken ct);
Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto d, CancellationToken ct);
Task<bool> DeleteGroupAsync(int id, CancellationToken ct);
Task<List<TrendLivePointDto>> GetLiveAsync(IEnumerable<string> tags, CancellationToken ct);
}
```
- `members``System.Text.Json`으로 (de)serialize, INSERT `@m::jsonb`.
- 뷰 조회는 ADO(`ctx.Database.GetDbConnection()`) 또는 keyless `ToView`. 프로젝트 raw 조회 패턴 답습.
- `GetLiveAsync`: `SELECT tagname, livevalue::double precision, timestamp FROM realtime_table WHERE tagname = ANY(@tags) AND livevalue ~ '^-?[0-9.]+$'`.
Controller (`/api/trend`): `analog-points`(q) · `groups` CRUD · `live?tags=` (§ 이전 설계와 동일).
DI: `builder.Services.AddScoped<ITrendService, TrendService>();`
### 5.3 P2 엔드포인트
```csharp
// 알람 한계선 — euhi/eulo + 현재 SP
[HttpGet("limits")] // ?tags=a,b
// SELECT base_tag, eu_hi, eu_lo, unit FROM v_instrument_range WHERE base_tag = ANY(@bases)
// + (선택) realtime_table 의 base||'.sp' 현재값을 SP 선으로
// → [{tag, hi, lo, sp, unit}]
// 이벤트 플래그 — 디지털 상태변경/알람
[HttpGet("events")] // ?from&to&tags=
// SELECT tagname, event_time, event_type, prev_value, curr_value
// FROM event_history_table
// WHERE event_time BETWEEN @from AND @to AND (@tags='' OR tagname = ANY(@tags))
// → [{tag, ts, type, label}]
// 운전상태 음영 — RUN 구간(상태+지속시간)
[HttpGet("runbands")] // ?from&to&area=
// event_history_table 의 펌프 상태변경에서 RUN→다음변경 구간을 밴드로 환산
// (duration_seconds 활용) → [{tag/area, t0, t1, state}]
```
> 통계패널·듀얼커서·자동집계는 **백엔드 무변경**(클라 계산 + 기존 interval API).
### 5.4 P3 엔드포인트
```csharp
// 🤖 보이는 구간 분석 — window 통계+이벤트를 받아 LLM 프롬프트 구성→ask_iiot_llm 위임
[HttpPost("analyze")]
// body: { window:{from,to}, members:[{tag,unit,min,max,avg,last,slope}], events:[...] }
// → OllamaController/McpService(ask_iiot_llm) 재사용. 한국어 진단 텍스트 반환.
// 스마트그룹 — 선택 태그의 상·하류 연관 아날로그 추천
[HttpGet("related")] // ?tag=
// TagMappingService/PidGraphService 의 trace + v_pump_signal_map → 연관 base_tag
// → v_analog_points 로 필터해 아날로그만 [{tagName, desc, relation}]
```
> 상관(X-Y)·공유링크는 **백엔드 무변경**(클라).
---
## 6. 프론트엔드 — 단일 워크스페이스
### 6.1 배선 — index.html
```html
<li class="nav-item" data-tab="trend"><span class="nav-ico">📈</span><span class="nav-txt">17 트렌드</span></li>
<section class="pane" id="pane-trend" data-src="/panes/trend.html"></section>
<script src="/lib/echarts.min.js"></script>
<script src="/js/trend.js"></script>
```
탭 라우터가 lazy-load + `paneInit.trend?.()` 자동 호출. CSS는 pane 안 `<link href="/css/trend.css">`.
### 6.2 레이아웃 — panes/trend.html (슬롯 포함)
```html
<link rel="stylesheet" href="/css/trend.css">
<header class="pane-hdr"><div><h1>트렌드 워크스페이스</h1><p>히스토리·실시간·분석 통합</p></div><div class="pane-tag">TREND</div></header>
<!-- 타임바: 퀵레인지 + from/to + 라이브 + 집계 + 토글들(슬롯) -->
<div class="card trend-timebar">
<div class="tr-quick"><button onclick="trQuickRange('1h')">1h</button><button onclick="trQuickRange('8h')">8h</button>
<button onclick="trQuickRange('24h')">24h</button><button onclick="trQuickRange('7d')">7d</button><button onclick="trQuickRange('shift')">교대</button></div>
<input type="hidden" id="hf-from"><div class="dt-display inp" id="dtp-from-display" onclick="dtOpen('from')"></div>
<input type="hidden" id="hf-to"><div class="dt-display inp" id="dtp-to-display" onclick="dtOpen('to')"></div>
<select id="tr-interval" class="inp"><option value="auto">자동</option><option value="1 minute">원시</option><option value="5 minutes">5분</option><option value="1 hour">1시간</option></select>
<button class="btn-a" onclick="trQuery()">🔍 조회</button>
<button class="btn-b" id="tr-live-btn" onclick="trToggleLive()">▶ 라이브</button>
<!-- 레이어 토글(슬롯): P1 minmax부터, P2/P3는 같은 자리에 버튼만 추가 -->
<span class="tr-layers">
<button class="tr-tog" data-layer="minmax" onclick="trToggleLayer('minmax')">↕ 최대/최소</button>
<button class="tr-tog" data-layer="limits" onclick="trToggleLayer('limits')">⚠ 알람선</button>
<button class="tr-tog" data-layer="runstate" onclick="trToggleLayer('runstate')">▒ 운전음영</button>
<button class="tr-tog" data-layer="events" onclick="trToggleLayer('events')">┊ 이벤트</button>
<button class="tr-tog" data-layer="insights" onclick="trToggleInsights()">🤖 분석</button>
<button class="tr-tog" onclick="trToggleXY()">⊹ 상관</button>
</span>
<span class="tr-groupbar"><select id="tr-group-select" class="inp"></select>
<button onclick="trLoadGroup()">불러오기</button><button onclick="trOpenBuilder()">+그룹</button>
<button onclick="trEditGroup()">편집</button><button onclick="trDeleteGroup()">삭제</button></span>
</div>
<div class="trend-main">
<div class="card trend-chartwrap"><div id="tr-chart" class="trend-chart"></div></div>
<!-- Insights 슬롯(P3): 기본 숨김, 토글 시 슬라이드인 -->
<aside id="tr-insights" class="card trend-insights hidden">
<div class="tr-ins-hd">🤖 분석 <button onclick="trAnalyze()">이 구간 설명</button></div>
<div id="tr-ins-body" class="tr-ins-body">보이는 구간을 선택하고 "이 구간 설명"을 누르세요.</div>
</aside>
</div>
<!-- 범례/통계 표 (엑셀식, 보이는 구간 통계 칸 포함) -->
<div class="card"><table class="tr-legend"><thead><tr>
<th></th><th>태그</th><th>설명</th><th>현재</th><th>최소</th><th>최대</th><th>평균</th><th>σ</th><th>단위</th><th></th><th>👁</th><th></th>
</tr></thead><tbody id="tr-legend-body"></tbody></table></div>
<!-- 그룹 빌더 모달 (아날로그 클릭 선택) -->
<div id="tr-builder" class="tr-modal hidden"><div class="tr-modal-box">
<div class="tr-modal-hd"><input id="tr-builder-name" class="inp" placeholder="그룹 이름">
<input id="tr-builder-search" class="inp" placeholder="태그/설명 검색" oninput="trRenderAnalog()">
<button onclick="trBuilderRelated()">🔗 연관추천</button></div> <!-- P3 슬롯 -->
<div class="tr-analog-wrap"><table class="tr-analog"><thead><tr>
<th></th><th>태그</th><th>설명</th><th>구역</th><th>현재값</th><th>단위</th><th>EU Lo</th><th>EU Hi</th></tr></thead>
<tbody id="tr-analog-body"></tbody></table></div>
<div class="tr-modal-ft"><span id="tr-builder-count">0개</span>
<button class="btn-a" onclick="trSaveGroup()">저장</button><button class="btn-b" onclick="trCloseBuilder()">취소</button></div>
</div></div>
```
### 6.3 ⭐ 상태 모델 + 합성 렌더 (P2/P3 확장의 토대) — js/trend.js
```js
let trChart = null, trLiveTimer = null, trEditingId = null;
const TR_PALETTE = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4','#42d4f4','#f032e6','#bfef45','#469990','#fabed4'];
// 중앙 상태 — 모든 레이어가 읽는 단일 진실원
const trState = {
group: null,
members: [], // [{tag,color,axis,unit,desc,euLo,euHi}]
seriesData: {}, // { tag: [[ms,val],...] }
window: { t0:null, t1:null },
selected: null, // 강조 중 태그
mode: 'line', // 'line' | 'xy'
live: false,
layers: { minmax:false, limits:false, runstate:false, events:false, insights:false },
cache: { limits:{}, events:[], runbands:[] }
};
// 레이어 레지스트리 — 새 기능은 여기 1줄 + 함수만 추가하면 같은 차트에 합류
const TR_LAYERS = [
{ id:'base', perSeries: layerBaseSeries },
{ id:'minmax', perSeries: layerMinMax, when: s => s.layers.minmax },
{ id:'limits', perSeries: layerLimits, when: s => s.layers.limits },
{ id:'runstate', global: layerRunState, when: s => s.layers.runstate },
{ id:'events', global: layerEvents, when: s => s.layers.events },
// P3 등 추가 레이어도 동일 형태로 push
];
// 합성 렌더 — 켜진 레이어를 모아 한 번에 setOption
function trRender() {
if (trState.mode === 'xy') return trRenderXY(); // 상관모드(같은 컴포넌트)
const series = trState.members.map(m => {
let s = { name:m.tag, type:'line', showSymbol:false };
for (const L of TR_LAYERS) {
if (L.when && !L.when(trState)) continue;
if (L.perSeries) s = trMerge(s, L.perSeries(m, trState));
}
return s;
});
for (const L of TR_LAYERS) { // 전역 레이어(markArea/markLine 운반용 helper series)
if (L.when && !L.when(trState)) continue;
if (L.global) { const g = L.global(trState); if (g) series.push(g); }
}
trChart.setOption({ series }, { replaceMerge:['series'] });
trRenderLegend(); // 통계칸 포함
}
function trMerge(a,b){ return { ...a, ...b, lineStyle:{...a.lineStyle,...b.lineStyle},
markPoint:b.markPoint||a.markPoint, markLine:b.markLine||a.markLine, markArea:b.markArea||a.markArea }; }
```
```js
// ── 레이어 함수들 ───────────────────────────────────────────
function layerBaseSeries(m, s){
return { yAxisIndex: m.axis==='right'?1:0,
lineStyle:{ color:m.color, width: m.tag===s.selected?4:1.5 },
itemStyle:{ color:m.color }, emphasis:{ focus:'series', lineStyle:{width:4} },
data: s.seriesData[m.tag] || [] };
}
function layerMinMax(m, s){ // P1 — 보이는 구간 max/min 핀
const [t0,t1]=trVisibleWindow(); const d=s.seriesData[m.tag]||[];
let mn=Infinity,mx=-Infinity,mnp=null,mxp=null;
for(const [ms,v] of d){ if(v==null||ms<t0||ms>t1)continue; if(v>mx){mx=v;mxp=[ms,v];} if(v<mn){mn=v;mnp=[ms,v];} }
const data=[]; if(mxp)data.push({coord:mxp,symbol:'pin',symbolSize:40,itemStyle:{color:m.color},label:{formatter:`${mx.toFixed(1)}`,fontSize:10}});
if(mnp)data.push({coord:mnp,symbol:'pin',symbolSize:40,symbolRotate:180,itemStyle:{color:m.color},label:{formatter:`${mn.toFixed(1)}`,fontSize:10,offset:[0,18]}});
return { markPoint:{ silent:true, data } };
}
function layerLimits(m, s){ // P2 — HI/LO/SP 수평선
const lim=s.cache.limits[m.tag]; if(!lim) return {};
const data=[];
if(lim.hi!=null)data.push({yAxis:lim.hi,lineStyle:{color:'#e55',type:'dashed'},label:{formatter:'HI'}});
if(lim.lo!=null)data.push({yAxis:lim.lo,lineStyle:{color:'#e55',type:'dashed'},label:{formatter:'LO'}});
if(lim.sp!=null)data.push({yAxis:lim.sp,lineStyle:{color:m.color,type:'dotted'},label:{formatter:'SP'}});
return { markLine:{ silent:true, symbol:'none', data } };
}
function layerRunState(s){ // P2 — RUN 구간 음영(전역 helper series)
const bands=s.cache.runbands.map(b=>[{xAxis:b.t0,itemStyle:{color:b.state==='TRIP'?'rgba(229,85,85,.10)':'rgba(60,180,75,.10)'}},{xAxis:b.t1}]);
return { name:'__runstate', type:'line', data:[], showSymbol:false, silent:true, markArea:{ data:bands } };
}
function layerEvents(s){ // P2 — 이벤트 세로 플래그(전역 helper series)
const data=s.cache.events.map(e=>({xAxis:e.ts,label:{formatter:e.label,rotate:90,fontSize:9},lineStyle:{color:'#f58231',type:'solid',width:1}}));
return { name:'__events', type:'line', data:[], showSymbol:false, silent:true, markLine:{ symbol:'none', data } };
}
```
> 보다시피 **P2 추가 = 함수 1개 + 레지스트리 1줄 + 캐시 채우는 fetch + 토글 버튼.** 차트 본체(`trRender`)는 손대지 않는다.
### 6.4 P1 기능
```js
function trInitChart(){
const el=document.getElementById('tr-chart'); if(trChart)trChart.dispose();
trChart=echarts.init(el,null,{renderer:'canvas'});
trChart.setOption({
animation:false, grid:{left:56,right:56,top:24,bottom:96},
tooltip:{trigger:'axis',axisPointer:{type:'cross'},valueFormatter:v=>v==null?'—':(+v).toFixed(2)},
legend:{show:false}, xAxis:{type:'time'},
yAxis:[{type:'value'},{type:'value',position:'right'}],
dataZoom:[{type:'inside',filterMode:'none'},{type:'slider',filterMode:'none',height:26,bottom:44}],
toolbox:{right:12,feature:{saveAsImage:{title:'PNG'},dataView:{readOnly:true,title:'표'},restore:{title:'복원'}}},
series:[]
});
trChart.on('dataZoom', trOnZoom);
window.addEventListener('resize',()=>trChart&&trChart.resize());
}
async function trQuery(){
const tags=trState.members.map(m=>m.tag); if(!tags.length)return;
const from=document.getElementById('hf-from').value, to=document.getElementById('hf-to').value;
let interval=document.getElementById('tr-interval').value;
if(interval==='auto') interval=trAutoInterval(from,to); // P2 자동집계
let rows,tk;
if(interval==='1 minute'){ const p=new URLSearchParams(); tags.forEach(t=>p.append('tagNames',t));
if(from)p.set('from',new Date(from).toISOString()); if(to)p.set('to',new Date(to).toISOString()); p.set('limit',5000);
const d=await api('GET',`/api/history/query?${p}`); rows=d.rows; tk='recordedAt';
} else { const d=await api('POST','/api/text-to-sql/query-history-interval',
{tagNames:tags,from:from?new Date(from).toISOString():null,to:to?new Date(to).toISOString():null,interval,limit:5000});
rows=d.rows; tk='timeBucket'; }
trState.seriesData={}; for(const m of trState.members)trState.seriesData[m.tag]=[];
for(const r of rows){ const ms=new Date(r[tk]).getTime();
for(const m of trState.members){ const v=r.values?.[m.tag]; trState.seriesData[m.tag].push([ms,v==null?null:+v]); } }
if(trState.layers.limits) await trLoadLimits(); // 켜져 있으면 갱신
if(trState.layers.runstate||trState.layers.events) await trLoadEvents(from,to);
trRender();
}
// 범례표(엑셀식) + 보이는 구간 통계 칸
function trRenderLegend(){
const [t0,t1]=trVisibleWindow();
const tb=document.getElementById('tr-legend-body');
tb.innerHTML=trState.members.map((m,i)=>{
const st=trStats(trState.seriesData[m.tag],t0,t1);
return `<tr data-tag="${esc(m.tag)}" class="${m.tag===trState.selected?'tr-sel':''}" onclick="trHighlight('${esc(m.tag)}')">
<td><input type="color" value="${m.color}" onclick="event.stopPropagation()" onchange="trSetColor('${esc(m.tag)}',this.value)"></td>
<td class="tr-tag">${esc(m.tag)}</td><td>${esc(m.desc||'')}</td>
<td class="val">${st.last??'—'}</td><td class="val">${st.min??'—'}</td><td class="val">${st.max??'—'}</td>
<td class="val">${st.avg??'—'}</td><td class="val">${st.sd??'—'}</td><td>${esc(m.unit||'')}</td>
<td><select onclick="event.stopPropagation()" onchange="trSetAxis('${esc(m.tag)}',this.value)">
<option value="left" ${m.axis!=='right'?'selected':''}>좌</option><option value="right" ${m.axis==='right'?'selected':''}>우</option></select></td>
<td><input type="checkbox" checked onclick="event.stopPropagation();trToggleVis('${esc(m.tag)}',this.checked)"></td>
<td><button onclick="event.stopPropagation();trRemoveMember('${esc(m.tag)}')">×</button></td></tr>`;
}).join('');
}
function trStats(d,t0,t1){ if(!d)return{}; let mn=Infinity,mx=-Infinity,sum=0,n=0,last=null,sq=0;
for(const [ms,v] of d){ if(v==null||ms<t0||ms>t1)continue; if(v<mn)mn=v; if(v>mx)mx=v; sum+=v; n++; last=v; }
if(!n)return{}; const avg=sum/n; for(const [ms,v] of d){ if(v==null||ms<t0||ms>t1)continue; sq+=(v-avg)**2; }
return {min:mn.toFixed(2),max:mx.toFixed(2),avg:avg.toFixed(2),sd:Math.sqrt(sq/n).toFixed(2),last:last.toFixed(2)}; }
// 클릭 강조(요구②): 선 굵게 + 나머지 흐림
function trHighlight(tag){ trState.selected=(trState.selected===tag)?null:tag; trRender();
trChart.dispatchAction({type:'downplay'}); if(trState.selected)trChart.dispatchAction({type:'highlight',seriesName:trState.selected}); }
function trSetColor(tag,c){ const m=trState.members.find(x=>x.tag===tag); if(!m)return; m.color=c; trRender(); trPersist(); }
function trSetAxis(tag,a){ const m=trState.members.find(x=>x.tag===tag); if(!m)return; m.axis=a; trRender(); trPersist(); }
// 날짜 ↔ dataZoom 동기(요구③)
function trOnZoom(){ const [t0,t1]=trVisibleWindow(); trState.window={t0,t1};
if(t0&&t1){ trSetDt('from',new Date(t0)); trSetDt('to',new Date(t1)); }
trRenderLegend(); if(trState.layers.minmax)trRender(); }
function trApplyDateRange(){ const f=document.getElementById('hf-from').value,t=document.getElementById('hf-to').value;
if(f&&t)trChart.dispatchAction({type:'dataZoom',startValue:new Date(f).getTime(),endValue:new Date(t).getTime()}); }
function trVisibleWindow(){ const dz=(trChart.getOption().dataZoom||[])[0]||{}; let t0=dz.startValue,t1=dz.endValue;
if(t0==null||t1==null){ let lo=Infinity,hi=-Infinity; for(const k in trState.seriesData)for(const [ms] of trState.seriesData[k]){if(ms<lo)lo=ms;if(ms>hi)hi=ms;}
if(!isFinite(lo))return[null,null]; t0=lo+(hi-lo)*((dz.start??0)/100); t1=lo+(hi-lo)*((dz.end??100)/100);} return[t0,t1]; }
// 라이브(요구④)
function trToggleLive(){ if(trLiveTimer){trStopLive();return;} const b=document.getElementById('tr-live-btn');
b.textContent='⏸ 정지'; b.classList.add('live-on'); trState.live=true; trLiveTimer=setInterval(trLiveTick,5000); trLiveTick(); }
function trStopLive(){ clearInterval(trLiveTimer); trLiveTimer=null; trState.live=false;
const b=document.getElementById('tr-live-btn'); b.textContent='▶ 라이브'; b.classList.remove('live-on'); }
async function trLiveTick(){ const tags=trState.members.map(m=>m.tag); if(!tags.length)return;
const d=await api('GET',`/api/trend/live?tags=${encodeURIComponent(tags.join(','))}`); let ch=false;
for(const p of (d.items||[])){ const a=trState.seriesData[p.tag]; if(!a||p.value==null)continue; const ms=new Date(p.ts).getTime();
if(!a.length||a[a.length-1][0]!==ms){a.push([ms,+p.value]);ch=true;} }
if(ch){ trChart.setOption({series:trState.members.map(m=>({name:m.tag,data:trState.seriesData[m.tag]}))});
if(trState.layers.minmax)trRender(); trRenderLegend(); } }
// 레이어 토글 — 공통
function trToggleLayer(id){ trState.layers[id]=!trState.layers[id];
document.querySelector(`.tr-tog[data-layer="${id}"]`)?.classList.toggle('on',trState.layers[id]);
if(id==='limits'&&trState.layers.limits)trLoadLimits().then(trRender); else
if((id==='runstate'||id==='events')&&trState.layers[id]){const f=document.getElementById('hf-from').value,t=document.getElementById('hf-to').value;trLoadEvents(f,t).then(trRender);} else trRender(); }
```
### 6.5 P2 레이어 (데이터 로더 — 차트 본체 무수정)
```js
async function trLoadLimits(){ const tags=trState.members.map(m=>m.tag);
const d=await api('GET',`/api/trend/limits?tags=${encodeURIComponent(tags.join(','))}`);
trState.cache.limits={}; for(const l of (d.items||[]))trState.cache.limits[l.tag]=l; }
async function trLoadEvents(from,to){ const tags=trState.members.map(m=>m.tag);
const qs=`from=${from?new Date(from).toISOString():''}&to=${to?new Date(to).toISOString():''}&tags=${encodeURIComponent(tags.join(','))}`;
if(trState.layers.events){ const e=await api('GET',`/api/trend/events?${qs}`); trState.cache.events=(e.items||[]).map(x=>({ts:new Date(x.ts).getTime(),label:x.label||x.type})); }
if(trState.layers.runstate){ const r=await api('GET',`/api/trend/runbands?${qs}`); trState.cache.runbands=(r.items||[]).map(x=>({t0:new Date(x.t0).getTime(),t1:new Date(x.t1).getTime(),state:x.state})); } }
function trAutoInterval(from,to){ if(!from||!to)return '1 minute'; const h=(new Date(to)-new Date(from))/3.6e6;
return h<=6?'1 minute':h<=48?'5 minutes':h<=24*14?'1 hour':'1 day'; }
// 듀얼커서Δ: trChart.getZr().on('click', ...) 로 두 점 선택 → graphic 텍스트로 Δt/Δy/기울기 표시 (P2 추가 함수)
```
### 6.6 P3 레이어 (슬롯에 합류)
```js
// 🤖 LLM 분석 — 보이는 구간 통계+이벤트를 백엔드로 → ask_iiot_llm
async function trAnalyze(){ const [t0,t1]=trVisibleWindow();
const members=trState.members.map(m=>({tag:m.tag,unit:m.unit,...trStats(trState.seriesData[m.tag],t0,t1)}));
const body={window:{from:new Date(t0).toISOString(),to:new Date(t1).toISOString()},members,events:trState.cache.events};
document.getElementById('tr-ins-body').textContent='분석 중…';
const d=await api('POST','/api/trend/analyze',body);
document.getElementById('tr-ins-body').innerHTML=marked.parse(d.text||''); } // marked 이미 번들됨
function trToggleInsights(){ document.getElementById('tr-insights').classList.toggle('hidden'); }
// 스마트그룹 — 연관 아날로그 추천(빌더에서)
async function trBuilderRelated(){ const seed=[...trBuilderSel][0]; if(!seed)return;
const d=await api('GET',`/api/trend/related?tag=${encodeURIComponent(seed)}`);
for(const r of (d.items||[]))trBuilderSel.add(r.tagName); trRenderAnalog(); }
// 상관(X-Y) 모드 — 같은 컴포넌트에서 line↔scatter 토글
function trToggleXY(){ trState.mode=trState.mode==='xy'?'line':'xy'; trState.mode==='xy'?trRenderXY():trRender(); }
function trRenderXY(){ /* 멤버[0]=X, 멤버[1]=Y 시간조인 → scatter + 회귀선 */ }
// 공유링크 — 그룹/윈도우/레이어 상태를 URL 해시로 인코딩/복원
function trShareLink(){ const st={g:trState.group?.id,t0:trState.window.t0,t1:trState.window.t1,L:trState.layers};
location.hash='trend='+btoa(JSON.stringify(st)); navigator.clipboard.writeText(location.href); }
```
### 6.7 그룹 빌더 + paneInit
```js
let trAnalogAll=[], trBuilderSel=new Set();
async function trOpenBuilder(){ document.getElementById('tr-builder').classList.remove('hidden');
trBuilderSel=new Set(trState.members.map(m=>m.tag)); const d=await api('GET','/api/trend/analog-points'); trAnalogAll=d.items||[]; trRenderAnalog(); }
function trRenderAnalog(){ const q=(document.getElementById('tr-builder-search').value||'').toLowerCase();
document.getElementById('tr-analog-body').innerHTML=trAnalogAll.filter(p=>!q||p.tagName.toLowerCase().includes(q)||(p.description||'').toLowerCase().includes(q))
.map(p=>`<tr class="${trBuilderSel.has(p.tagName)?'tr-pick':''}" onclick="trTogglePick('${esc(p.tagName)}')">
<td>${trBuilderSel.has(p.tagName)?'✔':''}</td><td class="tr-tag">${esc(p.tagName)}</td><td>${esc(p.description||'')}</td><td>${esc(p.area||'')}</td>
<td class="val">${p.value??'—'}</td><td>${esc(p.unit||'')}</td><td class="val">${p.euLo??'—'}</td><td class="val">${p.euHi??'—'}</td></tr>`).join('');
document.getElementById('tr-builder-count').textContent=`${trBuilderSel.size}`; }
function trTogglePick(t){ trBuilderSel.has(t)?trBuilderSel.delete(t):trBuilderSel.add(t); trRenderAnalog(); }
async function trSaveGroup(){ const name=document.getElementById('tr-builder-name').value.trim(); if(!name){alert('그룹 이름');return;}
const members=[...trBuilderSel].map((t,i)=>{const ex=trState.members.find(m=>m.tag===t);return{tag:t,color:ex?.color||TR_PALETTE[i%TR_PALETTE.length],axis:ex?.axis||'left'};});
const saved=trEditingId?await api('PUT',`/api/trend/groups/${trEditingId}`,{name,members}):await api('POST','/api/trend/groups',{name,members});
trCloseBuilder(); await trLoadGroupList(); document.getElementById('tr-group-select').value=saved.id; trLoadGroup(); }
paneInit.trend=function(){ if(!trChart)trInitChart(); trLoadGroupList(); trRestoreFromHash&&trRestoreFromHash(); };
```
> `core.js dtConfirm()` 끝에 한 줄: 트렌드 탭 활성 시 `trApplyDateRange()` 호출(또는 `tr-from/tr-to`로 분리).
### 6.8 CSS — css/trend.css (핵심)
```css
.trend-timebar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.tr-quick button,.tr-tog{background:var(--s3);border:1px solid var(--bd);color:var(--t1);border-radius:var(--r);padding:4px 8px;cursor:pointer}
.tr-tog.on{background:var(--blu,#4363d8);color:#fff} #tr-live-btn.live-on{background:var(--red,#e55);color:#fff}
.trend-main{display:flex;gap:12px} .trend-chartwrap{flex:1} .trend-chart{width:100%;height:480px}
.trend-insights{width:320px} .trend-insights.hidden{display:none}
.tr-legend,.tr-analog{width:100%;border-collapse:collapse;font-size:12px}
.tr-legend td,.tr-legend th,.tr-analog td,.tr-analog th{padding:6px 8px;border-bottom:1px solid var(--bd)}
.tr-legend tr{cursor:pointer} .tr-legend tr.tr-sel{background:rgba(67,99,216,.15);font-weight:600}
.tr-legend tr:hover,.tr-analog tr:hover{background:var(--s3)} .tr-analog tr.tr-pick{background:rgba(60,180,75,.15)}
.tr-tag{font-family:var(--fm)} .val{text-align:right;font-variant-numeric:tabular-nums}
.tr-modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:50}
.tr-modal.hidden{display:none} .tr-modal-box{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);width:min(960px,93vw);max-height:86vh;display:flex;flex-direction:column}
.tr-analog-wrap{overflow:auto;flex:1} input[type=color]{width:26px;height:20px;border:none;background:none;padding:0;cursor:pointer}
```
---
## 7. ⭐ 최대/최소 (보이는 범위) — 기술 검증
**가능(확정).** 채택안 = §6.3 `layerMinMax`: `dataZoom` 이벤트(`trOnZoom`)로 보이는 `[t0,t1]`를 얻어 멤버별 min/max를 스캔 → `markPoint`에 명시 좌표로 배치 + 범례표 통계칸 동시 갱신. 줌/팬마다 재계산, 멤버별 토글 가능.
대안(간단): `markPoint{type:'max'/'min'}` + `dataZoom.filterMode:'filter'` → 보이는 점만으로 자동 재계산(라벨/토글 제약). 요구 충족엔 채택안.
---
## 8. 검증 체크리스트
빌드/스모크: `dotnet build` 0/0 · `node -c js/trend.js` · 부팅 후 `trend_group`/`v_analog_points` 생성 · `analog-points`는 숫자태그만 · 기존 history API 무변경.
P1: 빌더 클릭선택→그룹 저장/복원 · 범례 색변경 영속·행클릭 굵게+흐림 · from/to↔슬라이더 동기 · 라이브 tail/정지 · minmax 줌 재계산 · 이중축 · resize.
P2: 알람선 토글(HI/LO/SP) · 운전음영 RUN/TRIP 색 · 이벤트 세로플래그 · 통계칸(min/max/avg/σ) 윈도우 반영 · 듀얼커서Δ · 자동집계 윈도우폭별 전환.
P3: 🤖"이 구간 설명"→한국어 진단 · 연관추천 태그 추가 · 상관모드 line↔scatter · 공유링크 복원 · PNG/표 내보내기.
---
## 9. 단계별 작업 순서
**P1 (토대 — 슬롯 포함)**
1. 설치: echarts.min.js + index.html 배선
2. DDL: trend_group + v_analog_points
3. 백엔드: DTO→TrendService→TrendController→DI
4. 프론트 스캐폴드: trend.html/css/js, `trState`+`TR_LAYERS`+`trRender` 골격, paneInit
5. 그룹 빌더(아날로그 클릭)·그룹 CRUD
6. 차트 init·history 조회·`layerBaseSeries`
7. 범례표(색/강조/축/통계칸)·날짜↔dataZoom·`layerMinMax`·라이브
**P2 (분석 레이어 — 함수+토글만 추가)**
8. limits/events/runbands 엔드포인트
9. `layerLimits`/`layerRunState`/`layerEvents` + 로더 + 토글 버튼
10. 자동집계(trAutoInterval+LTTB)·듀얼커서Δ
**P3 (지능/연동 레이어)**
11. `/api/trend/analyze`(LLM)·Insights 패널
12. `/api/trend/related`(trace)·빌더 연관추천
13. 상관(X-Y)모드·공유링크·내보내기
각 번호는 독립 커밋. P1 끝나면 화면이 완성 동작하고, P2·P3는 같은 차트에 무중단 증분.
---
## 10. 잔여 / 리스크
| 항목 | 메모 |
|------|------|
| 번들 ~1MB | 필요 시 echarts 커스텀 빌드 ~400KB |
| dt 피커 id 공유 | hist와 `hf-from/to` 공유. 분리 필요 시 `tr-from/to`+`dtOpen` 일반화 |
| 운전음영 데이터 | 현재값 뷰가 아닌 `event_history_table` 상태변경/지속시간에서 RUN 구간 산출 |
| 실시간 해상도 | history 60s·tail은 수집주기 의존. stall 시 `[[memory:project_realtime_collector_stall]]` 신선도 확인 |
| LLM 호출 | `ask_iiot_llm` 위임. MCP 재시작 필요 가능. 응답 지연 대비 로딩/취소 |
| 뷰 매핑 | v_analog_points keyless → ADO/ToView. 프로젝트 raw 조회 패턴 답습 |
| 인증 | 그룹 CRUD 공개 vs KB admin 토큰 재사용 — 결정 필요(문서 탐색기 패턴 참고) |
| 대용량 | 멤버多×장기 5천행↑ → 자동집계/LTTB 필수 |
```