scvcoder commited on
Commit
686f69a
·
verified ·
1 Parent(s): a8db799

feat: model preset selector (Gemma 4 + 4 Qwen variants)

Browse files

Adds 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 CHANGED
@@ -1,47 +1,23 @@
1
- """LLM 백엔드 팩토리 — 환경 자동 감지 + 강제 override.
2
 
3
- 선택 규칙:
4
- 1. `KPAA_LLM_BACKEND` 환경변수 (또는 settings 의 동명 필드) 명시 시 그 값.
5
- 허용값: "llama_cpp" | "zerogpu".
6
- 2. 미명시: HF Spaces 환경변수(`SPACE_ID`) 가 있으면 "zerogpu", 아니면
7
- "llama_cpp".
8
 
9
- HF Spaces (Gradio SDK + ZeroGPU) 에는 `SPACE_ID` 가 자동으로 주입된다 —
10
- `huggingface/your-space` 형태. 로컬 머신에는 없으므로 연스럽게 분기.
 
 
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
- name = _resolve_backend_name()
35
- if name == "zerogpu":
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
- self.model_id = s.kpaa_hf_model_repo
 
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 — 모델 1개 (kpaa-privacy-ko)
 
6
  POST /v1/chat/completions — 비스트리밍/스트리밍 모두 지원
7
  (stream=true 일 때 SSE)
8
 
9
- 요청의 `model`, `system` 메시지는 무시한다 항상 RAG 파이프라인을 거치므로
10
- 시스템 프롬프트는 서버에서 주입한다. 사용자 질문은 마지막 `role=user` 메시지
11
- `content`를 사용 (멀티턴 historyv1 미지원).
 
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
- MODEL_ID = "개인정보 미니 상담 AI"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": MODEL_ID,
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": MODEL_ID,
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": MODEL_ID,
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": MODEL_ID,
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> &nbsp; · &nbsp;
@@ -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=MODEL_ID, created=int(time.time()))]
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=MODEL_ID,
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__ = ["create_app", "run", "MODEL_ID"]
 
 
 
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="margin-left:auto; padding:4px 10px; border-radius:6px; border:1px solid var(--border); background:var(--card-bg); color:var(--text); cursor:pointer; font-size:0.78em;">
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
- <span class="muted">모델: kpaa-privacy-ko</span>
 
 
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> &nbsp; · &nbsp;
 
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
- 근거로 **Gemma 4 E2B** 가 답변합니다. 모든 답변에 **인용·면책** 자동 부착.
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(