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:
@@ -1,15 +0,0 @@
|
||||
파워포인트 파일 작성, 아래 요구사항에 따라서
|
||||
1) AX 도입의 필연성 및 효율성 (1 페이지)
|
||||
2) AX화 를 위해서 전산팀(소프트웨어 개발 부서)외의 현업 부서에서 해야할 사전 준비 항목 서술
|
||||
- 신규 프로젝트 시 ,
|
||||
- DXF, PDF GRAPH화를 위한 캐드 도면 작성 규칙
|
||||
- 문서 작성 규칙
|
||||
- 기존 프로젝트 RAG화 방안
|
||||
- 좋은 방법 제안해줘
|
||||
- 현실적인 OCR 등의 문제(기존 도면 및 문서의 표준 PDF, DXF 화 불가능시)를 감안
|
||||
|
||||
- 다른 관점에서도 제안
|
||||
3) 현재 ExperionCrawler 소개
|
||||
4) 위의 AX화를 위한 문제점을 ExperionCrawler에서 어떻게 보완하고 있는가 설명
|
||||
|
||||
5) 너의 의견 및 제안 환영함!!!
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 단일 청크 임베딩 실패 시 전체 실패 개선 권장
|
||||
@@ -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 제한** — 운영 형태 확정 후
|
||||
@@ -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
@@ -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 풀구현을 기다리지 않아도 됨.
|
||||
@@ -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
@@ -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 표시
|
||||
@@ -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개 태그 선택 가능
|
||||
@@ -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 에 정보 없슴)
|
||||
@@ -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 커밋 완료 |
|
||||
@@ -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
|
||||
|
||||
### 발견된 이슈 누적
|
||||
| # | 파일 | 심각도 | 내용 |
|
||||
|---|------|--------|------|
|
||||
```
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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 오버헤드 제거)
|
||||
@@ -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. 태그 선택 시 페이지 반응성 확인
|
||||
@@ -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 배포
|
||||
@@ -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 |
|
||||
@@ -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화를 위한 걸 기본으로 설계되어야 한다.
|
||||
@@ -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"` 직렬화 정상 작동 여부
|
||||
@@ -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 엔드포인트는 변경되지 않도록 유지
|
||||
@@ -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로 한계 도달 시)
|
||||
|
||||
---
|
||||
|
||||
## 본 문서 갱신 규칙
|
||||
|
||||
이 문서는 결정이 진행됨에 따라 사용자가 직접 갱신합니다.
|
||||
- 결정 완료 시: 해당 섹션에 "결정 결과", "결정자", "결정일" 추가
|
||||
- 보류 유지: "재검토 예정일" 추가
|
||||
- 옵션 변경: 새 옵션 행 추가 + 이전 결정 사유 보존
|
||||
@@ -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) 끝나면 끝났다고 보고
|
||||
@@ -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분 안에 통과 여부 판정 가능. 시간 낭비 방지.
|
||||
@@ -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)
|
||||
@@ -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 서빙. 한 답변에 토큰 4–6k 정도가 안정 구간. 따라서:
|
||||
|
||||
- **검색기가 똑똑할수록 LLM 부담이 줄어든다** — 청크 100개 던지고 LLM에게 정리시키면 토큰·지연 폭증. 청크 5–10개 + **관계 그래프 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`)
|
||||
- 그래프 1–3 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 (보통 115–125 범위)
|
||||
- 알람 상한: 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?)` | 1–3 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 A–B는 1–2일, 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는 별도 트랙. 채팅은 두 채널 결과를 모두 본다.
|
||||
@@ -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 기준 (단위 확인 필요)
|
||||
@@ -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
@@ -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차
|
||||
1389
plans/채팅-페이지-플랜.md
1389
plans/채팅-페이지-플랜.md
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
@@ -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`)
|
||||
@@ -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개 이상 시)
|
||||
@@ -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 |
|
||||
@@ -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 필수 |
|
||||
```
|
||||
Reference in New Issue
Block a user