fix: Phase 5 진단 핫픽스 + Phase 6 run_sql 안전 가드

진단 보고서(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>
This commit is contained in:
windpacer
2026-05-14 05:18:06 +09:00
parent 908bfe151f
commit 5a9d60e8a8
13 changed files with 473 additions and 136 deletions

3
.gitignore vendored
View File

@@ -58,3 +58,6 @@ env/
.pytest_cache/
.mypy_cache/
.ipynb_checkpoints/
# KB 업로드 원본 파일 (런타임 데이터)
storage/

View File

@@ -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)
#### 배경

View File

@@ -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)

View File

@@ -16,6 +16,7 @@ Usage: python nl2sql_worker.py <port>
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:

View File

@@ -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
}
}

View File

@@ -139,4 +139,150 @@
- 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 단일 청크 임베딩 실패 시 전체 실패 개선 권장

View File

@@ -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;

View File

@@ -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
{

View File

@@ -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<QdrantPoint>(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;

View File

@@ -16,10 +16,9 @@ public sealed class KbQdrantClient
private readonly ILogger<KbQdrantClient> _logger;
private readonly int _vectorSize;
public KbQdrantClient(IConfiguration config, ILogger<KbQdrantClient> logger)
public KbQdrantClient(IHttpClientFactory httpFactory, IConfiguration config, ILogger<KbQdrantClient> 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;
}

View File

@@ -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 =

View File

@@ -121,6 +121,13 @@ builder.Services.AddSingleton<IPidGraphEventBroadcaster, PidGraphEventBroadcaste
builder.Services.AddHostedService<ExperionFastCleanupService>();
// ── Knowledge Base (RAG) ──────────────────────────────────────────────────────
builder.Services.AddHttpClient("KbQdrant", (sp, c) =>
{
var cfg = sp.GetRequiredService<IConfiguration>();
var baseUrl = cfg["Kb:QdrantUrl"] ?? "http://localhost:6333";
c.BaseAddress = new Uri(baseUrl);
c.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton<KbQdrantClient>();
builder.Services.AddSingleton<KbStorageService>();
builder.Services.AddSingleton<KbEmbeddingClient>();

View File

@@ -43,6 +43,7 @@
"QdrantUrl": "http://localhost:6333",
"VectorSize": 768,
"StorageRoot": "../../storage/kb",
"AdminInitialPassword": "admin",
"AdminSessionMinutes": 60,
"WorkerPollIntervalSeconds": 2,
"MaxAttempts": 3