진단 보고서(plans/...phase5-사용자체크리스트.md) 기반 7건 코드 이슈
수정 + Phase 6 잔여 항목 중 최우선인 run_sql 가드 구현.
핫픽스:
- nl2sql_worker.py: _list_drawings 파싱 버그(문자열 분리) HIGH
- nl2sql_worker.py: 5개 async 함수 blocking DB 연결 → to_thread MED
- ExperionDbContext.cs: KB DDL의 {} 문자가 String.Format placeholder로
오인되어 부팅 실패 → 별도 NpgsqlCommand 사용 HIGH
- KbIngestWorker: 단일 청크 임베딩 실패 시 전체 abort → 부분 인덱싱 LOW
- KbAuthService: 초기 비번 로그 평문 → 마스킹 + 콘솔 분리 출력 LOW
- KbQdrantClient: new HttpClient → IHttpClientFactory LOW
- OllamaController: plant_context.md 매 요청 파일 읽기 → mtime 캐시 LOW
Phase 6 — run_sql 가드:
- _validate_sql 강화: \b 단어 경계로 updated_at 오탐 제거, WITH 허용,
TRUNCATE/COPY 추가, 다중 세미콜론 차단
- _apply_sql_guards: LIMIT 미지정 시 SELECT * FROM (...) _capped LIMIT 1000
- _execute_sql_internal: 매 호출 SET statement_timeout = 30000
- SQL_MAX_ROWS / SQL_STATEMENT_TIMEOUT_MS 환경변수화
- 응답 JSON에 row_limit 필드 추가
- nl2sql_worker.py의 _run_sql / _query_with_nl에도 동일 적용
기타:
- .gitignore: storage/ 추가 (KB 업로드 원본 디렉토리)
- opencode.json: 모델 항목을 실제 서빙 모델(Qwen3.6-27B-FP8 / 256K)로 동기화
검증:
- dotnet build: 경고 0건, 에러 0건
- python3 -m py_compile: OK
- _apply_sql_guards / _validate_sql 스모크 테스트 통과
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
❯ 현재까지 작업한 것의 사용자가 해야할 일 정리해줘
● 사용자 액션 가이드 (Phase 0~5 구현물 사용 절차)
- 사전 환경 셋업 (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.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
포함되어야 함
-
관리자 첫 로그인 + 비번 변경 (필수)
-
브라우저 → http://localhost:5000 → 사이드바 14 RAG 관리 클릭
-
콘솔에서 받은 초기 비번 입력 → 로그인
-
상단 [비밀번호 변경] → 새 비번 (6자 이상) 설정 → 자동 로그아웃
-
새 비번으로 재로그인
- (선택) 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 표시)
- 예시 질문 → 도구 매핑
수정 후 앱 재기동 없이 즉시 반영됩니다 (요청 시마다 파일 읽음).
-
KB 문서 업로드 워크플로우
-
14번 탭 → [📁 파일 업로드] 클릭
-
컬렉션 선택 (5종 중 1개): - system_instrument — 계기 datasheet, P&ID 사양서 - plant_operation — 재고, 생산현황, 정비이력, 교대일지 - procedure — SOP, 정비 절차, 알람 대응 매뉴얼 - report — 일/주/월 보고서 - vendor_doc — 벤더 카탈로그, 매뉴얼
-
제목(기본: 파일명) / 태그(콤마 구분, 예: unit-a, P-6201) 입력
-
파일 선택 → [업로드]
-
목록에 새 행이 pending 상태로 등장 → 1.5초마다 자동 갱신 → parsing → embedding → indexed ✓
-
실패(failed) 시 해당 행의 에러 메시지 확인, [↻] 재인덱스 가능
지원 형식: .xlsx / .xlsm / .pdf / .docx / .md / .txt
-
채팅에서 KB 활용
-
사이드바 13 로컬 LLM 채팅 클릭
-
LLM 종류 = vLLM 선택 (tool 사용은 vLLM 전용)
-
모델 = Qwen3.6-27B-FP8 선택
-
MCP 도구 ✓ 체크박스 켜기
-
welcome 화면의 추천 칩 중 하나 클릭 (예: "P-6201 펌프의 정비 이력") → 자동으로 입력창에 채워짐 → Enter
-
진행 상황 가시화: - 회색 spinning 카드: 툴 호출 시작 - 녹색 ✓: 성공 + 결과 (클릭하면 전체 펼침, 표/KB hits 자동 렌더) - 빨간 ✗: 실패 + 에러 메시지
-
KB에서 가져온 문서 제목이 답변 본문에 점선 밑줄 링크로 자동 표시됨 → 클릭하면 원본 파일 다운로드
- 검증 (옵션)
항목: 작은 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'
- 운영 팁
- 재인덱스: 청킹 정책을 바꿨거나 파일 손상 시 → 행의 [↻] 버튼
- 임시 숨김: 잘못 올렸지만 보관은 유지 → [🚫] (비활성화, 검색 결과에서 제외)
- 영구 삭제: [✖] 또는 하단 [비활성화 영구삭제(90일 이상)]
- 동일 제목 일괄 비활성화: 재업로드 후 구버전 한 번에 비활성화 — 하단 [🚫 동일 제목 일괄 비활성화]
- 로그 위치: mcp-server/logs/, dotnet 콘솔 — [Kb] [Kb][Worker] [Kb][Qdrant] 접두사로 grep
- 알려진 제약 / 다음 단계
- 시계열 시각화는 표만 자동 렌더됨 (스파클라인 보류)
- 툴 실행 카드는 스트리밍 중에만 보임 — 메시지 영구 보존은 Phase 6 이후
- 이벤트/알람 도구 (active_alarms 등), find_tags, generate_status_report 매크로는 Phase 6에서 추가 예정 — 현재 추천 칩 중 일부는 모델이 직접 SQL을 짜서 답변
- run_sql 안전장치 (자동 LIMIT, statement_timeout)도 Phase 6
막힘이 있는 단계가 있으면 어디서 멈췄는지 알려주시면 함께 보겠습니다.
- 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(), 0, 0) 호출 후 sha.Hash를 참조함. Q1 통과 — 이미 올바르게 구현됨. 보고서에서 제거.
- 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 통과 — 보고서에서 제거.
- 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 강등
- KbQdrantClient — HttpClient 직접 생성 (LOW) 문제: KbQdrantClient.cs:21 — new HttpClient 직접 생성. IHttpClientFactory 미사용.
영향: Singleton 등록이므로 단일 인스턴스에서는 소켓 고갈 없음. 하지만 명시적 Dispose가 없어 이론적 리소스 누수. 프로세스 종료 시 정리됨.
수정: Program.cs에서 AddHttpClient("KbQdrant") 팩토리 등록 후 KbQdrantClient에 주입
- 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 통과 — 유지
- 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)으로 변경
- 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 통과 — 유지
- 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]로 변경
- 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 강등
- OllamaController — plant_context.md 매 요청 파일 읽기 (LOW) 문제: LoadPlantContext()가 ComposeSystemPrompt() 호출마다 File.ReadAllText() 실행.
근거: OllamaController.cs:76-89
영향: 고빈도 채팅 시 불필요한 디스크 I/O. 동작에는 영향 없음.
수정: Lazy 또는 MemoryCache로 캐싱 (파일 변경 감지 옵션)
- KbAuthService.EnsureCredentialAsync — 자동 생성 비밀번호 로깅 (LOW) 문제: 자동 생성된 초기 비밀번호가 _logger.LogWarning()로 평문 로깅된다. 로그 파일이 적절히 보호되지 않은 환경에서는 비밀번호 노출 위험이 있다.
근거: KbAuthService.cs:64 — _logger.LogWarning("[Kb] 관리자 초기 비밀번호 자동 생성: {Pw} ← 즉시 변경하세요", initial)
교차 검증:
Q3: 의도적 설계인가? → 환경변수가 설정되지 않은 경우 사용자에게 비밀번호를 전달할 방법이 로그밖에 없으므로 의도적임. Q4: 실제 시나리오? → 로그 파일이 여러 사용자에게 노출되는 환경에서만 문제. Q3/Q4 → LOW 강등
- KbAuthService — 자동 생성 비밀번호 평문 로깅 (LOW) 문제: 자동 생성 비밀번호가 로그에 평문으로 기록됨.
근거: KbAuthService.cs:64
영향: 로그 파일 접근 권한이 느슨한 환경에서 비밀번호 노출. 한 번만 기록되며 즉시 변경 요구.
수정: 부분 마스킹({Pw[:4]}****) 또는 콘솔 출력 전용
- KbIngestWorker — 임베딩 실패 시 전체 실패 (LOW) 문제: 단일 청크의 임베딩 실패(vec == null) 시 전체 문서 인덱싱이 실패한다. 부분 실패 처리가 없어 일부 청크만 문제가 있어도 전체가 재시도된다.
근거: KbIngestWorker.cs:123 — if (vec == null) throw new Exception("임베딩 실패(부분)")
교차 검증:
Q3: 의도적 설계인가? → 임베딩 모델(Ollama)이 응답하지 않는 상태에서는 부분 인덱싱보다 전체 실패가 더 안전한 선택. Q4: 재시도 로직이 attempts 기반이므로 3회 시도 후 failed 상태로 전환됨. Q3/Q4 → LOW 강등
- 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 단일 청크 임베딩 실패 시 전체 실패 개선 권장