From 5a9d60e8a80088dec721166e915e6275dcdec276 Mon Sep 17 00:00:00 2001 From: windpacer Date: Thu, 14 May 2026 05:18:06 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20Phase=205=20=EC=A7=84=EB=8B=A8=20?= =?UTF-8?q?=ED=95=AB=ED=94=BD=EC=8A=A4=20+=20Phase=206=20run=5Fsql=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진단 보고서(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 --- .gitignore | 3 + CLAUDE.md | 51 ++++- mcp-server/server.py | 51 ++++- mcp-server/worker/nl2sql_worker.py | 80 ++++++- opencode.json | 6 +- ...LM채팅+지식증강-phase5-사용자체크리스트.md | 148 ++++++++++++- .../Database/ExperionDbContext.cs | 207 ++++++++++-------- src/Infrastructure/Kb/KbAuthService.cs | 12 +- src/Infrastructure/Kb/KbIngestWorker.cs | 17 +- src/Infrastructure/Kb/KbQdrantClient.cs | 5 +- src/Web/Controllers/OllamaController.cs | 21 +- src/Web/Program.cs | 7 + src/Web/appsettings.json | 1 + 13 files changed, 473 insertions(+), 136 deletions(-) diff --git a/.gitignore b/.gitignore index 33c2292..2026386 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ env/ .pytest_cache/ .mypy_cache/ .ipynb_checkpoints/ + +# KB 업로드 원본 파일 (런타임 데이터) +storage/ diff --git a/CLAUDE.md b/CLAUDE.md index a7c750c..d6e3d63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,13 +142,62 @@ - Qdrant 5개 컬렉션 생성 확인 — `curl http://localhost:6333/collections` #### 잔여 작업 -- Phase 6 (보강 도구): `query_events`, `summarize_events`, `active_alarms`, `find_tags`, `generate_status_report`, `run_sql` LIMIT/timeout +- Phase 6 (보강 도구): `query_events`, `summarize_events`, `active_alarms`, `find_tags`, `generate_status_report` (~~`run_sql` LIMIT/timeout~~ → 완료, 아래 참조) - 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` 안전 가드를 함께 처리. + +#### 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `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 추적 제외) | + +#### Phase 6 — `run_sql` 가드 동작 + +| 가드 | 동작 | +|------|------| +| 키워드 차단 | `\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`로 조정 가능 | + +응답 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"`은 사용자 지시로 유지 (운영 전 제거 권장) + +--- + ### 기능 추가 — OPC UA 서버 기능 (2026-04-15) #### 배경 diff --git a/mcp-server/server.py b/mcp-server/server.py index 1035f15..6de27e0 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -11,6 +11,7 @@ ExperionCrawler Unified MCP Server from __future__ import annotations import sys import os +import re import json import logging import httpx @@ -325,21 +326,39 @@ async def _get_db_connection(): def _validate_sql(sql: str) -> tuple[bool, str]: - """SQL 안전 검증 — SELECT만 허용, 위험 키워드 차단.""" + """SQL 안전 검증 — SELECT/WITH만 허용, 위험 키워드 차단.""" if len(sql) > 2000: return False, "쿼리 길이 2000자를 초과했습니다." - dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'] + dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY'] sql_upper = sql.upper() for kw in dangerous: - if kw in sql_upper: + if re.search(rf"\b{kw}\b", sql_upper): return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다." - if not sql_upper.strip().startswith('SELECT'): - return False, "단순 SELECT 쿼리만 허용됩니다." + head = sql_upper.lstrip().lstrip('(').lstrip() + if not (head.startswith('SELECT') or head.startswith('WITH')): + return False, "SELECT 또는 WITH 쿼리만 허용됩니다." if '..' in sql or '~' in sql: return False, "파일 경로 표현은 허용되지 않습니다." + if ';' in sql.rstrip().rstrip(';'): + return False, "다중 문장(세미콜론)은 허용되지 않습니다." return True, "" +# SQL 가드 — auto-LIMIT + statement_timeout +SQL_MAX_ROWS = int(os.environ.get("SQL_MAX_ROWS", "1000")) +SQL_STATEMENT_TIMEOUT_MS = int(os.environ.get("SQL_STATEMENT_TIMEOUT_MS", "30000")) + +_RE_LIMIT_TAIL = re.compile(r"\bLIMIT\b\s+\d+(\s+OFFSET\s+\d+)?\s*$", re.IGNORECASE) + + +def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str: + """LIMIT가 없으면 서브쿼리로 감싸 강제 부착. 이미 끝부분에 LIMIT가 있으면 그대로.""" + s = sql.strip().rstrip(';').strip() + if _RE_LIMIT_TAIL.search(s): + return s + return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}" + + # DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용 _DB_SCHEMA = """ PostgreSQL 시계열 데이터베이스 스키마 @@ -627,16 +646,21 @@ async def search_kb( # ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── async def _execute_sql_internal(sql: str) -> str: - """SQL 실행 내부 함수 (run_sql과 query_with_nl에서 공유).""" + """SQL 실행 내부 함수 (run_sql과 query_with_nl에서 공유). + + 가드: LIMIT 미지정 시 자동 LIMIT 부착(SQL_MAX_ROWS), statement_timeout 적용. + """ valid, err = _validate_sql(sql) if not valid: return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) + capped_sql = _apply_sql_guards(sql) conn = None try: conn = await _get_db_connection() with conn.cursor() as cur: - cur.execute(sql) + cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}") + cur.execute(capped_sql) rows = cur.fetchall() columns = [desc[0] for desc in cur.description] result_data = [dict(zip(columns, row)) for row in rows] @@ -644,6 +668,7 @@ async def _execute_sql_internal(sql: str) -> str: "success": True, "columns": columns, "count": len(result_data), + "row_limit": SQL_MAX_ROWS, "data": result_data }, ensure_ascii=False, default=str) except Exception as e: @@ -654,13 +679,19 @@ async def _execute_sql_internal(sql: str) -> str: @mcp.tool() async def run_sql(sql: str) -> str: - """SQL 쿼리 실행 (SELECT만 허용). + """SQL 쿼리 실행 (SELECT/WITH만 허용). + + 안전 가드: + - 위험 키워드(INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/GRANT/REVOKE/TRUNCATE/COPY/EXEC) 차단 + - 다중 문장(세미콜론) 차단 + - LIMIT 미지정 시 SQL_MAX_ROWS(기본 1000)로 자동 제한 + - statement_timeout = SQL_STATEMENT_TIMEOUT_MS(기본 30s) Args: - sql: 실행할 SELECT SQL 문자열 + sql: 실행할 SELECT/WITH SQL 문자열 (최대 2000자) Returns: - JSON: { success, columns, count, data } 또는 { success, error } + JSON: { success, columns, count, row_limit, data } 또는 { success, error } """ return await _execute_sql_internal(sql) diff --git a/mcp-server/worker/nl2sql_worker.py b/mcp-server/worker/nl2sql_worker.py index 42a52c4..b1909d6 100644 --- a/mcp-server/worker/nl2sql_worker.py +++ b/mcp-server/worker/nl2sql_worker.py @@ -16,6 +16,7 @@ Usage: python nl2sql_worker.py from __future__ import annotations import sys import os +import re # mcp-server 디렉토리를 Python 경로에 추가 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -51,6 +52,45 @@ def _get_db_connection(): import psycopg return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) + +async def _aget_db_connection(): + """비동기 환경에서 안전하게 DB 연결 획득 (blocking connect를 to_thread로 격리).""" + import asyncio + return await asyncio.to_thread(_get_db_connection) + + +# ── SQL 가드 ───────────────────────────────────────────────────────────────── + +SQL_MAX_ROWS = int(os.environ.get("SQL_MAX_ROWS", "1000")) +SQL_STATEMENT_TIMEOUT_MS = int(os.environ.get("SQL_STATEMENT_TIMEOUT_MS", "30000")) + +_RE_LIMIT_TAIL = re.compile(r"\bLIMIT\b\s+\d+(\s+OFFSET\s+\d+)?\s*$", re.IGNORECASE) +_DANGEROUS_KW = ('EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY') + + +def _validate_sql(sql: str) -> tuple[bool, str]: + """SELECT/WITH만 허용, 위험 키워드/다중 문장 차단.""" + if not sql or len(sql) > 2000: + return False, "쿼리가 비어있거나 2000자를 초과했습니다." + upper = sql.upper() + for kw in _DANGEROUS_KW: + if re.search(rf"\b{kw}\b", upper): + return False, f"허용되지 않은 키워드 '{kw}'" + head = upper.lstrip().lstrip('(').lstrip() + if not (head.startswith('SELECT') or head.startswith('WITH')): + return False, "SELECT 또는 WITH 쿼리만 허용됩니다." + if ';' in sql.rstrip().rstrip(';'): + return False, "다중 문장(세미콜론)은 허용되지 않습니다." + return True, "" + + +def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str: + s = sql.strip().rstrip(';').strip() + if _RE_LIMIT_TAIL.search(s): + return s + return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}" + + # ── LLM 클라이언트 ─────────────────────────────────────────────────────────── @lru_cache(maxsize=1) @@ -206,11 +246,17 @@ async def execute(request: Request): return {"success": False, "error": str(e)} async def _run_sql(sql: str) -> str: - """SQL 실행.""" - conn = _get_db_connection() + """SQL 실행 (가드: SELECT/WITH만, auto-LIMIT, statement_timeout).""" + valid, err = _validate_sql(sql) + if not valid: + return {"success": False, "error": f"SQL 검증 실패: {err}"} + + capped_sql = _apply_sql_guards(sql) + conn = await _aget_db_connection() try: with conn.cursor() as cur: - cur.execute(sql) + cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}") + cur.execute(capped_sql) if cur.description: columns = [desc[0] for desc in cur.description] rows = cur.fetchall() @@ -219,6 +265,7 @@ async def _run_sql(sql: str) -> str: "success": True, "columns": columns, "count": len(data), + "row_limit": SQL_MAX_ROWS, "data": data, } else: @@ -227,6 +274,8 @@ async def _run_sql(sql: str) -> str: "success": True, "message": f"Query executed successfully. {cur.rowcount} rows affected.", } + except Exception as e: + return {"success": False, "error": f"SQL 실행 실패: {e}"} finally: conn.close() @@ -235,7 +284,7 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, if not tag_names: return {"success": False, "error": "tag_names is required"} - conn = _get_db_connection() + conn = await _aget_db_connection() try: with conn.cursor() as cur: cur.execute( @@ -266,7 +315,7 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, async def _get_tag_metadata(query: str, limit: int = 10) -> str: """태그 메타데이터 검색.""" - conn = _get_db_connection() + conn = await _aget_db_connection() try: with conn.cursor() as cur: cur.execute( @@ -301,7 +350,7 @@ async def _get_tag_metadata(query: str, limit: int = 10) -> str: async def _list_drawings(unit_no: str = None) -> str: """단위별 도면 목록 조회.""" - conn = _get_db_connection() + conn = await _aget_db_connection() try: with conn.cursor() as cur: if unit_no: @@ -322,14 +371,13 @@ async def _list_drawings(unit_no: str = None) -> str: ORDER BY name """ ) - columns = ["name"] rows = cur.fetchall() - data = [dict(zip(columns, row[0])) for row in rows] + names = [row[0] for row in rows] return { "success": True, "unit_no": unit_no, - "count": len(data), - "names": [d["name"] for d in data], + "count": len(names), + "names": names, } finally: conn.close() @@ -343,10 +391,17 @@ async def _query_with_nl(question: str) -> str: if not sql: return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) - conn = _get_db_connection() + # LLM 생성 SQL도 동일 가드 적용 + valid, err = _validate_sql(sql) + if not valid: + return {"success": False, "sql": sql, "error": f"SQL 검증 실패: {err}"} + capped_sql = _apply_sql_guards(sql) + + conn = await _aget_db_connection() try: with conn.cursor() as cur: - cur.execute(sql) + cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}") + cur.execute(capped_sql) if cur.description: columns = [desc[0] for desc in cur.description] rows = cur.fetchall() @@ -356,6 +411,7 @@ async def _query_with_nl(question: str) -> str: "sql": sql, "columns": columns, "count": len(data), + "row_limit": SQL_MAX_ROWS, "data": data, } else: diff --git a/opencode.json b/opencode.json index 524d31b..3a9c64a 100644 --- a/opencode.json +++ b/opencode.json @@ -8,10 +8,10 @@ "baseURL": "http://localhost:8000/v1" }, "models": { - "google/gemma-4-31B-it-vllm-fp8-dflash-16k": { - "name": "Gemma 4 31B dflash", + "Qwen3.6-27B-FP8": { + "name": "Qwen 3.6 27B FP8", "limit": { - "context": 16384, + "context": 262144, "output": 8192 } } diff --git a/plans/LLM채팅+지식증강-phase5-사용자체크리스트.md b/plans/LLM채팅+지식증강-phase5-사용자체크리스트.md index 9e4975e..f4b26e4 100644 --- a/plans/LLM채팅+지식증강-phase5-사용자체크리스트.md +++ b/plans/LLM채팅+지식증강-phase5-사용자체크리스트.md @@ -139,4 +139,150 @@ - run_sql 안전장치 (자동 LIMIT, statement_timeout)도 Phase 6 --- - 막힘이 있는 단계가 있으면 어디서 멈췄는지 알려주시면 함께 보겠습니다. \ No newline at end of file + 막힘이 있는 단계가 있으면 어디서 멈췄는지 알려주시면 함께 보겠습니다. + + 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(), 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 또는 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 단일 청크 임베딩 실패 시 전체 실패 개선 권장 \ No newline at end of file diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 7433fca..1d72677 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -420,112 +420,125 @@ public class ExperionDbService : IExperionDbService // CreateHypertableAsync() 메서드에서 선택적으로 설정 가능 // ── Knowledge Base (RAG) 테이블 ────────────────────────────────── - await _ctx.Database.ExecuteSqlRawAsync( - "CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""); + // 주의: KB DDL은 JSONB '{}' / TEXT[] '{}' / 시드 JSON 객체에 중괄호가 들어가는데 + // EF Core의 ExecuteSqlRawAsync는 SQL을 String.Format으로 통과시켜 {}를 placeholder로 오해함. + // 이를 피하기 위해 raw NpgsqlCommand를 직접 사용한다. + await using (var kbConn = new NpgsqlConnection(_ctx.Database.GetConnectionString())) + { + await kbConn.OpenAsync(); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE TABLE IF NOT EXISTS kb_collections ( - collection_key TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - qdrant_name TEXT NOT NULL UNIQUE, - chunking_policy JSONB NOT NULL DEFAULT '{}'::jsonb, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - """); + async Task ExecKbAsync(string sql) + { + await using var cmd = new NpgsqlCommand(sql, kbConn); + await cmd.ExecuteNonQueryAsync(); + } - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE TABLE IF NOT EXISTS kb_documents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - collection_key TEXT NOT NULL REFERENCES kb_collections(collection_key), - title TEXT NOT NULL, - original_path TEXT NOT NULL, - file_sha256 TEXT NOT NULL, - file_size BIGINT, - mime_type TEXT, - tags TEXT[] NOT NULL DEFAULT '{}', - status TEXT NOT NULL DEFAULT 'pending', - chunk_count INTEGER NOT NULL DEFAULT 0, - error_message TEXT, - uploaded_by TEXT, - uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - indexed_at TIMESTAMPTZ, - disabled_at TIMESTAMPTZ - ) - """); + await ExecKbAsync("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE INDEX IF NOT EXISTS idx_kb_docs_coll_status - ON kb_documents(collection_key, status, uploaded_at DESC); - CREATE INDEX IF NOT EXISTS idx_kb_docs_title - ON kb_documents(title); - """); + await ExecKbAsync(""" + CREATE TABLE IF NOT EXISTS kb_collections ( + collection_key TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + qdrant_name TEXT NOT NULL UNIQUE, + chunking_policy JSONB NOT NULL DEFAULT '{}'::jsonb, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE TABLE IF NOT EXISTS kb_ingest_jobs ( - id BIGSERIAL PRIMARY KEY, - doc_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE, - stage TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ - ) - """); + await ExecKbAsync(""" + CREATE TABLE IF NOT EXISTS kb_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + collection_key TEXT NOT NULL REFERENCES kb_collections(collection_key), + title TEXT NOT NULL, + original_path TEXT NOT NULL, + file_sha256 TEXT NOT NULL, + file_size BIGINT, + mime_type TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending', + chunk_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + uploaded_by TEXT, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + indexed_at TIMESTAMPTZ, + disabled_at TIMESTAMPTZ + ) + """); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE INDEX IF NOT EXISTS idx_kb_jobs_pending - ON kb_ingest_jobs(stage, finished_at) - WHERE finished_at IS NULL; - """); + await ExecKbAsync(""" + CREATE INDEX IF NOT EXISTS idx_kb_docs_coll_status + ON kb_documents(collection_key, status, uploaded_at DESC); + CREATE INDEX IF NOT EXISTS idx_kb_docs_title + ON kb_documents(title); + """); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE TABLE IF NOT EXISTS kb_admin_credential ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), - password_hash TEXT NOT NULL, - salt TEXT NOT NULL, - algorithm TEXT NOT NULL DEFAULT 'argon2id', - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - """); + await ExecKbAsync(""" + CREATE TABLE IF NOT EXISTS kb_ingest_jobs ( + id BIGSERIAL PRIMARY KEY, + doc_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE, + stage TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ + ) + """); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE TABLE IF NOT EXISTS kb_admin_sessions ( - token TEXT PRIMARY KEY, - issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL, - client_ip TEXT - ) - """); + await ExecKbAsync(""" + CREATE INDEX IF NOT EXISTS idx_kb_jobs_pending + ON kb_ingest_jobs(stage, finished_at) + WHERE finished_at IS NULL; + """); - await _ctx.Database.ExecuteSqlRawAsync(""" - CREATE INDEX IF NOT EXISTS idx_kb_sessions_expires - ON kb_admin_sessions(expires_at); - """); + await ExecKbAsync(""" + CREATE TABLE IF NOT EXISTS kb_admin_credential ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + algorithm TEXT NOT NULL DEFAULT 'argon2id', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """); - // ── 시드: kb_collections 5종 ───────────────────────────────────── - await _ctx.Database.ExecuteSqlRawAsync(""" - INSERT INTO kb_collections (collection_key, display_name, qdrant_name, chunking_policy, description) - VALUES - ('system_instrument', '시스템 & 계기 정보', 'kb_system_instrument', - '{"pdf":"section+table","xlsx":"row+sheet","docx":"heading"}'::jsonb, - '계기 datasheet, P&ID, 사양서, 노드맵'), - ('plant_operation', '공장 운전 정보', 'kb_plant_operation', - '{"xlsx":"row","docx":"heading","md":"heading"}'::jsonb, - '재고, 생산현황, 고장이력, 교대일지'), - ('procedure', '절차서/SOP', 'kb_procedure', - '{"docx":"heading","md":"heading","pdf":"section"}'::jsonb, - 'SOP, 정비 절차, 알람 대응 매뉴얼'), - ('report', '보고서', 'kb_report', - '{"pdf":"section+table","docx":"heading"}'::jsonb, - '일/주/월 보고, 사고보고, 분석보고'), - ('vendor_doc', '벤더 자료', 'kb_vendor_doc', - '{"pdf":"section+table","docx":"heading"}'::jsonb, - '카탈로그, 매뉴얼, 인증서') - ON CONFLICT (collection_key) DO NOTHING - """); + await ExecKbAsync(""" + CREATE TABLE IF NOT EXISTS kb_admin_sessions ( + token TEXT PRIMARY KEY, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + client_ip TEXT + ) + """); + + await ExecKbAsync(""" + CREATE INDEX IF NOT EXISTS idx_kb_sessions_expires + ON kb_admin_sessions(expires_at); + """); + + // ── 시드: kb_collections 5종 ───────────────────────────────── + await ExecKbAsync(""" + INSERT INTO kb_collections (collection_key, display_name, qdrant_name, chunking_policy, description) + VALUES + ('system_instrument', '시스템 & 계기 정보', 'kb_system_instrument', + '{"pdf":"section+table","xlsx":"row+sheet","docx":"heading"}'::jsonb, + '계기 datasheet, P&ID, 사양서, 노드맵'), + ('plant_operation', '공장 운전 정보', 'kb_plant_operation', + '{"xlsx":"row","docx":"heading","md":"heading"}'::jsonb, + '재고, 생산현황, 고장이력, 교대일지'), + ('procedure', '절차서/SOP', 'kb_procedure', + '{"docx":"heading","md":"heading","pdf":"section"}'::jsonb, + 'SOP, 정비 절차, 알람 대응 매뉴얼'), + ('report', '보고서', 'kb_report', + '{"pdf":"section+table","docx":"heading"}'::jsonb, + '일/주/월 보고, 사고보고, 분석보고'), + ('vendor_doc', '벤더 자료', 'kb_vendor_doc', + '{"pdf":"section+table","docx":"heading"}'::jsonb, + '카탈로그, 매뉴얼, 인증서') + ON CONFLICT (collection_key) DO NOTHING + """); + } _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)"); return true; diff --git a/src/Infrastructure/Kb/KbAuthService.cs b/src/Infrastructure/Kb/KbAuthService.cs index cbb2a32..9eb5012 100644 --- a/src/Infrastructure/Kb/KbAuthService.cs +++ b/src/Infrastructure/Kb/KbAuthService.cs @@ -61,7 +61,17 @@ public sealed class KbAuthService : IKbAuthService if (generated) { - _logger.LogWarning("[Kb] 관리자 초기 비밀번호 자동 생성: {Pw} ← 즉시 변경하세요", initial); + // 로그 파일에는 마스킹된 값만 — 평문은 콘솔로만 1회 출력 (파일 logger 미포함) + var masked = initial.Length > 4 + ? initial.Substring(0, 4) + new string('*', initial.Length - 4) + : new string('*', initial.Length); + _logger.LogWarning("[Kb] 관리자 초기 비밀번호 자동 생성됨 (콘솔 참고, 마스킹: {Mask}) ← 즉시 변경하세요", masked); + Console.Out.WriteLine(); + Console.Out.WriteLine("════════════════════════════════════════════════════════════"); + Console.Out.WriteLine($" [Kb] 관리자 초기 비밀번호: {initial}"); + Console.Out.WriteLine(" ← 14번 RAG 관리 탭에서 즉시 변경하세요. 본 출력은 1회뿐입니다."); + Console.Out.WriteLine("════════════════════════════════════════════════════════════"); + Console.Out.WriteLine(); } else { diff --git a/src/Infrastructure/Kb/KbIngestWorker.cs b/src/Infrastructure/Kb/KbIngestWorker.cs index 6861798..3a0d6d9 100644 --- a/src/Infrastructure/Kb/KbIngestWorker.cs +++ b/src/Infrastructure/Kb/KbIngestWorker.cs @@ -112,15 +112,16 @@ public sealed class KbIngestWorker : BackgroundService _logger.LogInformation("[Kb][Worker] {Id} parse: {N} chunks", doc.Id, chunks.Count); - // 2) embed + // 2) embed — 단일 청크 실패는 skip, 모두 실패해야 진짜 실패 doc.Status = "embedding"; await db.SaveChangesAsync(ct); var points = new List(chunks.Count); + int skipped = 0; foreach (var c in chunks) { var vec = await _embed.EmbedAsync(c.Text, ct); - if (vec == null) throw new Exception("임베딩 실패(부분)"); + if (vec == null) { skipped++; continue; } points.Add(new QdrantPoint { id = Guid.NewGuid(), @@ -138,15 +139,21 @@ public sealed class KbIngestWorker : BackgroundService } }); } + if (points.Count == 0) + throw new Exception($"임베딩 실패: 청크 {chunks.Count}건 전부 실패"); + if (skipped > 0) + _logger.LogWarning("[Kb][Worker] {Id} 임베딩 일부 실패: {Skipped}/{Total} 건 누락", doc.Id, skipped, chunks.Count); // 3) index - doc.Status = "indexed"; // 낙관적 - 실패 시 catch에서 되돌림 + doc.Status = "indexed"; var ok = await _qdrant.UpsertAsync(coll.QdrantName, points, ct); if (!ok) throw new Exception("Qdrant upsert 실패"); - doc.ChunkCount = chunks.Count; + doc.ChunkCount = points.Count; doc.IndexedAt = DateTime.UtcNow; - doc.ErrorMessage = null; + doc.ErrorMessage = skipped > 0 + ? $"부분 인덱싱: {points.Count}/{chunks.Count} 청크 (임베딩 {skipped}건 실패)" + : null; job.FinishedAt = DateTime.UtcNow; job.LastError = null; diff --git a/src/Infrastructure/Kb/KbQdrantClient.cs b/src/Infrastructure/Kb/KbQdrantClient.cs index eaee129..0d0492a 100644 --- a/src/Infrastructure/Kb/KbQdrantClient.cs +++ b/src/Infrastructure/Kb/KbQdrantClient.cs @@ -16,10 +16,9 @@ public sealed class KbQdrantClient private readonly ILogger _logger; private readonly int _vectorSize; - public KbQdrantClient(IConfiguration config, ILogger logger) + public KbQdrantClient(IHttpClientFactory httpFactory, IConfiguration config, ILogger logger) { - var baseUrl = config["Kb:QdrantUrl"] ?? "http://localhost:6333"; - _http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) }; + _http = httpFactory.CreateClient("KbQdrant"); _vectorSize = int.TryParse(config["Kb:VectorSize"], out var v) ? v : 768; _logger = logger; } diff --git a/src/Web/Controllers/OllamaController.cs b/src/Web/Controllers/OllamaController.cs index cb998d6..0bd4200 100644 --- a/src/Web/Controllers/OllamaController.cs +++ b/src/Web/Controllers/OllamaController.cs @@ -73,19 +73,34 @@ public class OllamaController : ControllerBase } } + // plant_context.md 캐시 — 파일 mtime이 바뀌면 자동 재로드 + private static string _plantContextCached = ""; + private static DateTime _plantContextMtime = DateTime.MinValue; + private static readonly object _plantContextLock = new(); + string LoadPlantContext() { try { var p = PlantContextPath; - if (System.IO.File.Exists(p)) - return System.IO.File.ReadAllText(p).Trim(); + if (!System.IO.File.Exists(p)) return ""; + + var mtime = System.IO.File.GetLastWriteTimeUtc(p); + lock (_plantContextLock) + { + if (mtime != _plantContextMtime) + { + _plantContextCached = System.IO.File.ReadAllText(p).Trim(); + _plantContextMtime = mtime; + } + return _plantContextCached; + } } catch (Exception ex) { _logger.LogWarning(ex, "[OllamaController] plant_context.md 로드 실패"); + return ""; } - return ""; } private const string BaseSystemPromptKo = diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 6d29f26..6523fdc 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -121,6 +121,13 @@ builder.Services.AddSingleton(); // ── Knowledge Base (RAG) ────────────────────────────────────────────────────── +builder.Services.AddHttpClient("KbQdrant", (sp, c) => +{ + var cfg = sp.GetRequiredService(); + var baseUrl = cfg["Kb:QdrantUrl"] ?? "http://localhost:6333"; + c.BaseAddress = new Uri(baseUrl); + c.Timeout = TimeSpan.FromSeconds(30); +}); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index 4670f9d..300146f 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -43,6 +43,7 @@ "QdrantUrl": "http://localhost:6333", "VectorSize": 768, "StorageRoot": "../../storage/kb", + "AdminInitialPassword": "admin", "AdminSessionMinutes": 60, "WorkerPollIntervalSeconds": 2, "MaxAttempts": 3