❯ 현재까지 작업한 것의 사용자가 해야할 일 정리해줘

● 사용자 액션 가이드 (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	단일 청크 임베딩 실패 시 전체 실패	개선 권장