feat: Knowledge Base RAG 시스템 + 채팅 LLM 개선 (Phase 0~5 완료)
- KB RAG 전체 파이프라인: 업로드, 파싱(xlsx/pdf/docx/text), 임베딩, Qdrant 인덱싱 - KB 관리 UI(14번 탭): 로그인, 문서 목록, 업로드, 삭제, 재인덱스 - OllamaController: 한글 시스템 프롬프트, plant_context.md 외부 파일화, SSE tool_start/tool_result 이벤트 - 프론트: 툴 실행 카드, KB 인용 링크, 표 자동 렌더, 추천 질문 칩 - nl2sql_worker: history_table.recorded_at 사용, tag_metadata 응답 개선 - DB: KB 테이블 5개 DDL + 시드, pgcrypto 확장
This commit is contained in:
142
CLAUDE.md
142
CLAUDE.md
@@ -7,6 +7,148 @@
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
### 기능 추가 — 로컬 LLM 채팅 + 지식 베이스(RAG) Phase 0~5 (2026-05-13)
|
||||
|
||||
#### 배경
|
||||
운전원이 채팅 UI에서 자연어로 공장 상황·계기 상태·정비 이력 등을 질문하면, (a) PostgreSQL 시계열/이벤트 데이터와 (b) 관리자가 업로드한 KB 문서(Qdrant 벡터 검색)를 합성해 답하도록 통합. 별도 14번 탭 "RAG 관리"에서 관리자 비번 인증 후 문서 업로드/인덱싱/관리.
|
||||
|
||||
설계서: `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 (보강 도구): `query_events`, `summarize_events`, `active_alarms`, `find_tags`, `generate_status_report`, `run_sql` LIMIT/timeout
|
||||
- Phase 7 (옵션): NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 UI
|
||||
- Phase 5 후순위: 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존
|
||||
- 결정 보류: 현장 재고 데이터 출처, 임베딩 모델 BGE-M3 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
### 기능 추가 — OPC UA 서버 기능 (2026-04-15)
|
||||
|
||||
#### 배경
|
||||
|
||||
8
mcp-server/parsers/__init__.py
Normal file
8
mcp-server/parsers/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""KB 문서 파서 모음.
|
||||
|
||||
각 모듈은 `parse(path: str) -> list[dict]` 인터페이스를 제공한다.
|
||||
반환 청크는 다음 키를 가진다:
|
||||
text: str 임베딩 대상 본문 (보통 200~1500자)
|
||||
chunk_kind: str row | sheet | section | table | page | paragraph | heading
|
||||
locator: str 사람 가독 위치 문자열 (예: "sheet=Pump-A; row=12")
|
||||
"""
|
||||
41
mcp-server/parsers/docx_parser.py
Normal file
41
mcp-server/parsers/docx_parser.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""docx 청킹 — 헤딩 경로 별 청크."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def parse(path: str) -> list[dict]:
|
||||
from docx import Document
|
||||
|
||||
doc = Document(path)
|
||||
chunks: list[dict] = []
|
||||
|
||||
cur_path: list[str] = []
|
||||
buf: list[str] = []
|
||||
|
||||
def flush():
|
||||
if buf:
|
||||
heading = " / ".join(cur_path) if cur_path else "preface"
|
||||
chunks.append({
|
||||
"text": "\n".join(buf).strip(),
|
||||
"chunk_kind": "heading",
|
||||
"locator": f"heading={heading}",
|
||||
})
|
||||
|
||||
for p in doc.paragraphs:
|
||||
text = (p.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
style_name = (p.style.name or "").lower() if p.style else ""
|
||||
if style_name.startswith("heading"):
|
||||
flush()
|
||||
buf = []
|
||||
try:
|
||||
level = int(style_name.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
level = 1
|
||||
cur_path = cur_path[: max(0, level - 1)] + [text]
|
||||
else:
|
||||
buf.append(text)
|
||||
|
||||
flush()
|
||||
return chunks
|
||||
34
mcp-server/parsers/pdf_parser.py
Normal file
34
mcp-server/parsers/pdf_parser.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""pdf 청킹 — pdfplumber로 페이지/표 추출, 헤딩 분리 실패 시 페이지 단위 fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def parse(path: str) -> list[dict]:
|
||||
import pdfplumber
|
||||
|
||||
chunks: list[dict] = []
|
||||
with pdfplumber.open(path) as pdf:
|
||||
for pno, page in enumerate(pdf.pages, start=1):
|
||||
txt = (page.extract_text() or "").strip()
|
||||
if txt:
|
||||
chunks.append({
|
||||
"text": txt[:5000],
|
||||
"chunk_kind": "page",
|
||||
"locator": f"page={pno}",
|
||||
})
|
||||
|
||||
try:
|
||||
tables = page.extract_tables() or []
|
||||
except Exception:
|
||||
tables = []
|
||||
for ti, table in enumerate(tables, start=1):
|
||||
rows = [[(c or "").strip() for c in row] for row in table if row]
|
||||
if not rows:
|
||||
continue
|
||||
md = "\n".join(" | ".join(r) for r in rows[:200])
|
||||
chunks.append({
|
||||
"text": md,
|
||||
"chunk_kind": "table",
|
||||
"locator": f"page={pno}; table={ti}",
|
||||
})
|
||||
|
||||
return chunks
|
||||
56
mcp-server/parsers/text_parser.py
Normal file
56
mcp-server/parsers/text_parser.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""md / txt 청킹 — md는 # 헤딩 단위, txt는 빈 줄 두 개 단위."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
|
||||
|
||||
def parse(path: str) -> list[dict]:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
|
||||
if ext == ".md":
|
||||
return _parse_md(content)
|
||||
return _parse_txt(content)
|
||||
|
||||
|
||||
def _parse_md(text: str) -> list[dict]:
|
||||
chunks: list[dict] = []
|
||||
lines = text.split("\n")
|
||||
|
||||
cur_heading = "preface"
|
||||
buf: list[str] = []
|
||||
section_idx = 0
|
||||
|
||||
def flush():
|
||||
nonlocal section_idx
|
||||
body = "\n".join(buf).strip()
|
||||
if body:
|
||||
section_idx += 1
|
||||
chunks.append({
|
||||
"text": body,
|
||||
"chunk_kind": "heading",
|
||||
"locator": f"heading={cur_heading}",
|
||||
})
|
||||
|
||||
for ln in lines:
|
||||
s = ln.lstrip()
|
||||
if s.startswith("#"):
|
||||
flush()
|
||||
buf = []
|
||||
cur_heading = s.lstrip("#").strip() or "section"
|
||||
else:
|
||||
buf.append(ln)
|
||||
flush()
|
||||
return chunks
|
||||
|
||||
|
||||
def _parse_txt(text: str) -> list[dict]:
|
||||
chunks: list[dict] = []
|
||||
parts = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
for i, p in enumerate(parts, start=1):
|
||||
chunks.append({
|
||||
"text": p,
|
||||
"chunk_kind": "paragraph",
|
||||
"locator": f"paragraph={i}",
|
||||
})
|
||||
return chunks
|
||||
49
mcp-server/parsers/xlsx_parser.py
Normal file
49
mcp-server/parsers/xlsx_parser.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""xlsx 청킹 — 시트 단위(markdown) + 행 단위 둘 다 생성."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def parse(path: str) -> list[dict]:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
chunks: list[dict] = []
|
||||
|
||||
for sheet in wb.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header = [str(c) if c is not None else "" for c in rows[0]]
|
||||
sheet_name = sheet.title
|
||||
|
||||
# 1) 시트 청크 — markdown 표 (선두 1000행 제한)
|
||||
body_rows = rows[1:1001]
|
||||
md_lines = ["| " + " | ".join(header) + " |",
|
||||
"| " + " | ".join(["---"] * len(header)) + " |"]
|
||||
for r in body_rows:
|
||||
cells = [str(c) if c is not None else "" for c in r]
|
||||
cells += [""] * (len(header) - len(cells))
|
||||
md_lines.append("| " + " | ".join(cells[: len(header)]) + " |")
|
||||
chunks.append({
|
||||
"text": "\n".join(md_lines),
|
||||
"chunk_kind": "sheet",
|
||||
"locator": f"sheet={sheet_name}",
|
||||
})
|
||||
|
||||
# 2) 행 청크 — 각 행을 'col=val' 형식 한 줄로
|
||||
for i, r in enumerate(rows[1:], start=2):
|
||||
parts = []
|
||||
for j, val in enumerate(r):
|
||||
if val is None or val == "":
|
||||
continue
|
||||
col = header[j] if j < len(header) and header[j] else f"col{j+1}"
|
||||
parts.append(f"{col}={val}")
|
||||
if not parts:
|
||||
continue
|
||||
chunks.append({
|
||||
"text": f"{sheet_name}: " + ", ".join(parts),
|
||||
"chunk_kind": "row",
|
||||
"locator": f"sheet={sheet_name}; row={i}",
|
||||
})
|
||||
|
||||
return chunks
|
||||
@@ -24,6 +24,10 @@ dependencies = [
|
||||
"scikit-learn>=1.3.0",
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=10.0.0",
|
||||
# KB 문서 파싱
|
||||
"openpyxl>=3.1.0",
|
||||
"python-docx>=1.1.0",
|
||||
"pdfplumber>=0.11.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -31,6 +31,15 @@ VLLM_MODEL = get_vllm_model()
|
||||
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
||||
COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks)
|
||||
|
||||
# 사용자 KB 컬렉션 (kb_collections 시드 5종과 일치)
|
||||
KB_COLLECTIONS = {
|
||||
"system_instrument": "kb_system_instrument",
|
||||
"plant_operation": "kb_plant_operation",
|
||||
"procedure": "kb_procedure",
|
||||
"report": "kb_report",
|
||||
"vendor_doc": "kb_vendor_doc",
|
||||
}
|
||||
|
||||
# PostgreSQL 연결
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
@@ -248,6 +257,60 @@ async def _search(collection: str, query: str, top_k: int, threshold: float = 0.
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
async def _search_kb_collection(
|
||||
qdrant_name: str,
|
||||
vec: list[float],
|
||||
top_k: int,
|
||||
tags: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""KB 컬렉션 1개에 대해 의미 검색. 결과를 정규화된 dict 리스트로 반환."""
|
||||
must = []
|
||||
if tags:
|
||||
must.append({"key": "tags", "match": {"any": tags}})
|
||||
|
||||
body: dict = {
|
||||
"vector": vec,
|
||||
"limit": top_k,
|
||||
"with_payload": True,
|
||||
"score_threshold": 0.20,
|
||||
}
|
||||
if must:
|
||||
body["filter"] = {"must": must}
|
||||
|
||||
def _call():
|
||||
with httpx.Client(timeout=20) as client:
|
||||
resp = client.post(f"{QDRANT_URL}/collections/{qdrant_name}/points/search", json=body)
|
||||
if resp.status_code == 404:
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("result", [])
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_call)
|
||||
except Exception as e:
|
||||
logging.warning(f"[search_kb] {qdrant_name} 검색 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _recency_factor(uploaded_at_iso: str | None) -> float:
|
||||
"""uploaded_at 기준 최신 가중치. 최근 7일 +10%, 30일 +5%, 90일 +2%, 그 외 1.0."""
|
||||
if not uploaded_at_iso:
|
||||
return 1.0
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
ts = datetime.fromisoformat(uploaded_at_iso.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - ts).total_seconds() / 86400.0
|
||||
if age < 7: return 1.10
|
||||
if age < 30: return 1.05
|
||||
if age < 90: return 1.02
|
||||
return 1.0
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
|
||||
# ── DB 헬퍼 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_db_connection():
|
||||
@@ -406,25 +469,161 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
|
||||
async def rag_query(
|
||||
question: str,
|
||||
search_code: bool = False,
|
||||
search_docs: bool = True,
|
||||
search_kb: bool = False,
|
||||
kb_collections: list[str] | None = None,
|
||||
) -> str:
|
||||
"""검색 → LLM 답변 생성 (통합 RAG).
|
||||
|
||||
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
|
||||
ExperionCrawler 코드도 함께 보려면 search_code=True 추가.
|
||||
기본값: Experion HS R530 공식 문서만 검색.
|
||||
사용자 KB 검색을 포함하려면 search_kb=True. 코드 검색은 search_code=True.
|
||||
|
||||
Args:
|
||||
question: 질문
|
||||
search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True)
|
||||
search_code: ExperionCrawler 소스코드 검색 여부 (기본 False)
|
||||
question: 질문
|
||||
search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True)
|
||||
search_code: ExperionCrawler 소스코드 검색 여부 (기본 False)
|
||||
search_kb: 사용자 KB 검색 여부 (기본 False)
|
||||
kb_collections: 검색 대상 KB 컬렉션 키 목록. None이면 전체.
|
||||
예: ["plant_operation", "procedure"]
|
||||
"""
|
||||
context_parts: list[str] = []
|
||||
if search_docs:
|
||||
context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{await _search(COL_OPC_DOCS, question, 4)}")
|
||||
if search_code:
|
||||
context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{await _search(COL_CODEBASE, question, 3)}")
|
||||
if search_kb:
|
||||
kb_text = await _format_kb_results(question, kb_collections, top_k=6)
|
||||
context_parts.append(f"=== 사용자 지식 베이스 ===\n{kb_text}")
|
||||
return ask_iiot_llm(question, "\n\n".join(context_parts))
|
||||
|
||||
|
||||
async def _format_kb_results(
|
||||
query: str,
|
||||
collection_keys: list[str] | None,
|
||||
top_k: int,
|
||||
tags: list[str] | None = None,
|
||||
since: str | None = None,
|
||||
boost_recent: bool = True,
|
||||
) -> str:
|
||||
"""search_kb 내부 헬퍼: 다중 컬렉션 의미검색 후 인용 텍스트로 직렬화."""
|
||||
hits = await _search_kb_raw(query, collection_keys, top_k, tags, since, boost_recent)
|
||||
if not hits:
|
||||
return "관련 KB 결과 없음."
|
||||
|
||||
parts = []
|
||||
for h in hits:
|
||||
title = h.get("title") or "(제목없음)"
|
||||
loc = h.get("locator") or ""
|
||||
score = h.get("score", 0.0)
|
||||
text = (h.get("text") or "").strip()
|
||||
# 인용 헤더: "[score=0.812] 정비이력_2026Q1.xlsx > 시트:Pump-A > 행 12"
|
||||
loc_str = f" > {loc}" if loc else ""
|
||||
parts.append(f"[score={score:.3f}] {title}{loc_str}\n{text[:700]}")
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
async def _search_kb_raw(
|
||||
query: str,
|
||||
collection_keys: list[str] | None,
|
||||
top_k: int,
|
||||
tags: list[str] | None,
|
||||
since: str | None,
|
||||
boost_recent: bool,
|
||||
) -> list[dict]:
|
||||
"""KB 검색 핵심 로직 — 다중 컬렉션 의미검색 + 최신 가중치 + 후필터."""
|
||||
targets = collection_keys or list(KB_COLLECTIONS.keys())
|
||||
qdrant_names = [KB_COLLECTIONS[k] for k in targets if k in KB_COLLECTIONS]
|
||||
if not qdrant_names:
|
||||
return []
|
||||
|
||||
vec = await _embed(query)
|
||||
per_coll_k = max(top_k, 8)
|
||||
|
||||
results: list[dict] = []
|
||||
for qname in qdrant_names:
|
||||
hits = await _search_kb_collection(qname, vec, per_coll_k, tags=tags)
|
||||
for h in hits:
|
||||
p = h.get("payload", {})
|
||||
uploaded_at = p.get("uploaded_at")
|
||||
|
||||
if since and uploaded_at:
|
||||
try:
|
||||
if uploaded_at < since:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
base_score = h.get("score", 0.0)
|
||||
recency = _recency_factor(uploaded_at) if boost_recent else 1.0
|
||||
results.append({
|
||||
"score": base_score * recency,
|
||||
"raw_score": base_score,
|
||||
"doc_id": p.get("doc_id"),
|
||||
"collection_key": p.get("collection_key"),
|
||||
"title": p.get("title"),
|
||||
"text": p.get("text", ""),
|
||||
"chunk_kind": p.get("chunk_kind"),
|
||||
"locator": p.get("locator"),
|
||||
"uploaded_at": uploaded_at,
|
||||
"tags": p.get("tags") or [],
|
||||
})
|
||||
|
||||
# 점수 내림차순 정렬, 동일 doc_id 중복 dedup(최고점만)
|
||||
results.sort(key=lambda r: r["score"], reverse=True)
|
||||
seen: set[str] = set()
|
||||
unique: list[dict] = []
|
||||
for r in results:
|
||||
key = f'{r.get("doc_id")}::{r.get("locator")}'
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
if len(unique) >= top_k:
|
||||
break
|
||||
return unique
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_kb(
|
||||
query: str,
|
||||
collection_keys: list[str] | None = None,
|
||||
top_k: int = 8,
|
||||
tags: list[str] | None = None,
|
||||
since: str | None = None,
|
||||
boost_recent: bool = True,
|
||||
) -> str:
|
||||
"""사용자 지식 베이스(KB) 다중 컬렉션 의미 검색.
|
||||
|
||||
관리탭에서 업로드/인덱싱한 문서에서 질의와 의미적으로 가까운 청크를 찾는다.
|
||||
|
||||
Args:
|
||||
query: 검색어 또는 자연어 질문
|
||||
collection_keys: 대상 컬렉션 키 목록. None이면 전체.
|
||||
가능한 값: system_instrument, plant_operation,
|
||||
procedure, report, vendor_doc
|
||||
top_k: 반환 결과 수 (기본 8)
|
||||
tags: 태그 필터 (any 매칭). 예: ["unit-a", "P-6201"]
|
||||
since: 이 ISO 시각 이후 업로드된 문서만. 예: "2026-04-01T00:00:00Z"
|
||||
boost_recent: True이면 uploaded_at 기준 최신 가중치 적용 (기본 True)
|
||||
|
||||
Returns:
|
||||
JSON 문자열: { success, count, hits: [{ doc_id, collection_key, title,
|
||||
text, chunk_kind, locator, score, uploaded_at, tags }, ...] }
|
||||
"""
|
||||
try:
|
||||
hits = await _search_kb_raw(query, collection_keys, top_k, tags, since, boost_recent)
|
||||
return json.dumps(
|
||||
{"success": True, "count": len(hits), "hits": hits},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": f"search_kb 실패: {e}"}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ── NL2SQL 도구 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def _execute_sql_internal(sql: str) -> str:
|
||||
@@ -1224,6 +1423,63 @@ async def parse_pid_drawing(filepath: str) -> str:
|
||||
|
||||
|
||||
|
||||
# ── KB ingest 파서 ────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def parse_document(
|
||||
doc_id: str,
|
||||
title: str,
|
||||
file_path: str,
|
||||
mime_type: str = "",
|
||||
collection_key: str = "",
|
||||
chunking_policy: str = "",
|
||||
) -> str:
|
||||
"""KB ingest 파서. 파일 확장자에 따라 적절한 청킹을 수행한다.
|
||||
|
||||
Args:
|
||||
doc_id: 문서 ID (UUID 문자열)
|
||||
title: 제목 (오류 메시지에만 사용)
|
||||
file_path: 절대 경로
|
||||
mime_type: 정보용 (옵션)
|
||||
collection_key: 정보용 (옵션)
|
||||
chunking_policy: JSON 문자열, 향후 정책 분기에 사용
|
||||
|
||||
Returns:
|
||||
JSON 문자열: {"success": true, "chunks": [{"text", "chunk_kind", "locator"}, ...]}
|
||||
or {"success": false, "error": "..."}
|
||||
"""
|
||||
import os
|
||||
if not os.path.isfile(file_path):
|
||||
return json.dumps({"success": False, "error": f"file not found: {file_path}"}, ensure_ascii=False)
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
try:
|
||||
if ext in (".xlsx", ".xlsm"):
|
||||
from parsers import xlsx_parser
|
||||
chunks = await asyncio.to_thread(xlsx_parser.parse, file_path)
|
||||
elif ext == ".pdf":
|
||||
from parsers import pdf_parser
|
||||
chunks = await asyncio.to_thread(pdf_parser.parse, file_path)
|
||||
elif ext == ".docx":
|
||||
from parsers import docx_parser
|
||||
chunks = await asyncio.to_thread(docx_parser.parse, file_path)
|
||||
elif ext in (".md", ".txt", ".markdown"):
|
||||
from parsers import text_parser
|
||||
chunks = await asyncio.to_thread(text_parser.parse, file_path)
|
||||
else:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"unsupported extension: {ext}"},
|
||||
ensure_ascii=False
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{"success": True, "doc_id": doc_id, "chunks": chunks, "count": len(chunks)},
|
||||
ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": f"parse failed: {e}"}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ── 엔트리포인트 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
|
||||
75
mcp-server/uv.lock
generated
75
mcp-server/uv.lock
generated
@@ -1226,11 +1226,14 @@ dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "openai" },
|
||||
{ name = "openpyxl" },
|
||||
{ name = "paddleocr" },
|
||||
{ name = "paddlepaddle" },
|
||||
{ name = "pdfplumber" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pymupdf" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
@@ -1246,11 +1249,14 @@ requires-dist = [
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.0.0" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "openai", specifier = ">=1.0.0" },
|
||||
{ name = "openpyxl", specifier = ">=3.1.0" },
|
||||
{ name = "paddleocr", specifier = ">=2.6.0,<2.7.0" },
|
||||
{ name = "paddlepaddle", specifier = ">=2.6.0,<3.0.0" },
|
||||
{ name = "pdfplumber", specifier = ">=0.11.0" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
|
||||
{ name = "pymupdf", specifier = ">=1.24.0" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "qdrant-client", specifier = ">=1.9.0" },
|
||||
{ name = "scikit-learn", specifier = ">=1.3.0" },
|
||||
{ name = "sentence-transformers", specifier = ">=3.0.0" },
|
||||
@@ -2597,6 +2603,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20251230"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfplumber"
|
||||
version = "0.11.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pdfminer-six" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pypdfium2" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
@@ -3156,6 +3189,35 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdfium2"
|
||||
version = "5.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -3168,6 +3230,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
|
||||
@@ -238,15 +238,14 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str,
|
||||
conn = _get_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# TimescaleDB의 time_bucket 함수 사용
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT time_bucket('1 min', ts) AS time, tag_name, value
|
||||
FROM realtime_table
|
||||
WHERE tag_name = ANY(%s)
|
||||
AND ts >= %s
|
||||
AND ts <= %s
|
||||
ORDER BY time DESC
|
||||
SELECT recorded_at AS time, tagname AS tag_name, value
|
||||
FROM history_table
|
||||
WHERE tagname = ANY(%s)
|
||||
AND recorded_at >= %s
|
||||
AND recorded_at <= %s
|
||||
ORDER BY recorded_at DESC, tagname
|
||||
LIMIT %s
|
||||
""",
|
||||
(tag_names, time_from, time_to, limit),
|
||||
@@ -272,17 +271,25 @@ async def _get_tag_metadata(query: str, limit: int = 10) -> str:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT tag_name, unit, description
|
||||
SELECT tagname, livevalue, timestamp, node_id
|
||||
FROM realtime_table
|
||||
WHERE tag_name ILIKE %s
|
||||
ORDER BY tag_name
|
||||
WHERE tagname ILIKE %s
|
||||
ORDER BY tagname
|
||||
LIMIT %s
|
||||
""",
|
||||
(f"%{query}%", limit),
|
||||
)
|
||||
columns = ["tag_name", "unit", "description"]
|
||||
columns = ["tag_name", "current_value", "last_updated", "node_id"]
|
||||
rows = cur.fetchall()
|
||||
data = [dict(zip(columns, row)) for row in rows]
|
||||
data = [
|
||||
{
|
||||
"tag_name": r[0],
|
||||
"current_value": r[1],
|
||||
"last_updated": r[2].isoformat() if r[2] else None,
|
||||
"node_id": r[3],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
|
||||
142
plans/LLM채팅+지식증강-phase5-사용자체크리스트.md
Normal file
142
plans/LLM채팅+지식증강-phase5-사용자체크리스트.md
Normal file
@@ -0,0 +1,142 @@
|
||||
❯ 현재까지 작업한 것의 사용자가 해야할 일 정리해줘
|
||||
|
||||
● 사용자 액션 가이드 (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
|
||||
|
||||
---
|
||||
막힘이 있는 단계가 있으면 어디서 멈췄는지 알려주시면 함께 보겠습니다.
|
||||
@@ -64,20 +64,20 @@
|
||||
|
||||
### 2.1 채팅 페이지 개선 방향 (별도 사용자 결정 대기 항목 있음)
|
||||
|
||||
| # | 항목 | 합의 |
|
||||
|---|---|---|
|
||||
| C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 |
|
||||
| C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 |
|
||||
| C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 |
|
||||
| C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 |
|
||||
| C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 |
|
||||
| C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 |
|
||||
| C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 |
|
||||
| C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 |
|
||||
| C9 | NL2SQL 의도 라우터 | 검토 후 결정 |
|
||||
| C10 | 대화 요약/압축 | 후순위 |
|
||||
| C11 | 에이전트 모드(자율 멀티스텝) | 후순위 |
|
||||
| C12 | 위 1.3 결함 픽스 | 즉시 진행 |
|
||||
| # | 항목 | 합의 | 상태 (2026-05-13) |
|
||||
|---|---|---|---|
|
||||
| C1 | 추천 질문 칩(welcome 화면) | 추천 — 구현 예정 | ✅ 완료 (Phase 5.5) |
|
||||
| C2 | plant_context.md 시스템 프롬프트 주입 | 추천 — 구현 예정 | ✅ 완료 (Phase 0.3, 빈 골격 생성) |
|
||||
| C3 | event MCP 툴 3종 (`query_events`/`summarize_events`/`active_alarms`) | 추천 — 구현 예정 | ⏳ Phase 6.1 |
|
||||
| C4 | SSE `tool_start`/`tool_result` 이벤트 + UI 가시화 | 추천 — 구현 예정 | ✅ 완료 (Phase 5.1, 5.2) |
|
||||
| C5 | 테이블/시계열 자동 렌더링 | 추천 — 구현 예정 | ✅ 표 완료 / 스파클라인 보류 (Phase 5.4) |
|
||||
| C6 | `generate_status_report` 매크로 툴 | 추천 — 구현 예정 | ⏳ Phase 6.3 |
|
||||
| C7 | 태그 시맨틱 검색(`find_tags`) | 추천 — 구현 예정 | ⏳ Phase 6.2 |
|
||||
| C8 | SQL 안전장치 (LIMIT 자동/statement_timeout) | 추천 — 구현 예정 | ⏳ Phase 6.4 |
|
||||
| C9 | NL2SQL 의도 라우터 | 검토 후 결정 | ⏳ 보류 (Phase 7.1) |
|
||||
| C10 | 대화 요약/압축 | 후순위 | ⏳ 보류 (Phase 7.2) |
|
||||
| C11 | 에이전트 모드(자율 멀티스텝) | 후순위 | ⏳ 보류 (Phase 7.3) |
|
||||
| C12 | 위 1.3 결함 픽스 | 즉시 진행 | ✅ 완료 (Phase 0.1, 0.2) |
|
||||
|
||||
### 2.2 지식 증강(RAG ingest) 결정
|
||||
|
||||
@@ -477,56 +477,51 @@ rag_query(question: str,
|
||||
|
||||
## 4. 구현 순서 (Todo)
|
||||
|
||||
### Phase 0 — 사전 정비 (반나절)
|
||||
0.1 `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정
|
||||
0.2 `mcp-server/llm-model.json` 모델명을 실제 vLLM 서빙명과 일치
|
||||
0.3 `OllamaController.cs:608` 시스템 프롬프트 한글화 + plant_context.md 외부 파일화
|
||||
### Phase 0 — 사전 정비 (반나절) ✅ 완료
|
||||
0.1 ✅ `mcp-server/worker/nl2sql_worker.py:244` `time_bucket('1 min', ts)` 버그 수정 (history_table.recorded_at 사용, `_get_tag_metadata`도 같이 수정)
|
||||
0.2 ✅ `mcp-server/llm-model.json` — 실제 서빙 `Qwen3.6-27B-FP8`과 이미 동기화 (memory만 갱신)
|
||||
0.3 ✅ `OllamaController.ComposeSystemPrompt(...)` — 한글 base + `prompts/plant_context.md`(빈 골격) + tool guide 합성, app.js 영문 하드코딩 제거
|
||||
|
||||
### Phase 1 — 데이터 모델 & 인증 (1일)
|
||||
1.1 PostgreSQL 마이그레이션: `kb_collections`, `kb_documents`, `kb_ingest_jobs`,
|
||||
`kb_admin_credential`, `kb_admin_sessions` 테이블 생성
|
||||
1.2 시드 데이터 INSERT: kb_collections 5건 (system_instrument, plant_operation, procedure,
|
||||
report, vendor_doc)
|
||||
1.3 Qdrant 컬렉션 5개 생성 (kb_system_instrument, kb_plant_operation, kb_procedure,
|
||||
kb_report, kb_vendor_doc) — 임베딩 차원에 맞춰
|
||||
1.4 `KbAuthController` (login/logout/status/change-password) + Argon2 해시 유틸
|
||||
1.5 첫 실행 시 초기 비번 시드 로직
|
||||
### Phase 1 — 데이터 모델 & 인증 (1일) ✅ 완료
|
||||
1.1 ✅ DDL 5개 + 시드 5건 (ExperionDbContext.InitializeAsync 자동 적용, pgcrypto 활성화)
|
||||
1.2 ✅ KbStartupService — 부팅 시 활성 컬렉션 5개 Qdrant idempotent ensure (`KbQdrantClient.EnsureCollectionAsync`)
|
||||
1.3 ✅ KbAuthController (login/logout/status/change-password) + `PasswordHasher` (Konscious Argon2id), `X-Kb-Token` 헤더, 초기 비번 env or 자동 생성
|
||||
|
||||
### Phase 2 — 업로드 & 비동기 워커 (2일)
|
||||
2.1 `KbController.Upload` — multipart 수신 → storage 저장 → kb_documents/kb_ingest_jobs INSERT
|
||||
2.2 `KbIngestWorker (BackgroundService)` — 큐 폴링 + 단계별 처리
|
||||
2.3 MCP `parse_document` — xlsx (행+시트), pdf (섹션+표), docx, md/txt
|
||||
2.4 MCP `_embed` 배치 호출 + Qdrant upsert (collection_key 기반 라우팅)
|
||||
2.5 `KbController.Documents/Jobs` — 목록·상세·진행률 폴링
|
||||
### Phase 2 — 업로드 & 비동기 워커 (2일) ✅ 완료
|
||||
2.1 ✅ KbController.Upload — multipart 수신, `storage/kb/{yyyy-MM}/{uuid}.{ext}` 저장, SHA256, kb_documents/kb_ingest_jobs INSERT
|
||||
2.2 ✅ KbIngestWorker — 2초 폴링, parse→embed→index 단일 패스, attempts ≥3=failed
|
||||
2.3 ✅ MCP `parse_document` + `parsers/{xlsx,pdf,docx,text}_parser.py` (행+시트 / 페이지+표 / 헤딩 path / md헤딩·txt단락)
|
||||
2.4 ✅ KbEmbeddingClient (Ollama nomic-embed-text 768-dim) + KbQdrantClient.UpsertAsync (collection_key 라우팅)
|
||||
2.5 ✅ Documents/Jobs/Download/Delete/Reindex/Disable/BulkDisable/PurgeDisabled
|
||||
|
||||
### Phase 3 — 관리 탭 #14 (1일)
|
||||
3.1 사이드바 14번 탭 추가, `<section id="pane-kbadmin">` 신설
|
||||
3.2 비번 입력 → 토큰 받아 sessionStorage 저장
|
||||
3.3 컬렉션 필터, 상태/태그 필터, 검색
|
||||
3.4 업로드 모달(드래그앤드롭 + collection_key 드롭다운 + 태그)
|
||||
3.5 목록, 상세 보기, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제
|
||||
3.6 1초 폴링으로 ingesting 진행률 표시
|
||||
### Phase 3 — 관리 탭 #14 (1일) ✅ 완료
|
||||
3.1 ✅ 사이드바 14번 + pane-kbadmin
|
||||
3.2 ✅ 비번 로그인 → sessionStorage 토큰 (X-Kb-Token)
|
||||
3.3 ✅ 컬렉션/상태/제목 필터
|
||||
3.4 ✅ 업로드 모달 (collection_key 드롭다운 강제 + 제목 + 태그) — 드래그앤드롭은 후순위
|
||||
3.5 ✅ 목록, 다운로드, 삭제, 재인덱스, 일괄 비활성화, 비활성화 영구삭제
|
||||
3.6 ✅ 1.5초 폴링 (pending/parsing/embedding 있을 때만 새로고침)
|
||||
|
||||
### Phase 4 — 다운로드 & 검색 (반나절)
|
||||
4.1 `/api/kb/download/{docId}` — 원본 스트림, Content-Disposition
|
||||
4.2 MCP `search_kb` — 다중 컬렉션 + uploaded_at 최신 가중치 + 태그 필터
|
||||
4.3 기존 `rag_query` 확장: `search_kb` 통합 옵션
|
||||
### Phase 4 — 다운로드 & 검색 (반나절) ✅ 완료
|
||||
4.1 ✅ /api/kb/download/{docId} — Content-Disposition + MIME (KbController.Download)
|
||||
4.2 ✅ MCP `search_kb` — `_search_kb_collection`(태그 filter), `_recency_factor`(7d+10%/30d+5%/90d+2%), `_search_kb_raw`(다중 컬렉션 + since 후필터 + doc_id::locator dedup)
|
||||
4.3 ✅ rag_query 확장 — `search_kb`, `kb_collections` 인자 + `_format_kb_results` 인용 직렬화
|
||||
|
||||
### Phase 5 — 채팅 통합 (1~2일)
|
||||
5.1 SSE 이벤트 추가: `tool_start`, `tool_result` (백엔드 `VllmChatStreamWithTools` 안)
|
||||
5.2 프론트 채팅 메시지에 툴 실행 카드 렌더 (접이식)
|
||||
5.3 모델 인용 자동 → 다운로드 링크 치환
|
||||
5.4 테이블/시계열 자동 렌더 (`{success, columns, data}` JSON 감지)
|
||||
5.5 추천 질문 칩(welcome 화면)
|
||||
5.6 system prompt 합성 로직 (plant_context.md + 도구 가이드 + 사용자 입력)
|
||||
### Phase 5 — 채팅 통합 (1~2일) ✅ 완료
|
||||
5.1 ✅ SSE `tool_start`/`tool_result` 이벤트 — `EmitToolStart/EmitToolResult` 헬퍼, 공식 tool_calls 경로 + JSON-텍스트 폴백 경로 둘 다 발행
|
||||
5.2 ✅ 툴 실행 카드 (접이식, running/ok/err 색상, spin 애니메이션)
|
||||
5.3 ✅ KB 인용 자동 링크 — `llmKbDocMap`에 search_kb hits title→docId 누적, `llmLinkKbCitations`로 본문 치환
|
||||
5.4 ✅ 표 자동 렌더 (`{success, columns, data}` 및 `data:[{...}]` 감지, 최대 50행) / 스파클라인은 보류
|
||||
5.5 ✅ 추천 질문 칩 7종 (활성 알람, Unit A 요약, FIC-6113 추이, 디지털 이벤트, 정비 이력, 주간 보고, find_tags)
|
||||
5.6 ✅ Phase 0.3에서 이미 완료
|
||||
|
||||
### Phase 6 — 보강 도구 (1일)
|
||||
### Phase 6 — 보강 도구 (1일) ⏳ 미구현
|
||||
6.1 MCP `query_events`, `summarize_events`, `active_alarms` (event_history_table 기반)
|
||||
6.2 MCP `find_tags` — tag_metadata 시맨틱 검색 (별도 Qdrant 컬렉션 또는 KB와 통합)
|
||||
6.3 MCP `generate_status_report` — 매크로 툴
|
||||
6.4 `run_sql` LIMIT 자동 + `SET LOCAL statement_timeout = 10s`
|
||||
|
||||
### Phase 7 — 운영 보강 (옵션)
|
||||
### Phase 7 — 운영 보강 (옵션) ⏳ 미구현
|
||||
7.1 NL2SQL 의도 라우터
|
||||
7.2 대화 요약/압축 (장기 세션)
|
||||
7.3 에이전트 모드 (자율 멀티스텝 계획)
|
||||
|
||||
0
plans/phase5-사용자점검리스트.md
Normal file
0
plans/phase5-사용자점검리스트.md
Normal file
27
prompts/plant_context.md
Normal file
27
prompts/plant_context.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 플랜트 운전 컨텍스트
|
||||
|
||||
> 본 파일은 LLM 채팅의 시스템 프롬프트에 자동 주입됩니다.
|
||||
> 운영 환경에 맞춰 단위(Area / Unit), 계기 prefix, 태그 명명 규칙, 예시 질문 등을 채워주세요.
|
||||
|
||||
## 단위(Area / Unit)
|
||||
|
||||
<!-- 예: Unit A — 압축 -->
|
||||
<!-- 예: Unit B — 분리 -->
|
||||
|
||||
## 계기 명명 약어
|
||||
|
||||
<!-- 예: FIC: Flow Indicator Controller -->
|
||||
<!-- 예: PT: Pressure Transmitter -->
|
||||
|
||||
## 태그 명명 규칙
|
||||
|
||||
<!-- 예: 모두 소문자 (ficq-6113.pv) -->
|
||||
<!-- 예: 접미사 .pv/.sp/.op/.instate0..7 -->
|
||||
|
||||
## 시간대
|
||||
|
||||
<!-- 예: DB 저장 UTC, 사용자 입력 KST -->
|
||||
|
||||
## 예시 질문 / 의도 라우팅
|
||||
|
||||
<!-- 자유 작성 -->
|
||||
@@ -150,6 +150,84 @@ public class TagMetadata
|
||||
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// ── Knowledge Base (RAG) ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>kb_collections — 컬렉션 레지스트리(seed: system_instrument 등 5종)</summary>
|
||||
[Table("kb_collections")]
|
||||
public class KbCollection
|
||||
{
|
||||
[Key]
|
||||
[Column("collection_key")] public string CollectionKey { get; set; } = string.Empty;
|
||||
[Column("display_name")] public string DisplayName { get; set; } = string.Empty;
|
||||
[Column("qdrant_name")] public string QdrantName { get; set; } = string.Empty;
|
||||
[Column("chunking_policy", TypeName = "jsonb")]
|
||||
public string ChunkingPolicy { get; set; } = "{}";
|
||||
[Column("description")] public string? Description { get; set; }
|
||||
[Column("is_active")] public bool IsActive { get; set; } = true;
|
||||
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>kb_documents — 업로드된 원본 문서 메타</summary>
|
||||
[Table("kb_documents")]
|
||||
public class KbDocument
|
||||
{
|
||||
[Key]
|
||||
[Column("id")] public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[Column("collection_key")] public string CollectionKey { get; set; } = string.Empty;
|
||||
[Column("title")] public string Title { get; set; } = string.Empty;
|
||||
[Column("original_path")] public string OriginalPath { get; set; } = string.Empty;
|
||||
[Column("file_sha256")] public string FileSha256 { get; set; } = string.Empty;
|
||||
[Column("file_size")] public long? FileSize { get; set; }
|
||||
[Column("mime_type")] public string? MimeType { get; set; }
|
||||
[Column("tags")] public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
[Column("status")] public string Status { get; set; } = "pending";
|
||||
// pending / parsing / embedding / indexed / failed / disabled
|
||||
[Column("chunk_count")] public int ChunkCount { get; set; }
|
||||
[Column("error_message")] public string? ErrorMessage { get; set; }
|
||||
[Column("uploaded_by")] public string? UploadedBy { get; set; }
|
||||
[Column("uploaded_at")] public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||
[Column("indexed_at")] public DateTime? IndexedAt { get; set; }
|
||||
[Column("disabled_at")] public DateTime? DisabledAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>kb_ingest_jobs — 비동기 처리 큐</summary>
|
||||
[Table("kb_ingest_jobs")]
|
||||
public class KbIngestJob
|
||||
{
|
||||
[Key]
|
||||
[Column("id")] public long Id { get; set; }
|
||||
[Column("doc_id")] public Guid DocId { get; set; }
|
||||
[Column("stage")] public string Stage { get; set; } = "parse"; // parse / embed / index
|
||||
[Column("attempts")] public int Attempts { get; set; }
|
||||
[Column("last_error")] public string? LastError { get; set; }
|
||||
[Column("enqueued_at")] public DateTime EnqueuedAt { get; set; } = DateTime.UtcNow;
|
||||
[Column("started_at")] public DateTime? StartedAt { get; set; }
|
||||
[Column("finished_at")] public DateTime? FinishedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>kb_admin_credential — 관리자 비밀번호(단일 행)</summary>
|
||||
[Table("kb_admin_credential")]
|
||||
public class KbAdminCredential
|
||||
{
|
||||
[Key]
|
||||
[Column("id")] public int Id { get; set; } = 1;
|
||||
[Column("password_hash")] public string PasswordHash { get; set; } = string.Empty;
|
||||
[Column("salt")] public string Salt { get; set; } = string.Empty;
|
||||
[Column("algorithm")] public string Algorithm { get; set; } = "argon2id";
|
||||
[Column("updated_at")] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>kb_admin_sessions — 관리자 세션 토큰</summary>
|
||||
[Table("kb_admin_sessions")]
|
||||
public class KbAdminSession
|
||||
{
|
||||
[Key]
|
||||
[Column("token")] public string Token { get; set; } = string.Empty;
|
||||
[Column("issued_at")] public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
|
||||
[Column("expires_at")] public DateTime ExpiresAt { get; set; }
|
||||
[Column("client_ip")] public string? ClientIp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
|
||||
[Table("event_history_table")]
|
||||
public class EventHistoryRecord
|
||||
|
||||
@@ -30,6 +30,13 @@ public class ExperionDbContext : DbContext
|
||||
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
|
||||
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
|
||||
|
||||
// ── Knowledge Base ────────────────────────────────────────────────────────
|
||||
public DbSet<KbCollection> KbCollections => Set<KbCollection>();
|
||||
public DbSet<KbDocument> KbDocuments => Set<KbDocument>();
|
||||
public DbSet<KbIngestJob> KbIngestJobs => Set<KbIngestJob>();
|
||||
public DbSet<KbAdminCredential> KbAdminCredentials => Set<KbAdminCredential>();
|
||||
public DbSet<KbAdminSession> KbAdminSessions => Set<KbAdminSession>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ExperionRecord>(e =>
|
||||
@@ -180,6 +187,37 @@ public class ExperionDbContext : DbContext
|
||||
entity.HasIndex(e => new { e.Area, e.EventTime });
|
||||
entity.HasIndex(e => new { e.EventType, e.EventTime });
|
||||
});
|
||||
|
||||
// ── Knowledge Base ───────────────────────────────────────────────────
|
||||
modelBuilder.Entity<KbCollection>(e =>
|
||||
{
|
||||
e.HasKey(x => x.CollectionKey);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KbDocument>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Tags).HasColumnType("text[]");
|
||||
e.HasIndex(x => new { x.CollectionKey, x.Status, x.UploadedAt });
|
||||
e.HasIndex(x => x.Title);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KbIngestJob>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasIndex(x => new { x.Stage, x.FinishedAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KbAdminCredential>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KbAdminSession>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Token);
|
||||
e.HasIndex(x => x.ExpiresAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +419,114 @@ public class ExperionDbService : IExperionDbService
|
||||
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
||||
// CreateHypertableAsync() 메서드에서 선택적으로 설정 가능
|
||||
|
||||
// ── Knowledge Base (RAG) 테이블 ──────────────────────────────────
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"");
|
||||
|
||||
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()
|
||||
)
|
||||
""");
|
||||
|
||||
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 _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 _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 _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 _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 _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 _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_sessions_expires
|
||||
ON kb_admin_sessions(expires_at);
|
||||
""");
|
||||
|
||||
// ── 시드: 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
|
||||
""");
|
||||
|
||||
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
|
||||
return true;
|
||||
}
|
||||
|
||||
149
src/Infrastructure/Kb/KbAuthService.cs
Normal file
149
src/Infrastructure/Kb/KbAuthService.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
public sealed record KbLoginResult(bool Success, string? Token, DateTime? ExpiresAt, string? Error);
|
||||
|
||||
public interface IKbAuthService
|
||||
{
|
||||
Task EnsureCredentialAsync(CancellationToken ct = default);
|
||||
Task<KbLoginResult> LoginAsync(string password, string? clientIp, CancellationToken ct = default);
|
||||
Task<bool> ValidateAsync(string? token, CancellationToken ct = default);
|
||||
Task LogoutAsync(string token, CancellationToken ct = default);
|
||||
Task<bool> ChangePasswordAsync(string oldPassword, string newPassword, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class KbAuthService : IKbAuthService
|
||||
{
|
||||
private readonly ExperionDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<KbAuthService> _logger;
|
||||
|
||||
public KbAuthService(ExperionDbContext db, IConfiguration config, ILogger<KbAuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private int SessionMinutes =>
|
||||
int.TryParse(_config["Kb:AdminSessionMinutes"], out var m) && m > 0 ? m : 60;
|
||||
|
||||
public async Task EnsureCredentialAsync(CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _db.KbAdminCredentials.FirstOrDefaultAsync(ct);
|
||||
if (existing != null) return;
|
||||
|
||||
var initial = _config["Kb:AdminInitialPassword"]
|
||||
?? Environment.GetEnvironmentVariable("KB_ADMIN_INITIAL_PASSWORD");
|
||||
|
||||
bool generated = false;
|
||||
if (string.IsNullOrWhiteSpace(initial))
|
||||
{
|
||||
initial = Guid.NewGuid().ToString("N")[..16];
|
||||
generated = true;
|
||||
}
|
||||
|
||||
var (hash, salt) = PasswordHasher.Hash(initial);
|
||||
_db.KbAdminCredentials.Add(new KbAdminCredential
|
||||
{
|
||||
Id = 1,
|
||||
PasswordHash = hash,
|
||||
Salt = salt,
|
||||
Algorithm = "argon2id",
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (generated)
|
||||
{
|
||||
_logger.LogWarning("[Kb] 관리자 초기 비밀번호 자동 생성: {Pw} ← 즉시 변경하세요", initial);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[Kb] 관리자 초기 비밀번호 설정 (환경변수 사용)");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<KbLoginResult> LoginAsync(string password, string? clientIp, CancellationToken ct = default)
|
||||
{
|
||||
var cred = await _db.KbAdminCredentials.FirstOrDefaultAsync(ct);
|
||||
if (cred == null) return new(false, null, null, "credential not initialized");
|
||||
|
||||
if (!PasswordHasher.Verify(password, cred.PasswordHash, cred.Salt))
|
||||
return new(false, null, null, "invalid password");
|
||||
|
||||
var token = PasswordHasher.NewSessionToken();
|
||||
var expires = DateTime.UtcNow.AddMinutes(SessionMinutes);
|
||||
_db.KbAdminSessions.Add(new KbAdminSession
|
||||
{
|
||||
Token = token,
|
||||
IssuedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expires,
|
||||
ClientIp = clientIp
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await PurgeExpiredAsync(ct);
|
||||
return new(true, token, expires, null);
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string? token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
var s = await _db.KbAdminSessions.FirstOrDefaultAsync(x => x.Token == token, ct);
|
||||
if (s == null) return false;
|
||||
if (s.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
_db.KbAdminSessions.Remove(s);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
var s = await _db.KbAdminSessions.FirstOrDefaultAsync(x => x.Token == token, ct);
|
||||
if (s != null)
|
||||
{
|
||||
_db.KbAdminSessions.Remove(s);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ChangePasswordAsync(string oldPassword, string newPassword, CancellationToken ct = default)
|
||||
{
|
||||
var cred = await _db.KbAdminCredentials.FirstOrDefaultAsync(ct);
|
||||
if (cred == null) return false;
|
||||
if (!PasswordHasher.Verify(oldPassword, cred.PasswordHash, cred.Salt)) return false;
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6) return false;
|
||||
|
||||
var (hash, salt) = PasswordHasher.Hash(newPassword);
|
||||
cred.PasswordHash = hash;
|
||||
cred.Salt = salt;
|
||||
cred.Algorithm = "argon2id";
|
||||
cred.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var sessions = await _db.KbAdminSessions.ToListAsync(ct);
|
||||
_db.KbAdminSessions.RemoveRange(sessions);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task PurgeExpiredAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expired = await _db.KbAdminSessions.Where(s => s.ExpiresAt < now).ToListAsync(ct);
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
_db.KbAdminSessions.RemoveRange(expired);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/Infrastructure/Kb/KbEmbeddingClient.cs
Normal file
49
src/Infrastructure/Kb/KbEmbeddingClient.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
/// <summary>
|
||||
/// Ollama nomic-embed-text(768-dim) 임베딩 클라이언트. /api/embeddings 직접 호출.
|
||||
/// </summary>
|
||||
public sealed class KbEmbeddingClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _model;
|
||||
private readonly ILogger<KbEmbeddingClient> _logger;
|
||||
|
||||
public KbEmbeddingClient(IHttpClientFactory factory, IConfiguration config, ILogger<KbEmbeddingClient> logger)
|
||||
{
|
||||
_http = factory.CreateClient("Ollama");
|
||||
_model = config["Kb:EmbeddingModel"] ?? "nomic-embed-text";
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<float[]?> EmbedAsync(string text, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await _http.PostAsJsonAsync("/api/embeddings", new { model = _model, prompt = text }, ct);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[Kb][Embed] HTTP {Code}", (int)res.StatusCode);
|
||||
return null;
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
if (!doc.RootElement.TryGetProperty("embedding", out var arr)) return null;
|
||||
var len = arr.GetArrayLength();
|
||||
var vec = new float[len];
|
||||
int i = 0;
|
||||
foreach (var e in arr.EnumerateArray())
|
||||
vec[i++] = (float)e.GetDouble();
|
||||
return vec;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Kb][Embed] 실패");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
228
src/Infrastructure/Kb/KbIngestWorker.cs
Normal file
228
src/Infrastructure/Kb/KbIngestWorker.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System.Text.Json;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
using ExperionCrawler.Infrastructure.Mcp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
/// <summary>
|
||||
/// kb_ingest_jobs 큐를 폴링하며 parse → embed → index 단계를 수행.
|
||||
/// 한 잡(stage='parse')을 픽업해서 단일 트랜잭션 안에서 끝까지 진행한다.
|
||||
/// </summary>
|
||||
public sealed class KbIngestWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly KbQdrantClient _qdrant;
|
||||
private readonly KbEmbeddingClient _embed;
|
||||
private readonly McpClient _mcp;
|
||||
private readonly KbStorageService _storage;
|
||||
private readonly ILogger<KbIngestWorker> _logger;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly int _maxAttempts;
|
||||
|
||||
public KbIngestWorker(
|
||||
IServiceProvider sp,
|
||||
KbQdrantClient qdrant,
|
||||
KbEmbeddingClient embed,
|
||||
McpClient mcp,
|
||||
KbStorageService storage,
|
||||
IConfiguration config,
|
||||
ILogger<KbIngestWorker> logger)
|
||||
{
|
||||
_sp = sp;
|
||||
_qdrant = qdrant;
|
||||
_embed = embed;
|
||||
_mcp = mcp;
|
||||
_storage = storage;
|
||||
_logger = logger;
|
||||
|
||||
var sec = int.TryParse(config["Kb:WorkerPollIntervalSeconds"], out var s) && s > 0 ? s : 2;
|
||||
_pollInterval = TimeSpan.FromSeconds(sec);
|
||||
_maxAttempts = int.TryParse(config["Kb:MaxAttempts"], out var m) && m > 0 ? m : 3;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[Kb][Worker] 시작 (poll {Sec}s, maxAttempts {N})", _pollInterval.TotalSeconds, _maxAttempts);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessOneAsync(stoppingToken);
|
||||
if (!processed)
|
||||
await Task.Delay(_pollInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Kb][Worker] 루프 오류");
|
||||
try { await Task.Delay(_pollInterval, stoppingToken); } catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessOneAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ExperionDbContext>();
|
||||
|
||||
var job = await db.KbIngestJobs
|
||||
.Where(j => j.FinishedAt == null && j.Stage == "parse" && j.Attempts < _maxAttempts)
|
||||
.OrderBy(j => j.EnqueuedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (job == null) return false;
|
||||
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.Attempts++;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var doc = await db.KbDocuments.FirstOrDefaultAsync(d => d.Id == job.DocId, ct);
|
||||
if (doc == null)
|
||||
{
|
||||
job.FinishedAt = DateTime.UtcNow;
|
||||
job.LastError = "document not found";
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
var coll = await db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == doc.CollectionKey, ct);
|
||||
if (coll == null)
|
||||
{
|
||||
await FailAsync(db, job, doc, "collection not found", ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1) parse
|
||||
doc.Status = "parsing";
|
||||
doc.ErrorMessage = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var abs = _storage.Resolve(doc.OriginalPath);
|
||||
var chunks = await ParseAsync(doc, coll, abs, ct);
|
||||
if (chunks == null || chunks.Count == 0)
|
||||
throw new Exception("파싱 결과 청크 0건");
|
||||
|
||||
_logger.LogInformation("[Kb][Worker] {Id} parse: {N} chunks", doc.Id, chunks.Count);
|
||||
|
||||
// 2) embed
|
||||
doc.Status = "embedding";
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var points = new List<QdrantPoint>(chunks.Count);
|
||||
foreach (var c in chunks)
|
||||
{
|
||||
var vec = await _embed.EmbedAsync(c.Text, ct);
|
||||
if (vec == null) throw new Exception("임베딩 실패(부분)");
|
||||
points.Add(new QdrantPoint
|
||||
{
|
||||
id = Guid.NewGuid(),
|
||||
vector = vec,
|
||||
payload = new Dictionary<string, object?>
|
||||
{
|
||||
["doc_id"] = doc.Id.ToString(),
|
||||
["collection_key"] = doc.CollectionKey,
|
||||
["title"] = doc.Title,
|
||||
["text"] = c.Text,
|
||||
["chunk_kind"] = c.ChunkKind,
|
||||
["locator"] = c.Locator,
|
||||
["uploaded_at"] = doc.UploadedAt.ToString("O"),
|
||||
["tags"] = doc.Tags
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3) index
|
||||
doc.Status = "indexed"; // 낙관적 - 실패 시 catch에서 되돌림
|
||||
var ok = await _qdrant.UpsertAsync(coll.QdrantName, points, ct);
|
||||
if (!ok) throw new Exception("Qdrant upsert 실패");
|
||||
|
||||
doc.ChunkCount = chunks.Count;
|
||||
doc.IndexedAt = DateTime.UtcNow;
|
||||
doc.ErrorMessage = null;
|
||||
|
||||
job.FinishedAt = DateTime.UtcNow;
|
||||
job.LastError = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("[Kb][Worker] {Id} indexed ({N} chunks)", doc.Id, chunks.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Kb][Worker] {Id} 처리 실패 (attempt {A}/{M})", doc.Id, job.Attempts, _maxAttempts);
|
||||
await FailAsync(db, job, doc, ex.Message, ct);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task FailAsync(ExperionDbContext db, KbIngestJob job, KbDocument doc, string error, CancellationToken ct)
|
||||
{
|
||||
job.LastError = error;
|
||||
if (job.Attempts >= _maxAttempts)
|
||||
{
|
||||
job.FinishedAt = DateTime.UtcNow;
|
||||
doc.Status = "failed";
|
||||
doc.ErrorMessage = error;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 재시도 대기 — finished_at NULL, attempts 누적
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private sealed record ParsedChunk(string Text, string ChunkKind, string Locator);
|
||||
|
||||
private async Task<List<ParsedChunk>?> ParseAsync(KbDocument doc, KbCollection coll, string absPath, CancellationToken ct)
|
||||
{
|
||||
var args = new Dictionary<string, object>
|
||||
{
|
||||
["doc_id"] = doc.Id.ToString(),
|
||||
["title"] = doc.Title,
|
||||
["file_path"] = absPath,
|
||||
["mime_type"] = doc.MimeType ?? "",
|
||||
["collection_key"] = doc.CollectionKey,
|
||||
["chunking_policy"] = coll.ChunkingPolicy
|
||||
};
|
||||
|
||||
var raw = await _mcp.CallToolAsync("parse_document", args, ct);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new Exception("MCP parse_document 응답 없음");
|
||||
|
||||
// FastMCP는 텍스트 컨텐츠를 단순 JSON 문자열로 돌려준다.
|
||||
try
|
||||
{
|
||||
using var jdoc = JsonDocument.Parse(raw);
|
||||
if (jdoc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
var err = jdoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "unknown";
|
||||
throw new Exception($"MCP parse_document 실패: {err}");
|
||||
}
|
||||
|
||||
if (!jdoc.RootElement.TryGetProperty("chunks", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return new List<ParsedChunk>();
|
||||
|
||||
var list = new List<ParsedChunk>();
|
||||
foreach (var c in arr.EnumerateArray())
|
||||
{
|
||||
var text = c.TryGetProperty("text", out var t) ? (t.GetString() ?? "") : "";
|
||||
if (string.IsNullOrWhiteSpace(text)) continue;
|
||||
var kind = c.TryGetProperty("chunk_kind", out var k) ? (k.GetString() ?? "section") : "section";
|
||||
var loc = c.TryGetProperty("locator", out var l) ? (l.GetString() ?? "") : "";
|
||||
list.Add(new ParsedChunk(text, kind, loc));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new Exception("MCP parse_document JSON 파싱 실패");
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/Infrastructure/Kb/KbQdrantClient.cs
Normal file
86
src/Infrastructure/Kb/KbQdrantClient.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
/// <summary>
|
||||
/// Qdrant HTTP API 래퍼. nomic-embed-text 임베딩이 768-dim 이므로 동일하게 가정.
|
||||
/// </summary>
|
||||
public sealed class KbQdrantClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<KbQdrantClient> _logger;
|
||||
private readonly int _vectorSize;
|
||||
|
||||
public KbQdrantClient(IConfiguration config, ILogger<KbQdrantClient> logger)
|
||||
{
|
||||
var baseUrl = config["Kb:QdrantUrl"] ?? "http://localhost:6333";
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) };
|
||||
_vectorSize = int.TryParse(config["Kb:VectorSize"], out var v) ? v : 768;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureCollectionAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var get = await _http.GetAsync($"/collections/{name}", ct);
|
||||
if (get.StatusCode == HttpStatusCode.OK) return true;
|
||||
if (get.StatusCode != HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning("[Kb][Qdrant] 컬렉션 조회 실패 {Name} HTTP {Code}", name, (int)get.StatusCode);
|
||||
}
|
||||
|
||||
var body = new
|
||||
{
|
||||
vectors = new { size = _vectorSize, distance = "Cosine" }
|
||||
};
|
||||
var res = await _http.PutAsJsonAsync($"/collections/{name}", body, ct);
|
||||
if (res.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("[Kb][Qdrant] 컬렉션 생성 {Name} (size={Size})", name, _vectorSize);
|
||||
return true;
|
||||
}
|
||||
var err = await res.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("[Kb][Qdrant] 컬렉션 생성 실패 {Name} HTTP {Code}: {Err}", name, (int)res.StatusCode, err);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByDocAsync(string collection, Guid docId, CancellationToken ct = default)
|
||||
{
|
||||
var body = new
|
||||
{
|
||||
filter = new
|
||||
{
|
||||
must = new[]
|
||||
{
|
||||
new { key = "doc_id", match = new { value = docId.ToString() } }
|
||||
}
|
||||
}
|
||||
};
|
||||
var res = await _http.PostAsJsonAsync($"/collections/{collection}/points/delete?wait=true", body, ct);
|
||||
return res.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> UpsertAsync(string collection, IEnumerable<QdrantPoint> points, CancellationToken ct = default)
|
||||
{
|
||||
var body = new { points };
|
||||
var res = await _http.PutAsJsonAsync($"/collections/{collection}/points?wait=true", body, ct);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("[Kb][Qdrant] upsert 실패 {Name} HTTP {Code}: {Err}", collection, (int)res.StatusCode, err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class QdrantPoint
|
||||
{
|
||||
public Guid id { get; set; }
|
||||
public float[] vector { get; set; } = Array.Empty<float>();
|
||||
public Dictionary<string, object?> payload { get; set; } = new();
|
||||
}
|
||||
48
src/Infrastructure/Kb/KbStartupService.cs
Normal file
48
src/Infrastructure/Kb/KbStartupService.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
/// <summary>
|
||||
/// 앱 기동 시 kb_collections.is_active=TRUE 인 컬렉션에 대해 Qdrant 컬렉션을 idempotent 생성.
|
||||
/// </summary>
|
||||
public sealed class KbStartupService : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly KbQdrantClient _qdrant;
|
||||
private readonly ILogger<KbStartupService> _logger;
|
||||
|
||||
public KbStartupService(IServiceProvider sp, KbQdrantClient qdrant, ILogger<KbStartupService> logger)
|
||||
{
|
||||
_sp = sp;
|
||||
_qdrant = qdrant;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ExperionDbContext>();
|
||||
var active = await db.KbCollections
|
||||
.Where(c => c.IsActive)
|
||||
.Select(c => c.QdrantName)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var name in active)
|
||||
await _qdrant.EnsureCollectionAsync(name, cancellationToken);
|
||||
|
||||
_logger.LogInformation("[Kb] Qdrant 컬렉션 ensure 완료: {Count}건", active.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Kb] Qdrant 컬렉션 ensure 실패 — 추후 첫 사용 시 재시도 필요");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
73
src/Infrastructure/Kb/KbStorageService.cs
Normal file
73
src/Infrastructure/Kb/KbStorageService.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
public sealed record KbStoredFile(string AbsolutePath, string RelativePath, string Sha256, long Size);
|
||||
|
||||
public sealed class KbStorageService
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public KbStorageService(IConfiguration config)
|
||||
{
|
||||
var configured = config["Kb:StorageRoot"] ?? "../../storage/kb";
|
||||
_root = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), configured));
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public string Root => _root;
|
||||
|
||||
public async Task<KbStoredFile> SaveAsync(Stream input, string originalFileName, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var monthDir = Path.Combine(_root, $"{now:yyyy-MM}");
|
||||
Directory.CreateDirectory(monthDir);
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var ext = Path.GetExtension(originalFileName);
|
||||
if (string.IsNullOrWhiteSpace(ext)) ext = "";
|
||||
var fileName = $"{id:N}{ext}";
|
||||
var abs = Path.Combine(monthDir, fileName);
|
||||
|
||||
using var fs = new FileStream(abs, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, true);
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[64 * 1024];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0)
|
||||
{
|
||||
sha.TransformBlock(buffer, 0, read, null, 0);
|
||||
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
total += read;
|
||||
}
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
var hashHex = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
|
||||
var rel = Path.Combine($"{now:yyyy-MM}", fileName).Replace('\\', '/');
|
||||
return new KbStoredFile(abs, rel, hashHex, total);
|
||||
}
|
||||
|
||||
public string Resolve(string relativePath)
|
||||
{
|
||||
if (Path.IsPathRooted(relativePath)) return relativePath;
|
||||
return Path.Combine(_root, relativePath);
|
||||
}
|
||||
|
||||
public bool Delete(string relativePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = Resolve(relativePath);
|
||||
if (File.Exists(p))
|
||||
{
|
||||
File.Delete(p);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
55
src/Infrastructure/Kb/PasswordHasher.cs
Normal file
55
src/Infrastructure/Kb/PasswordHasher.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Kb;
|
||||
|
||||
public static class PasswordHasher
|
||||
{
|
||||
private const int SaltBytes = 16;
|
||||
private const int HashBytes = 32;
|
||||
private const int DegreeOfParallelism = 4;
|
||||
private const int MemoryKb = 65536;
|
||||
private const int Iterations = 3;
|
||||
|
||||
public static (string HashB64, string SaltB64) Hash(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
|
||||
return (HashWithSalt(password, salt), Convert.ToBase64String(salt));
|
||||
}
|
||||
|
||||
public static bool Verify(string password, string hashB64, string saltB64)
|
||||
{
|
||||
try
|
||||
{
|
||||
var salt = Convert.FromBase64String(saltB64);
|
||||
var expected = Convert.FromBase64String(hashB64);
|
||||
var actualB64 = HashWithSalt(password, salt);
|
||||
var actual = Convert.FromBase64String(actualB64);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string HashWithSalt(string password, byte[] salt)
|
||||
{
|
||||
using var argon = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
DegreeOfParallelism = DegreeOfParallelism,
|
||||
MemorySize = MemoryKb,
|
||||
Iterations = Iterations
|
||||
};
|
||||
return Convert.ToBase64String(argon.GetBytes(HashBytes));
|
||||
}
|
||||
|
||||
public static string NewSessionToken()
|
||||
{
|
||||
Span<byte> buf = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buf);
|
||||
return Convert.ToHexString(buf).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
68
src/Web/Controllers/KbAuthController.cs
Normal file
68
src/Web/Controllers/KbAuthController.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using ExperionCrawler.Infrastructure.Kb;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ExperionCrawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/kb/auth")]
|
||||
public class KbAuthController : ControllerBase
|
||||
{
|
||||
private readonly IKbAuthService _auth;
|
||||
private readonly ILogger<KbAuthController> _logger;
|
||||
|
||||
public KbAuthController(IKbAuthService auth, ILogger<KbAuthController> logger)
|
||||
{
|
||||
_auth = auth;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(string Password);
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword);
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct)
|
||||
{
|
||||
if (req == null || string.IsNullOrWhiteSpace(req.Password))
|
||||
return Ok(new { success = false, error = "password is required" });
|
||||
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var result = await _auth.LoginAsync(req.Password, ip, ct);
|
||||
if (!result.Success)
|
||||
return Ok(new { success = false, error = result.Error ?? "login failed" });
|
||||
|
||||
return Ok(new { success = true, token = result.Token, expiresAt = result.ExpiresAt });
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||
{
|
||||
var token = Request.Headers["X-Kb-Token"].ToString();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
await _auth.LogoutAsync(token, ct);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken ct)
|
||||
{
|
||||
var token = Request.Headers["X-Kb-Token"].ToString();
|
||||
var valid = await _auth.ValidateAsync(token, ct);
|
||||
return Ok(new { valid });
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req, CancellationToken ct)
|
||||
{
|
||||
var token = Request.Headers["X-Kb-Token"].ToString();
|
||||
if (!await _auth.ValidateAsync(token, ct))
|
||||
return Unauthorized(new { success = false, error = "invalid token" });
|
||||
|
||||
if (req == null || string.IsNullOrWhiteSpace(req.OldPassword) || string.IsNullOrWhiteSpace(req.NewPassword))
|
||||
return Ok(new { success = false, error = "passwords required" });
|
||||
if (req.NewPassword.Length < 6)
|
||||
return Ok(new { success = false, error = "new password must be at least 6 chars" });
|
||||
|
||||
var ok = await _auth.ChangePasswordAsync(req.OldPassword, req.NewPassword, ct);
|
||||
return Ok(new { success = ok });
|
||||
}
|
||||
}
|
||||
337
src/Web/Controllers/KbController.cs
Normal file
337
src/Web/Controllers/KbController.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
using ExperionCrawler.Infrastructure.Kb;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ExperionCrawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/kb")]
|
||||
public class KbController : ControllerBase
|
||||
{
|
||||
private readonly ExperionDbContext _db;
|
||||
private readonly KbStorageService _storage;
|
||||
private readonly KbQdrantClient _qdrant;
|
||||
private readonly IKbAuthService _auth;
|
||||
private readonly ILogger<KbController> _logger;
|
||||
|
||||
public KbController(
|
||||
ExperionDbContext db,
|
||||
KbStorageService storage,
|
||||
KbQdrantClient qdrant,
|
||||
IKbAuthService auth,
|
||||
ILogger<KbController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_storage = storage;
|
||||
_qdrant = qdrant;
|
||||
_auth = auth;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<bool> RequireAdminAsync(CancellationToken ct)
|
||||
{
|
||||
var token = Request.Headers["X-Kb-Token"].ToString();
|
||||
return await _auth.ValidateAsync(token, ct);
|
||||
}
|
||||
|
||||
[HttpGet("collections")]
|
||||
public async Task<IActionResult> GetCollections(CancellationToken ct)
|
||||
{
|
||||
var items = await _db.KbCollections
|
||||
.Where(c => c.IsActive)
|
||||
.OrderBy(c => c.CollectionKey)
|
||||
.Select(c => new
|
||||
{
|
||||
key = c.CollectionKey,
|
||||
name = c.DisplayName,
|
||||
qdrant = c.QdrantName,
|
||||
description = c.Description
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var docCounts = await _db.KbDocuments
|
||||
.Where(d => d.Status != "disabled")
|
||||
.GroupBy(d => d.CollectionKey)
|
||||
.Select(g => new { Key = g.Key, Count = g.Count(), Chunks = g.Sum(x => x.ChunkCount) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var byKey = docCounts.ToDictionary(x => x.Key, x => (x.Count, x.Chunks));
|
||||
var result = items.Select(c =>
|
||||
{
|
||||
byKey.TryGetValue(c.key, out var counts);
|
||||
return new { c.key, c.name, c.qdrant, c.description, docCount = counts.Count, chunkCount = counts.Chunks };
|
||||
});
|
||||
return Ok(new { success = true, items = result });
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(500_000_000)]
|
||||
public async Task<IActionResult> Upload(
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] string collectionKey,
|
||||
[FromForm] string? title,
|
||||
[FromForm] string? tags,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { success = false, error = "file required" });
|
||||
if (string.IsNullOrWhiteSpace(collectionKey))
|
||||
return BadRequest(new { success = false, error = "collectionKey required" });
|
||||
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == collectionKey, ct);
|
||||
if (coll == null) return BadRequest(new { success = false, error = "unknown collectionKey" });
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var stored = await _storage.SaveAsync(stream, file.FileName, ct);
|
||||
|
||||
var tagArr = (tags ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(t => t.Length > 0).ToArray();
|
||||
|
||||
var doc = new KbDocument
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CollectionKey = collectionKey,
|
||||
Title = string.IsNullOrWhiteSpace(title) ? Path.GetFileNameWithoutExtension(file.FileName) : title.Trim(),
|
||||
OriginalPath = stored.RelativePath,
|
||||
FileSha256 = stored.Sha256,
|
||||
FileSize = stored.Size,
|
||||
MimeType = file.ContentType,
|
||||
Tags = tagArr,
|
||||
Status = "pending",
|
||||
ChunkCount = 0,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
UploadedBy = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
};
|
||||
_db.KbDocuments.Add(doc);
|
||||
|
||||
_db.KbIngestJobs.Add(new KbIngestJob
|
||||
{
|
||||
DocId = doc.Id,
|
||||
Stage = "parse",
|
||||
EnqueuedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("[Kb] 업로드 {Id} {Title} ({Size} bytes)", doc.Id, doc.Title, doc.FileSize);
|
||||
return Ok(new { success = true, docId = doc.Id, status = doc.Status });
|
||||
}
|
||||
|
||||
[HttpGet("documents")]
|
||||
public async Task<IActionResult> GetDocuments(
|
||||
[FromQuery] string? collection,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] int page = 0,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
page = Math.Max(0, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 200);
|
||||
|
||||
var query = _db.KbDocuments.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(collection)) query = query.Where(d => d.CollectionKey == collection);
|
||||
if (!string.IsNullOrWhiteSpace(status)) query = query.Where(d => d.Status == status);
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var like = $"%{q}%";
|
||||
query = query.Where(d => EF.Functions.ILike(d.Title, like));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query
|
||||
.OrderByDescending(d => d.UploadedAt)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(d => new
|
||||
{
|
||||
id = d.Id,
|
||||
title = d.Title,
|
||||
collection = d.CollectionKey,
|
||||
tags = d.Tags,
|
||||
status = d.Status,
|
||||
chunkCount = d.ChunkCount,
|
||||
fileSize = d.FileSize,
|
||||
uploadedAt = d.UploadedAt,
|
||||
indexedAt = d.IndexedAt,
|
||||
errorMessage = d.ErrorMessage
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return Ok(new { success = true, total, page, pageSize, items });
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id:guid}")]
|
||||
public async Task<IActionResult> GetDocument(Guid id, CancellationToken ct)
|
||||
{
|
||||
var d = await _db.KbDocuments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
item = new
|
||||
{
|
||||
id = d.Id,
|
||||
title = d.Title,
|
||||
collection = d.CollectionKey,
|
||||
tags = d.Tags,
|
||||
status = d.Status,
|
||||
chunkCount = d.ChunkCount,
|
||||
fileSize = d.FileSize,
|
||||
mimeType = d.MimeType,
|
||||
uploadedAt = d.UploadedAt,
|
||||
indexedAt = d.IndexedAt,
|
||||
disabledAt = d.DisabledAt,
|
||||
originalPath = d.OriginalPath,
|
||||
fileSha256 = d.FileSha256,
|
||||
errorMessage = d.ErrorMessage
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
public async Task<IActionResult> GetJobs(
|
||||
[FromQuery] Guid? docId,
|
||||
[FromQuery] string? stage,
|
||||
[FromQuery] bool pendingOnly = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.KbIngestJobs.AsNoTracking().AsQueryable();
|
||||
if (docId.HasValue) q = q.Where(j => j.DocId == docId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(stage)) q = q.Where(j => j.Stage == stage);
|
||||
if (pendingOnly) q = q.Where(j => j.FinishedAt == null);
|
||||
|
||||
var items = await q.OrderByDescending(j => j.EnqueuedAt).Take(200).Select(j => new
|
||||
{
|
||||
id = j.Id,
|
||||
docId = j.DocId,
|
||||
stage = j.Stage,
|
||||
attempts = j.Attempts,
|
||||
lastError = j.LastError,
|
||||
enqueuedAt = j.EnqueuedAt,
|
||||
startedAt = j.StartedAt,
|
||||
finishedAt = j.FinishedAt
|
||||
}).ToListAsync(ct);
|
||||
return Ok(new { success = true, items });
|
||||
}
|
||||
|
||||
[HttpGet("download/{id:guid}")]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
|
||||
{
|
||||
var d = await _db.KbDocuments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound();
|
||||
var abs = _storage.Resolve(d.OriginalPath);
|
||||
if (!System.IO.File.Exists(abs)) return NotFound();
|
||||
|
||||
var stream = new FileStream(abs, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, true);
|
||||
var ext = Path.GetExtension(abs);
|
||||
var fileName = string.IsNullOrEmpty(ext) ? d.Title : d.Title + ext;
|
||||
return File(stream, d.MimeType ?? "application/octet-stream", fileName);
|
||||
}
|
||||
|
||||
[HttpDelete("documents/{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
|
||||
if (coll != null)
|
||||
await _qdrant.DeleteByDocAsync(coll.QdrantName, d.Id, ct);
|
||||
|
||||
_storage.Delete(d.OriginalPath);
|
||||
|
||||
_db.KbDocuments.Remove(d);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("documents/{id:guid}/reindex")]
|
||||
public async Task<IActionResult> Reindex(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
|
||||
if (coll != null)
|
||||
await _qdrant.DeleteByDocAsync(coll.QdrantName, d.Id, ct);
|
||||
|
||||
d.Status = "pending";
|
||||
d.ChunkCount = 0;
|
||||
d.ErrorMessage = null;
|
||||
d.IndexedAt = null;
|
||||
|
||||
_db.KbIngestJobs.Add(new KbIngestJob { DocId = d.Id, Stage = "parse" });
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("documents/{id:guid}/disable")]
|
||||
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
|
||||
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (d == null) return NotFound(new { success = false });
|
||||
d.Status = "disabled";
|
||||
d.DisabledAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
public sealed record BulkDisableRequest(string Title);
|
||||
|
||||
[HttpPost("documents/bulk-disable")]
|
||||
public async Task<IActionResult> BulkDisable([FromBody] BulkDisableRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
if (req == null || string.IsNullOrWhiteSpace(req.Title))
|
||||
return BadRequest(new { success = false, error = "title required" });
|
||||
|
||||
var rows = await _db.KbDocuments
|
||||
.Where(d => d.Title == req.Title && d.Status != "disabled")
|
||||
.ExecuteUpdateAsync(set => set
|
||||
.SetProperty(x => x.Status, _ => "disabled")
|
||||
.SetProperty(x => x.DisabledAt, _ => DateTime.UtcNow), ct);
|
||||
return Ok(new { success = true, affected = rows });
|
||||
}
|
||||
|
||||
public sealed record PurgeRequest(int? OlderThanDays);
|
||||
|
||||
[HttpPost("documents/purge-disabled")]
|
||||
public async Task<IActionResult> PurgeDisabled([FromBody] PurgeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!await RequireAdminAsync(ct))
|
||||
return Unauthorized(new { success = false, error = "unauthorized" });
|
||||
|
||||
var cutoff = req?.OlderThanDays is int days && days > 0
|
||||
? DateTime.UtcNow.AddDays(-days)
|
||||
: (DateTime?)null;
|
||||
|
||||
var query = _db.KbDocuments.Where(d => d.Status == "disabled");
|
||||
if (cutoff.HasValue) query = query.Where(d => d.DisabledAt != null && d.DisabledAt < cutoff);
|
||||
|
||||
var victims = await query.ToListAsync(ct);
|
||||
foreach (var d in victims)
|
||||
{
|
||||
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
|
||||
if (coll != null) await _qdrant.DeleteByDocAsync(coll.QdrantName, d.Id, ct);
|
||||
_storage.Delete(d.OriginalPath);
|
||||
}
|
||||
_db.KbDocuments.RemoveRange(victims);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true, deleted = victims.Count });
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,107 @@ public class OllamaController : ControllerBase
|
||||
return new OllamaConfig();
|
||||
}
|
||||
|
||||
string PlantContextPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = _config["PromptsDirectory"] ?? "../../prompts";
|
||||
if (!Path.IsPathRooted(dir))
|
||||
dir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), dir));
|
||||
return Path.Combine(dir, "plant_context.md");
|
||||
}
|
||||
}
|
||||
|
||||
string LoadPlantContext()
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = PlantContextPath;
|
||||
if (System.IO.File.Exists(p))
|
||||
return System.IO.File.ReadAllText(p).Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[OllamaController] plant_context.md 로드 실패");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private const string BaseSystemPromptKo =
|
||||
"당신은 ExperionCrawler 시스템의 운전 보조 AI입니다.\n" +
|
||||
"한국어로 정확하고 간결하게 답변합니다. 추측하지 말고, 확실치 않으면 도구로 확인합니다.\n" +
|
||||
"수치/시각은 그대로 인용하고, 표/시계열 결과는 가능한 한 표 형식으로 정리해 보여줍니다.";
|
||||
|
||||
private const string ToolGuideKo =
|
||||
"\n\n## 사용 가능한 MCP 도구\n" +
|
||||
"- run_sql: PostgreSQL SELECT 실행 (LIMIT 권장)\n" +
|
||||
"- query_pv_history: 태그 PV 이력 조회 (history_table, recorded_at)\n" +
|
||||
"- get_tag_metadata: 태그명 패턴 매칭 검색 (realtime_table)\n" +
|
||||
"- list_drawings: P&ID 도면 목록 (node_map_master)\n" +
|
||||
"- query_with_nl: 자연어 → SQL 변환 후 실행\n" +
|
||||
"사용자가 태그 값/이력/DB 정보를 물으면 알맞은 도구를 function calling으로 호출하세요.\n" +
|
||||
"도구 결과의 JSON은 그대로 노출하지 말고, 사람이 읽기 쉬운 표/요약으로 변환합니다.\n" +
|
||||
"DB 시계열 컬럼은 history_table.recorded_at 이며, time_bucket() 대신 date_trunc 또는 to_timestamp(FLOOR(EPOCH/N*60)*N*60) 공식을 사용합니다.";
|
||||
|
||||
private async Task EmitToolStart(string toolCallId, string name, string argsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
object argsObj;
|
||||
try { argsObj = JsonSerializer.Deserialize<object>(argsJson) ?? new { }; }
|
||||
catch { argsObj = argsJson; }
|
||||
var data = JsonSerializer.Serialize(new { id = toolCallId, name, args = argsObj });
|
||||
await Response.WriteAsync($"event: tool_start\ndata: {data}\n\n");
|
||||
await Response.Body.FlushAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task EmitToolResult(string toolCallId, string name, bool ok, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
const int previewMax = 600;
|
||||
var preview = payload.Length > previewMax ? payload.Substring(0, previewMax) + "…" : payload;
|
||||
var data = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = toolCallId,
|
||||
name,
|
||||
ok,
|
||||
preview,
|
||||
length = payload.Length,
|
||||
payload // 전체 JSON 그대로(테이블/시계열 자동 렌더에 사용)
|
||||
});
|
||||
await Response.WriteAsync($"event: tool_result\ndata: {data}\n\n");
|
||||
await Response.Body.FlushAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
string ComposeSystemPrompt(string? userPrompt, bool toolsEnabled)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(BaseSystemPromptKo);
|
||||
|
||||
var plant = LoadPlantContext();
|
||||
if (!string.IsNullOrWhiteSpace(plant))
|
||||
{
|
||||
sb.Append("\n\n## 플랜트 컨텍스트\n");
|
||||
sb.Append(plant);
|
||||
}
|
||||
|
||||
if (toolsEnabled)
|
||||
sb.Append(ToolGuideKo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userPrompt))
|
||||
{
|
||||
sb.Append("\n\n## 사용자 추가 지침\n");
|
||||
sb.Append(userPrompt.Trim());
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[HttpGet("models")]
|
||||
public async Task<IActionResult> GetModels()
|
||||
{
|
||||
@@ -102,7 +203,7 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
model = req.Model,
|
||||
messages = req.Messages,
|
||||
system = req.SystemPrompt,
|
||||
system = ComposeSystemPrompt(req.SystemPrompt, toolsEnabled: false),
|
||||
stream = false
|
||||
};
|
||||
var content = new StringContent(
|
||||
@@ -147,7 +248,7 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
model = req.Model,
|
||||
messages = req.Messages,
|
||||
system = req.SystemPrompt,
|
||||
system = ComposeSystemPrompt(req.SystemPrompt, toolsEnabled: false),
|
||||
stream = true
|
||||
};
|
||||
var content = new StringContent(
|
||||
@@ -324,8 +425,9 @@ public class OllamaController : ControllerBase
|
||||
{
|
||||
var model = req.Model;
|
||||
var msgList = new List<object>();
|
||||
if (req.SystemPrompt != null)
|
||||
msgList.Add(new { role = "system", content = req.SystemPrompt });
|
||||
var sysPrompt = ComposeSystemPrompt(req.SystemPrompt, toolsEnabled: false);
|
||||
if (!string.IsNullOrEmpty(sysPrompt))
|
||||
msgList.Add(new { role = "system", content = sysPrompt });
|
||||
foreach (var m in req.Messages)
|
||||
msgList.Add(m);
|
||||
var payload = new
|
||||
@@ -400,8 +502,9 @@ public class OllamaController : ControllerBase
|
||||
private async Task VllmChatStreamSimple(OllamaChatRequest req)
|
||||
{
|
||||
var msgList = new List<object>();
|
||||
if (req.SystemPrompt != null)
|
||||
msgList.Add(new { role = "system", content = req.SystemPrompt });
|
||||
var sysPrompt = ComposeSystemPrompt(req.SystemPrompt, toolsEnabled: false);
|
||||
if (!string.IsNullOrEmpty(sysPrompt))
|
||||
msgList.Add(new { role = "system", content = sysPrompt });
|
||||
foreach (var m in req.Messages)
|
||||
msgList.Add(m);
|
||||
var payload = new
|
||||
@@ -469,8 +572,9 @@ public class OllamaController : ControllerBase
|
||||
private async Task VllmChatStreamWithTools(OllamaChatRequest req)
|
||||
{
|
||||
var messages = new List<object>();
|
||||
if (req.SystemPrompt != null)
|
||||
messages.Add(new { role = "system", content = req.SystemPrompt });
|
||||
var sysPrompt = ComposeSystemPrompt(req.SystemPrompt, toolsEnabled: true);
|
||||
if (!string.IsNullOrEmpty(sysPrompt))
|
||||
messages.Add(new { role = "system", content = sysPrompt });
|
||||
foreach (var m in req.Messages)
|
||||
messages.Add(m);
|
||||
|
||||
@@ -562,6 +666,8 @@ public class OllamaController : ControllerBase
|
||||
var funcName = func?.GetType().GetProperty("name")?.GetValue(func) as string ?? "";
|
||||
var funcArgs = func?.GetType().GetProperty("arguments")?.GetValue(func) as string ?? "{}";
|
||||
|
||||
await EmitToolStart(tcId, funcName, funcArgs);
|
||||
|
||||
try
|
||||
{
|
||||
var args = JsonSerializer.Deserialize<Dictionary<string, object>>(funcArgs)
|
||||
@@ -569,6 +675,8 @@ public class OllamaController : ControllerBase
|
||||
|
||||
var toolResult = await _mcpClient.CallToolAsync(funcName, args, HttpContext.RequestAborted);
|
||||
|
||||
await EmitToolResult(tcId, funcName, ok: true, payload: toolResult);
|
||||
|
||||
messages.Add(new
|
||||
{
|
||||
role = "tool",
|
||||
@@ -578,6 +686,7 @@ public class OllamaController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EmitToolResult(tcId, funcName, ok: false, payload: ex.Message);
|
||||
messages.Add(new
|
||||
{
|
||||
role = "tool",
|
||||
@@ -666,15 +775,20 @@ public class OllamaController : ControllerBase
|
||||
|
||||
if (detectedTool != null && args.Count > 0)
|
||||
{
|
||||
var pseudoId = $"jsontc_{toolRound}_{Guid.NewGuid():N}";
|
||||
var argsJson = JsonSerializer.Serialize(args);
|
||||
await EmitToolStart(pseudoId, detectedTool, argsJson);
|
||||
try
|
||||
{
|
||||
var toolResult = await _mcpClient.CallToolAsync(detectedTool, args, HttpContext.RequestAborted);
|
||||
await EmitToolResult(pseudoId, detectedTool, ok: true, payload: toolResult);
|
||||
messages.Add(new { role = "assistant", content = stopContent });
|
||||
messages.Add(new { role = "user", content = $"[{detectedTool} 실행 결과]\n{toolResult}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." });
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await EmitToolResult(pseudoId, detectedTool, ok: false, payload: ex.Message);
|
||||
_logger.LogWarning(ex, "[OllamaController] 텍스트 형식 도구 호출 실패: {Tool}", detectedTool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
<PackageReference Include="PdfPig" Version="0.1.9" />
|
||||
<!-- Excel 내보내기 -->
|
||||
<PackageReference Include="EPPlus" Version="7.4.2" />
|
||||
<!-- 관리자 비밀번호 해시 (Argon2id) -->
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,7 @@ using ExperionCrawler.Core.Application.Services;
|
||||
using ExperionCrawler.Infrastructure.Certificates;
|
||||
using ExperionCrawler.Infrastructure.Csv;
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
using ExperionCrawler.Infrastructure.Kb;
|
||||
using ExperionCrawler.Infrastructure.Mcp;
|
||||
using ExperionCrawler.Infrastructure.OpcUa;
|
||||
using ExperionCrawler.Web;
|
||||
@@ -119,6 +120,14 @@ builder.Services.AddSingleton<IPidGraphEventBroadcaster, PidGraphEventBroadcaste
|
||||
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<ExperionFastCleanupService>();
|
||||
|
||||
// ── Knowledge Base (RAG) ──────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<KbQdrantClient>();
|
||||
builder.Services.AddSingleton<KbStorageService>();
|
||||
builder.Services.AddSingleton<KbEmbeddingClient>();
|
||||
builder.Services.AddScoped<IKbAuthService, KbAuthService>();
|
||||
builder.Services.AddHostedService<KbStartupService>();
|
||||
builder.Services.AddHostedService<KbIngestWorker>();
|
||||
|
||||
// ── Ollama HttpClient ─────────────────────────────────────────────────────────
|
||||
builder.Services.AddHttpClient("Ollama", c =>
|
||||
{
|
||||
@@ -149,6 +158,17 @@ try
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
await db.InitializeAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var kbAuth = scope.ServiceProvider.GetRequiredService<IKbAuthService>();
|
||||
await kbAuth.EnsureCredentialAsync();
|
||||
}
|
||||
catch (Exception kbEx)
|
||||
{
|
||||
var lg = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
lg.LogWarning(kbEx, "[Kb] 관리자 비밀번호 초기화 실패");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
"McpServer": {
|
||||
"WorkingDirectory": "../../mcp-server"
|
||||
},
|
||||
"PromptsDirectory": "../../prompts",
|
||||
"Kb": {
|
||||
"QdrantUrl": "http://localhost:6333",
|
||||
"VectorSize": 768,
|
||||
"StorageRoot": "../../storage/kb",
|
||||
"AdminSessionMinutes": 60,
|
||||
"WorkerPollIntervalSeconds": 2,
|
||||
"MaxAttempts": 3
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
|
||||
@@ -1897,3 +1897,174 @@ tr:last-child td { border-bottom: none; }
|
||||
.llm-sidebar { display: none; }
|
||||
.llm-layout { height: calc(100vh - var(--sw) - 120px); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
14 RAG 관리 (KB Admin)
|
||||
══════════════════════════════════════════════════════ */
|
||||
.kb-login-card { max-width: 460px; }
|
||||
.kb-main { display: flex; flex-direction: column; gap: 12px; }
|
||||
.kb-topbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px; background: var(--s1); border: 1px solid var(--bd1);
|
||||
border-radius: var(--rm);
|
||||
}
|
||||
.kb-session { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--t1); }
|
||||
.kb-actions { display: flex; gap: 8px; }
|
||||
.kb-filters { padding: 12px; }
|
||||
.kb-msg { font-size: 12px; color: var(--t2); margin-left: 8px; }
|
||||
.kb-stats { font-size: 12px; color: var(--t2); padding: 4px 6px; }
|
||||
|
||||
.kb-doc-tbl { width: 100%; font-size: 12px; }
|
||||
.kb-doc-tbl th, .kb-doc-tbl td { padding: 8px 10px; border-bottom: 1px solid var(--bd1); }
|
||||
.kb-doc-tbl th { background: var(--s1); text-align: left; font-weight: 600; color: var(--t1); }
|
||||
.kb-doc-tbl td.mono { font-family: var(--ffm); font-size: 11px; color: var(--t2); }
|
||||
|
||||
.kb-tag {
|
||||
display: inline-block; padding: 1px 6px; margin: 0 2px 2px 0;
|
||||
background: var(--s2); border: 1px solid var(--bd1); border-radius: 8px;
|
||||
font-size: 11px; color: var(--t1);
|
||||
}
|
||||
|
||||
.kb-status {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
}
|
||||
.kb-st-pending { background: #3a3a55; color: #aab2d4; }
|
||||
.kb-st-parsing { background: #4a4a1a; color: #f3d76b; }
|
||||
.kb-st-embedding { background: #4a4a1a; color: #f3d76b; }
|
||||
.kb-st-indexed { background: #1f4a2a; color: #6bd58b; }
|
||||
.kb-st-failed { background: #5a1f1f; color: #f37070; }
|
||||
.kb-st-disabled { background: #303032; color: #888; }
|
||||
|
||||
.kb-err {
|
||||
font-size: 11px; color: #f37070; margin-top: 2px;
|
||||
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.kb-modal {
|
||||
position: fixed; inset: 0; z-index: 950;
|
||||
background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.kb-modal.hidden { display: none; }
|
||||
.kb-modal-body {
|
||||
background: var(--s2); border: 1px solid var(--bd2);
|
||||
border-radius: var(--rl); padding: 22px;
|
||||
width: 460px; max-width: 92vw; max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.kb-modal-title { font-weight: 700; font-size: 15px; margin-bottom: 14px; color: var(--t0); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Phase 5 — 채팅 통합 (툴 카드 / KB 인용 / 표 / 추천 칩)
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
/* 툴 카드 컨테이너 — assistant 메시지 버블 위 */
|
||||
.llm-tool-cards {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
margin: 4px 0 8px 0;
|
||||
}
|
||||
|
||||
.llm-tool-card {
|
||||
border: 1px solid var(--bd1);
|
||||
border-radius: var(--rm);
|
||||
background: var(--s1);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.llm-tool-card.running .llm-tool-icon { animation: spin 1.4s linear infinite; }
|
||||
.llm-tool-card.ok { border-color: #2a5a3a; }
|
||||
.llm-tool-card.err { border-color: #5a2a2a; }
|
||||
|
||||
.llm-tool-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; cursor: pointer;
|
||||
background: var(--s2);
|
||||
user-select: none;
|
||||
}
|
||||
.llm-tool-head:hover { background: var(--s3, #2a2a2e); }
|
||||
.llm-tool-icon { font-size: 13px; }
|
||||
.llm-tool-name { font-weight: 600; color: var(--t0); }
|
||||
.llm-tool-args {
|
||||
flex: 1; color: var(--t2); font-size: 11px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.llm-tool-status { font-size: 11px; color: var(--t2); flex-shrink: 0; }
|
||||
.llm-tool-card.ok .llm-tool-status { color: #6bd58b; }
|
||||
.llm-tool-card.err .llm-tool-status { color: #f37070; }
|
||||
|
||||
.llm-tool-body { display: none; padding: 8px 10px; }
|
||||
.llm-tool-card.open .llm-tool-body { display: block; }
|
||||
.llm-tool-raw {
|
||||
margin: 0; padding: 6px 8px; max-height: 240px; overflow: auto;
|
||||
font-family: var(--ffm); font-size: 11px;
|
||||
background: var(--s0); border: 1px solid var(--bd1); border-radius: 4px;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.llm-tool-err {
|
||||
color: #f37070; font-family: var(--ffm); font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.llm-tool-more {
|
||||
font-size: 11px; color: var(--t2); margin-top: 4px; text-align: right;
|
||||
}
|
||||
|
||||
/* 툴 결과 표 */
|
||||
.llm-tool-tbl-wrap { max-height: 320px; overflow: auto; border: 1px solid var(--bd1); border-radius: 4px; }
|
||||
.llm-tool-tbl {
|
||||
width: 100%; border-collapse: collapse; font-size: 11px; font-family: var(--ffm);
|
||||
}
|
||||
.llm-tool-tbl th, .llm-tool-tbl td {
|
||||
padding: 4px 8px; border-bottom: 1px solid var(--bd1);
|
||||
text-align: left; white-space: nowrap;
|
||||
}
|
||||
.llm-tool-tbl th {
|
||||
background: var(--s2); position: sticky; top: 0;
|
||||
font-weight: 600; color: var(--t1);
|
||||
}
|
||||
|
||||
/* KB 검색 결과 (search_kb 카드 안) */
|
||||
.llm-kb-hits { display: flex; flex-direction: column; gap: 6px; }
|
||||
.llm-kb-hit {
|
||||
padding: 6px 8px; background: var(--s0);
|
||||
border: 1px solid var(--bd1); border-radius: 4px;
|
||||
}
|
||||
.llm-kb-head { font-size: 12px; color: var(--t0); margin-bottom: 2px; }
|
||||
.llm-kb-head .mono { color: var(--t2); margin-right: 6px; }
|
||||
.llm-kb-snip { font-size: 11px; color: var(--t1); line-height: 1.4; }
|
||||
|
||||
/* KB 인용 링크 (본문 안) */
|
||||
.kb-cite-link {
|
||||
color: var(--a); text-decoration: none;
|
||||
border-bottom: 1px dashed var(--a);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.kb-cite-link:hover { color: #fff; border-bottom-style: solid; }
|
||||
|
||||
/* welcome 추천 질문 칩 */
|
||||
.llm-chip-row {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
justify-content: center; margin-top: 16px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.llm-chip {
|
||||
padding: 6px 12px;
|
||||
background: var(--s2);
|
||||
border: 1px solid var(--bd1);
|
||||
border-radius: 16px;
|
||||
color: var(--t0);
|
||||
font-family: var(--ff);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.llm-chip:hover {
|
||||
background: var(--s3, var(--s2));
|
||||
border-color: var(--a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
<span class="ni">13</span>
|
||||
<span class="nl">로컬 LLM 채팅</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="kbadmin">
|
||||
<span class="ni">14</span>
|
||||
<span class="nl">RAG 관리</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -1314,6 +1318,138 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
14 RAG 관리 (관리자 전용)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-kbadmin">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>RAG 관리</h1>
|
||||
<p>지식 베이스 문서 업로드 / 인덱싱 / 관리 — 관리자 비밀번호 필요.</p>
|
||||
</div>
|
||||
<div class="pane-tag">KB / RAG</div>
|
||||
</header>
|
||||
|
||||
<!-- 로그인 카드 -->
|
||||
<div id="kb-login-card" class="card kb-login-card">
|
||||
<div class="card-cap">🔒 관리자 로그인</div>
|
||||
<div class="fg" style="max-width:360px">
|
||||
<label>비밀번호</label>
|
||||
<input id="kb-pw" class="inp" type="password" placeholder="관리자 비밀번호"
|
||||
onkeydown="if(event.key==='Enter')kbLogin()"/>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-a btn-sm" onclick="kbLogin()">로그인</button>
|
||||
<span id="kb-login-msg" class="kb-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 패널 (로그인 후) -->
|
||||
<div id="kb-main" class="kb-main hidden">
|
||||
|
||||
<div class="kb-topbar">
|
||||
<div class="kb-session">
|
||||
<span class="dot grn"></span>
|
||||
<span class="mono" id="kb-session-info">세션: --:--</span>
|
||||
<button class="btn-b btn-sm" onclick="kbChangePwOpen()">비밀번호 변경</button>
|
||||
<button class="btn-b btn-sm" onclick="kbLogout()">로그아웃</button>
|
||||
</div>
|
||||
<div class="kb-actions">
|
||||
<button class="btn-a btn-sm" onclick="kbUploadOpen()">📁 파일 업로드</button>
|
||||
<button class="btn-b btn-sm" onclick="kbRefresh()">🔄 새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card kb-filters">
|
||||
<div class="cols-3">
|
||||
<div class="fg">
|
||||
<label>컬렉션</label>
|
||||
<select id="kb-f-coll" class="inp" onchange="kbRefresh()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>상태</label>
|
||||
<select id="kb-f-status" class="inp" onchange="kbRefresh()">
|
||||
<option value="">전체</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="parsing">parsing</option>
|
||||
<option value="embedding">embedding</option>
|
||||
<option value="indexed">indexed</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>제목 검색</label>
|
||||
<input id="kb-f-q" class="inp" placeholder="제목 일부..."
|
||||
onkeydown="if(event.key==='Enter')kbRefresh()"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div id="kb-doc-stats" class="kb-stats"></div>
|
||||
<div id="kb-doc-table" class="tbl-wrap"></div>
|
||||
|
||||
<div class="btn-row" style="margin-top:8px">
|
||||
<button class="btn-b btn-sm" onclick="kbBulkDisable()">🚫 동일 제목 일괄 비활성화</button>
|
||||
<button class="btn-b btn-sm" onclick="kbPurgeDisabled()">🗑 비활성화 영구삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 모달 -->
|
||||
<div id="kb-upload-modal" class="kb-modal hidden" onclick="if(event.target===this)kbUploadClose()">
|
||||
<div class="kb-modal-body">
|
||||
<div class="kb-modal-title">📁 KB 문서 업로드</div>
|
||||
<div class="fg">
|
||||
<label>컬렉션 <em>*</em></label>
|
||||
<select id="kb-up-coll" class="inp">
|
||||
<option value="">-- 선택 --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>제목 <em>(비워두면 파일명 사용)</em></label>
|
||||
<input id="kb-up-title" class="inp" placeholder="제목"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>태그 <em>(콤마 분리, 예: unit-a, P-6201)</em></label>
|
||||
<input id="kb-up-tags" class="inp" placeholder="unit-a, P-6201"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>파일</label>
|
||||
<input id="kb-up-file" class="inp" type="file"/>
|
||||
</div>
|
||||
<div id="kb-up-msg" class="kb-msg"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-a btn-sm" onclick="kbUploadSubmit()">업로드</button>
|
||||
<button class="btn-b btn-sm" onclick="kbUploadClose()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 모달 -->
|
||||
<div id="kb-pw-modal" class="kb-modal hidden" onclick="if(event.target===this)kbChangePwClose()">
|
||||
<div class="kb-modal-body">
|
||||
<div class="kb-modal-title">🔐 비밀번호 변경</div>
|
||||
<div class="fg">
|
||||
<label>현재 비밀번호</label>
|
||||
<input id="kb-pw-old" class="inp" type="password"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>새 비밀번호 <em>(6자 이상)</em></label>
|
||||
<input id="kb-pw-new" class="inp" type="password"/>
|
||||
</div>
|
||||
<div id="kb-pw-msg" class="kb-msg"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-a btn-sm" onclick="kbChangePwSubmit()">변경</button>
|
||||
<button class="btn-b btn-sm" onclick="kbChangePwClose()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3297,6 +3297,26 @@ let llmType = localStorage.getItem('llmType') || 'ollama';
|
||||
let llmUseTools = localStorage.getItem('llmUseTools') === 'true';
|
||||
let llmMcpTools = [];
|
||||
|
||||
// ── Phase 5.5: welcome 화면 추천 질문 ───────────────────
|
||||
const LLM_STARTER_CHIPS = [
|
||||
'지금 활성 알람을 보여줘',
|
||||
'Unit A의 24시간 운전 상황을 요약해줘',
|
||||
'FIC-6113.PV 최근 1시간 추이',
|
||||
'오늘 발생한 디지털 이벤트 정리',
|
||||
'P-6201 펌프의 정비 이력',
|
||||
'이번 주 보고서를 작성해줘',
|
||||
'냉각수 펌프 토출 압력 태그를 찾아줘'
|
||||
];
|
||||
|
||||
function llmUseChip(btn) {
|
||||
const input = document.getElementById('llm-input');
|
||||
if (!input) return;
|
||||
input.value = btn.textContent;
|
||||
input.focus();
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||||
}
|
||||
|
||||
// ── 초기화 (탭 진입 시 API 호출 없음) ──────────────────
|
||||
document.querySelectorAll('[data-tab="llmchat"]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
@@ -3401,11 +3421,15 @@ function llmRenderMessages() {
|
||||
|
||||
const sess = llmGetActiveSession();
|
||||
if (!sess || sess.messages.length === 0) {
|
||||
const chips = LLM_STARTER_CHIPS.map(q =>
|
||||
`<button class="llm-chip" type="button" onclick="llmUseChip(this)">${esc(q)}</button>`
|
||||
).join('');
|
||||
el.innerHTML = `
|
||||
<div class="llm-welcome">
|
||||
<div class="llm-welcome-icon">💬</div>
|
||||
<div class="llm-welcome-text">새 대화를 시작하세요</div>
|
||||
<div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요</div>
|
||||
<div class="llm-welcome-hint">모델을 선택하고 메시지를 입력하세요. 또는 아래 추천 질문을 클릭하세요.</div>
|
||||
<div class="llm-chip-row">${chips}</div>
|
||||
</div>
|
||||
`;
|
||||
if (sess) {
|
||||
@@ -3449,7 +3473,167 @@ function llmFormatMessage(text) {
|
||||
return `\x00B${blocks.length - 1}\x00`;
|
||||
});
|
||||
text = esc(text).replace(/\n/g, '<br>');
|
||||
return text.replace(/\x00B(\d+)\x00/g, (_, i) => blocks[+i]);
|
||||
text = text.replace(/\x00B(\d+)\x00/g, (_, i) => blocks[+i]);
|
||||
return llmLinkKbCitations(text);
|
||||
}
|
||||
|
||||
/* ── Phase 5: KB 인용 → 다운로드 링크 치환 ─────────────── */
|
||||
let llmKbDocMap = {}; // title → docId (search_kb 결과로 누적)
|
||||
|
||||
function llmRegisterKbHits(hits) {
|
||||
if (!Array.isArray(hits)) return;
|
||||
for (const h of hits) {
|
||||
if (h && h.title && h.doc_id) llmKbDocMap[h.title] = h.doc_id;
|
||||
}
|
||||
}
|
||||
|
||||
function llmLinkKbCitations(html) {
|
||||
const titles = Object.keys(llmKbDocMap);
|
||||
if (titles.length === 0) return html;
|
||||
// 긴 제목부터 매칭 (부분문자열 겹침 회피)
|
||||
titles.sort((a, b) => b.length - a.length);
|
||||
for (const title of titles) {
|
||||
const docId = llmKbDocMap[title];
|
||||
const safeTitle = esc(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(safeTitle, 'g');
|
||||
html = html.replace(re,
|
||||
`<a class="kb-cite-link" href="/api/kb/download/${docId}" target="_blank" rel="noopener">${esc(title)} ⬇</a>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Phase 5: 툴 실행 카드 ─────────────────────────────── */
|
||||
function llmEnsureStreamingMsgEl() {
|
||||
let msgEl = document.getElementById('llm-streaming-msg');
|
||||
if (msgEl) return msgEl;
|
||||
const messagesEl = document.getElementById('llm-messages');
|
||||
msgEl = document.createElement('div');
|
||||
msgEl.className = 'llm-msg assistant';
|
||||
msgEl.id = 'llm-streaming-msg';
|
||||
msgEl.innerHTML = `
|
||||
<div class="llm-msg-avatar">AI</div>
|
||||
<div class="llm-msg-bubble"></div>
|
||||
`;
|
||||
messagesEl.appendChild(msgEl);
|
||||
return msgEl;
|
||||
}
|
||||
|
||||
function llmEnsureToolCardContainer() {
|
||||
const msgEl = llmEnsureStreamingMsgEl();
|
||||
let cont = msgEl.querySelector('.llm-tool-cards');
|
||||
if (!cont) {
|
||||
cont = document.createElement('div');
|
||||
cont.className = 'llm-tool-cards';
|
||||
const bubble = msgEl.querySelector('.llm-msg-bubble');
|
||||
msgEl.insertBefore(cont, bubble);
|
||||
}
|
||||
return cont;
|
||||
}
|
||||
|
||||
function llmAppendToolCard(id, name, args) {
|
||||
const cont = llmEnsureToolCardContainer();
|
||||
const argSummary = llmSummarizeArgs(args);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'llm-tool-card running';
|
||||
card.dataset.toolId = id;
|
||||
card.innerHTML = `
|
||||
<div class="llm-tool-head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="llm-tool-icon">⚙</span>
|
||||
<span class="llm-tool-name">${esc(name)}</span>
|
||||
<span class="llm-tool-args mono">${esc(argSummary)}</span>
|
||||
<span class="llm-tool-status">실행 중…</span>
|
||||
</div>
|
||||
<div class="llm-tool-body">
|
||||
<div class="placeholder">결과 대기 중…</div>
|
||||
</div>
|
||||
`;
|
||||
cont.appendChild(card);
|
||||
const messagesEl = document.getElementById('llm-messages');
|
||||
if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function llmUpdateToolCard(id, name, ok, preview, length, payload) {
|
||||
const cont = document.querySelector('.llm-tool-cards');
|
||||
if (!cont) return;
|
||||
const card = cont.querySelector(`.llm-tool-card[data-tool-id="${CSS.escape(id)}"]`);
|
||||
if (!card) return;
|
||||
card.classList.remove('running');
|
||||
card.classList.add(ok ? 'ok' : 'err');
|
||||
const st = card.querySelector('.llm-tool-status');
|
||||
if (st) st.textContent = ok ? `완료 · ${length}자` : '실패';
|
||||
const body = card.querySelector('.llm-tool-body');
|
||||
if (body) body.innerHTML = llmRenderToolPayload(name, ok, preview, payload);
|
||||
|
||||
// search_kb 결과면 인용 매핑 등록
|
||||
if (ok && name === 'search_kb') {
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (parsed.success && Array.isArray(parsed.hits)) llmRegisterKbHits(parsed.hits);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function llmSummarizeArgs(args) {
|
||||
if (!args) return '';
|
||||
if (typeof args === 'string') {
|
||||
return args.length > 100 ? args.slice(0, 100) + '…' : args;
|
||||
}
|
||||
try {
|
||||
const s = JSON.stringify(args);
|
||||
return s.length > 100 ? s.slice(0, 100) + '…' : s;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function llmRenderToolPayload(name, ok, preview, payload) {
|
||||
if (!ok) return `<div class="llm-tool-err">${esc(preview || '오류')}</div>`;
|
||||
// JSON 응답이면 표/시계열 자동 렌더 시도
|
||||
try {
|
||||
const j = JSON.parse(payload || preview);
|
||||
// search_kb 형태
|
||||
if (Array.isArray(j.hits)) return llmRenderKbHits(j.hits);
|
||||
// run_sql/query_with_nl 형태: {success, columns:[], data:[{...}, ...]}
|
||||
if (j.success && Array.isArray(j.columns) && Array.isArray(j.data)) {
|
||||
return llmRenderTable(j.columns, j.data);
|
||||
}
|
||||
// query_pv_history 형태: {success, data:[{tag_name, timestamp, value}, ...]}
|
||||
if (j.success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') {
|
||||
const cols = Object.keys(j.data[0]);
|
||||
return llmRenderTable(cols, j.data);
|
||||
}
|
||||
} catch {}
|
||||
return `<pre class="llm-tool-raw">${esc((preview || '').slice(0, 800))}</pre>`;
|
||||
}
|
||||
|
||||
function llmRenderTable(columns, data) {
|
||||
if (!data || data.length === 0) return '<div class="placeholder">결과 0건</div>';
|
||||
const limit = Math.min(data.length, 50);
|
||||
const ths = columns.map(c => `<th>${esc(c)}</th>`).join('');
|
||||
const rows = data.slice(0, limit).map(row => {
|
||||
const tds = columns.map(c => {
|
||||
const v = row[c];
|
||||
return `<td>${v == null ? '' : esc(String(v))}</td>`;
|
||||
}).join('');
|
||||
return `<tr>${tds}</tr>`;
|
||||
}).join('');
|
||||
const more = data.length > limit ? `<div class="llm-tool-more">…나머지 ${data.length - limit}건 생략</div>` : '';
|
||||
return `<div class="llm-tool-tbl-wrap"><table class="llm-tool-tbl"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table></div>${more}`;
|
||||
}
|
||||
|
||||
function llmRenderKbHits(hits) {
|
||||
if (!hits || hits.length === 0) return '<div class="placeholder">검색 결과 0건</div>';
|
||||
llmRegisterKbHits(hits);
|
||||
return '<div class="llm-kb-hits">' + hits.slice(0, 8).map(h => {
|
||||
const score = (h.score || 0).toFixed(3);
|
||||
const link = h.doc_id
|
||||
? `<a href="/api/kb/download/${h.doc_id}" target="_blank" rel="noopener">${esc(h.title || '(제목없음)')} ⬇</a>`
|
||||
: esc(h.title || '(제목없음)');
|
||||
const loc = h.locator ? ` · <span class="mono">${esc(h.locator)}</span>` : '';
|
||||
const snippet = (h.text || '').slice(0, 200).replace(/\s+/g, ' ');
|
||||
return `<div class="llm-kb-hit">
|
||||
<div class="llm-kb-head"><span class="mono">${score}</span> ${link}${loc}</div>
|
||||
<div class="llm-kb-snip">${esc(snippet)}…</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
// ── 모델 목록 로드 ─────────────────────────────────────
|
||||
@@ -3602,10 +3786,6 @@ async function llmSend() {
|
||||
};
|
||||
if (llmType === 'vllm' && llmUseTools && llmMcpTools.length > 0) {
|
||||
requestBody.tools = llmMcpTools;
|
||||
if (!requestBody.systemPrompt) {
|
||||
requestBody.systemPrompt = '';
|
||||
}
|
||||
requestBody.systemPrompt += '\n\nYou have access to MCP tools for querying the Experion database: run_sql (execute SQL), query_pv_history (tag PV history), get_tag_metadata (tag metadata search), list_drawings (P&ID drawings), query_with_nl (natural language to SQL). When the user asks about tag values, history, or database information, call the appropriate tool using the function calling API.';
|
||||
}
|
||||
const res = await fetch(`${prefix}/chat/stream`, {
|
||||
method: 'POST',
|
||||
@@ -3637,18 +3817,41 @@ async function llmSend() {
|
||||
for (const part of parts) {
|
||||
if (streamDone) break;
|
||||
const lines = part.split('\n');
|
||||
let eventType = 'message';
|
||||
let eventData = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
eventData = line.slice(6);
|
||||
} else if (line.startsWith('event: error')) {
|
||||
throw new Error(eventData || '스트리밍 오류');
|
||||
} else if (line.startsWith('event: done')) {
|
||||
streamDone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'error') {
|
||||
throw new Error(eventData || '스트리밍 오류');
|
||||
}
|
||||
if (eventType === 'done') {
|
||||
streamDone = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (eventType === 'tool_start') {
|
||||
try {
|
||||
const t = JSON.parse(eventData);
|
||||
llmAppendToolCard(t.id, t.name, t.args);
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === 'tool_result') {
|
||||
try {
|
||||
const t = JSON.parse(eventData);
|
||||
llmUpdateToolCard(t.id, t.name, t.ok, t.preview, t.length, t.payload);
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 일반 message 이벤트
|
||||
if (eventData && eventData !== '{}') {
|
||||
try {
|
||||
const json = JSON.parse(eventData);
|
||||
@@ -3838,3 +4041,306 @@ function llmExportAll() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
14 RAG 관리 (KB Admin)
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
let kbToken = sessionStorage.getItem('kbToken') || '';
|
||||
let kbExpiresAt = sessionStorage.getItem('kbExpiresAt') || '';
|
||||
let kbCollections = [];
|
||||
let kbPollTimer = null;
|
||||
|
||||
function kbHeaders(extra) {
|
||||
const h = { ...(extra || {}) };
|
||||
if (kbToken) h['X-Kb-Token'] = kbToken;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function kbFetch(method, path, body, opt) {
|
||||
const init = { method, headers: kbHeaders({ 'Content-Type': 'application/json' }), ...(opt || {}) };
|
||||
if (body !== undefined && body !== null) init.body = JSON.stringify(body);
|
||||
const res = await fetch(path, init);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch { /* ignore */ }
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
// ── 탭 클릭 핸들러 (API 호출 없음, 세션 검증만) ───────
|
||||
document.querySelectorAll('[data-tab="kbadmin"]').forEach(item => {
|
||||
item.addEventListener('click', async () => {
|
||||
if (kbToken) {
|
||||
const r = await kbFetch('GET', '/api/kb/auth/status');
|
||||
if (r.ok && r.data && r.data.valid) {
|
||||
kbShowMain();
|
||||
} else {
|
||||
kbShowLogin('세션이 만료되었습니다. 다시 로그인하세요.');
|
||||
}
|
||||
} else {
|
||||
kbShowLogin('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function kbShowLogin(msg) {
|
||||
kbToken = ''; kbExpiresAt = '';
|
||||
sessionStorage.removeItem('kbToken'); sessionStorage.removeItem('kbExpiresAt');
|
||||
document.getElementById('kb-login-card').classList.remove('hidden');
|
||||
document.getElementById('kb-main').classList.add('hidden');
|
||||
const m = document.getElementById('kb-login-msg');
|
||||
if (m) m.textContent = msg || '';
|
||||
kbStopPoll();
|
||||
}
|
||||
|
||||
function kbShowMain() {
|
||||
document.getElementById('kb-login-card').classList.add('hidden');
|
||||
document.getElementById('kb-main').classList.remove('hidden');
|
||||
kbUpdateSessionInfo();
|
||||
kbLoadCollections().then(() => kbRefresh());
|
||||
kbStartPoll();
|
||||
}
|
||||
|
||||
function kbUpdateSessionInfo() {
|
||||
const el = document.getElementById('kb-session-info');
|
||||
if (!el) return;
|
||||
if (kbExpiresAt) {
|
||||
const t = new Date(kbExpiresAt);
|
||||
el.textContent = `세션 만료: ${t.toLocaleTimeString('ko-KR')}`;
|
||||
} else {
|
||||
el.textContent = '세션: --:--';
|
||||
}
|
||||
}
|
||||
|
||||
async function kbLogin() {
|
||||
const pw = document.getElementById('kb-pw').value;
|
||||
const msg = document.getElementById('kb-login-msg');
|
||||
if (!pw) { msg.textContent = '비밀번호를 입력하세요.'; return; }
|
||||
msg.textContent = '로그인 중...';
|
||||
const r = await fetch('/api/kb/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.success) {
|
||||
msg.textContent = '❌ ' + (data.error || '로그인 실패');
|
||||
return;
|
||||
}
|
||||
kbToken = data.token;
|
||||
kbExpiresAt = data.expiresAt;
|
||||
sessionStorage.setItem('kbToken', kbToken);
|
||||
sessionStorage.setItem('kbExpiresAt', kbExpiresAt || '');
|
||||
document.getElementById('kb-pw').value = '';
|
||||
msg.textContent = '';
|
||||
kbShowMain();
|
||||
}
|
||||
|
||||
async function kbLogout() {
|
||||
if (kbToken) await kbFetch('POST', '/api/kb/auth/logout');
|
||||
kbShowLogin('로그아웃되었습니다.');
|
||||
}
|
||||
|
||||
// ── 컬렉션 ─────────────────────────────────────────────
|
||||
async function kbLoadCollections() {
|
||||
const r = await kbFetch('GET', '/api/kb/collections');
|
||||
if (!r.ok || !r.data || !r.data.success) return;
|
||||
kbCollections = r.data.items || [];
|
||||
const fSel = document.getElementById('kb-f-coll');
|
||||
const uSel = document.getElementById('kb-up-coll');
|
||||
fSel.innerHTML = '<option value="">전체</option>' +
|
||||
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
|
||||
uSel.innerHTML = '<option value="">-- 선택 --</option>' +
|
||||
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
|
||||
}
|
||||
|
||||
// ── 목록 ───────────────────────────────────────────────
|
||||
async function kbRefresh() {
|
||||
const coll = document.getElementById('kb-f-coll').value;
|
||||
const status = document.getElementById('kb-f-status').value;
|
||||
const q = document.getElementById('kb-f-q').value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (coll) qs.set('collection', coll);
|
||||
if (status) qs.set('status', status);
|
||||
if (q) qs.set('q', q);
|
||||
qs.set('pageSize', '200');
|
||||
|
||||
const r = await kbFetch('GET', '/api/kb/documents?' + qs.toString());
|
||||
if (!r.ok || !r.data || !r.data.success) {
|
||||
document.getElementById('kb-doc-table').innerHTML = '<div class="placeholder">조회 실패</div>';
|
||||
return;
|
||||
}
|
||||
kbRenderDocs(r.data.items, r.data.total);
|
||||
}
|
||||
|
||||
function kbRenderDocs(items, total) {
|
||||
const stats = document.getElementById('kb-doc-stats');
|
||||
stats.textContent = `총 ${total}건`;
|
||||
const tbl = document.getElementById('kb-doc-table');
|
||||
if (!items || items.length === 0) {
|
||||
tbl.innerHTML = '<div class="placeholder">문서 없음</div>';
|
||||
return;
|
||||
}
|
||||
const collMap = Object.fromEntries(kbCollections.map(c => [c.key, c.name]));
|
||||
const rows = items.map(d => {
|
||||
const tags = (d.tags || []).map(t => `<span class="kb-tag">${t}</span>`).join(' ');
|
||||
const dt = d.uploadedAt ? new Date(d.uploadedAt).toLocaleString('ko-KR') : '';
|
||||
const size = d.fileSize ? kbFmtSize(d.fileSize) : '';
|
||||
return `<tr>
|
||||
<td class="mono">${kbShortId(d.id)}</td>
|
||||
<td>${kbEscape(d.title)}</td>
|
||||
<td>${collMap[d.collection] || d.collection}</td>
|
||||
<td>${tags}</td>
|
||||
<td class="mono">${size}</td>
|
||||
<td><span class="kb-status kb-st-${d.status}">${d.status}</span>${d.errorMessage ? `<div class="kb-err" title="${kbEscape(d.errorMessage)}">${kbEscape(d.errorMessage.slice(0,60))}…</div>`:''}</td>
|
||||
<td class="mono">${d.chunkCount || 0}</td>
|
||||
<td class="mono">${dt}</td>
|
||||
<td>
|
||||
<button class="btn-b btn-sm" onclick="kbDownload('${d.id}')">⬇</button>
|
||||
<button class="btn-b btn-sm" onclick="kbReindex('${d.id}')">↻</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDisable('${d.id}')">🚫</button>
|
||||
<button class="btn-b btn-sm" onclick="kbDelete('${d.id}','${kbEscape(d.title)}')">✖</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
tbl.innerHTML = `<table class="tbl kb-doc-tbl"><thead><tr>
|
||||
<th>ID</th><th>제목</th><th>컬렉션</th><th>태그</th><th>크기</th>
|
||||
<th>상태</th><th>청크</th><th>업로드</th><th>액션</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
function kbShortId(id) { return (id || '').replace(/-/g, '').slice(0, 8); }
|
||||
function kbEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); }
|
||||
function kbFmtSize(n) {
|
||||
if (n < 1024) return n + 'B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + 'K';
|
||||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + 'M';
|
||||
return (n / 1024 / 1024 / 1024).toFixed(2) + 'G';
|
||||
}
|
||||
|
||||
// ── 업로드 모달 ────────────────────────────────────────
|
||||
function kbUploadOpen() {
|
||||
document.getElementById('kb-up-msg').textContent = '';
|
||||
document.getElementById('kb-up-title').value = '';
|
||||
document.getElementById('kb-up-tags').value = '';
|
||||
document.getElementById('kb-up-file').value = '';
|
||||
document.getElementById('kb-upload-modal').classList.remove('hidden');
|
||||
}
|
||||
function kbUploadClose() {
|
||||
document.getElementById('kb-upload-modal').classList.add('hidden');
|
||||
}
|
||||
async function kbUploadSubmit() {
|
||||
const coll = document.getElementById('kb-up-coll').value;
|
||||
const title = document.getElementById('kb-up-title').value.trim();
|
||||
const tags = document.getElementById('kb-up-tags').value.trim();
|
||||
const fileInput = document.getElementById('kb-up-file');
|
||||
const msg = document.getElementById('kb-up-msg');
|
||||
if (!coll) { msg.textContent = '❌ 컬렉션을 선택하세요.'; return; }
|
||||
if (!fileInput.files || fileInput.files.length === 0) { msg.textContent = '❌ 파일을 선택하세요.'; return; }
|
||||
const fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
fd.append('collectionKey', coll);
|
||||
if (title) fd.append('title', title);
|
||||
if (tags) fd.append('tags', tags);
|
||||
msg.textContent = '업로드 중...';
|
||||
const r = await fetch('/api/kb/upload', { method: 'POST', headers: kbHeaders(), body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.success) {
|
||||
msg.textContent = '❌ ' + (data.error || ('HTTP ' + r.status));
|
||||
return;
|
||||
}
|
||||
msg.textContent = '✅ 업로드 완료. 인덱싱 진행 중...';
|
||||
setTimeout(() => kbUploadClose(), 600);
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
// ── 액션 ───────────────────────────────────────────────
|
||||
function kbDownload(id) {
|
||||
window.open('/api/kb/download/' + id, '_blank');
|
||||
}
|
||||
|
||||
async function kbReindex(id) {
|
||||
if (!confirm('재인덱싱하시겠습니까? (Qdrant 기존 청크 삭제 후 다시 처리)')) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/' + id + '/reindex');
|
||||
if (!r.ok) alert('실패: ' + (r.data && r.data.error ? r.data.error : r.status));
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
async function kbDisable(id) {
|
||||
if (!confirm('이 문서를 비활성화하시겠습니까?')) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/' + id + '/disable');
|
||||
if (!r.ok) alert('실패');
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
async function kbDelete(id, title) {
|
||||
if (!confirm(`삭제하시겠습니까?\n${title}\n(Qdrant 청크와 원본 파일도 함께 삭제됩니다)`)) return;
|
||||
const r = await kbFetch('DELETE', '/api/kb/documents/' + id);
|
||||
if (!r.ok) alert('실패');
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
async function kbBulkDisable() {
|
||||
const title = prompt('일괄 비활성화할 제목을 정확히 입력하세요:');
|
||||
if (!title) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/bulk-disable', { title });
|
||||
if (r.ok && r.data && r.data.success) alert(`${r.data.affected}건 비활성화 완료`);
|
||||
else alert('실패');
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
async function kbPurgeDisabled() {
|
||||
const ds = prompt('비활성화 후 며칠 지난 문서를 영구삭제할까요? (공백이면 모든 disabled 삭제)', '90');
|
||||
let body = {};
|
||||
if (ds && ds.trim()) {
|
||||
const n = parseInt(ds, 10);
|
||||
if (isNaN(n) || n < 0) { alert('숫자를 입력하세요.'); return; }
|
||||
body.olderThanDays = n;
|
||||
}
|
||||
if (!confirm('정말 영구삭제하시겠습니까? (되돌릴 수 없습니다)')) return;
|
||||
const r = await kbFetch('POST', '/api/kb/documents/purge-disabled', body);
|
||||
if (r.ok && r.data && r.data.success) alert(`${r.data.deleted}건 영구삭제 완료`);
|
||||
else alert('실패');
|
||||
kbRefresh();
|
||||
}
|
||||
|
||||
// ── 비밀번호 변경 ──────────────────────────────────────
|
||||
function kbChangePwOpen() {
|
||||
document.getElementById('kb-pw-old').value = '';
|
||||
document.getElementById('kb-pw-new').value = '';
|
||||
document.getElementById('kb-pw-msg').textContent = '';
|
||||
document.getElementById('kb-pw-modal').classList.remove('hidden');
|
||||
}
|
||||
function kbChangePwClose() {
|
||||
document.getElementById('kb-pw-modal').classList.add('hidden');
|
||||
}
|
||||
async function kbChangePwSubmit() {
|
||||
const oldPw = document.getElementById('kb-pw-old').value;
|
||||
const newPw = document.getElementById('kb-pw-new').value;
|
||||
const msg = document.getElementById('kb-pw-msg');
|
||||
if (!oldPw || !newPw) { msg.textContent = '❌ 비밀번호를 입력하세요.'; return; }
|
||||
if (newPw.length < 6) { msg.textContent = '❌ 새 비밀번호는 6자 이상.'; return; }
|
||||
const r = await kbFetch('POST', '/api/kb/auth/change-password', { oldPassword: oldPw, newPassword: newPw });
|
||||
if (r.ok && r.data && r.data.success) {
|
||||
msg.textContent = '✅ 변경 완료. 다시 로그인해 주세요.';
|
||||
setTimeout(() => { kbChangePwClose(); kbLogout(); }, 800);
|
||||
} else {
|
||||
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : '변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 진행률 폴링 (활성 ingest가 있으면 1초마다 새로고침) ─
|
||||
function kbStartPoll() {
|
||||
kbStopPoll();
|
||||
kbPollTimer = setInterval(async () => {
|
||||
if (!kbToken) return;
|
||||
if (document.getElementById('pane-kbadmin').classList.contains('active') === false) return;
|
||||
const r = await kbFetch('GET', '/api/kb/documents?status=parsing&pageSize=1');
|
||||
const r2 = await kbFetch('GET', '/api/kb/documents?status=embedding&pageSize=1');
|
||||
const r3 = await kbFetch('GET', '/api/kb/documents?status=pending&pageSize=1');
|
||||
const active = [r, r2, r3].some(x => x.ok && x.data && x.data.total > 0);
|
||||
if (active) kbRefresh();
|
||||
}, 1500);
|
||||
}
|
||||
function kbStopPoll() {
|
||||
if (kbPollTimer) { clearInterval(kbPollTimer); kbPollTimer = null; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user