feat: model preset selector (Gemma 4 + 4 Qwen variants)
Browse filesAdds runtime model switching via Open WebUI dropdown:
- New ModelPreset registry (gemma-4-e2b default + qwen2.5-1.5b/3b, qwen3-1.7b, qwen3-4b-instruct-2507)
- ModelManager with persisted selection (config_root/model.json)
- /v1/models exposes all 5 presets so they appear in Open WebUI
- /v1/chat/completions parses req.model and switches manager
- Gradio UI gains a model dropdown in '⚙️ 모델 설정' accordion
- Split-view + standalone /chat headers gain dropdown
- src/kpaa/llm/factory.py +10 -34
- src/kpaa/llm/manager.py +175 -0
- src/kpaa/llm/presets.py +101 -0
- src/kpaa/llm/zerogpu_backend.py +3 -2
- src/kpaa/server.py +231 -17
- src/kpaa/ui/gradio.py +37 -1
src/kpaa/llm/factory.py
CHANGED
|
@@ -1,47 +1,23 @@
|
|
| 1 |
-
"""LLM 백엔드 팩토리 —
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
허용값: "llama_cpp" | "zerogpu".
|
| 6 |
-
2. 미명시: HF Spaces 환경변수(`SPACE_ID`) 가 있으면 "zerogpu", 아니면
|
| 7 |
-
"llama_cpp".
|
| 8 |
|
| 9 |
-
|
| 10 |
-
`
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import logging
|
| 15 |
-
import os
|
| 16 |
|
| 17 |
-
from kpaa.config import get_settings
|
| 18 |
from kpaa.llm.base import LLMBackend
|
|
|
|
| 19 |
|
| 20 |
logger = logging.getLogger("kpaa.llm.factory")
|
| 21 |
|
| 22 |
|
| 23 |
-
def _resolve_backend_name() -> str:
|
| 24 |
-
s = get_settings()
|
| 25 |
-
chosen = s.kpaa_llm_backend or os.environ.get("KPAA_LLM_BACKEND")
|
| 26 |
-
if chosen:
|
| 27 |
-
return chosen.strip().lower()
|
| 28 |
-
if os.environ.get("SPACE_ID"):
|
| 29 |
-
return "zerogpu"
|
| 30 |
-
return "llama_cpp"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
def get_backend() -> LLMBackend:
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
from kpaa.llm.zerogpu_backend import ZeroGPUBackend
|
| 37 |
-
|
| 38 |
-
logger.info("LLM backend selected: zerogpu (transformers + @spaces.GPU)")
|
| 39 |
-
return ZeroGPUBackend() # type: ignore[return-value]
|
| 40 |
-
if name == "llama_cpp":
|
| 41 |
-
from kpaa.llm.llama_cpp_backend import LlamaCppBackend
|
| 42 |
-
|
| 43 |
-
logger.info("LLM backend selected: llama_cpp (GGUF embed)")
|
| 44 |
-
return LlamaCppBackend() # type: ignore[return-value]
|
| 45 |
-
raise ValueError(
|
| 46 |
-
f"unknown KPAA_LLM_BACKEND={name!r} — expected 'llama_cpp' or 'zerogpu'"
|
| 47 |
-
)
|
|
|
|
| 1 |
+
"""LLM 백엔드 팩토리 — `ModelManager` 위임 (런타임 모델 전환 지원).
|
| 2 |
|
| 3 |
+
기존 호출자(`kpaa.llm.get_backend()`) 인터페이스는 동일.
|
| 4 |
+
실제 선택 정책은 `kpaa.llm.manager.ModelManager` 가 담당:
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
* 백엔드 종류(zerogpu vs llama_cpp): 환경변수 `KPAA_LLM_BACKEND`,
|
| 7 |
+
HF Spaces 환경(`SPACE_ID`) 자동 감지.
|
| 8 |
+
* 모델 가중치(어느 GGUF / HF repo): 프리셋 카탈로그(`presets.PRESETS`) 에서
|
| 9 |
+
선택. UI 또는 환경변수 `KPAA_MODEL_PRESET` 으로 변경 가능.
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import logging
|
|
|
|
| 14 |
|
|
|
|
| 15 |
from kpaa.llm.base import LLMBackend
|
| 16 |
+
from kpaa.llm.manager import get_manager
|
| 17 |
|
| 18 |
logger = logging.getLogger("kpaa.llm.factory")
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def get_backend() -> LLMBackend:
|
| 22 |
+
"""현재 선택된 프리셋의 백엔드 인스턴스 (lazy 빌드)."""
|
| 23 |
+
return get_manager().get_backend()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/kpaa/llm/manager.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""모델 매니저 — 현재 선택된 프리셋 + 백엔드 인스턴스 단일 캐시.
|
| 2 |
+
|
| 3 |
+
설계:
|
| 4 |
+
- 동시에 *최대 1개* 백엔드만 메모리 상주 (모델 swap 시 이전 인스턴스 unload).
|
| 5 |
+
이유: GGUF 1개당 1.5~5GB. 여러 모델 동시 캐시는 노트북 RAM 부담.
|
| 6 |
+
- 선택값은 `config_root/model.json` 에 영속화 → 재시작해도 같은 모델.
|
| 7 |
+
- 환경변수 강제 override 우선순위:
|
| 8 |
+
KPAA_MODEL_PRESET > 영속 파일 > presets.default_preset()
|
| 9 |
+
- HF Spaces (`SPACE_ID` 존재) 일 때는 ZeroGPU 백엔드 반환, 그 외 llama_cpp.
|
| 10 |
+
(factory 의 기존 정책과 동일.)
|
| 11 |
+
|
| 12 |
+
스레드세이프티: Gradio 큐 + FastAPI 모두 단일 이벤트 루프 안에서 사용된다는
|
| 13 |
+
가정. swap 시 짧은 race 가능성은 있으나 사용자 의도(모델 바꾸기) 와 다음 답변
|
| 14 |
+
사이에 자연스럽게 직렬화된다.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import os
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from kpaa.config import get_settings
|
| 25 |
+
from kpaa.llm.base import LLMBackend
|
| 26 |
+
from kpaa.llm.presets import ModelPreset, default_preset, get_preset, list_presets
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger("kpaa.llm.manager")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _state_path() -> Path:
|
| 32 |
+
return get_settings().config_root / "model.json"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _load_persisted_id() -> str | None:
|
| 36 |
+
p = _state_path()
|
| 37 |
+
if not p.exists():
|
| 38 |
+
return None
|
| 39 |
+
try:
|
| 40 |
+
data = json.loads(p.read_text(encoding="utf-8"))
|
| 41 |
+
v = data.get("preset_id")
|
| 42 |
+
return str(v) if v else None
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.warning("model.json 읽기 실패 — 무시 (%s)", e)
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _save_persisted_id(preset_id: str) -> None:
|
| 49 |
+
p = _state_path()
|
| 50 |
+
try:
|
| 51 |
+
p.write_text(
|
| 52 |
+
json.dumps({"preset_id": preset_id}, ensure_ascii=False, indent=2),
|
| 53 |
+
encoding="utf-8",
|
| 54 |
+
)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.warning("model.json 쓰기 실패 — 무시 (%s)", e)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _resolve_initial_id() -> str:
|
| 60 |
+
"""초기 프리셋 결정 — env > 영속 파일 > 기본."""
|
| 61 |
+
env = os.environ.get("KPAA_MODEL_PRESET", "").strip()
|
| 62 |
+
if env and get_preset(env):
|
| 63 |
+
return env
|
| 64 |
+
persisted = _load_persisted_id()
|
| 65 |
+
if persisted and get_preset(persisted):
|
| 66 |
+
return persisted
|
| 67 |
+
return default_preset().id
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _resolve_backend_kind() -> str:
|
| 71 |
+
"""zerogpu | llama_cpp — factory 와 같은 정책."""
|
| 72 |
+
s = get_settings()
|
| 73 |
+
chosen = s.kpaa_llm_backend or os.environ.get("KPAA_LLM_BACKEND")
|
| 74 |
+
if chosen:
|
| 75 |
+
return chosen.strip().lower()
|
| 76 |
+
if os.environ.get("SPACE_ID"):
|
| 77 |
+
return "zerogpu"
|
| 78 |
+
return "llama_cpp"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _build_backend(preset: ModelPreset) -> LLMBackend:
|
| 82 |
+
kind = _resolve_backend_kind()
|
| 83 |
+
if kind == "zerogpu":
|
| 84 |
+
from kpaa.llm.zerogpu_backend import ZeroGPUBackend
|
| 85 |
+
|
| 86 |
+
logger.info("backend build: zerogpu preset=%s repo=%s", preset.id, preset.hf_repo)
|
| 87 |
+
return ZeroGPUBackend(model_id=preset.hf_repo) # type: ignore[return-value]
|
| 88 |
+
if kind == "llama_cpp":
|
| 89 |
+
from kpaa.llm.llama_cpp_backend import LlamaCppBackend
|
| 90 |
+
|
| 91 |
+
logger.info(
|
| 92 |
+
"backend build: llama_cpp preset=%s repo=%s file=%s",
|
| 93 |
+
preset.id, preset.llama_cpp_repo, preset.llama_cpp_file,
|
| 94 |
+
)
|
| 95 |
+
return LlamaCppBackend(
|
| 96 |
+
repo_id=preset.llama_cpp_repo,
|
| 97 |
+
filename=preset.llama_cpp_file,
|
| 98 |
+
) # type: ignore[return-value]
|
| 99 |
+
raise ValueError(
|
| 100 |
+
f"unknown KPAA_LLM_BACKEND={kind!r} — expected 'llama_cpp' or 'zerogpu'"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class ModelManager:
|
| 105 |
+
"""싱글턴 — `kpaa.llm.factory.get_backend()` 가 위임."""
|
| 106 |
+
|
| 107 |
+
def __init__(self) -> None:
|
| 108 |
+
self._current_id: str = _resolve_initial_id()
|
| 109 |
+
self._backend: LLMBackend | None = None
|
| 110 |
+
self._backend_id: str | None = None
|
| 111 |
+
|
| 112 |
+
# ─── 조회 ───
|
| 113 |
+
@property
|
| 114 |
+
def current_id(self) -> str:
|
| 115 |
+
return self._current_id
|
| 116 |
+
|
| 117 |
+
def current_preset(self) -> ModelPreset:
|
| 118 |
+
p = get_preset(self._current_id) or default_preset()
|
| 119 |
+
return p
|
| 120 |
+
|
| 121 |
+
def list(self) -> list[ModelPreset]:
|
| 122 |
+
return list_presets()
|
| 123 |
+
|
| 124 |
+
# ─── 백엔드 ───
|
| 125 |
+
def get_backend(self) -> LLMBackend:
|
| 126 |
+
"""현재 프리셋의 백엔드 — 첫 호출 시 lazy 빌드 + 가중치 다운로드."""
|
| 127 |
+
if self._backend is not None and self._backend_id == self._current_id:
|
| 128 |
+
return self._backend
|
| 129 |
+
# 새 백엔드 빌드 — 이전 것 unload (close 비동기지만 GC로 충분)
|
| 130 |
+
if self._backend is not None:
|
| 131 |
+
logger.info("unloading previous backend: %s", self._backend_id)
|
| 132 |
+
self._backend = None
|
| 133 |
+
self._backend_id = None
|
| 134 |
+
preset = self.current_preset()
|
| 135 |
+
self._backend = _build_backend(preset)
|
| 136 |
+
self._backend_id = self._current_id
|
| 137 |
+
return self._backend
|
| 138 |
+
|
| 139 |
+
# ─── 변경 ───
|
| 140 |
+
def set_current(self, preset_id: str, *, persist: bool = True) -> ModelPreset:
|
| 141 |
+
"""프리셋 전환 — 다음 `get_backend()` 시점에 새 모델 로드.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
전환된 프리셋.
|
| 145 |
+
Raises:
|
| 146 |
+
ValueError: 알 수 없는 preset_id.
|
| 147 |
+
"""
|
| 148 |
+
preset = get_preset(preset_id)
|
| 149 |
+
if preset is None:
|
| 150 |
+
raise ValueError(f"unknown preset_id: {preset_id!r}")
|
| 151 |
+
if preset_id == self._current_id and self._backend is not None:
|
| 152 |
+
return preset # no-op
|
| 153 |
+
logger.info("model preset switch: %s → %s", self._current_id, preset_id)
|
| 154 |
+
self._current_id = preset_id
|
| 155 |
+
# 기존 백엔드 unload (다음 get_backend 호출 시 재빌드)
|
| 156 |
+
if self._backend is not None:
|
| 157 |
+
self._backend = None
|
| 158 |
+
self._backend_id = None
|
| 159 |
+
if persist:
|
| 160 |
+
_save_persisted_id(preset_id)
|
| 161 |
+
return preset
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
_manager: ModelManager | None = None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def get_manager() -> ModelManager:
|
| 168 |
+
"""프로세스 단일 매니저 인스턴스."""
|
| 169 |
+
global _manager
|
| 170 |
+
if _manager is None:
|
| 171 |
+
_manager = ModelManager()
|
| 172 |
+
return _manager
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
__all__ = ["ModelManager", "get_manager"]
|
src/kpaa/llm/presets.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""모델 프리셋 카탈로그 — UI 에서 선택 가능한 후보 목록.
|
| 2 |
+
|
| 3 |
+
각 프리셋은 *동일 가중치의 두 형식* 을 함께 가진다:
|
| 4 |
+
- llama_cpp_repo / llama_cpp_file : 로컬 노트북용 GGUF (Hugging Face 자동 다운로드)
|
| 5 |
+
- hf_repo : HF Spaces ZeroGPU 용 transformers 가중치 (옵션)
|
| 6 |
+
|
| 7 |
+
목적: 사용자가 채팅 답변 속도/품질 트레이드오프를 *런타임에* 비교해볼 수 있게.
|
| 8 |
+
초기 후보는 한국어 RAG 답변 + 라우팅 분류 양쪽 모두에 충분히 작동한다고 알려진
|
| 9 |
+
모델 위주.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class ModelPreset:
|
| 18 |
+
id: str # 영구 식별자 (config 저장용)
|
| 19 |
+
label: str # UI 표시 이름
|
| 20 |
+
short: str # 한줄 설명 (속도·품질 힌트)
|
| 21 |
+
llama_cpp_repo: str # GGUF repo
|
| 22 |
+
llama_cpp_file: str # GGUF 파일명
|
| 23 |
+
hf_repo: str # transformers repo (ZeroGPU 용; 없으면 llama_cpp 와 동일 모델군 사용)
|
| 24 |
+
family: str # "gemma" | "qwen2.5" | "qwen3"
|
| 25 |
+
is_default: bool = False
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# 후보 목록 — 답변 속도 빠른 순서 (대략).
|
| 29 |
+
# Q4_K_M 양자화 기준. 모두 instruct/chat 변형.
|
| 30 |
+
PRESETS: list[ModelPreset] = [
|
| 31 |
+
ModelPreset(
|
| 32 |
+
id="gemma-4-e2b",
|
| 33 |
+
label="Gemma 4 E2B (기본·균형)",
|
| 34 |
+
short="2B 유효 · 한국어 자연스러움 · 인용 포맷 안정",
|
| 35 |
+
llama_cpp_repo="bartowski/google_gemma-4-E2B-it-GGUF",
|
| 36 |
+
llama_cpp_file="google_gemma-4-E2B-it-Q4_K_M.gguf",
|
| 37 |
+
hf_repo="google/gemma-4-E2B-it",
|
| 38 |
+
family="gemma",
|
| 39 |
+
is_default=True,
|
| 40 |
+
),
|
| 41 |
+
ModelPreset(
|
| 42 |
+
id="qwen2.5-1.5b",
|
| 43 |
+
label="Qwen2.5 1.5B Instruct (가장 빠름)",
|
| 44 |
+
short="1.5B · 토큰 속도 최우선 · 한국어 톤은 다소 뻣뻣",
|
| 45 |
+
llama_cpp_repo="bartowski/Qwen2.5-1.5B-Instruct-GGUF",
|
| 46 |
+
llama_cpp_file="Qwen2.5-1.5B-Instruct-Q4_K_M.gguf",
|
| 47 |
+
hf_repo="Qwen/Qwen2.5-1.5B-Instruct",
|
| 48 |
+
family="qwen2.5",
|
| 49 |
+
),
|
| 50 |
+
ModelPreset(
|
| 51 |
+
id="qwen2.5-3b",
|
| 52 |
+
label="Qwen2.5 3B Instruct (빠름·안정)",
|
| 53 |
+
short="3B · Gemma 4 E2B 보다 약간 빠름 · 한국어 품질 양호",
|
| 54 |
+
llama_cpp_repo="bartowski/Qwen2.5-3B-Instruct-GGUF",
|
| 55 |
+
llama_cpp_file="Qwen2.5-3B-Instruct-Q4_K_M.gguf",
|
| 56 |
+
hf_repo="Qwen/Qwen2.5-3B-Instruct",
|
| 57 |
+
family="qwen2.5",
|
| 58 |
+
),
|
| 59 |
+
ModelPreset(
|
| 60 |
+
id="qwen3-1.7b",
|
| 61 |
+
label="Qwen3 1.7B (최신·빠름)",
|
| 62 |
+
short="1.7B · 최신 세대 · 다국어 토크나이저 개선",
|
| 63 |
+
llama_cpp_repo="bartowski/Qwen_Qwen3-1.7B-GGUF",
|
| 64 |
+
llama_cpp_file="Qwen_Qwen3-1.7B-Q4_K_M.gguf",
|
| 65 |
+
hf_repo="Qwen/Qwen3-1.7B",
|
| 66 |
+
family="qwen3",
|
| 67 |
+
),
|
| 68 |
+
ModelPreset(
|
| 69 |
+
id="qwen3-4b-instruct-2507",
|
| 70 |
+
label="Qwen3 4B Instruct 2507 (큰 모델·non-thinking)",
|
| 71 |
+
short="4B · 더 정확하나 더 느림 · thinking off 변형",
|
| 72 |
+
llama_cpp_repo="bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF",
|
| 73 |
+
llama_cpp_file="Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
| 74 |
+
hf_repo="Qwen/Qwen3-4B-Instruct-2507",
|
| 75 |
+
family="qwen3",
|
| 76 |
+
),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
_BY_ID: dict[str, ModelPreset] = {p.id: p for p in PRESETS}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def list_presets() -> list[ModelPreset]:
|
| 84 |
+
return list(PRESETS)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_preset(preset_id: str) -> ModelPreset | None:
|
| 88 |
+
return _BY_ID.get(preset_id)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def default_preset() -> ModelPreset:
|
| 92 |
+
for p in PRESETS:
|
| 93 |
+
if p.is_default:
|
| 94 |
+
return p
|
| 95 |
+
return PRESETS[0]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
__all__ = [
|
| 99 |
+
"ModelPreset", "PRESETS",
|
| 100 |
+
"list_presets", "get_preset", "default_preset",
|
| 101 |
+
]
|
src/kpaa/llm/zerogpu_backend.py
CHANGED
|
@@ -55,9 +55,10 @@ class ZeroGPUBackend:
|
|
| 55 |
|
| 56 |
name = "zerogpu"
|
| 57 |
|
| 58 |
-
def __init__(self) -> None:
|
| 59 |
s = get_settings()
|
| 60 |
-
|
|
|
|
| 61 |
self._dtype_name = s.kpaa_hf_model_dtype
|
| 62 |
self._gpu_duration = s.kpaa_hf_gpu_duration
|
| 63 |
self._tok: Any = None
|
|
|
|
| 55 |
|
| 56 |
name = "zerogpu"
|
| 57 |
|
| 58 |
+
def __init__(self, *, model_id: str | None = None) -> None:
|
| 59 |
s = get_settings()
|
| 60 |
+
# ModelManager 가 프리셋의 hf_repo 를 넘겨준다. 명시 없으면 settings 기본값.
|
| 61 |
+
self.model_id = model_id or s.kpaa_hf_model_repo
|
| 62 |
self._dtype_name = s.kpaa_hf_model_dtype
|
| 63 |
self._gpu_duration = s.kpaa_hf_gpu_duration
|
| 64 |
self._tok: Any = None
|
src/kpaa/server.py
CHANGED
|
@@ -2,19 +2,23 @@
|
|
| 2 |
|
| 3 |
엔드포인트:
|
| 4 |
GET /healthz — liveness
|
| 5 |
-
GET /v1/models — 모델
|
|
|
|
| 6 |
POST /v1/chat/completions — 비스트리밍/스트리밍 모두 지원
|
| 7 |
(stream=true 일 때 SSE)
|
| 8 |
|
| 9 |
-
요청의 `model`
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import asyncio
|
| 16 |
import contextlib
|
| 17 |
import json
|
|
|
|
|
|
|
| 18 |
import time
|
| 19 |
import uuid
|
| 20 |
from collections.abc import AsyncIterator
|
|
@@ -27,10 +31,55 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
| 27 |
from kpaa import __version__
|
| 28 |
from kpaa.llm import ChatMessage as LLMChatMessage
|
| 29 |
from kpaa.llm import LLMOptions
|
|
|
|
|
|
|
| 30 |
from kpaa.pipeline import generate
|
| 31 |
from kpaa.retrieval.excerpts import Excerpt
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _excerpt_to_dict(e: Excerpt) -> dict[str, Any]:
|
|
@@ -141,6 +190,12 @@ class ChatMessage(BaseModel):
|
|
| 141 |
content: str
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
class ChatRequest(BaseModel):
|
| 145 |
model_config = ConfigDict(extra="ignore") # 모르는 필드는 무시 (Open WebUI가 보내는 필드 다양)
|
| 146 |
|
|
@@ -329,8 +384,13 @@ async def _stream_chat(
|
|
| 329 |
req: ChatRequest,
|
| 330 |
query: str,
|
| 331 |
history: list[LLMChatMessage],
|
|
|
|
| 332 |
) -> AsyncIterator[str]:
|
| 333 |
-
"""OpenAI SSE chunk 형식으로 토큰 스트리밍.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
completion_id = _new_id()
|
| 335 |
created = int(time.time())
|
| 336 |
options = _options_from_request(req)
|
|
@@ -340,7 +400,7 @@ async def _stream_chat(
|
|
| 340 |
"id": completion_id,
|
| 341 |
"object": "chat.completion.chunk",
|
| 342 |
"created": created,
|
| 343 |
-
"model":
|
| 344 |
"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}],
|
| 345 |
}
|
| 346 |
|
|
@@ -354,7 +414,7 @@ async def _stream_chat(
|
|
| 354 |
"id": completion_id,
|
| 355 |
"object": "chat.completion.chunk",
|
| 356 |
"created": created,
|
| 357 |
-
"model":
|
| 358 |
"choices": [
|
| 359 |
{
|
| 360 |
"index": 0,
|
|
@@ -369,7 +429,7 @@ async def _stream_chat(
|
|
| 369 |
"id": completion_id,
|
| 370 |
"object": "chat.completion.chunk",
|
| 371 |
"created": created,
|
| 372 |
-
"model":
|
| 373 |
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
| 374 |
})
|
| 375 |
|
|
@@ -489,7 +549,7 @@ async def _stream_chat(
|
|
| 489 |
"id": completion_id,
|
| 490 |
"object": "chat.completion.chunk",
|
| 491 |
"created": created,
|
| 492 |
-
"model":
|
| 493 |
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason or "stop"}],
|
| 494 |
})
|
| 495 |
yield _sse("[DONE]")
|
|
@@ -528,7 +588,8 @@ def create_app() -> FastAPI:
|
|
| 528 |
</style></head>
|
| 529 |
<body>
|
| 530 |
<h1>KPAA — 개인정보보호법 미니 상담 백엔드</h1>
|
| 531 |
-
<p class="muted">버전 {__version__} · 모델 <code>{MODEL_ID}</code></p>
|
|
|
|
| 532 |
|
| 533 |
<p style="background:#0a66c2;color:#fff;padding:14px 16px;border-radius:8px;font-weight:600;">
|
| 534 |
👉 <a href="/" style="color:#fff;">Open WebUI + 참고자료 분할 화면 (홈)</a> ·
|
|
@@ -564,14 +625,23 @@ def create_app() -> FastAPI:
|
|
| 564 |
|
| 565 |
@app.get("/v1/models")
|
| 566 |
async def list_models() -> ModelList:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
return ModelList(
|
| 568 |
-
data=[ModelInfo(id=
|
| 569 |
)
|
| 570 |
|
| 571 |
@app.post("/v1/chat/completions")
|
| 572 |
async def chat_completions(req: ChatRequest):
|
| 573 |
history, query = _split_history_and_query(req.messages)
|
| 574 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
# 새 대화 자동 감지 — Open WebUI 의 "새 대화" 클릭 후 첫 질문은
|
| 576 |
# `history` 가 비어 있다 (prior assistant turn 없음). 메타 프롬프트
|
| 577 |
# (제목 자동생성 등) 는 제외하고, 사용자 첫 질문일 때만 우측 패널
|
|
@@ -591,7 +661,7 @@ def create_app() -> FastAPI:
|
|
| 591 |
|
| 592 |
if req.stream:
|
| 593 |
return StreamingResponse(
|
| 594 |
-
_stream_chat(req, query, history),
|
| 595 |
media_type="text/event-stream",
|
| 596 |
headers={
|
| 597 |
"Cache-Control": "no-cache",
|
|
@@ -622,7 +692,7 @@ def create_app() -> FastAPI:
|
|
| 622 |
return ChatResponse(
|
| 623 |
id=_new_id(),
|
| 624 |
created=int(time.time()),
|
| 625 |
-
model=
|
| 626 |
choices=[ChatChoice(message=ChatChoiceMessage(content=text))],
|
| 627 |
)
|
| 628 |
|
|
@@ -636,6 +706,38 @@ def create_app() -> FastAPI:
|
|
| 636 |
async def api_last_refs() -> dict[str, Any]:
|
| 637 |
return dict(_last_refs)
|
| 638 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
@app.post("/api/clear-references")
|
| 640 |
async def api_clear_refs() -> dict[str, str]:
|
| 641 |
"""우측 참고자료 패널 초기화 — Open WebUI 새 채팅 등에서 사용."""
|
|
@@ -697,7 +799,10 @@ def run(*, host: str = "127.0.0.1", port: int = 8000) -> None:
|
|
| 697 |
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
| 698 |
|
| 699 |
|
| 700 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
| 701 |
|
| 702 |
|
| 703 |
_SPLIT_HTML = """<!doctype html>
|
|
@@ -829,11 +934,15 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 829 |
<header class="right-header">
|
| 830 |
<h1>참고한 자료 <span class="pulse" id="pulse"></span></h1>
|
| 831 |
<span class="muted" id="refs-count"></span>
|
|
|
|
|
|
|
|
|
|
| 832 |
<button id="clear-btn" title="새 검색 — 우측 참고자료 초기화"
|
| 833 |
-
style="
|
| 834 |
🔄 초기화
|
| 835 |
</button>
|
| 836 |
</header>
|
|
|
|
| 837 |
<div class="meta-line" id="meta">Open WebUI에서 질문하면 LLM이 본 근거가 여기에 표시됩니다 (1초마다 갱신).</div>
|
| 838 |
<div class="refs-list" id="refs">
|
| 839 |
<div class="refs-empty">아직 답변이 없습니다.</div>
|
|
@@ -963,6 +1072,59 @@ async function clearRefsUI() {
|
|
| 963 |
// 초기화 버튼 — 백엔드의 _last_refs 를 비우고 우측 패널 즉시 비움.
|
| 964 |
document.getElementById("clear-btn").addEventListener("click", clearRefsUI);
|
| 965 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
// Open WebUI iframe 의 route 변경 자동 감지.
|
| 967 |
window.addEventListener("message", (e) => {
|
| 968 |
console.log("[kpaa-parent] message:", e.origin, e.data);
|
|
@@ -1036,8 +1198,11 @@ _CHAT_HTML = """<!doctype html>
|
|
| 1036 |
<section class="pane left">
|
| 1037 |
<header>
|
| 1038 |
<h1>KPAA — 개인정보보호법 상담</h1>
|
| 1039 |
-
<
|
|
|
|
|
|
|
| 1040 |
</header>
|
|
|
|
| 1041 |
<div class="messages" id="messages">
|
| 1042 |
<div class="msg bot">
|
| 1043 |
<div class="role">상담 도우미</div>
|
|
@@ -1169,6 +1334,55 @@ form.addEventListener("submit", (ev) => {
|
|
| 1169 |
};
|
| 1170 |
});
|
| 1171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
input.addEventListener("keydown", (e) => {
|
| 1173 |
// 일반 채팅 UX: Enter = 전송, Shift+Enter = 줄바꿈.
|
| 1174 |
// 한국어 IME 조합 중 Enter(글자 확정)는 무시.
|
|
|
|
| 2 |
|
| 3 |
엔드포인트:
|
| 4 |
GET /healthz — liveness
|
| 5 |
+
GET /v1/models — 프리셋별 모델 목록
|
| 6 |
+
(`개인정보 상담 AI(<preset.id>)`)
|
| 7 |
POST /v1/chat/completions — 비스트리밍/스트리밍 모두 지원
|
| 8 |
(stream=true 일 때 SSE)
|
| 9 |
|
| 10 |
+
요청의 `model` 은 ModelManager 전환 신호로 사용 (Open WebUI 모델 dropdown 에서
|
| 11 |
+
선택한 그 이름이 들어옴). `system` 메시지는 무시한다 — 항상 RAG 파이프라인을
|
| 12 |
+
거치므로 시스템 프롬프트는 서버에서 주입한다. 사용자 질문은 마지막 `role=user`
|
| 13 |
+
메시지의 `content` 를 사용.
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
import asyncio
|
| 18 |
import contextlib
|
| 19 |
import json
|
| 20 |
+
import logging
|
| 21 |
+
import re
|
| 22 |
import time
|
| 23 |
import uuid
|
| 24 |
from collections.abc import AsyncIterator
|
|
|
|
| 31 |
from kpaa import __version__
|
| 32 |
from kpaa.llm import ChatMessage as LLMChatMessage
|
| 33 |
from kpaa.llm import LLMOptions
|
| 34 |
+
from kpaa.llm.manager import get_manager
|
| 35 |
+
from kpaa.llm.presets import ModelPreset, default_preset, get_preset, list_presets
|
| 36 |
from kpaa.pipeline import generate
|
| 37 |
from kpaa.retrieval.excerpts import Excerpt
|
| 38 |
|
| 39 |
+
logger = logging.getLogger("kpaa.server")
|
| 40 |
+
|
| 41 |
+
# OpenAI-호환 모델 ID 형식 — Open WebUI 모델 dropdown 에 노출되는 이름.
|
| 42 |
+
# 프리셋별로 1개씩 생성: "개인정보 상담 AI(<preset.id>)"
|
| 43 |
+
# 사용자가 Open WebUI 에서 모델을 선택하면 그 이름이 그대로 ChatRequest.model 에
|
| 44 |
+
# 들어오고, 서버는 preset.id 를 추출해 ModelManager 를 그 모델로 전환한다.
|
| 45 |
+
_MODEL_ID_PREFIX = "개인정보 상담 AI"
|
| 46 |
+
_MODEL_ID_RE = re.compile(rf"^{re.escape(_MODEL_ID_PREFIX)}\((?P<id>[\w.\-]+)\)$")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def model_id_for(preset: ModelPreset) -> str:
|
| 50 |
+
"""프리셋 → OpenAI-호환 모델 ID."""
|
| 51 |
+
return f"{_MODEL_ID_PREFIX}({preset.id})"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def preset_id_from_model(model: str | None) -> str | None:
|
| 55 |
+
"""`개인정보 상담 AI(<id>)` → preset.id. 매칭 실패 시 None."""
|
| 56 |
+
if not model:
|
| 57 |
+
return None
|
| 58 |
+
m = _MODEL_ID_RE.match(model.strip())
|
| 59 |
+
return m.group("id") if m else None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# 기본 모델 ID — `/healthz`, 자체 chat UI 헤더, `/info` curl 예시, 그리고
|
| 63 |
+
# 테스트 호환용. 항상 default_preset() 의 표시 ID 와 동기.
|
| 64 |
+
MODEL_ID = model_id_for(default_preset())
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _switch_to_requested_model(model: str | None) -> str:
|
| 68 |
+
"""요청 model 필드 → preset 매핑 + 매니저 전환. 항상 *최종 사용된* 모델 ID 반환.
|
| 69 |
+
|
| 70 |
+
매핑 실패 (Open WebUI 의 메타 호출이 prefix 가 다른 임의 model 을 보낼 때 등)
|
| 71 |
+
시엔 매니저 그대로 두고 *현재 모델 ID* 반환.
|
| 72 |
+
"""
|
| 73 |
+
pid = preset_id_from_model(model)
|
| 74 |
+
if pid is not None and get_preset(pid) is not None:
|
| 75 |
+
mgr = get_manager()
|
| 76 |
+
if pid != mgr.current_id:
|
| 77 |
+
try:
|
| 78 |
+
mgr.set_current(pid)
|
| 79 |
+
except ValueError as e:
|
| 80 |
+
logger.warning("모델 전환 실패 — %s", e)
|
| 81 |
+
# 항상 현재 매니저 상태 기준으로 응답 model 필드 채움.
|
| 82 |
+
return model_id_for(get_manager().current_preset())
|
| 83 |
|
| 84 |
|
| 85 |
def _excerpt_to_dict(e: Excerpt) -> dict[str, Any]:
|
|
|
|
| 190 |
content: str
|
| 191 |
|
| 192 |
|
| 193 |
+
class SelectModelReq(BaseModel):
|
| 194 |
+
"""`/api/select-model` 요청 바디 — preset_id 하나."""
|
| 195 |
+
|
| 196 |
+
preset_id: str
|
| 197 |
+
|
| 198 |
+
|
| 199 |
class ChatRequest(BaseModel):
|
| 200 |
model_config = ConfigDict(extra="ignore") # 모르는 필드는 무시 (Open WebUI가 보내는 필드 다양)
|
| 201 |
|
|
|
|
| 384 |
req: ChatRequest,
|
| 385 |
query: str,
|
| 386 |
history: list[LLMChatMessage],
|
| 387 |
+
model_id: str,
|
| 388 |
) -> AsyncIterator[str]:
|
| 389 |
+
"""OpenAI SSE chunk 형식으로 토큰 스트리밍.
|
| 390 |
+
|
| 391 |
+
model_id 는 응답 chunk 의 `model` 필드 값 — 보통 사용자가 Open WebUI 에서
|
| 392 |
+
선택한 그 이름 그대로. ModelManager 전환은 호출 전에 끝나 있어야 한다.
|
| 393 |
+
"""
|
| 394 |
completion_id = _new_id()
|
| 395 |
created = int(time.time())
|
| 396 |
options = _options_from_request(req)
|
|
|
|
| 400 |
"id": completion_id,
|
| 401 |
"object": "chat.completion.chunk",
|
| 402 |
"created": created,
|
| 403 |
+
"model": model_id,
|
| 404 |
"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}],
|
| 405 |
}
|
| 406 |
|
|
|
|
| 414 |
"id": completion_id,
|
| 415 |
"object": "chat.completion.chunk",
|
| 416 |
"created": created,
|
| 417 |
+
"model": model_id,
|
| 418 |
"choices": [
|
| 419 |
{
|
| 420 |
"index": 0,
|
|
|
|
| 429 |
"id": completion_id,
|
| 430 |
"object": "chat.completion.chunk",
|
| 431 |
"created": created,
|
| 432 |
+
"model": model_id,
|
| 433 |
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
| 434 |
})
|
| 435 |
|
|
|
|
| 549 |
"id": completion_id,
|
| 550 |
"object": "chat.completion.chunk",
|
| 551 |
"created": created,
|
| 552 |
+
"model": model_id,
|
| 553 |
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason or "stop"}],
|
| 554 |
})
|
| 555 |
yield _sse("[DONE]")
|
|
|
|
| 588 |
</style></head>
|
| 589 |
<body>
|
| 590 |
<h1>KPAA — 개인정보보호법 미니 상담 백엔드</h1>
|
| 591 |
+
<p class="muted">버전 {__version__} · 기본 모델 <code>{MODEL_ID}</code></p>
|
| 592 |
+
<p class="muted">선택 가능: {", ".join(f"<code>{model_id_for(p)}</code>" for p in list_presets())}</p>
|
| 593 |
|
| 594 |
<p style="background:#0a66c2;color:#fff;padding:14px 16px;border-radius:8px;font-weight:600;">
|
| 595 |
👉 <a href="/" style="color:#fff;">Open WebUI + 참고자료 분할 화면 (홈)</a> ·
|
|
|
|
| 625 |
|
| 626 |
@app.get("/v1/models")
|
| 627 |
async def list_models() -> ModelList:
|
| 628 |
+
# 프리셋별 1개씩 — Open WebUI 모델 dropdown 에 동시에 노출.
|
| 629 |
+
# 사용자가 dropdown 에서 선택한 모델 이름이 ChatRequest.model 로 전달되며,
|
| 630 |
+
# `_switch_to_requested_model` 가 그 이름을 보고 ModelManager 를 전환한다.
|
| 631 |
+
now = int(time.time())
|
| 632 |
return ModelList(
|
| 633 |
+
data=[ModelInfo(id=model_id_for(p), created=now) for p in list_presets()]
|
| 634 |
)
|
| 635 |
|
| 636 |
@app.post("/v1/chat/completions")
|
| 637 |
async def chat_completions(req: ChatRequest):
|
| 638 |
history, query = _split_history_and_query(req.messages)
|
| 639 |
|
| 640 |
+
# Open WebUI 가 보낸 모델 이름 (`개인정보 상담 AI(<preset.id>)`) → 매니저 전환.
|
| 641 |
+
# 메타 호출(제목/태그 생성 등)도 같은 매니저를 쓰므로 자동으로 같은 모델로 처리.
|
| 642 |
+
# 매핑 안 되는 임의 model 이면 현재 매니저 상태 유지.
|
| 643 |
+
active_model_id = _switch_to_requested_model(req.model)
|
| 644 |
+
|
| 645 |
# 새 대화 자동 감지 — Open WebUI 의 "새 대화" 클릭 후 첫 질문은
|
| 646 |
# `history` 가 비어 있다 (prior assistant turn 없음). 메타 프롬프트
|
| 647 |
# (제목 자동생성 등) 는 제외하고, 사용자 첫 질문일 때만 우측 패널
|
|
|
|
| 661 |
|
| 662 |
if req.stream:
|
| 663 |
return StreamingResponse(
|
| 664 |
+
_stream_chat(req, query, history, active_model_id),
|
| 665 |
media_type="text/event-stream",
|
| 666 |
headers={
|
| 667 |
"Cache-Control": "no-cache",
|
|
|
|
| 692 |
return ChatResponse(
|
| 693 |
id=_new_id(),
|
| 694 |
created=int(time.time()),
|
| 695 |
+
model=active_model_id,
|
| 696 |
choices=[ChatChoice(message=ChatChoiceMessage(content=text))],
|
| 697 |
)
|
| 698 |
|
|
|
|
| 706 |
async def api_last_refs() -> dict[str, Any]:
|
| 707 |
return dict(_last_refs)
|
| 708 |
|
| 709 |
+
@app.get("/api/models")
|
| 710 |
+
async def api_models() -> dict[str, Any]:
|
| 711 |
+
"""프리셋 목록 + 현재 선택. 프런트 dropdown 채우기용."""
|
| 712 |
+
mgr = get_manager()
|
| 713 |
+
return {
|
| 714 |
+
"current": mgr.current_id,
|
| 715 |
+
"presets": [
|
| 716 |
+
{
|
| 717 |
+
"id": p.id,
|
| 718 |
+
"label": p.label,
|
| 719 |
+
"short": p.short,
|
| 720 |
+
"family": p.family,
|
| 721 |
+
"is_default": p.is_default,
|
| 722 |
+
}
|
| 723 |
+
for p in list_presets()
|
| 724 |
+
],
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
@app.post("/api/select-model")
|
| 728 |
+
async def api_select_model(req: SelectModelReq) -> dict[str, Any]:
|
| 729 |
+
"""모델 프리셋 전환 — 다음 답변부터 새 모델로."""
|
| 730 |
+
try:
|
| 731 |
+
preset = get_manager().set_current(req.preset_id)
|
| 732 |
+
except ValueError as e:
|
| 733 |
+
raise HTTPException(400, str(e)) from e
|
| 734 |
+
return {
|
| 735 |
+
"status": "ok",
|
| 736 |
+
"current": preset.id,
|
| 737 |
+
"label": preset.label,
|
| 738 |
+
"short": preset.short,
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
@app.post("/api/clear-references")
|
| 742 |
async def api_clear_refs() -> dict[str, str]:
|
| 743 |
"""우측 참고자료 패널 초기화 — Open WebUI 새 채팅 등에서 사용."""
|
|
|
|
| 799 |
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
| 800 |
|
| 801 |
|
| 802 |
+
__all__ = [
|
| 803 |
+
"create_app", "run", "MODEL_ID",
|
| 804 |
+
"model_id_for", "preset_id_from_model",
|
| 805 |
+
]
|
| 806 |
|
| 807 |
|
| 808 |
_SPLIT_HTML = """<!doctype html>
|
|
|
|
| 934 |
<header class="right-header">
|
| 935 |
<h1>참고한 자료 <span class="pulse" id="pulse"></span></h1>
|
| 936 |
<span class="muted" id="refs-count"></span>
|
| 937 |
+
<select id="model-select" title="답변 LLM 모델 — 변경 시 다음 질문부터 적용"
|
| 938 |
+
style="margin-left:auto; padding:4px 8px; border-radius:6px; border:1px solid var(--border); background:var(--card-bg); color:var(--text); font-size:0.78em; max-width: 220px;">
|
| 939 |
+
</select>
|
| 940 |
<button id="clear-btn" title="새 검색 — 우측 참고자료 초기화"
|
| 941 |
+
style="padding:4px 10px; border-radius:6px; border:1px solid var(--border); background:var(--card-bg); color:var(--text); cursor:pointer; font-size:0.78em;">
|
| 942 |
🔄 초기화
|
| 943 |
</button>
|
| 944 |
</header>
|
| 945 |
+
<div id="model-status" class="meta-line" style="display:none;"></div>
|
| 946 |
<div class="meta-line" id="meta">Open WebUI에서 질문하면 LLM이 본 근거가 여기에 표시됩니다 (1초마다 갱신).</div>
|
| 947 |
<div class="refs-list" id="refs">
|
| 948 |
<div class="refs-empty">아직 답변이 없습니다.</div>
|
|
|
|
| 1072 |
// 초기화 버튼 — 백엔드의 _last_refs 를 비우고 우측 패널 즉시 비움.
|
| 1073 |
document.getElementById("clear-btn").addEventListener("click", clearRefsUI);
|
| 1074 |
|
| 1075 |
+
// ─ 모델 선택 dropdown ─
|
| 1076 |
+
const modelSelect = document.getElementById("model-select");
|
| 1077 |
+
const modelStatus = document.getElementById("model-status");
|
| 1078 |
+
|
| 1079 |
+
function showModelStatus(msg, ok) {
|
| 1080 |
+
modelStatus.textContent = msg;
|
| 1081 |
+
modelStatus.style.display = "block";
|
| 1082 |
+
modelStatus.style.color = ok ? "var(--pulse)" : "#c0392b";
|
| 1083 |
+
setTimeout(() => { modelStatus.style.display = "none"; }, 4000);
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
async function loadModels() {
|
| 1087 |
+
try {
|
| 1088 |
+
const r = await fetch("/api/models", { cache: "no-store" });
|
| 1089 |
+
if (!r.ok) return;
|
| 1090 |
+
const data = await r.json();
|
| 1091 |
+
modelSelect.innerHTML = "";
|
| 1092 |
+
for (const p of data.presets || []) {
|
| 1093 |
+
const opt = document.createElement("option");
|
| 1094 |
+
opt.value = p.id;
|
| 1095 |
+
opt.textContent = p.label;
|
| 1096 |
+
opt.title = p.short;
|
| 1097 |
+
if (p.id === data.current) opt.selected = true;
|
| 1098 |
+
modelSelect.appendChild(opt);
|
| 1099 |
+
}
|
| 1100 |
+
} catch (_) {}
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
modelSelect.addEventListener("change", async () => {
|
| 1104 |
+
const preset_id = modelSelect.value;
|
| 1105 |
+
modelSelect.disabled = true;
|
| 1106 |
+
try {
|
| 1107 |
+
const r = await fetch("/api/select-model", {
|
| 1108 |
+
method: "POST",
|
| 1109 |
+
headers: { "Content-Type": "application/json" },
|
| 1110 |
+
body: JSON.stringify({ preset_id }),
|
| 1111 |
+
});
|
| 1112 |
+
if (!r.ok) {
|
| 1113 |
+
const txt = await r.text();
|
| 1114 |
+
showModelStatus(`모델 변경 실패: ${txt}`, false);
|
| 1115 |
+
return;
|
| 1116 |
+
}
|
| 1117 |
+
const data = await r.json();
|
| 1118 |
+
showModelStatus(`✅ 모델 변경됨 — ${data.label} (다음 질문부터 적용 · 첫 사용 시 다운로드)`, true);
|
| 1119 |
+
} catch (e) {
|
| 1120 |
+
showModelStatus(`네트워크 오류: ${e}`, false);
|
| 1121 |
+
} finally {
|
| 1122 |
+
modelSelect.disabled = false;
|
| 1123 |
+
}
|
| 1124 |
+
});
|
| 1125 |
+
|
| 1126 |
+
loadModels();
|
| 1127 |
+
|
| 1128 |
// Open WebUI iframe 의 route 변경 자동 감지.
|
| 1129 |
window.addEventListener("message", (e) => {
|
| 1130 |
console.log("[kpaa-parent] message:", e.origin, e.data);
|
|
|
|
| 1198 |
<section class="pane left">
|
| 1199 |
<header>
|
| 1200 |
<h1>KPAA — 개인정보보호법 상담</h1>
|
| 1201 |
+
<select id="model-select" title="답변 LLM 모델 — 변경 시 다음 질문부터 적용"
|
| 1202 |
+
style="margin-left:auto; padding:4px 8px; border-radius:6px; border:1px solid #d0d0d0; background:#fff; font-size:0.82em; max-width: 240px;">
|
| 1203 |
+
</select>
|
| 1204 |
</header>
|
| 1205 |
+
<div id="model-status" class="meta-line" style="display:none;"></div>
|
| 1206 |
<div class="messages" id="messages">
|
| 1207 |
<div class="msg bot">
|
| 1208 |
<div class="role">상담 도우미</div>
|
|
|
|
| 1334 |
};
|
| 1335 |
});
|
| 1336 |
|
| 1337 |
+
// ─ 모델 선택 dropdown (자체 chat UI) ─
|
| 1338 |
+
const modelSelect = document.getElementById("model-select");
|
| 1339 |
+
const modelStatus = document.getElementById("model-status");
|
| 1340 |
+
function showModelStatus(msg, ok) {
|
| 1341 |
+
modelStatus.textContent = msg;
|
| 1342 |
+
modelStatus.style.display = "block";
|
| 1343 |
+
modelStatus.style.color = ok ? "#15833a" : "#c0392b";
|
| 1344 |
+
setTimeout(() => { modelStatus.style.display = "none"; }, 4000);
|
| 1345 |
+
}
|
| 1346 |
+
async function loadModels() {
|
| 1347 |
+
try {
|
| 1348 |
+
const r = await fetch("/api/models", { cache: "no-store" });
|
| 1349 |
+
if (!r.ok) return;
|
| 1350 |
+
const data = await r.json();
|
| 1351 |
+
modelSelect.innerHTML = "";
|
| 1352 |
+
for (const p of data.presets || []) {
|
| 1353 |
+
const opt = document.createElement("option");
|
| 1354 |
+
opt.value = p.id;
|
| 1355 |
+
opt.textContent = p.label;
|
| 1356 |
+
opt.title = p.short;
|
| 1357 |
+
if (p.id === data.current) opt.selected = true;
|
| 1358 |
+
modelSelect.appendChild(opt);
|
| 1359 |
+
}
|
| 1360 |
+
} catch (_) {}
|
| 1361 |
+
}
|
| 1362 |
+
modelSelect.addEventListener("change", async () => {
|
| 1363 |
+
const preset_id = modelSelect.value;
|
| 1364 |
+
modelSelect.disabled = true;
|
| 1365 |
+
try {
|
| 1366 |
+
const r = await fetch("/api/select-model", {
|
| 1367 |
+
method: "POST",
|
| 1368 |
+
headers: { "Content-Type": "application/json" },
|
| 1369 |
+
body: JSON.stringify({ preset_id }),
|
| 1370 |
+
});
|
| 1371 |
+
if (!r.ok) {
|
| 1372 |
+
const txt = await r.text();
|
| 1373 |
+
showModelStatus(`모델 변경 실패: ${txt}`, false);
|
| 1374 |
+
return;
|
| 1375 |
+
}
|
| 1376 |
+
const data = await r.json();
|
| 1377 |
+
showModelStatus(`✅ 모델 변경됨 — ${data.label} (다음 질문부터 적용 · 첫 사용 시 다운로드)`, true);
|
| 1378 |
+
} catch (e) {
|
| 1379 |
+
showModelStatus(`네트워크 오류: ${e}`, false);
|
| 1380 |
+
} finally {
|
| 1381 |
+
modelSelect.disabled = false;
|
| 1382 |
+
}
|
| 1383 |
+
});
|
| 1384 |
+
loadModels();
|
| 1385 |
+
|
| 1386 |
input.addEventListener("keydown", (e) => {
|
| 1387 |
// 일반 채팅 UX: Enter = 전송, Shift+Enter = 줄바꿈.
|
| 1388 |
// 한국어 IME 조합 중 Enter(글자 확정)는 무시.
|
src/kpaa/ui/gradio.py
CHANGED
|
@@ -26,6 +26,8 @@ from typing import Any
|
|
| 26 |
|
| 27 |
from kpaa.llm import ChatMessage as LLMChatMessage
|
| 28 |
from kpaa.llm import LLMOptions
|
|
|
|
|
|
|
| 29 |
from kpaa.pipeline import generate
|
| 30 |
from kpaa.retrieval.citation_match import (
|
| 31 |
compute_cited_with_indices,
|
|
@@ -381,10 +383,44 @@ def build_app():
|
|
| 381 |
|
| 382 |
한국 **개인정보보호법** 을 평이한 한국어로 안내합니다.
|
| 383 |
법제처 OPEN API + 개인정보보호위원회 상담사례 1,745건 + 안내서를
|
| 384 |
-
근거로
|
| 385 |
"""
|
| 386 |
)
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
with gr.Row():
|
| 389 |
with gr.Column(scale=3):
|
| 390 |
chatbot = gr.Chatbot(
|
|
|
|
| 26 |
|
| 27 |
from kpaa.llm import ChatMessage as LLMChatMessage
|
| 28 |
from kpaa.llm import LLMOptions
|
| 29 |
+
from kpaa.llm.manager import get_manager
|
| 30 |
+
from kpaa.llm.presets import list_presets
|
| 31 |
from kpaa.pipeline import generate
|
| 32 |
from kpaa.retrieval.citation_match import (
|
| 33 |
compute_cited_with_indices,
|
|
|
|
| 383 |
|
| 384 |
한국 **개인정보보호법** 을 평이한 한국어로 안내합니다.
|
| 385 |
법제처 OPEN API + 개인정보보호위원회 상담사례 1,745건 + 안내서를
|
| 386 |
+
근거로 답변합니다. 모든 답변에 **인용·면책** 자동 부착.
|
| 387 |
"""
|
| 388 |
)
|
| 389 |
|
| 390 |
+
# ─── 모델 선택 (테스트용) ─────────────────────────────────────────
|
| 391 |
+
# 사용자가 답변 LLM 을 런타임에 교체해 *속도 vs 품질* 비교 가능.
|
| 392 |
+
# 처음 선택된 모델은 다음 질문 시 자동 다운로드 + 로드 (수 GB, 1-2분).
|
| 393 |
+
_mgr = get_manager()
|
| 394 |
+
_presets = list_presets()
|
| 395 |
+
_choices = [(f"{p.label} — {p.short}", p.id) for p in _presets]
|
| 396 |
+
with gr.Accordion("⚙️ 모델 설정", open=False):
|
| 397 |
+
model_dd = gr.Dropdown(
|
| 398 |
+
choices=_choices,
|
| 399 |
+
value=_mgr.current_id,
|
| 400 |
+
label="답변 LLM 모델",
|
| 401 |
+
info=(
|
| 402 |
+
"모델 변경 시 다음 질문부터 적용됩니다. 처음 쓰는 모델은 "
|
| 403 |
+
"Hugging Face 에서 자동 다운로드 (수 GB, 1-2분 소요)."
|
| 404 |
+
),
|
| 405 |
+
interactive=True,
|
| 406 |
+
)
|
| 407 |
+
model_status = gr.Markdown(
|
| 408 |
+
f"현재: **{_mgr.current_preset().label}**",
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
def _on_model_change(preset_id: str) -> str:
|
| 412 |
+
try:
|
| 413 |
+
p = _mgr.set_current(preset_id)
|
| 414 |
+
return (
|
| 415 |
+
f"✅ 변경됨 — **{p.label}** \n"
|
| 416 |
+
f"_{p.short}_ \n"
|
| 417 |
+
f"다음 질문 시 모델 로드 (필요 시 자동 다운로드)."
|
| 418 |
+
)
|
| 419 |
+
except ValueError as e:
|
| 420 |
+
return f"❌ 오류: {e}"
|
| 421 |
+
|
| 422 |
+
model_dd.change(_on_model_change, inputs=model_dd, outputs=model_status)
|
| 423 |
+
|
| 424 |
with gr.Row():
|
| 425 |
with gr.Column(scale=3):
|
| 426 |
chatbot = gr.Chatbot(
|