From 0ccec38c186f8aab34097899d72c464806f608c6 Mon Sep 17 00:00:00 2001 From: windpacer Date: Sat, 16 May 2026 07:13:39 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20CLAUDE.md=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EC=95=95?= =?UTF-8?q?=EC=B6=95=20(963=E2=86=9281=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git 히스토리에 이미 있는 Phase 0~6 changelog, OPC UA 서버, 버그 수정 이력, 성능 분석, 구현 계획(Task 1~4) 전부 삭제. 작업 규칙 + 최신 1건(Phase 7)만 유지해 세션 컨텍스트 로드량 축소. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 917 ++++-------------------------------------------------- 1 file changed, 55 insertions(+), 862 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9bebadf..4d1c885 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,882 +7,75 @@ ## 완료된 작업 -### 기능 추가 — 로컬 LLM 채팅 + 지식 베이스(RAG) Phase 0~5 (2026-05-13) +### Phase 7 + Phase 5 후순위 일괄 구현 (2026-05-14) #### 배경 -운전원이 채팅 UI에서 자연어로 공장 상황·계기 상태·정비 이력 등을 질문하면, (a) PostgreSQL 시계열/이벤트 데이터와 (b) 관리자가 업로드한 KB 문서(Qdrant 벡터 검색)를 합성해 답하도록 통합. 별도 14번 탭 "RAG 관리"에서 관리자 비번 인증 후 문서 업로드/인덱싱/관리. +`plans/빅피클-잔여작업-코딩계획.md`의 1~6번 항목을 일괄 구현. Phase 7 옵션 4종 + Phase 5 +후순위 2종으로 채팅 UX·관리 편의·운영자 분석 능력을 강화. -설계서: `plans/LLM채팅+지식증강플랜.md` +#### 구현 내역 -#### 아키텍처 -``` -[채팅 #13] ── /api/ollama/vllm/chat/stream ──► [OllamaController] - │ SSE: message, tool_start, tool_result - │ tool_calls 루프(최대 10라운드) - ▼ - [McpClient → Python MCP] - │ - ┌───────────────────────────────┴───────────┐ - ▼ ▼ - PostgreSQL Qdrant 컬렉션 7개 - (history/realtime/event/ ├── ws-65f457145aee80b2 (코드) - tag_metadata/kb_*) ├── experion-opc-docs (R530) - └── kb_* 5개 (사용자 KB) - -[RAG 관리 #14] ── /api/kb/{auth,upload,documents,jobs,download,...} ──► - [KbAuthController + KbController + KbIngestWorker(BackgroundService)] - │ Argon2id 비번 + 60분 세션 토큰 - │ 업로드 → storage/kb/{yyyy-MM}/{uuid}.ext (SHA256) - │ 큐 폴링 2초 → parse(MCP) → embed(Ollama 768-dim) → upsert(Qdrant) - │ attempts ≥3 = failed -``` - -#### KB 데이터 모델 (PostgreSQL, 자동 마이그레이션) -- `kb_collections` (5종 시드: system_instrument / plant_operation / procedure / report / vendor_doc) -- `kb_documents` (UUID PK + collection_key FK + status: pending/parsing/embedding/indexed/failed/disabled) -- `kb_ingest_jobs` (stage: parse, attempts, finished_at) -- `kb_admin_credential` (단일 행, Argon2id 해시) -- `kb_admin_sessions` (60분 만료) - -#### Phase별 구현 - -**Phase 0 — 사전 정비** -| 파일 | 수정 내용 | -|------|----------| -| `mcp-server/worker/nl2sql_worker.py` | `time_bucket('1 min', ts)` → history_table.recorded_at 직접 SELECT, `_get_tag_metadata`도 server.py 형식과 일치 | -| `mcp-server/llm-model.json` | 실제 서빙 모델(`Qwen3.6-27B-FP8`)과 이미 동기화 — 변경 없음 | -| `prompts/plant_context.md` | 신규 (빈 골격) — 단위/계기 약어/태그 규칙/예시 자유 작성 영역 | -| `src/Web/Controllers/OllamaController.cs` | `ComposeSystemPrompt(userPrompt, toolsEnabled)` 추가 — `BaseSystemPromptKo` + plant_context.md + `ToolGuideKo` + 사용자 입력 순서로 합성 | -| `src/Web/wwwroot/js/app.js` | 영문 하드코딩된 tool description 제거 (서버에서 합성) | -| `src/Web/appsettings.json` | `PromptsDirectory: "../../prompts"` 추가 | - -**Phase 1 — 데이터 모델 & 인증** -| 파일 | 수정 내용 | -|------|----------| -| `src/Core/Domain/Entities/ExperionEntities.cs` | `KbCollection / KbDocument / KbIngestJob / KbAdminCredential / KbAdminSession` 5개 엔티티 추가 | -| `src/Infrastructure/Database/ExperionDbContext.cs` | DbSet 5개 + OnModelCreating 인덱스 + InitializeAsync에 DDL/시드 (pgcrypto 활성화 포함) | -| `src/Infrastructure/Kb/KbQdrantClient.cs` | 신규 — `EnsureCollectionAsync`, `DeleteByDocAsync`, `UpsertAsync` | -| `src/Infrastructure/Kb/KbStartupService.cs` | 신규 IHostedService — 부팅 시 활성 컬렉션 5개 Qdrant ensure | -| `src/Infrastructure/Kb/PasswordHasher.cs` | 신규 — Argon2id (4 thread, 64MB, 3 iter) + `NewSessionToken` | -| `src/Infrastructure/Kb/KbAuthService.cs` | 신규 — `EnsureCredentialAsync` (env or 자동생성), Login/Validate/Logout/ChangePassword | -| `src/Web/Controllers/KbAuthController.cs` | 신규 — `/api/kb/auth/{login\|logout\|status\|change-password}`, `X-Kb-Token` 헤더 | -| `src/Web/ExperionCrawler.csproj` | `Konscious.Security.Cryptography.Argon2 v1.3.1` 추가 | -| `src/Web/Program.cs` | KB 서비스 등록 + 부팅 시 `EnsureCredentialAsync` 호출 | - -**Phase 2 — 업로드 & 비동기 워커** -| 파일 | 수정 내용 | -|------|----------| -| `src/Infrastructure/Kb/KbStorageService.cs` | 신규 — `storage/kb/{yyyy-MM}/{uuid}.{ext}`, SHA256 스트림 계산 | -| `src/Infrastructure/Kb/KbEmbeddingClient.cs` | 신규 — Ollama nomic-embed-text(`/api/embeddings`) 768-dim | -| `src/Infrastructure/Kb/KbIngestWorker.cs` | 신규 BackgroundService — 2초 폴링, parse→embed→index 단일 패스, attempts ≥3=failed | -| `src/Web/Controllers/KbController.cs` | 신규 — upload(multipart, RequestSizeLimit 500MB), documents 페이지네이션, jobs 조회, download(Content-Disposition), delete(Qdrant+storage 동시정리), reindex, disable, bulk-disable, purge-disabled | -| `mcp-server/parsers/` | 신규 디렉터리 — `xlsx_parser`(시트+행), `pdf_parser`(페이지+표), `docx_parser`(헤딩 path), `text_parser`(md/txt) | -| `mcp-server/server.py` | `@mcp.tool() parse_document(doc_id, title, file_path, mime_type, collection_key, chunking_policy)` 추가 | -| `mcp-server/pyproject.toml` | `openpyxl / python-docx / pdfplumber` 의존성 추가 | - -**Phase 3 — 관리 탭 #14** -| 파일 | 수정 내용 | -|------|----------| -| `src/Web/wwwroot/index.html` | 사이드바 14번 탭 + `pane-kbadmin` 섹션 (로그인 카드, 필터, 문서 테이블, 업로드/비번변경 모달) | -| `src/Web/wwwroot/js/app.js` | `kbLogin / kbLogout / kbLoadCollections / kbRefresh / kbRenderDocs / kbUpload* / kbDelete / kbReindex / kbDisable / kbBulkDisable / kbPurgeDisabled / kbChangePw*` + 1.5초 진행률 폴링 + sessionStorage 토큰 | -| `src/Web/wwwroot/css/style.css` | `.kb-login-card / .kb-main / .kb-doc-tbl / .kb-status (pending/parsing/embedding/indexed/failed/disabled 색상) / .kb-modal` | - -**Phase 4 — 다운로드 & 검색** -| 파일 | 수정 내용 | -|------|----------| -| `mcp-server/server.py` | `KB_COLLECTIONS` 상수, `_search_kb_collection` (Qdrant 단일 + tags filter), `_recency_factor` (7d+10% / 30d+5% / 90d+2%), `_search_kb_raw` (다중 컬렉션 검색→가중치→since 후필터→dedup→top_k), `@mcp.tool() search_kb`, `rag_query` 확장 (`search_kb`, `kb_collections` 인자) | - -**Phase 5 — 채팅 통합** -| 파일 | 수정 내용 | -|------|----------| -| `src/Web/Controllers/OllamaController.cs` | `EmitToolStart(id, name, argsJson)` / `EmitToolResult(id, name, ok, payload)` 헬퍼. `VllmChatStreamWithTools`의 공식 tool_calls 경로 + JSON-텍스트 폴백 경로 모두 SSE 이벤트 발행 | -| `src/Web/wwwroot/js/app.js` | SSE 파서 버그 수정 (`event:` 라인 추적), `llmAppendToolCard / llmUpdateToolCard / llmRenderToolPayload / llmRenderTable / llmRenderKbHits` 추가, `llmKbDocMap` + `llmLinkKbCitations` (제목→다운로드 링크 치환), `LLM_STARTER_CHIPS` 7종 + `llmUseChip` | -| `src/Web/wwwroot/css/style.css` | `.llm-tool-cards / .llm-tool-card (spin 애니, ok/err 색상) / .llm-tool-tbl (sticky header) / .llm-kb-hits / .kb-cite-link / .llm-chip` | - -#### 주요 설계 결정 - -| 항목 | 결정 | -|------|------| -| 관리자 권한 | 비번 인증 (Argon2id), 세션 토큰 60분, `X-Kb-Token` 헤더 | -| 초기 비번 | 환경변수 `KB_ADMIN_INITIAL_PASSWORD` 우선, 없으면 부팅 시 콘솔에 16자 랜덤 출력 | -| 컬렉션 구조 | doc_type별 5개 분리 컬렉션 (마스터 시드) + 자유 태그 | -| 임베딩 모델 | 기존 `nomic-embed-text` (768-dim) — Phase 0~5는 그대로, BGE-M3 마이그레이션은 보류 | -| 청킹 정책 | xlsx 시트+행 둘 다, pdf 페이지+표 별도, docx 헤딩 path, md/txt 단순 | -| 재인덱스/삭제 | Qdrant(`doc_id` filter)와 storage 파일 동시 정리 | -| Worker 큐 처리 | parse→embed→index 단일 패스(한 잡으로 끝까지), attempts ≥3 = failed, 2초 폴링 | -| 시스템 프롬프트 | 서버에서 합성 (한글 base + plant_context.md + tool guide + 사용자 입력) | -| SSE 이벤트 | `message` / `tool_start` / `tool_result` / `done` / `error` — 클라이언트 파서가 event-type 추적 | -| KB 인용 | search_kb 결과 title↔doc_id 매핑 누적, 본문에 등장 시 다운로드 링크로 자동 치환 | -| 자동 표 렌더 | `{success, columns, data}` 또는 `data:[{...}]` 형태 감지 시 최대 50행 HTML 테이블 | - -#### API 엔드포인트 (신규) -- `POST /api/kb/auth/login` / `logout` / `change-password`, `GET /api/kb/auth/status` -- `GET /api/kb/collections` — 활성 컬렉션 + 문서/청크 카운트 -- `POST /api/kb/upload` (multipart, admin) — 즉시 doc_id 반환, 큐 적재 -- `GET /api/kb/documents?collection=&status=&q=&page=&pageSize=` -- `GET /api/kb/documents/{id}` / `DELETE /api/kb/documents/{id}` (admin) -- `POST /api/kb/documents/{id}/reindex` / `/disable` (admin) -- `POST /api/kb/documents/bulk-disable` / `/purge-disabled` (admin) -- `GET /api/kb/jobs?docId=&stage=&pendingOnly=` -- `GET /api/kb/download/{docId}` — Content-Disposition 원본 스트림 (인증 X) - -#### MCP 도구 (신규) -- `parse_document(doc_id, title, file_path, mime_type, collection_key, chunking_policy)` — KbIngestWorker 전용 -- `search_kb(query, collection_keys?, top_k=8, tags?, since?, boost_recent=True)` — 채팅 노출 -- `rag_query` 확장 — `search_kb=False`, `kb_collections=None` 옵션 - -#### 빌드 결과 -- `dotnet build` — 경고 0건, 에러 0건 -- `mcp-server` Python 6개 파일 (server.py, nl2sql_worker.py, parsers/*) syntax OK - -#### 런타임 셋업 (코드 외) -- `cd mcp-server && uv pip install -e .` — Phase 2에서 추가된 `openpyxl/python-docx/pdfplumber` 설치 -- `mcp-server` 재시작 — `parse_document`, `search_kb` 새 도구 인식 -- 앱 첫 기동 후 콘솔의 `[Kb] 관리자 초기 비밀번호 자동 생성: XXXX` 로그 → 14번 탭에서 즉시 변경 -- Qdrant 5개 컬렉션 생성 확인 — `curl http://localhost:6333/collections` - -#### 잔여 작업 -- ~~Phase 6 (보강 도구)~~ → 완료 (2026-05-14 두 섹션 아래 참조) -- Phase 7 (옵션): NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 UI -- Phase 5 후순위: 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존 -- 결정 보류: 현장 재고 데이터 출처, 임베딩 모델 BGE-M3 마이그레이션 - ---- - -### Phase 5 후속 — 진단 보고서 핫픽스 + Phase 6 첫 항목 (2026-05-14) - -#### 배경 -`plans/LLM채팅+지식증강-phase5-사용자체크리스트.md` 진단 보고서에서 도출된 6건의 코드 이슈와 Phase 6 잔여 항목 중 우선순위가 가장 높은 `run_sql` 안전 가드를 함께 처리. +| # | 항목 | 핵심 | +|---|------|------| +| 1 | 툴 카드 영구 보존 | `sess.messages[*].toolCalls[]` 저장 → `llmRenderMessages`에서 재렌더링. F5 새로고침 후에도 툴 카드 유지 | +| 2 | KB 청크 미리보기 UI | Qdrant Scroll API → `/api/kb/documents/{id}/chunks` → 모달에 청크 카드 (접기/펼침) | +| 3 | 시계열 미니 스파클라인 | `llmDetectTimeSeries` (timestamp+value 키 자동 감지) → uPlot 90px 차트가 표 위에 자동 렌더링 | +| 4 | NL2SQL 의도 라우터 | `_classify_intent` 정규식 6규칙 → `query_with_nl` 진입 시 알람/요약/태그검색/이벤트로 위임. `classify_intent` MCP 도구로도 노출 | +| 5 | 대화 요약 | `sess.summary` + `summarizedUpTo` 인덱스, `LLM_MAX_HISTORY=20` 초과 시 `/api/ollama/summarize` 호출 → systemPrompt에 누적 요약 prepend | +| 6 | 에이전트 모드 | `#llm-agent-mode` 토글 → `AgentMode` 요청 필드 → `ComposeSystemPrompt`에 `AgentModeGuideKo` (ReAct 사이클) 주입 | #### 수정 파일 -| 파일 | 수정 내용 | +| 파일 | 변경 요약 | |------|----------| -| `mcp-server/worker/nl2sql_worker.py` | (HIGH) `_list_drawings` `dict(zip(columns, row[0]))` → `[row[0] for row in rows]` 버그 수정 — 문자열이 문자 단위로 분리되어 잘못된 dict 생성되던 문제 | -| `mcp-server/worker/nl2sql_worker.py` | (MED) `_run_sql`/`_query_pv_history`/`_get_tag_metadata`/`_list_drawings`/`_query_with_nl` 5개 함수가 async 안에서 `psycopg.connect()` blocking 호출 → `_aget_db_connection()` 헬퍼(`asyncio.to_thread`)로 격리 | -| `mcp-server/worker/nl2sql_worker.py` | (Phase 6) `_validate_sql` + `_apply_sql_guards` 추가, `_run_sql`·`_query_with_nl`에 auto-LIMIT/statement_timeout 적용 | -| `mcp-server/server.py` | (Phase 6) `_validate_sql` 강화 (단어 경계 매칭으로 `updated_at` 오탐 제거, `WITH` 허용, 세미콜론 다중문장 차단, `TRUNCATE`/`COPY` 추가). `SQL_MAX_ROWS=1000`, `SQL_STATEMENT_TIMEOUT_MS=30000` 환경변수화. `_apply_sql_guards`로 LIMIT 미지정 시 서브쿼리 wrap. `_execute_sql_internal`에서 매 호출 `SET statement_timeout` 적용. `run_sql` 도구 docstring 갱신 | -| `src/Infrastructure/Kb/KbIngestWorker.cs` | (LOW) 단일 청크 임베딩 실패 시 전체 abort → skip 후 부분 인덱싱, `error_message`에 `"부분 인덱싱: N/M 청크"` 기록. 전 청크 실패 시에만 throw | -| `src/Infrastructure/Kb/KbAuthService.cs` | (LOW) 초기 비번 로그 평문 → 마스킹(앞 4자만)으로 변경, 평문은 `Console.Out`으로 1회 분리 출력 (파일 로거 미포함) | -| `src/Infrastructure/Kb/KbQdrantClient.cs` | (LOW) `new HttpClient` 직접 생성 → `IHttpClientFactory.CreateClient("KbQdrant")` | -| `src/Web/Program.cs` | (LOW) `AddHttpClient("KbQdrant")` 팩토리 등록 (BaseAddress + 30s Timeout) | -| `src/Web/Controllers/OllamaController.cs` | (LOW) `LoadPlantContext()` 매 요청 `File.ReadAllText` → mtime 기반 캐시 (lock 보호) | -| `src/Infrastructure/Database/ExperionDbContext.cs` | (HIGH) KB DDL의 `ExecuteSqlRawAsync` 사용 → `JSONB '{}'` / `TEXT[] '{}'` / 시드 JSON의 중괄호가 `String.Format` placeholder로 오인되어 부팅 실패. 별도 `NpgsqlConnection` + `NpgsqlCommand.ExecuteNonQueryAsync`로 전환 | -| `.gitignore` | KB 업로드 원본 디렉토리 `storage/` 추가 (런타임 데이터는 git 추적 제외) | +| `src/Web/wwwroot/js/app.js` | `llmRenderToolCardsHtml`(영구 렌더), `llmDetectTimeSeries`+`llmBuildSparklineHtml`+`llmMountSparkline`(uPlot), `kbShowChunks`+`kbRenderChunks`(모달), `LLM_MAX_HISTORY`+`llmEnsureSummary`+`sess.summary` 표시, `llmAgentMode`+`llmToggleAgentMode`, 툴 카드 표시 + 요약 prepend 로직 | +| `src/Web/wwwroot/index.html` | `#kb-chunk-modal` 모달, `#llm-agent-row`/`#llm-agent-mode` 토글 | +| `src/Web/wwwroot/css/style.css` | `.kb-chunk-*`(청크 카드/뱃지/locator), `.llm-sparkline-box/-chart`, `.llm-summary-card`(접기/펼침) | +| `src/Web/Controllers/OllamaController.cs` | `AgentMode` request 필드, `ComposeSystemPrompt(agentMode)`, `AgentModeGuideKo` ReAct 가이드, `POST /api/ollama/summarize` 엔드포인트, `OllamaSummarizeRequest` DTO, `ToolGuideKo`에 `classify_intent` 추가 | +| `src/Web/Controllers/KbController.cs` | `GET /api/kb/documents/{id}/chunks` (admin) — KbQdrantClient Scroll 호출 | +| `src/Infrastructure/Kb/KbQdrantClient.cs` | `GetChunksByDocIdAsync(collection, docId, limit)` — payload-only Scroll API | +| `mcp-server/server.py` | `_CLASSIFY_RULES`+`_classify_intent`+`@mcp.tool() classify_intent`, `query_with_nl` 진입부에서 라우팅 후 5개 전용 도구로 위임 (실패 시 SQL fallback) | -#### Phase 6 — `run_sql` 가드 동작 +#### 의도 라우터 규칙 -| 가드 | 동작 | +| 정규식 | 라우팅 대상 | +|--------|------------| +| `활성.*알람\|현재.*알람\|지금.*알람\|active.*alarm` | `active_alarms` | +| `트립\|trip` | `active_alarms` | +| `상태\s*보고서\|교대.*보고\|status.*report\|운전.*보고` | `generate_status_report` | +| `요약\|보고서\|리포트\|summary\|summarize\|report` | `summarize_events` | +| `태그.*찾\|tag.*찾\|찾아\s*줘\|find.*tag\|어떤.*태그` | `find_tags` | +| `이벤트.*조회\|이벤트.*목록\|event.*list\|event.*query\|로그.*조회` | `query_events` | +| (그 외) | `query_with_nl` (기본 SQL 경로) | + +스모크 테스트 10건 모두 통과 — "지금 알람 알려줘"→active_alarms / "FIC-6113.PV 값 보여줘"→query_with_nl / "안녕"→query_with_nl 등. + +#### 설계 결정 + +| 항목 | 결정 | |------|------| -| 키워드 차단 | `\b{kw}\b` 단어 경계 매칭 — `INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/GRANT/REVOKE/TRUNCATE/COPY/EXEC` | -| 시작 키워드 | `SELECT` 또는 `WITH`만 허용 (선행 `(` 허용) | -| 다중 문장 | 끝의 `;` 제외하고 `;` 포함 시 차단 | -| 자동 LIMIT | 꼬리 `LIMIT N [OFFSET M]` 미지정 시 `SELECT * FROM ({sql}) _capped LIMIT 1000` wrap | -| 타임아웃 | `SET statement_timeout = 30000` (커넥션별) | -| 환경변수 | `SQL_MAX_ROWS`, `SQL_STATEMENT_TIMEOUT_MS`로 조정 가능 | +| 툴 카드 영구화 | `assistantMsg.toolCalls`에 `{id,name,args,ok,preview,length,payload}` 누적 저장. 기존 세션에 `toolCalls` 없으면 렌더링 생략(역호환) | +| AbortError 시 | `content` 또는 `toolCalls`가 비어있지 않으면 메시지 유지 (도구만 호출하고 중단된 경우도 보존) | +| 시계열 감지 | `timestamp/recorded_at/ts/time/datetime` + `value/pv/val/fieldvalue/sp/op` 페어. 3건 이상이어야 차트 생성. uPlot 미로딩 시 렌더 생략 | +| 스파클라인 부착 | innerHTML로 컨테이너만 만든 후 `requestAnimationFrame`에서 uPlot 생성 (DOM 마운트 후 실행 보장) | +| 요약 임계 | `LLM_MAX_HISTORY=20` 초과 시 오래된 절반을 요약. 누적 요약은 이전 요약을 system 메시지로 함께 전송 | +| 요약 송신 | sess.summary가 있으면 systemPrompt 맨 앞에 `[이전 대화 요약]` 블록 prepend. 사용자에게는 접이식 카드로 표시 | +| 의도 라우터 fallback | 라우팅 시도 실패(예외) 시 조용히 SQL 경로로 fallback | +| 에이전트 모드 조건부 표시 | `llmType==='vllm' && llmUseTools` 일 때만 토글 노출. localStorage 영속 | +| 청크 미리보기 권한 | admin 토큰 필요. payload만 조회(vector 제외), 최대 500개/문서 | -응답 JSON에 `row_limit` 필드 추가 — 클라이언트가 잘림 여부 판단 가능. - -#### 빌드/검증 결과 +#### 빌드/검증 - `dotnet build src/Web/ExperionCrawler.csproj` — 경고 0건, **에러 0건** - `python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py` — OK -- `_apply_sql_guards` / `_validate_sql` 스모크 테스트 통과 - - `SELECT *` → wrap 적용, `LIMIT 10` / `LIMIT 10 OFFSET 5` → 그대로 - - `SELECT 1; DROP TABLE x` → 차단, `WITH t AS ...` → 허용 - - `SELECT updated_at` → 허용 (단어 경계 매칭으로 `UPDATE` 키워드 오탐 제거) - -#### 잔여 (이 커밋 이후) -- Phase 6 나머지: `query_events`, `summarize_events`, `active_alarms`, `find_tags`, `generate_status_report` -- Phase 7 옵션, Phase 5 후순위는 변동 없음 -- `appsettings.json`의 `AdminInitialPassword: "admin"`은 사용자 지시로 유지 (운영 전 제거 권장) - ---- - -### Phase 6 보강 도구 5종 추가 (2026-05-14) - -#### 배경 -운전원이 채팅으로 "지금 활성 알람 뭐 있어?", "오늘 6-1차 area 이벤트 요약해줘", "교대 시작 보고서 만들어줘" 등을 묻는 경우 단순 SQL 조회만으로는 부족. 이벤트 중심 도구와 LLM 요약/보고서 도구가 필요. - -#### 신규 MCP 도구 - -| 도구 | 입력 | 동작 | -|------|------|------| -| `find_tags(query, area?, top_k=20)` | 자연어 일부 | `v_tag_summary` 뷰에서 `base_tag` 또는 `description` ILIKE 매칭, 옵션 area 필터. PV/SP/OP/설명/area 함께 반환 | -| `query_events(tag_name?, event_type?, area?, since?, until?, limit=100)` | 필터 조건 | `event_history_table`에서 시간/태그/타입/area 필터로 조회. since/until 미지정 시 최근 24시간 | -| `active_alarms(area?, limit=100)` | area 옵션 | `DISTINCT ON (tagname)` + `WHERE event_type IN ('ALARM','TRIP')`로 태그별 최신 이벤트가 알람/트립인 것만 | -| `summarize_events(since?, area?, event_type?, max_events=200, focus?)` | 범위 + 강조점 | `query_events` 결과를 LLM에 넣어 한국어 6~10줄 구조화 요약 (현황/알람/패턴/권고) + 통계 | -| `generate_status_report(area?, hours=24)` | area + 윈도우 | 활성알람 + 최근 이벤트 통계 + 표본을 LLM에 넘겨 교대 보고서 형식(요약/알람/이벤트분석/권고) 마크다운 생성 | - -모두 `SET statement_timeout = 30000` 적용, prepared statement(파라미터 바인딩)으로 SQL 인젝션 방지. - -#### 수정 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `mcp-server/server.py` | "Phase 6 보강 도구" 섹션 추가 — 5개 `@mcp.tool()` 함수 + `_VALID_EVENT_TYPES` 상수. `_DB_SCHEMA` 컨텍스트에 `event_history_table` 정의 추가 (NL2SQL이 인지하도록) | -| `src/Web/Controllers/OllamaController.cs` | `ToolGuideKo` 갱신 — `find_tags / query_events / active_alarms / summarize_events / generate_status_report / search_kb` 항목 추가, event_type 5종 명시, `run_sql` 자동 가드 안내 | - -#### 설계 결정 - -| 항목 | 결정 | -|------|------| -| 알람 정의 | "활성 알람 = 태그의 최신 이벤트가 ALARM 또는 TRIP" — NORMAL이 들어오면 자동 해제. 최근 30일 윈도우로 DISTINCT ON 처리 | -| 이벤트 타입 | `DigitalEventDetectorService.DetermineEventType` 그대로 — TRIP/ALARM/NORMAL/RUN/CHANGE 5종 | -| LLM 모델 | `VLLM_MODEL`(현재 Qwen3.6-27B-FP8) 공용. summarize: max_tokens 1200, report: 2048 | -| 보고서 윈도우 | 1~168시간(최대 7일), 기본 24시간 | -| 도구 노출 | 클라이언트가 `McpService.ListToolsAsync`로 동적 수집 → 새 도구는 mcp-server 재시작만으로 자동 등록 | -| 시스템 프롬프트 | `ToolGuideKo`에 도구 + event_type 5종 명시해 LLM이 적절한 도구를 선택하도록 유도 | - -#### 빌드/검증 결과 -- `dotnet build`: 경고 0건, **에러 0건** -- `python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py`: OK -- `import server` 후 5개 도구 모두 attribute로 노출 확인 +- `python3 -c "import server"` — 9개 도구 모두 attribute로 노출 확인 +- `node -c src/Web/wwwroot/js/app.js` — syntax OK +- 의도 분류기 10/10 통과 #### 런타임 셋업 -- `mcp-server` 재시작 필요 — 5개 신규 도구 인식 -- 클라이언트(채팅 #13)는 도구 토글 ON 상태에서 자동으로 새 도구 함수 시그니처 노출 +- `mcp-server` 재시작 — `classify_intent` 신규 도구 인식 +- 브라우저 캐시 무효화 (Ctrl+F5) — 신규 JS/CSS 적용 +- 사용자 첫 진입 시 에이전트 모드 토글은 OFF (옵트인) -#### 잔여 (이 커밋 이후) -- Phase 7 (옵션): NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 UI -- Phase 5 후순위: 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존 -- 결정 보류: 현장 재고 데이터 출처, 임베딩 모델 BGE-M3 마이그레이션 - -→ Phase 6 모두 완료. CLAUDE.md 첫 잔여 작업 항목에서 Phase 6 줄 삭제 가능. - ---- - -### 기능 추가 — OPC UA 서버 기능 (2026-04-15) - -#### 배경 -ExperionCrawler가 OPC UA 클라이언트 역할만 했으나, 외부 OPC UA 클라이언트(SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽을 수 있도록 OPC UA 서버 기능 추가. - -#### 아키텍처 -``` -[Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템] - │ - (OPC UA Server) - │ - [PostgreSQL DB] -``` - -#### 주소 공간 구조 -``` -Root/Objects/ExperionCrawler - ├── ServerInfo/Status, PointCount, LastUpdateTime - └── Realtime/, , … (ns=2;s=tag_{tagname}) -``` - -#### 수정/추가 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `src/Web/ExperionCrawler.csproj` | `OPCFoundation.NetStandard.Opc.Ua.Server v1.5.378.134` 패키지 추가 | -| `src/Web/appsettings.json` | `OpcUaServer` 섹션 추가 (Port:4841, EnableSecurity:false, AllowAnonymous:true) | -| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionOpcServerService` 인터페이스, `OpcServerStatus` record, `GetRealtimeNodeDataTypesAsync()` 추가 | -| `src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs` | 신규 — `CustomNodeManager2` 상속, 주소 공간 관리 (`CreateAddressSpace`, `RebuildAddressSpace`, `UpdateNodeValue`) | -| `src/Infrastructure/OpcUa/ExperionOpcServerService.cs` | 신규 — `ExperionStandardServer` + `ExperionOpcServerService` (`IHostedService` + `IExperionOpcServerService`) | -| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `_pointCache` (nodeId→RealtimePoint) 추가; `FlushPendingAsync`에서 OPC 서버 노드 값 lazy 갱신 | -| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetRealtimeNodeDataTypesAsync()` — realtime_table × node_map_master 조인 | -| `src/Web/Controllers/ExperionControllers.cs` | `ExperionOpcServerController` 추가 (start/stop/status/rebuild) | -| `src/Web/Program.cs` | `ExperionOpcServerService` Singleton+HostedService 등록 | -| `src/Web/wwwroot/index.html` | 08 OPC UA 서버 탭 + pane-opcsvr 섹션 추가 | -| `src/Web/wwwroot/js/app.js` | `srvLoad/Start/Stop/Rebuild/_srvRender/_srvStartPoll/_srvStopPoll` 구현 | -| `src/Web/wwwroot/css/style.css` | `.srv-status-card`, `.srv-meta`, `.dot.grn` 스타일 추가 | - -#### 주요 설계 결정 - -| 항목 | 결정 | -|------|------| -| 인증서 | 기존 `pki/own/certs/{hostname}.pfx` 재사용 (`ApplicationType.ClientAndServer`) | -| 포트 | 기본 4841 (4840은 Experion HS R530이 사용 가능) | -| 보안 | 기본 None (appsettings.json에서 변경 가능) | -| 자동 재시작 | `opcserver_autostart.json` 플래그 파일 패턴 (RealtimeService와 동일) | -| 순환 참조 | `IServiceProvider` lazy resolve — `_opcServer ??= _sp.GetService()` | -| FlushLoop 연동 | 500ms 배치 DB 업데이트 후 → OPC 서버 노드 값도 동시 갱신 (DB 폴링 없음) | - -#### API 엔드포인트 -- `GET /api/opcserver/status` — 상태 조회 (running, clientCount, nodeCount, endpointUrl, startedAt) -- `POST /api/opcserver/start` — 서버 시작 -- `POST /api/opcserver/stop` — 서버 중지 -- `POST /api/opcserver/rebuild` — 주소 공간 재구성 - -#### 빌드 결과 -- 경고 11건 (기존 8건 + OPC SDK Server Start/Stop deprecated 3건), **에러 0건** — 빌드 성공 - -#### OPC UA 서버가 노출하는 데이터 - -**데이터 출처**: `realtime_table`에 등록된 포인트 전체 (포인트빌더에서 빌드/수동 추가한 포인트) - -**주소 공간 구조** -``` -Root/Objects/ExperionCrawler - ├── ServerInfo/ - │ ├── Status (String) — "Running" / "Stopped" - │ ├── PointCount (Int32) — 구독 중인 포인트 수 - │ └── LastUpdateTime (DateTime) — 마지막 값 갱신 시각 - └── Realtime/ - ├── ns=2;s=tag_FIC101_PV - ├── - └── … -``` - -**NodeId 명명 규칙**: `ns=2;s=tag_{tagname}` - -**DataType 결정**: `realtime_table` × `node_map_master` 조인 -- Double/Float/Int32/Int64/Boolean/DateTime → 해당 OPC UA 타입 -- 기타/NULL → String (fallback) - -**접근 제한**: 읽기 전용 (`AccessLevel = CurrentRead`), `Historizing = false` - -**갱신 주기**: Experion HS R530 → FlushLoop 500ms 배치 → DB + OPC 서버 노드 동시 갱신 - ---- - -### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15) - -#### 증상 -히스토리 스냅샷 1회 저장마다 터미널에 로그 2줄 출력: -``` -[ExperionDb] history 스냅샷: 1752건 @ 01:14:18 -[HistoryService] 스냅샷 저장: 1752건 -``` - -#### 원인 -DB 저장 완료 후 `ExperionDbService`에서 `LogInformation`, 호출자 `ExperionHistoryService`에서도 `LogInformation`. 저장은 1회이나 로그가 2줄. - -#### 수정 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `src/Infrastructure/Database/ExperionDbContext.cs` | `SnapshotToHistoryAsync()` 내부 로그를 `LogInformation` → `LogDebug`로 변경 | - -#### 결과 -운영 로그(`Information` 레벨)에서 `[HistoryService] 스냅샷 저장: N건` 1줄만 출력. - ---- - -### 버그 수정 — Ctrl+C 종료 시 자동재시작 플래그 삭제 오류 (2026-04-15) - -#### 증상 -Ctrl+C로 앱 종료 시 `realtime_autostart.json` 플래그 파일이 삭제되어, 재기동 후 자동 구독 시작이 동작하지 않음. - -#### 원인 -`IHostedService.StopAsync(CancellationToken)` (앱 종료 훅)이 UI 수동 중지 메서드인 `StopAsync()`를 그대로 호출. `StopAsync()`는 플래그 파일을 삭제하므로 앱 종료와 수동 중지를 구분하지 못했음. - -#### 수정 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `IHostedService.StopAsync(CancellationToken)` 분리 — `_cts.Cancel()` + 태스크 대기만 수행, 플래그 파일 삭제 없음 | - -#### 동작 구분 - -| 종료 방식 | 플래그 파일 | -|----------|------------| -| Ctrl+C (앱 종료) | **유지** → 재기동 시 자동 구독 시작 | -| UI 중지 버튼 | **삭제** → 재기동 후 자동 시작 없음 | - ---- - -### 버그 수정 — 이력 조회 중복 키 예외 (2026-04-15) - -#### 증상 -이력 조회 시 서버 500 에러: -``` -System.ArgumentException: An item with the same key has already been added. -Key: p-6102.hzset.fieldvalue -at ExperionDbService.QueryHistoryAsync ... line 342 -``` - -#### 원인 -`history_table`에 동일 `recorded_at` + 동일 `tagname` 조합이 중복 저장된 행 존재. `.ToDictionary(r => r.TagName, r => r.Value)` 호출 시 중복 키로 예외 발생. - -#### 수정 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `src/Infrastructure/Database/ExperionDbContext.cs` | `TagName` 기준 `GroupBy` 추가 → 중복 시 `.Last().Value` 사용 | - ---- - -### 기능 추가 — 이력 조회 날짜/시간 팝업 피커 (2026-04-15) - -#### 배경 -- `datetime-local` 입력이 Windows 브라우저 로케일에 따라 AM/PM 12시간제로 표시됨 -- 서버(Ubuntu UTC) / 브라우저(Windows KST) 시간대 차이로 인한 표시 혼란 - -#### 설계 -- `datetime-local` 입력 제거 → 클릭 시 커스텀 달력+시간 팝업 오픈 -- 달력: 월 이동 가능, 오늘 날짜 amber 강조, 선택일 반전 표시 -- 시간: 24시간제, `−`/`+` 버튼 또는 직접 입력 (0–23시, 0–59분) -- 확인 시 `YYYY-MM-DD HH:MM` 형식으로 필드 표시 -- hidden input에 로컬 시간 문자열 저장 → `new Date(...).toISOString()`으로 KST→UTC 변환 후 서버 전송 (기존 로직 유지) - -#### 수정 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `src/Web/wwwroot/index.html` | `datetime-local` 2개 → `.dt-display` + `hidden input` 교체; 팝업 HTML(`#dt-popup`, `#dt-overlay`) 추가 | -| `src/Web/wwwroot/css/style.css` | `.dt-popup`, `.dt-cal-grid`, `.dt-day`, `.dt-time-row` 등 피커 전용 다크 테마 스타일 추가; 기존 `datetime-local` AM/PM 숨김 CSS 제거 | -| `src/Web/wwwroot/js/app.js` | `dtOpen()`, `dtRenderCal()`, `dtSelectDay()`, `dtPrevMonth()`, `dtNextMonth()`, `dtAdjTime()`, `dtClampTime()`, `dtConfirm()`, `dtClear()`, `dtClose()` 구현; `histReset()`에서 `dtClearField()` 호출로 표시 텍스트 초기화 | - -#### 빌드 결과 -- 경고 8건 (기존 동일), **에러 0건** — 빌드 성공 - ---- - -### 버그 수정 — 단일 태그 읽기 성공/실패 판정 오류 (2026-04-15) - -#### 증상 -서버접속테스트 페이지에서 단일 태그 읽기 시, OPC UA 서버가 `BadNodeIdUnknown(0x80340000)` 등 에러 상태 코드를 반환해도 "✅ 읽기 성공"으로 표시되는 버그. - -#### 원인 -`ExperionOpcClient.cs`의 `ReadTagsAsync` 내부에서 `StatusCode` 값과 무관하게 `Success = true`를 하드코딩해서 `ExperionReadResult`를 생성했음. - -#### 수정 파일 - -| 파일 | 수정 내용 | -|------|----------| -| `src/Infrastructure/OpcUa/ExperionOpcClient.cs` | `StatusCode.IsGood()` 결과를 `Success` 플래그로 사용. Bad이면 `Success=false`, `Value=null`, `Error`에 상태 코드 메시지 설정 | - -#### 결과 -`BadNodeIdUnknown` 등 Bad 상태 코드 수신 시 → ❌ 읽기 실패로 정상 표시 - -#### 빌드 결과 (경고 상세) -경고 8건, **에러 0건** — 빌드 성공 - -| # | 파일 | 내용 | -|---|------|------| -| 1 | `ExperionOpcClient.cs:108` | `Session.Create()` → `ISessionFactory.CreateAsync` 사용 권장 | -| 2 | `ExperionRealtimeService.cs:161` | `Subscription.ApplyChanges()` → `ApplyChangesAsync()` 사용 권장 | -| 3 | `ExperionRealtimeService.cs:168` | 동일 | -| 4 | `ExperionRealtimeService.cs:277` | `Subscription.Create()` → `CreateAsync()` 사용 권장 | -| 5 | `ExperionRealtimeService.cs:346` | `Subscription.Delete()` → `DeleteAsync()` 사용 권장 | -| 6 | `ExperionRealtimeService.cs:424` | `Session.Create()` → `ISessionFactory.CreateAsync` 사용 권장 | -| 7–8 | (위 항목 중 중복 카운트) | — | - -전부 OPC UA SDK가 동기 메서드를 `[Obsolete]`로 표시하고 비동기 버전을 권장하는 경고. 기능상 문제 없음. - ---- - -### 노드맵 대시보드 구현 (2026-04-14) - -node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다. - -#### 수정된 파일 - -| 파일 | 내용 | -|------|------| -| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService`에 `GetMasterStatsAsync()` / `QueryMasterAsync()` 추가, `NodeMapStats` / `NodeMapQueryResult` record 추가 | -| `src/Infrastructure/Database/ExperionDbContext.cs` | `ExperionDbService`에 두 메서드 구현 (통계·필터 조회, 페이지네이션) | -| `src/Web/Controllers/ExperionControllers.cs` | `ExperionNodeMapController` 추가 (`GET /api/nodemap/stats`, `GET /api/nodemap/query`) | -| `src/Web/wwwroot/index.html` | 사이드바 05번 탭 추가, `#pane-nm-dash` 섹션 추가 (통계 카드·필터폼·페이지네이션·테이블) | -| `src/Web/wwwroot/js/app.js` | `nmLoad()` / `nmQuery()` / `nmPrev()` / `nmNext()` / `nmReset()` 구현, 탭 클릭 핸들러에 `nmLoad()` 호출 추가 | -| `src/Web/wwwroot/css/style.css` | `.nm-stat-row`, `.nm-cls`, `.nm-dtype`, `.pg`, `.btn-sm` 등 대시보드 전용 스타일 추가 | - -#### 빌드 결과 -- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공 - -#### 주의 사항 -- 인증서 관련 코드(`ExperionCertificateService.cs`, 인증서 컨트롤러)는 일절 수정하지 않음 - ---- - -### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14) - - -노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다. - -#### 수정된 파일 - -| 파일 | 내용 | -|------|------| -| `src/Core/Application/Interfaces/IExperionServices.cs` | `GetNameListAsync()` 추가; `QueryMasterAsync` 파라미터 `string? name` → `IEnumerable? names` | -| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetNameListAsync()` 구현 (distinct + 오름차순 정렬); `QueryMasterAsync`에서 `nameList.Contains(x.Name)` → EF가 `WHERE name IN (...)` SQL 생성 | -| `src/Web/Controllers/ExperionControllers.cs` | `GET /api/nodemap/names` 엔드포인트 추가; `Query` 액션 파라미터 `string? name` → `List? names` (ASP.NET Core가 `?names=A&names=B` 자동 바인딩) | -| `src/Web/wwwroot/index.html` | "이름 검색" 텍스트 입력 제거 → `nf-name-1` ~ `nf-name-4` 4개 `