scvcoder commited on
Commit
9344f01
·
verified ·
1 Parent(s): 3665623

Cleanup: dead code, route deletion (/info, /chat, /api/*), comment polish, auth mode docs, URL rename

Browse files
.dockerignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ *.gguf
14
+ .git/
15
+ .github/
16
+ .idea/
17
+ .vscode/
18
+ .DS_Store
19
+ Thumbs.db
20
+ docs/
21
+ tests/
22
+ .claude/
23
+
24
+ # 모델 가중치는 컨테이너 안에서 첫 실행 시 받음
25
+ models/
26
+ .cache/
.gitattributes CHANGED
@@ -1,38 +1,9 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- data/cases.sqlite filter=lfs diff=lfs merge=lfs -text
37
- data/guides.sqlite filter=lfs diff=lfs merge=lfs -text
38
- data/embeddings.sqlite filter=lfs diff=lfs merge=lfs -text
 
1
+ * text=auto eol=lf
2
+
3
+ *.sqlite binary
4
+ *.gguf binary
5
+ *.png binary
6
+ *.jpg binary
7
+ *.jpeg binary
8
+ *.gif binary
9
+ *.ico binary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ wheels/
9
+
10
+ .venv/
11
+ venv/
12
+ .env
13
+
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ .coverage.*
18
+ htmlcov/
19
+
20
+ .cache/
21
+ .kpaa-cache/
22
+ *.gguf
23
+ models/
24
+ .run/
25
+ .webui_secret_key
26
+
27
+ .DS_Store
28
+ Thumbs.db
29
+ .idea/
30
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 멀티아키 backend 이미지 — llama-cpp-python 임베드 단일 프로세스.
2
+ # 외부 추론 데몬·서비스 의존 없음.
3
+ #
4
+ # 빌드:
5
+ # docker buildx build --platform linux/amd64,linux/arm64 -t kpaa-backend .
6
+ # 또는 docker-compose가 알아서 (build: .)
7
+
8
+ FROM python:3.11-slim AS base
9
+
10
+ # Build deps for native wheels (lxml, llama-cpp-python sdist fallback).
11
+ # llama-cpp-python은 보통 manylinux wheel을 받지만, arm64에서 빌드가 필요할 수 있어
12
+ # build-essential + cmake 포함.
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ cmake \
16
+ ca-certificates \
17
+ curl \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ ENV PYTHONUNBUFFERED=1 \
21
+ PYTHONDONTWRITEBYTECODE=1 \
22
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
23
+ PIP_NO_CACHE_DIR=1 \
24
+ PYTHONUTF8=1 \
25
+ KPAA_HOST=0.0.0.0 \
26
+ KPAA_PORT=8000
27
+ # GPU offload 는 컨테이너 *런타임* 의 llama-cpp 빌드에 따라 자동 결정.
28
+ # CPU 빌드 컨테이너: 자동 0. GPU 빌드 컨테이너: 자동 -1.
29
+ # 강제 override 가 필요하면 docker-compose 의 environment 에 KPAA_N_GPU_LAYERS=... 추가.
30
+
31
+ WORKDIR /app
32
+
33
+ # 의존성 먼저 설치 (캐시 효율)
34
+ COPY pyproject.toml README.md LICENSE NOTICE ./
35
+ COPY src/ ./src/
36
+ RUN pip install --upgrade pip \
37
+ && pip install '.[llm]'
38
+
39
+ # 데이터 자산 (상담사례 스냅샷 동봉)
40
+ COPY data/ ./data/
41
+
42
+ # 모델·법제처 캐시는 named volume으로 마운트 (도커가 처리)
43
+ VOLUME ["/root/.cache/kpaa"]
44
+
45
+ EXPOSE 8000
46
+
47
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
48
+ CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1
49
+
50
+ CMD ["python", "-m", "kpaa", "serve", "--host", "0.0.0.0", "--port", "8000"]
Dockerfile.ui ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KPAA UI Space — Open WebUI pointing to KPAA Backend Space.
2
+ #
3
+ # Open WebUI 공식 이미지를 그대로 쓰되 HF Spaces 정책(UID 1000 + writable /app)에
4
+ # 맞추기 위해 chown + 필수 env 추가.
5
+
6
+ FROM ghcr.io/open-webui/open-webui:main
7
+
8
+ # ─── Backend wiring ──────────────────────────────────────────────────────
9
+ ENV OPENAI_API_BASE_URLS="https://scvcoder-kpaa.hf.space/v1"
10
+ ENV OPENAI_API_KEYS="hf-spaces-internal"
11
+ ENV ENABLE_OLLAMA_API=false
12
+ ENV WEBUI_NAME="KPAA — 개인정보보호법 상담"
13
+ # 백엔드의 default preset (`gemma-4-e2b-q4`) 과 동일해야 Open WebUI 자동 선택.
14
+ # 사용자가 dropdown 에서 다른 프리셋을 고르면 백엔드의 ModelManager 가 자동 전환.
15
+ ENV DEFAULT_MODELS="개인정보 상담 AI(gemma-4-e2b-q4)"
16
+ ENV WEBUI_AUTH=false
17
+
18
+ # ─── HF Spaces / UID 1000 권한 정리 ───────────────────────────────────────
19
+ USER root
20
+ RUN chown -R 1000:1000 /app && \
21
+ mkdir -p /tmp/openwebui-data && \
22
+ chown -R 1000:1000 /tmp/openwebui-data
23
+ ENV DATA_DIR=/tmp/openwebui-data
24
+
25
+ # ─── Inject route-change postMessage script into Open WebUI HTML ─────────
26
+ # Cross-origin iframe 정책상 부모 창은 자식의 URL 변경을 감지할 수 없다.
27
+ # Open WebUI HTML 에 작은 polling 스크립트를 주입해서 location.pathname
28
+ # 변경 시 부모 창에 postMessage 로 알린다. 백엔드 Space 의 split HTML 이
29
+ # 이 메시지를 받으면 우측 참고자료를 자동 초기화.
30
+ RUN sh -c "for f in /app/build/index.html /app/backend/open_webui/static/index.html; do \
31
+ if [ -f \"\$f\" ]; then \
32
+ sed -i 's|</body>|<script>console.log(\"[kpaa] injected script loaded\");(function(){var _kp;function emit(){var p=location.pathname;if(p!==_kp){_kp=p;console.log(\"[kpaa] route change:\",p);try{window.parent.postMessage({type:\"kpaa-route\",path:p},\"*\");}catch(e){console.log(\"[kpaa] postMessage failed\",e);}}}var _origPush=history.pushState,_origRep=history.replaceState;history.pushState=function(){_origPush.apply(this,arguments);emit();};history.replaceState=function(){_origRep.apply(this,arguments);emit();};window.addEventListener(\"popstate\",emit);setInterval(emit,500);emit();})();</script></body>|' \"\$f\"; \
33
+ echo \"injected into: \$f\"; \
34
+ fi; \
35
+ done"
36
+
37
+ USER 1000
38
+
39
+ # Open WebUI listens on 8080 by default.
40
+ ENV PORT=8080
41
+ EXPOSE 8080
README.md CHANGED
@@ -1,56 +1,266 @@
1
  ---
2
- title: Korean Privacy AI Assistant 백앤드 API
3
- emoji: 🧠
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: "5.20.0"
8
  app_file: app.py
9
  pinned: false
10
- short_description: 경량 AI 모델 사용
11
  hardware: zero-a10g
12
  license: mit
13
  ---
14
 
15
- # KPAA Backend
16
 
17
- 한국 개인정보보호법 RAG 백엔드. **OpenAI 호환 API**를 노출합니다.
 
 
18
 
19
- Space는 **추론 백엔드 전용**입니다. UI는 별도 Space([scvcoder/korean-privacy-ai-assistant](https://huggingface.co/spaces/scvcoder/korean-privacy-ai-assistant))에서 Open WebUI로 제공됩니다.
 
 
 
20
 
21
- ## 아키텍처
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
 
 
 
 
 
 
23
  ```
24
- 사용자 브라우저
25
- (UI 접속)
26
- [Open WebUI Space]
27
- (OpenAI API출)
28
- [이 Space — KPAA Backend]
29
- (RAG 검색 + Gemma 4 추론)
30
- 법제처 OPEN API + 상담사례 SQLite + ZeroGPU
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  ```
32
 
33
- ## Endpoints
 
 
 
 
 
34
 
35
- | Method | Path | 설명 |
36
  |---|---|---|
37
- | POST | `/v1/chat/completions` | OpenAI 호환 chat (`stream=true` 지원) |
38
- | GET | `/v1/models` | 사용 가능 모델 (`kpaa-privacy-ko`) |
39
- | GET | `/healthz` | liveness check |
40
- | GET | `/info` | 상세 정보 + Swagger UI |
41
- | GET | `/gradio` | Gradio 상태 페이지 |
 
 
 
 
 
 
 
42
 
43
- ## Open WebUI 연결 (UI Space에서 자동 설정)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  ```bash
46
- OPENAI_API_BASE_URL=https://scvcoder-kpaa-backend.hf.space/v1
47
- OPENAI_API_KEY=any-value
48
- DEFAULT_MODELS=kpaa-privacy-ko
49
  ```
50
 
51
- ## Secrets / Hardware
52
- - **Secret** `LAW_OC` — 법제처 OPEN API ID (필수)
53
- - **Hardware** ZeroGPU (zero-a10g) Pro 무료
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- ## 선스
56
- MIT (코드) · 답변 데이터는 PIPC/privacy.go.kr 출처표시
 
1
  ---
2
+ title: 개인정보보호법 미니 상담 (KPAA)
3
+ emoji: ⚖️
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
  sdk_version: "5.20.0"
8
  app_file: app.py
9
  pinned: false
10
+ short_description: 한국 개인정보보호법 RAG 상담 챗봇 (Gemma 4 E2B + 법제처 OPEN API)
11
  hardware: zero-a10g
12
  license: mit
13
  ---
14
 
15
+ # 개인정보보호법 미니 상담 챗봇 (KPAA)
16
 
17
+ > 개인 · 소상공인 · 작은 병원을 위국 개인정보보호법 안내 챗봇.
18
+ > 법제처 OPEN API + 개인정보보호위원회 상담사례 1,745건을 근거로
19
+ > 작은 모델(Gemma 4 E2B)이 한국어 평문으로 답합니다.
20
 
21
+ - **법제처 OPEN API**(MCP 레이어 없이 Python으로 직접 호출, 15개 카테고리 SDK)
22
+ - **개인정보보호위원회 상담사례** 1,745건 로컬 SQLite FTS5 인덱스
23
+ - **RAG 사전조회** — 룰 라우터로 법조문/사례/PIPC결정/해석례 병렬 fan-out → Gemma는 그 컨텍스트만 보고 답변
24
+ - **모든 답변에 인용 + 면책 자동 부착**
25
 
26
+ ---
27
+
28
+ ## 두 가지 사용 경로
29
+
30
+ ### 🤗 한 번 클릭으로 체험 — Hugging Face Spaces 데모
31
+
32
+ 배포된 Space 링크에 접속해서 바로 채팅. 별도 설치·키 발급 불필요.
33
+ ZeroGPU(A100) 가속으로 빠른 응답.
34
+
35
+ > Space 가 잠시 휴면(sleep) 상태일 수 있습니다 — 첫 방문 시 wake-up 5–10초 대기.
36
+
37
+ ### 💻 노트북에서 직접 돌리기 (권장 — SMB·장기 사용자)
38
+
39
+ GitHub clone → `pip install` → 자기 데이터/키로 운영. 외부 의존 없음 (법제처
40
+ OPEN API 호출 외 모든 것이 로컬). 아래 "사전 조건" 부터 따라가세요.
41
+
42
+ ---
43
+
44
+ ## 사전 조건
45
+
46
+ - Python **3.11 이상** (Win/Mac/Linux 공통) — 또는 Docker Desktop
47
+ - 8GB 이상 여유 RAM (Gemma 4 E2B Q4_K_M ~3.2GB + KV cache + 백엔드)
48
+ - **법제처 OPEN API 키 (LAW_OC) 무료 발급** — https://open.law.go.kr 가입 후 마이페이지에서 ID(이메일 @ 앞부분) 확인
49
+
50
+ ---
51
+
52
+ ## 빠른 시작
53
+
54
+ `.env`에 OC 키부터 입력:
55
+
56
+ ```bash
57
+ git clone https://github.com/sz1-kca/korean-privacy-ai-assistant
58
+ cd korean-privacy-ai-assistant
59
+
60
+ # Mac/Linux
61
+ cp .env.example .env
62
+ # Windows PowerShell
63
+ copy .env.example .env
64
+
65
+ # 편집기로 .env 열어 LAW_OC=<발급받은_id> 입력
66
+ ```
67
+
68
+ 이후 두 가지 경로 중 하나.
69
+
70
+ ### 경로 A — 네이티브 (권장, Docker 불필요)
71
+
72
+ ```bash
73
+ # 의존성 + 패키지 설치
74
+ pip install -e ".[dev,llm]"
75
+
76
+ # (1) 백엔드 — RAG + LLM (FastAPI :8000)
77
+ python -m kpaa serve
78
+
79
+ # (2) 새 터미널에서 Open WebUI (한 번만 설치)
80
+ pip install open-webui
81
+ open-webui serve # http://localhost:8080
82
+ ```
83
+
84
+ Open WebUI에서 Settings → Connections → **OpenAI API** → `+ Add Connection`:
85
+ - URL: `http://localhost:8000/v1`
86
+ - Key: `local`
87
+ - 모델 드롭다운에서 **`kpaa-privacy-ko`** 선택
88
+
89
+ 첫 `kpaa serve` 실행 시 Gemma 4 E2B GGUF (~3.2GB)을 자동 다운로드합니다 (~5–15분, 한 번만).
90
+
91
+ ### 경로 B — Docker Compose
92
+
93
+ ```bash
94
+ docker compose up -d
95
+ # → http://localhost:3000 (Open WebUI 자동 연결)
96
+ # 첫 가동 시 backend가 모델 다운로드, 5–15분 소요
97
+ docker compose logs -f backend # 진행 확인
98
+ ```
99
+
100
+ `docker-compose.yml`이 Open WebUI에 OpenAI 커넥션을 미리 주입하므로 UI에서 별도 설정 없이 즉시 `kpaa-privacy-ko` 모델로 채팅 가능합니다.
101
+
102
+ ---
103
+
104
+ ## 🔐 인증 모드 (개인용 vs 다중 사용자)
105
+
106
+ **기본값 — 즉시 사용 모드 (`WEBUI_AUTH=false`).** `manage.sh` / `Dockerfile.ui` / `docker-compose.yml` 모두 이 값을 주입하므로 첫 부팅 시 `admin@localhost`/`admin` 관리자 계정이 자동 생성되어 가입·로그인 화면 없이 바로 채팅 화면이 뜹니다. **개인 PC 로컬 (`127.0.0.1`) 단독 사용 가정**.
107
+
108
+ ⚠️ **공용/원격 노출 시 위험**: 이 모드에서는 호스트:포트에 접근 가능한 누구나 admin 권한으로 들어옵니다. LAN/공용 서버로 노출(0.0.0.0 바인딩, 포워딩, 리버스 프록시) 시 반드시 아래 인증 모드로 전환하세요.
109
 
110
+ **인증 모드 — 본인 이메일/비밀번호 사용.** 환경변수만 바꾸고 재시작:
111
+
112
+ ```bash
113
+ # 네이티브 (manage.sh):
114
+ export KPAA_OPENWEBUI_WEBUI_AUTH=true
115
+ ./manage.sh restart
116
  ```
117
+
118
+ Docker 사용자는 [`Dockerfile.ui:16`](Dockerfile.ui#L16) / [`docker-compose.yml`](docker-compose.yml) 의 `WEBUI_AUTH` 를 `true` 로 바꿔 재빌드.
119
+
120
+ 전환 부팅에서 가입 화면이 뜨고, 본인 이메일/비밀번로 admin 등록 → 이후 매 세션 로그인 + 비밀번호 자유 변경 가능. **즉시 사용 모드의 `admin@localhost`/`admin` 계정에서 비밀번호를 바꾸려고 시도하면 자동 로그인이 깨져 잠기므로** 본인 비밀번호 운영을 원하면 처음부터 인증 모드로 시작하세요.
121
+
122
+ | 모드 | 화면 마찰 | 비밀번호 자유도 | 권장 환경 |
123
+ |---|---|---|---|
124
+ | `WEBUI_AUTH=false` (기본) | 0 (즉시 채팅) | 고정 `admin/admin` | 개인 PC 로컬 |
125
+ | `WEBUI_AUTH=true` | 매 세션 로그인 | 자유 | LAN/공용 서버 |
126
+
127
+ ---
128
+
129
+ ## 사용 예 (CLI)
130
+
131
+ ```bash
132
+ # 법제처 SDK 단독 호출 (LLM 없음)
133
+ python -m kpaa law search "개인정보보호법"
134
+ python -m kpaa law text 270351 --jo "24의2"
135
+ python -m kpaa pipc search "동의 철회"
136
+ python -m kpaa expc search "개인정보 수집"
137
+
138
+ # 상담사례 검색 (로컬 SQLite, ms 단위)
139
+ python -m kpaa cases search "병원 환자 동의"
140
+
141
+ # RAG 컨텍스트만 빌드 (LLM 호출 없음, 빠름)
142
+ python -m kpaa retrieve "매장 CCTV 안내문구 어떻게 써요?"
143
+
144
+ # RAG + LLM 종단
145
+ python -m kpaa smoke "매장 CCTV 안내문구 어떻게 써요?"
146
+
147
+ # 상담사례 갱신 (privacy.go.kr 재스크래이프 ~2분)
148
+ python -m kpaa refresh-cases
149
+ python -m kpaa refresh-cases --since 2025-01-01 # 증분
150
  ```
151
 
152
+ ---
153
+
154
+ ## OS별 주의사항
155
+
156
+ KPAA 는 *장비별 자동 선택* 으로 GPU 가속 여부를 결정합니다.
157
+ 사용자가 `KPAA_N_GPU_LAYERS` 를 명시하지 않으면 다음 규칙:
158
 
159
+ | 플랫폼 | llama-cpp 빌드 | 자동 결과 |
160
  |---|---|---|
161
+ | Windows / Linux | GPU 빌드 (CUDA·ROCm·Vulkan 등) | `-1` (모든 레이어 GPU) |
162
+ | Windows / Linux | CPU 빌드 (기본 `pip install`) | `0` (CPU) |
163
+ | macOS (Apple Silicon / Intel) | Metal 빌드 / 일반 | `0` (CPU, opt-in) |
164
+
165
+ ### Windows
166
+ - PowerShell 한글 깨짐 방지: `chcp 65001` + `PYTHONUTF8=1` (CLI 진입 시 자동 UTF-8 reconfigure 적용)
167
+ - NVIDIA GPU 사용 시 (CUDA Toolkit 설치 후):
168
+ ```powershell
169
+ $env:CMAKE_ARGS = "-DGGML_CUDA=on"
170
+ pip install --force-reinstall --no-cache-dir llama-cpp-python
171
+ ```
172
+ 재설치 후엔 자동으로 GPU 모드로 동작 (`KPAA_N_GPU_LAYERS` 명시 불필요).
173
 
174
+ ### macOS (Apple Silicon / Intel)
175
+ - **기본 CPU 추론** — Gemma 4 E2B Q4_K_M + Metal 조합 segfault 회귀 회피
176
+ (라이브 검증 2026-05-01: `n_gpu_layers=-1` 시 모델 로드 직후 프로세스
177
+ silently die, `n_gpu_layers=12` 시 응답 hang. 안정성 미확정).
178
+ - 노트북 *팬 소음* 이 거슬리면 스레드 수를 더 줄이세요:
179
+ ```bash
180
+ KPAA_N_THREADS=4 python -m kpaa serve # 16코어 M3 Max → 4 만 사용
181
+ ```
182
+ 반대로 빠른 추론을 원하면 늘리거나 (속도 ↑ 발열·소음 ↑):
183
+ ```bash
184
+ KPAA_N_THREADS=12 python -m kpaa serve
185
+ ```
186
+ - Apple Silicon Metal GPU 가속 *opt-in* (segfault 우려, 자기 책임):
187
+ ```bash
188
+ KPAA_N_GPU_LAYERS=-1 python -m kpaa serve
189
+ ```
190
+ segfault 등 호환성 이슈가 보이면 변수 제거 (자동 CPU 환원).
191
+ - 정부 사이트 SSL 인증서: `truststore` 가 시스템 신뢰 저장소 자동 사용 (별도 설정 불필요).
192
 
193
+ ### Linux
194
+ - NVIDIA GPU 사용 시:
195
+ ```bash
196
+ CMAKE_ARGS="-DGGML_CUDA=on" pip install --force-reinstall --no-cache-dir llama-cpp-python
197
+ ```
198
+ 재설치 후 자동 감지. `KPAA_N_GPU_LAYERS` 명시 불필요.
199
+ - ROCm: `CMAKE_ARGS="-DGGML_HIPBLAS=on"`, Vulkan: `CMAKE_ARGS="-DGGML_VULKAN=on"` — 동일 패턴.
200
+
201
+ ---
202
+
203
+ ## 환경변수 (`.env`)
204
+
205
+ | 변수 | 기본 | 설명 |
206
+ |---|---|---|
207
+ | `LAW_OC` | (필수) | 법제처 OPEN API 인증 ID (이메일 @ 앞부분) |
208
+ | `KPAA_N_GPU_LAYERS` | (자동) | GPU offload 레이어 수 — 미지정 시 *플랫폼·빌드 자동 감지* (위 표). 강제 override: `-1`=모두 / `0`=CPU / 정수=일부 |
209
+ | `KPAA_N_THREADS` | (자동) | CPU 추론 스레드 수 — *플랫폼별 자동*: macOS=`cpu/3` (4~6 cap, 노트북 발열·소음 절제), Linux/Win=`cpu/2` (4~8 cap). 빠른 추론 원하면 늘리고 더 조용히 원하면 줄임 |
210
+ | `KPAA_MODEL_DIR` | (자동) | GGUF 캐시 경로 — 미지정 시 `platformdirs.user_cache_dir/kpaa/models` |
211
+ | `KPAA_HOST` / `KPAA_PORT` | `127.0.0.1` / `8000` | FastAPI 바인딩 |
212
+ | `KPAA_LLM_BACKEND` | (자동) | LLM 백엔드 선택 — `llama_cpp`(로컬 GGUF) 또는 `zerogpu`(HF Spaces transformers). 미지정 시 `SPACE_ID` 환경변수로 자동 분기 |
213
+ | `KPAA_HF_MODEL_REPO` | `google/gemma-4-E2B-it` | ZeroGPU 백엔드용 transformers repo |
214
+ | `KPAA_HF_GPU_DURATION` | `120` | `@spaces.GPU` ���수당 GPU 점유 한도(초) |
215
+ | `KPAA_OPENWEBUI_WEBUI_AUTH` | `false` | `true` 로 두면 OpenWebUI 가입/로그인 화면 활성화 (본인 이메일·비밀번호). 자세히는 위 "🔐 인증 모드" |
216
+
217
+ ---
218
+
219
+ ## 🤗 Hugging Face Spaces 직접 배포하기
220
+
221
+ 본 리포는 **HF Spaces 의 Gradio + ZeroGPU 환경에서 그대로 동작**한다.
222
+ 저장소를 fork 하거나 자체 Space 에 클론해 자기 도메인으로 운영 가능.
223
+
224
+ ### 절차
225
+ 1. https://huggingface.co/new-space — Space 생성. SDK = **Gradio**, Hardware = **ZeroGPU** (Pro 무료) 또는 CPU upgrade.
226
+ 2. **Persistent Storage** 활성화 (Pro: 20GB 무료) — transformers 모델이 한 번만 받아짐.
227
+ 3. **Settings → Variables and secrets**:
228
+ - Secret: `LAW_OC` = 본인 법제처 OPEN API ID
229
+ - Variable: `HF_HOME=/data/.huggingface` (persistent storage 위에 모델 캐시)
230
+ 4. 본 리포를 Space 에 push (`git remote add hf https://huggingface.co/spaces/<user>/<space>` → `git push hf main`).
231
+ 5. 첫 빌드 10–15분 (torch + transformers + 모델 다운로드). 이후 재시작 즉시 부팅.
232
+ 6. 채팅 테스트.
233
+
234
+ ### 로컬에서 Gradio UI 미리보기
235
  ```bash
236
+ pip install -e ".[dev,llm,hf]"
237
+ KPAA_LLM_BACKEND=llama_cpp python app.py # 로컬 GGUF + Gradio UI
238
+ # → http://127.0.0.1:7860
239
  ```
240
 
241
+ ### 듀얼 모드 정리
242
+
243
+ | 환경 | 진입점 | LLM 백엔드 | UI |
244
+ |---|---|---|---|
245
+ | 로컬 노트북 (장기 사용·SMB) | `python -m kpaa serve` | `llama_cpp` (GGUF, CPU/Metal/CUDA) | FastAPI `/chat` (자체 SSE) 또는 Open WebUI |
246
+ | HF Spaces 데모 (대중 체험) | `python app.py` (자동) | `zerogpu` (transformers + `@spaces.GPU`) | Gradio Blocks |
247
+ | 로컬 Gradio 미리보기 | `KPAA_LLM_BACKEND=llama_cpp python app.py` | `llama_cpp` (강제) | Gradio Blocks |
248
+
249
+ 같은 RAG 파이프라인(법제처 호출, 라우팅, 인용·면책 부착) 을 양쪽이 공유.
250
+
251
+ ---
252
+
253
+ ## 데이터 출처 / 라이선스
254
+
255
+ - **법제처 OPEN API** (https://open.law.go.kr) — 1인 1키 무료. 응답 데이터는 공공누리 표시 후 사용 가능.
256
+ - **개인정보 상담사례** (https://www.privacy.go.kr) — 약 1,745건, 공공누리 제1유형 추정. 본 리포의 [`data/cases.sqlite`](data/cases.sqlite)는 시점 스냅샷이며 갱신은 `kpaa refresh-cases` 또는 월 1회 GitHub Actions 자동 PR.
257
+ - **Gemma 4** — [Gemma Terms of Use](https://ai.google.dev/gemma/terms) + Apache 2.0. GGUF (로컬용): [`bartowski/google_gemma-4-E2B-it-GGUF`](https://huggingface.co/bartowski/google_gemma-4-E2B-it-GGUF). transformers (HF Spaces 용, 같은 가중치): [`google/gemma-4-E2B-it`](https://huggingface.co/google/gemma-4-E2B-it).
258
+ - 본 프로젝트 코드: **MIT** ([LICENSE](LICENSE), [NOTICE](NOTICE))
259
+
260
+ 로컬 사용자: 채팅 기록은 Open WebUI 로컬 SQLite 또는 자체 `/chat` UI 의 브라우저 메모리에만. HF Spaces 데모: Gradio 세션 종료 시 휘발. 외부 텔레메트리 없음.
261
+
262
+ ---
263
+
264
+ ## 면책
265
 
266
+ 챗봇 답변은 **일반적 정보 제공**며 법률 자문이 아닙니다. 구체적 사안은 개인정보보호위원회(privacy.go.kr) 또는 변호사 상담을 권합니다. 신고: KISA 개인정보침해신고센터 국번없이 **118**.
 
README_backend.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: KPAA Backend - 개인정보보호법 RAG 추론 API
3
+ emoji: 🧠
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: "5.20.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ short_description: KPAA RAG 추론 백엔드 (OpenAI 호환 API)
11
+ hardware: zero-a10g
12
+ license: mit
13
+ ---
14
+
15
+ # KPAA Backend
16
+
17
+ 한국 개인정보보호법 RAG 백엔드. **OpenAI 호환 API**를 노출합니다.
18
+
19
+ 이 Space는 **추론 백엔드 전용**입니다. UI는 별도 Space([scvcoder/korean-privacy-ai-assistant](https://huggingface.co/spaces/scvcoder/korean-privacy-ai-assistant))에서 Open WebUI로 제공됩니다.
20
+
21
+ ## 아키텍처
22
+
23
+ ```
24
+ 사용자 브라우저
25
+ ↓ (UI 접속)
26
+ [Open WebUI Space]
27
+ ↓ (OpenAI API 호출)
28
+ [이 Space — KPAA Backend]
29
+ ↓ (RAG 검색 + Gemma 4 추론)
30
+ 법제처 OPEN API + 상담사례 SQLite + ZeroGPU
31
+ ```
32
+
33
+ ## Endpoints
34
+
35
+ | Method | Path | 설명 |
36
+ |---|---|---|
37
+ | POST | `/v1/chat/completions` | OpenAI 호환 chat (`stream=true` 지원) |
38
+ | GET | `/v1/models` | 사용 가능 모델 (`kpaa-privacy-ko`) |
39
+ | GET | `/healthz` | liveness check |
40
+ | GET | `/info` | 상세 정보 + Swagger UI |
41
+ | GET | `/gradio` | Gradio 상태 페이지 |
42
+
43
+ ## Open WebUI 연결 (UI Space에서 자동 설정)
44
+
45
+ ```bash
46
+ OPENAI_API_BASE_URL=https://scvcoder-kpaa.hf.space/v1
47
+ OPENAI_API_KEY=any-value
48
+ DEFAULT_MODELS=kpaa-privacy-ko
49
+ ```
50
+
51
+ ## Secrets / Hardware
52
+ - **Secret** `LAW_OC` — 법제처 OPEN API ID (필수)
53
+ - **Hardware** ZeroGPU (zero-a10g) — Pro 무료
54
+
55
+ ## 라이선스
56
+ MIT (코드) · 답변 데이터는 PIPC/privacy.go.kr 출처표시
README_ui.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 개인정보보호법 미니 상담 (KPAA)
3
+ emoji: ⚖️
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 8080
8
+ pinned: false
9
+ short_description: 한국 개인정보보호법 RAG 챗봇 (Open WebUI + Gemma 4)
10
+ license: mit
11
+ ---
12
+
13
+ # 개인정보보호법 미니 상담 챗봇 (KPAA)
14
+
15
+ > 개인 · 소상공인 · 작은 병원을 위한 한국 개인정보보호법 안내 챗봇.
16
+ >
17
+ > **Open WebUI** 인터페이스. 추론은 [scvcoder/kpaa](https://huggingface.co/spaces/scvcoder/kpaa) Space (Gemma 4 E2B + ZeroGPU)로 위임됩니다.
18
+
19
+ ## 아키텍처
20
+
21
+ ```
22
+ 사용자 ──▶ [이 Space — Open WebUI UI]
23
+
24
+ │ POST /v1/chat/completions
25
+
26
+ [kpaa Space — Gemma 4 + RAG + ZeroGPU]
27
+
28
+
29
+ 법제처 OPEN API + 상담사례 SQLite
30
+ ```
31
+
32
+ ## 사용 방법
33
+ 1. 이 Space 페이지 열기 → 채팅창에 질문 입력
34
+ 2. 첫 응답은 백엔드 모델 cold start로 30-60초 소요 가능
35
+ 3. 답변에 인용·면책 자동 부착
36
+
37
+ ## 한계
38
+ - 법률 자문이 아닙니다. 구체적 사안은 변호사 또는 [개인정보보호위원회](https://www.privacy.go.kr) 문의
39
+ - 신고: KISA 개인정보침해신고센터 ☎ 118
40
+
41
+ ## 라이선스
42
+ MIT (코드) · 답변 데이터는 PIPC/privacy.go.kr 출처표시
app.py CHANGED
@@ -1,31 +1,32 @@
1
- """KPAA Backend Space — Gradio + ZeroGPU + KPAA OpenAI-compatible API.
2
 
3
- Strategy validated via minimal test:
4
- - demo.launch() (Gradio's own uvicorn) is the path that activates ZeroGPU.
5
- - mount_gradio_app + manual uvicorn does NOT activate ZeroGPU.
6
 
7
- So we use demo.launch(), and AFTER launch we attach KPAA's /v1 routes to
8
- the underlying FastAPI (demo.app) via app.include_router. Routes added at
9
- runtime are picked up because Starlette dispatches by traversing app.routes
10
- on each request.
11
 
12
- Hardware: ZeroGPU (zero-a10g).
13
- Required secret: LAW_OC.
14
  """
 
 
15
  import os
16
  import sys
17
- import time
18
  from pathlib import Path
19
 
20
- print(f"[kpaa-backend] SPACES_ZERO_GPU={os.environ.get('SPACES_ZERO_GPU')!r}", flush=True)
21
- print(f"[kpaa-backend] SPACE_ID={os.environ.get('SPACE_ID')!r}", flush=True)
22
-
23
- # HF Spaces: src/ on sys.path
24
  sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
25
 
26
 
27
- # ─── monkey-patch: gradio_client `/api_info` schema bug ────────────────────
28
- import gradio_client.utils as _gc_utils
 
 
 
29
 
30
  _orig_get_type = _gc_utils.get_type
31
  _orig_jstpt = _gc_utils._json_schema_to_python_type
@@ -48,136 +49,42 @@ _gc_utils._json_schema_to_python_type = _safe_jstpt
48
  # ──────────────────────────────────────────────────────────────────────────
49
 
50
 
51
- import spaces
52
- import gradio as gr
53
-
54
-
55
- # ─── ZeroGPU canary wired to a Gradio event ───────────────────────────────
56
- # Critical insight: HF detector requires @spaces.GPU functions to be wired
57
- # to Gradio components, not standalone. So we keep `echo` as a real button
58
- # handler in the status UI.
59
- @spaces.GPU(duration=10)
60
- def echo(text: str) -> str:
61
- import torch
62
- device = "cuda" if torch.cuda.is_available() else "cpu"
63
- return f"GPU echo ({device}): {text}"
64
-
65
-
66
- with gr.Blocks(title="KPAA Backend") as demo:
67
- gr.Markdown(
68
- """
69
- # 🧠 KPAA Backend
70
-
71
- 한국 개인정보보호법 RAG 추론 백엔드.
72
-
73
- ## API
74
- - `POST /v1/chat/completions`
75
- - `GET /v1/models`
76
- - `GET /healthz`
77
-
78
- UI 는 [`scvcoder/korean-privacy-ai-assistant`](https://huggingface.co/spaces/scvcoder/korean-privacy-ai-assistant) 에서 제공.
79
-
80
- ---
81
- ### GPU 진단
82
- """
83
- )
84
- with gr.Row():
85
- inp = gr.Textbox(label="입력", value="hello", scale=3)
86
- out = gr.Textbox(label="출력 (GPU 검증)", scale=3)
87
- btn = gr.Button("GPU echo 테스트")
88
- btn.click(echo, inputs=inp, outputs=out)
89
-
90
-
91
- def _attach_kpaa_routes() -> None:
92
- """Mount KPAA OpenAI-compatible /v1 routes onto demo's FastAPI.
93
-
94
- Called AFTER demo.launch() — demo.app is the live Gradio FastAPI by then.
95
- """
96
- from kpaa.server import create_app
97
- kpaa_app = create_app()
98
-
99
- n_added = 0
100
- skipped = 0
101
- for route in kpaa_app.routes:
102
- path = getattr(route, "path", None)
103
- if path in ("/", None):
104
- skipped += 1
105
- continue
106
- demo.app.routes.append(route)
107
- n_added += 1
108
- print(f"[kpaa-backend] attached {n_added} KPAA routes (skipped {skipped})", flush=True)
109
-
110
-
111
- def _attach_split_view() -> None:
112
- """`/split` endpoint — Open WebUI iframe + 참고자료 polling 분할 레이아웃.
113
-
114
- KPAA local 의 _SPLIT_HTML 을 그대로 재사용하되 iframe src 만 UI Space URL 로
115
- 교체. / 접속 시 /split 으로 리다이렉트 — Gradio 가 / 를 점유하지만 우리
116
- redirect 라우트를 routes 리스트 *앞* 에 끼워넣어 우선권 획득.
117
- """
118
- from fastapi.responses import HTMLResponse, RedirectResponse
119
- from fastapi.routing import APIRoute
120
-
121
- from kpaa.server import _SPLIT_HTML
122
-
123
- UI_SPACE_URL = "https://scvcoder-korean-privacy-ai-assistant.hf.space"
124
- hf_html = _SPLIT_HTML.replace(
125
- 'src="http://localhost:8080/"',
126
- f'src="{UI_SPACE_URL}"',
127
- )
128
-
129
- # 핸들러 한 개를 /split 와 / 양쪽에 라우팅 — 동일 HTML + 페이지 진입 시
130
- # 우측 참고자료 자동 초기화 (이전 세션 잔여 데이터 노출 방지).
131
- async def _split_handler():
132
- import time as _time
133
- from kpaa.server import _last_refs
134
-
135
- _last_refs.update({
136
- "ts": _time.time(),
137
- "query": "",
138
- "intents": [],
139
- "jo_targets": [],
140
- "elapsed_ms": 0,
141
- "excerpts": [],
142
- "cited_citations": [],
143
- "llm_excerpt_citations": [],
144
- "geungeo_indices_in_answer": [],
145
- })
146
- return HTMLResponse(hf_html)
147
-
148
- # /split — 명시적 별칭 (백워드 호환).
149
- demo.app.routes.insert(
150
- 0,
151
- APIRoute("/split", _split_handler, methods=["GET"], include_in_schema=False),
152
- )
153
 
154
- # / — Gradio 의 / 보다 *앞* 에 끼워 넣어 우선권 획득. 사용자가 백엔드 URL 만
155
- # 입력해도 분할 화면이 바로 보임. Gradio status UI 는 더 이상 노출되지 않지만
156
- # ZeroGPU 검출은 module-level @spaces.GPU 캐나리로 이미 충족됨.
157
- demo.app.routes.insert(
158
- 0,
159
- APIRoute("/", _split_handler, methods=["GET"], include_in_schema=False),
160
- )
161
 
162
- print(f"[kpaa-backend] / and /split serve split HTML (UI iframe -> {UI_SPACE_URL})", flush=True)
163
 
164
 
165
- if __name__ == "__main__":
166
- # Launch Gradio in a non-blocking way so we can patch demo.app afterwards.
167
- demo.queue()
168
- demo.launch(
 
 
 
 
169
  server_name="0.0.0.0",
170
- server_port=int(os.environ.get("PORT", "7860")),
 
171
  ssr_mode=False,
172
  show_api=False,
173
- prevent_thread_lock=True,
174
  )
175
 
176
- # demo.app is now a live Starlette/FastAPI app — attach KPAA routes + split view.
177
- _attach_kpaa_routes()
178
- _attach_split_view()
179
- print("[kpaa-backend] ready: Gradio at /, /v1/... API, /split (Open WebUI + 참고자료)", flush=True)
180
 
181
- # Block forever (Gradio runs on background thread).
182
- while True:
183
- time.sleep(60)
 
1
+ """HF Spaces (Gradio SDK + ZeroGPU) 진입점.
2
 
3
+ HF Spaces 빌더가 자동으로 `python app.py` 를 실행한다. 로컬에서도 같은
4
+ 파일로 미리보기 가능:
 
5
 
6
+ pip install -e ".[dev,llm,hf]"
7
+ KPAA_LLM_BACKEND=llama_cpp python app.py # 로컬 GGUF UI 만 미리보기
8
+ # http://127.0.0.1:7860
 
9
 
10
+ HF Spaces 환경에서는 자동으로 `SPACE_ID` 가 잡혀 ZeroGPU 백엔드가 활성화된다.
11
+ LAW_OC Space Settings > Secrets 에 등록.
12
  """
13
+ from __future__ import annotations
14
+
15
  import os
16
  import sys
 
17
  from pathlib import Path
18
 
19
+ # HF Spaces 에서는 `pip install -e .` 가 동작하지 않는다 (requirements.txt 처리
20
+ # 시점에 app 파일이 아직 mount 되지 않음). 대신 src/ 를 sys.path 에 prepend.
21
+ # 로컬 editable install 환경에서도 무해.
 
22
  sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
23
 
24
 
25
+ # ─── monkey-patch: Gradio /api_info schema bug ────────────────────────────
26
+ # Gradio 5.x 의 gradio_client.utils JSON Schema 의 `additionalProperties: True`
27
+ # (bool, 합법적 형식) 를 dict 로만 가정해서 `if "const" in schema:` 에서 TypeError.
28
+ # get_type 와 _json_schema_to_python_type 모두 bool 입력을 안전하게 처리하도록 wrap.
29
+ import gradio_client.utils as _gc_utils # noqa: E402
30
 
31
  _orig_get_type = _gc_utils.get_type
32
  _orig_jstpt = _gc_utils._json_schema_to_python_type
 
49
  # ──────────────────────────────────────────────────────────────────────────
50
 
51
 
52
+ # ─── HF Spaces ZeroGPU startup canary ─────────────────────────────────────
53
+ # HF Spaces 의 ZeroGPU 는 startup 시점에 module-level `@spaces.GPU` 함수가
54
+ # 적어도 하나 검출되어야 GPU 스케줄을 잡는다. 실제 GPU 작업은
55
+ # ZeroGPUBackend.stream_chat 안의 `_run_generate` 에서 일어나지만, 그건 함수
56
+ # 호출 시점에야 데코레이트되므로 startup 스캔에서 보임.
57
+ # 카나리는 호출되지 않으며, 단지 detector 통과용.
58
+ try:
59
+ import spaces # type: ignore[import-not-found]
60
+
61
+ @spaces.GPU(duration=1)
62
+ def _zerogpu_startup_canary() -> None:
63
+ """HF Spaces ZeroGPU detector 통과용 sentinel."""
64
+ return None
65
+ except ImportError:
66
+ pass # 로컬 dev — spaces 패키지 없음
67
+ # ──────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
 
 
 
 
 
 
 
69
 
70
+ from kpaa.ui.gradio import build_app # noqa: E402
71
 
72
 
73
+ def main() -> None:
74
+ app = build_app()
75
+ # HF Spaces 는 7860 노출 표준. 로컬 미리보기도 동일 포트 사용.
76
+ port = int(os.environ.get("PORT", "7860"))
77
+ # 큐 활성화 — async generator (스트리밍) 이 작동하려면 필수.
78
+ # ssr_mode=False — Node SSR 서브프로세스 없이 순수 uvicorn 으로 단일 프로세스화.
79
+ # show_api=False — /api_info 노출 스킵 (위 monkey-patch 와 함께 belt-and-suspenders).
80
+ app.queue(max_size=20).launch(
81
  server_name="0.0.0.0",
82
+ server_port=port,
83
+ show_error=True,
84
  ssr_mode=False,
85
  show_api=False,
 
86
  )
87
 
 
 
 
 
88
 
89
+ if __name__ == "__main__":
90
+ main()
 
app_backend.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KPAA Backend Space — Gradio + ZeroGPU + KPAA OpenAI-compatible API.
2
+
3
+ Strategy validated via minimal test:
4
+ - demo.launch() (Gradio's own uvicorn) is the path that activates ZeroGPU.
5
+ - mount_gradio_app + manual uvicorn does NOT activate ZeroGPU.
6
+
7
+ So we use demo.launch(), and AFTER launch we attach KPAA's /v1 routes to
8
+ the underlying FastAPI (demo.app) via app.include_router. Routes added at
9
+ runtime are picked up because Starlette dispatches by traversing app.routes
10
+ on each request.
11
+
12
+ Hardware: ZeroGPU (zero-a10g).
13
+ Required secret: LAW_OC.
14
+ """
15
+ import os
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ print(f"[kpaa-backend] SPACES_ZERO_GPU={os.environ.get('SPACES_ZERO_GPU')!r}", flush=True)
21
+ print(f"[kpaa-backend] SPACE_ID={os.environ.get('SPACE_ID')!r}", flush=True)
22
+
23
+ # HF Spaces: src/ on sys.path
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
25
+
26
+
27
+ # ─── monkey-patch: gradio_client `/api_info` schema bug ────────────────────
28
+ import gradio_client.utils as _gc_utils
29
+
30
+ _orig_get_type = _gc_utils.get_type
31
+ _orig_jstpt = _gc_utils._json_schema_to_python_type
32
+
33
+
34
+ def _safe_get_type(schema):
35
+ if not isinstance(schema, dict):
36
+ return ""
37
+ return _orig_get_type(schema)
38
+
39
+
40
+ def _safe_jstpt(schema, defs):
41
+ if not isinstance(schema, dict):
42
+ return "Any"
43
+ return _orig_jstpt(schema, defs)
44
+
45
+
46
+ _gc_utils.get_type = _safe_get_type
47
+ _gc_utils._json_schema_to_python_type = _safe_jstpt
48
+ # ──────────────────────────────────────────────────────────────────────────
49
+
50
+
51
+ import spaces
52
+ import gradio as gr
53
+
54
+
55
+ # ─── ZeroGPU canary wired to a Gradio event ───────────────────────────────
56
+ # Critical insight: HF detector requires @spaces.GPU functions to be wired
57
+ # to Gradio components, not standalone. So we keep `echo` as a real button
58
+ # handler in the status UI.
59
+ @spaces.GPU(duration=10)
60
+ def echo(text: str) -> str:
61
+ import torch
62
+ device = "cuda" if torch.cuda.is_available() else "cpu"
63
+ return f"GPU echo ({device}): {text}"
64
+
65
+
66
+ with gr.Blocks(title="KPAA Backend") as demo:
67
+ gr.Markdown(
68
+ """
69
+ # 🧠 KPAA Backend
70
+
71
+ 한국 개인정보보호법 RAG 추론 백엔드.
72
+
73
+ ## API
74
+ - `POST /v1/chat/completions`
75
+ - `GET /v1/models`
76
+ - `GET /healthz`
77
+
78
+ UI 는 [`scvcoder/korean-privacy-ai-assistant`](https://huggingface.co/spaces/scvcoder/korean-privacy-ai-assistant) 에서 제공.
79
+
80
+ ---
81
+ ### GPU 진단
82
+ """
83
+ )
84
+ with gr.Row():
85
+ inp = gr.Textbox(label="입력", value="hello", scale=3)
86
+ out = gr.Textbox(label="출력 (GPU 검증)", scale=3)
87
+ btn = gr.Button("GPU echo 테스트")
88
+ btn.click(echo, inputs=inp, outputs=out)
89
+
90
+
91
+ def _attach_kpaa_routes() -> None:
92
+ """Mount KPAA OpenAI-compatible /v1 routes onto demo's FastAPI.
93
+
94
+ Called AFTER demo.launch() — demo.app is the live Gradio FastAPI by then.
95
+ """
96
+ from kpaa.server import create_app
97
+ kpaa_app = create_app()
98
+
99
+ n_added = 0
100
+ skipped = 0
101
+ for route in kpaa_app.routes:
102
+ path = getattr(route, "path", None)
103
+ if path in ("/", None):
104
+ skipped += 1
105
+ continue
106
+ demo.app.routes.append(route)
107
+ n_added += 1
108
+ print(f"[kpaa-backend] attached {n_added} KPAA routes (skipped {skipped})", flush=True)
109
+
110
+
111
+ def _attach_split_view() -> None:
112
+ """`/split` endpoint — Open WebUI iframe + 참고자료 polling 분할 레이아웃.
113
+
114
+ KPAA local 의 _SPLIT_HTML 을 그대로 재사용하되 iframe src 만 UI Space URL 로
115
+ 교체. / 접속 시 /split 으로 리다이렉트 — Gradio 가 / 를 점유하지만 우리
116
+ redirect 라우트를 routes 리스트 *앞* 에 끼워넣어 우선권 획득.
117
+ """
118
+ from fastapi.responses import HTMLResponse, RedirectResponse
119
+ from fastapi.routing import APIRoute
120
+
121
+ from kpaa.server import _SPLIT_HTML
122
+
123
+ UI_SPACE_URL = "https://scvcoder-korean-privacy-ai-assistant.hf.space"
124
+ hf_html = _SPLIT_HTML.replace(
125
+ 'src="http://localhost:8080/"',
126
+ f'src="{UI_SPACE_URL}"',
127
+ )
128
+
129
+ # 핸들러 한 개를 /split 와 / 양쪽에 라우팅 — 동일 HTML + 페이지 진입 시
130
+ # 우측 참고자료 자동 초기화 (이전 세션 잔여 데이터 노출 방지).
131
+ async def _split_handler():
132
+ import time as _time
133
+ from kpaa.server import _last_refs
134
+
135
+ _last_refs.update({
136
+ "ts": _time.time(),
137
+ "query": "",
138
+ "intents": [],
139
+ "jo_targets": [],
140
+ "elapsed_ms": 0,
141
+ "excerpts": [],
142
+ "cited_citations": [],
143
+ "llm_excerpt_citations": [],
144
+ "geungeo_indices_in_answer": [],
145
+ })
146
+ return HTMLResponse(hf_html)
147
+
148
+ # /split — 명시적 별칭 (백워드 호환).
149
+ demo.app.routes.insert(
150
+ 0,
151
+ APIRoute("/split", _split_handler, methods=["GET"], include_in_schema=False),
152
+ )
153
+
154
+ # / — Gradio 의 / 보다 *앞* 에 끼워 넣어 우선권 획득. 사용자가 백엔드 URL 만
155
+ # 입력해도 분할 화면이 바로 보임. Gradio status UI 는 더 이상 노출되지 않지만
156
+ # ZeroGPU 검출은 module-level @spaces.GPU 캐나리로 이미 충족됨.
157
+ demo.app.routes.insert(
158
+ 0,
159
+ APIRoute("/", _split_handler, methods=["GET"], include_in_schema=False),
160
+ )
161
+
162
+ print(f"[kpaa-backend] / and /split serve split HTML (UI iframe -> {UI_SPACE_URL})", flush=True)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ # Launch Gradio in a non-blocking way so we can patch demo.app afterwards.
167
+ demo.queue()
168
+ demo.launch(
169
+ server_name="0.0.0.0",
170
+ server_port=int(os.environ.get("PORT", "7860")),
171
+ ssr_mode=False,
172
+ show_api=False,
173
+ prevent_thread_lock=True,
174
+ )
175
+
176
+ # demo.app is now a live Starlette/FastAPI app — attach KPAA routes + split view.
177
+ _attach_kpaa_routes()
178
+ _attach_split_view()
179
+ print("[kpaa-backend] ready: Gradio at /, /v1/... API, /split (Open WebUI + 참고자료)", flush=True)
180
+
181
+ # Block forever (Gradio runs on background thread).
182
+ while True:
183
+ time.sleep(60)
data/hf_dataset/README.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - ko
4
+ license: other
5
+ license_name: pipc-attribution
6
+ license_link: LICENSE.md
7
+ pretty_name: Korean Privacy Law RAG Corpus
8
+ size_categories:
9
+ - 1K<n<10K
10
+ task_categories:
11
+ - question-answering
12
+ - text-retrieval
13
+ - text-generation
14
+ tags:
15
+ - legal
16
+ - privacy
17
+ - korean
18
+ - rag
19
+ - retrieval-augmented-generation
20
+ - contextual-retrieval
21
+ - pipa
22
+ - pipc
23
+ - privacy-law
24
+ configs:
25
+ - config_name: default
26
+ data_files:
27
+ - split: train
28
+ path: "*.jsonl"
29
+ ---
30
+
31
+ # 한국 개인정보보호법 관련 RAG 구축을 위한 코퍼스
32
+
33
+ 개인정보 포털(privacy.go.kr)의 각종 개인정보보호법 관련 가이드와 상담사례 1,745건을
34
+ RAG(Retrieval-Augmented Generation)에 바로 쓸 수 있도록 **의미 단위 청킹·문맥 보강**한
35
+ 코퍼스입니다. 모든 청크에는 [Contextual Retrieval](https://www.anthropic.com/news/contextual-retrieval)
36
+ 기법을 적용한 `chunk_context` 필드가 포함되어 있어, 임베딩 검색 정확도를 즉시 끌어올릴
37
+ 수 있습니다.
38
+
39
+ 테스트링크: https://scvcoder-kpaa.hf.space/
40
+
41
+ ### 구성
42
+
43
+ - 개인정보_질의응답_모음집(2025.12.).pdf
44
+ - 소상공인을_위한_개인정보 보호_핸드북(2024.12).pdf
45
+ - 고정형 영상정보처리기기_설치_운영_안내서(2024.12).pdf
46
+ - 분야별 개인정보 보호 안내서(2024.12).pdf — 의료기관 편까지 청킹 완료(부분 공개), 약국·학원·통계·공공·온라인 경품 편 추가 예정
47
+ - 개인정보포털 홈페이지 상담사례 1745건
48
+
49
+ **English** — A Korean RAG corpus on personal information protection law (PIPA),
50
+ built from official guides and 1,745 consultation cases published on the
51
+ Personal Information Portal (privacy.go.kr). Each chunk is semantically
52
+ segmented and enriched with a `chunk_context` field following the Contextual
53
+ Retrieval technique — ready to drop into a RAG pipeline and improve
54
+ embedding-search accuracy out of the box.
55
+
56
+ ---
57
+
58
+ ## 1. 데이터셋 개요
59
+
60
+ | 항목 | 값 |
61
+ |---|---|
62
+ | 총 레코드 수 | **2,202** 청크 |
63
+ | 언어 | 한국어 |
64
+ | 도메인 | 개인정보보호법(PIPA) · 개인정보보호 실무 |
65
+ | 형식 | JSON Lines (`.jsonl`) |
66
+ | 인코딩 | UTF-8 |
67
+ | 출처 | 개인정보보호위원회 (자세한 내용은 §7 참조) |
68
+ | 적용 기법 | Semantic Chunking, Contextual Retrieval |
69
+
70
+ ### 구성 (`source_type`으로 필터링)
71
+
72
+ | 파일 | `source_type` | 청크 수 | 출처 | 발행/수집 시점 |
73
+ |---|---|---:|---|---|
74
+ | 개인정보_질의응답_모음집(2025.12.).jsonl | `guide` | 99 | PIPC 공식 가이드 PDF | 2025.12 |
75
+ | 소상공인을_위한_개인정보 보호_핸드북(2024.12).jsonl | `guide` | 41 | PIPC 공식 가이드 PDF | 2024.12 |
76
+ | 고정형 영상정보처리기기_설치_운영_안내서(2024.12).jsonl | `guide` | 71 | PIPC 공식 가이드 PDF | 2024.12 |
77
+ | 분야별_개인정보_보호_안내서(2024.12).jsonl | `guide` | 246 *(진행 중)* | PIPC 공식 가이드 PDF — 8개 편 중 인사·노무, 사회복지시설, 의료기관 편 완료 | 2024.12 |
78
+ | 개인정보포털_상담사례.jsonl | `case` | 1,745 | privacy.go.kr 상담사례 | 2012~ 누적 |
79
+
80
+ ---
81
+
82
+ ## 2. 스키마
83
+
84
+ 모든 레코드는 다음 **공통 필드**(앞 10개)를 갖고, 그 뒤에 출처별 필드가 이어집니다.
85
+ 출처별 원본 필드는 모두 보존했습니다.
86
+
87
+ ### 공통 필드
88
+
89
+ | 필드 | 타입 | 설명 |
90
+ |---|---|---|
91
+ | `chunk_id` | string | 청크 고유 ID (예: `질의응답_모음집_0000`, `상담사례_0001`) |
92
+ | `source_type` | string | `"guide"` 또는 `"case"` |
93
+ | `doc_id` | string | 원문 문서 식별자 |
94
+ | `doc_title` | string | 원문 제목 |
95
+ | `doc_date` | string | 발행일 (`YYYY.MM` 또는 `YYYY.MM.DD`) |
96
+ | `section` | string | 장·절·카테고리 (가이드: 목차, 사례: `대분류 > 중분류 > 소분류`) |
97
+ | `body` | string | **임베딩 대상 본문** |
98
+ | `chunk_context` | string | Contextual Retrieval — 본문이 속한 맥락·인접 조항·법 근거 요약 |
99
+ | `source_pdf` | string | 가이드의 원본 PDF 파일명 (사례는 빈 문자열) |
100
+ | `source_url` | string | 사례의 원본 URL (가이드는 빈 문자열) |
101
+
102
+ ### 가이드(`source_type="guide"`) 추가 필드
103
+
104
+ | 필드 | 타입 | 설명 |
105
+ |---|---|---|
106
+ | `chunk_no` | int | 문서 내 청크 일련번호 |
107
+ | `pages` | string | 책의 페이지 번호 (예: `"p.3"`) |
108
+
109
+ ### 상담사례(`source_type="case"`) 추가 필드
110
+
111
+ | 필드 | 타입 | 설명 |
112
+ |---|---|---|
113
+ | `ntt_id`, `ntt_no` | string | privacy.go.kr 게시물 ID |
114
+ | `title` | string | 사례 제목(질문) |
115
+ | `summary` | string | 사례 요약 |
116
+ | `type_code`, `type_label` | string | 사례 유형 (`COU` = 상담 사례집 등) |
117
+ | `category1`, `category2`, `category3` | string | 분류 체계 |
118
+ | `reg_dt` | string | 등록일 (`YYYYMMDD`) |
119
+ | `case_year` | string | 사례 연도 |
120
+ | `source_note` | string | 출처 주석 |
121
+ | `detail_url` | string | privacy.go.kr 상세 경로 |
122
+
123
+ ---
124
+
125
+ ## 3. 샘플 레코드
126
+
127
+ ### 가이드 청크 예시
128
+
129
+ ```json
130
+ {
131
+ "chunk_id": "질의응답_모음집_0000",
132
+ "source_type": "guide",
133
+ "doc_id": "질의응답_모음집",
134
+ "doc_title": "개인정보 질의응답 모음집",
135
+ "doc_date": "2025.12",
136
+ "section": "I.정의 [Q&A] Q1 ID와 결제상품정보가 개인정보에 해당",
137
+ "body": "Q1 ID와 결제상품정보가 개인정보에 해당하나요? …",
138
+ "chunk_context": "이 청크는 「개인정보 질의응답 모음집(2025.12)」 Ⅰ.정의 영역 첫 사례(Q1, 책 p.3)로, …",
139
+ "source_pdf": "1. 개인정보 질의응답 모음집(2025.12.).pdf",
140
+ "source_url": "",
141
+ "chunk_no": 0,
142
+ "pages": "p.3"
143
+ }
144
+ ```
145
+
146
+ ### 상담사례 청크 예시
147
+
148
+ ```json
149
+ {
150
+ "chunk_id": "상담사례_0001",
151
+ "source_type": "case",
152
+ "doc_id": "개인정보포털_상담사례",
153
+ "doc_title": "개인정보포털 상담사례",
154
+ "doc_date": "2012.01.20",
155
+ "section": "개인정보처리자(민간) > 개인정보 수집·이용 > 보건·의료",
156
+ "body": "Q) 의료기관에 환자가 처음으로 내원한 경우에 …",
157
+ "chunk_context": "이 청크는 「개인정보 상담사례 #1(2012, privacy.go.kr)」 보건·의료 업종 …",
158
+ "source_pdf": "",
159
+ "source_url": "https://www.privacy.go.kr/front/case/view.do?ntt_id=1&nttno=1",
160
+ "ntt_id": "1",
161
+ "title": "병원에서 초진 환자의 개인정보 수집시 동의 취득 여부",
162
+ "category1": "개인정보처리자(민간)",
163
+ "category2": "개인정보 수집·이용",
164
+ "category3": "보건·의료",
165
+ "reg_dt": "20120120",
166
+ "case_year": "2012"
167
+ }
168
+ ```
169
+
170
+ ---
171
+
172
+ ## 4. 사용 예시
173
+
174
+ ### 🤗 Datasets 로드
175
+
176
+ ```python
177
+ from datasets import load_dataset
178
+
179
+ ds = load_dataset("scvcoder/korean-privacy-law-corpus", split="train")
180
+
181
+ guides = ds.filter(lambda x: x["source_type"] == "guide") # 457
182
+ cases = ds.filter(lambda x: x["source_type"] == "case") # 1,745
183
+ ```
184
+
185
+ ### 임베딩 시 권장: `chunk_context` + `body` 결합
186
+
187
+ Contextual Retrieval 방식에 따라 임베딩 입력은 두 필드를 합쳐 사용하는 것을 권장합니다.
188
+
189
+ ```python
190
+ def to_embedding_text(rec: dict) -> str:
191
+ return f"{rec['chunk_context']}\n\n{rec['body']}"
192
+ ```
193
+
194
+ ### BM25 / 하이브리드 검색
195
+
196
+ `body`만 BM25 색인에 넣어도 무방하나, `chunk_context`까지 함께 색인하면 짧은 질의에서 회수율이
197
+ 크게 향상됩니다(특히 법조항 단편 검색).
198
+
199
+ ---
200
+
201
+ ## 5. 데이터 수집·가공 방법
202
+
203
+ 1. **원천 수집**
204
+ - 가이드 PDF 3종: PIPC 발행 공식 가이드 다운로드
205
+ - 상담사례: privacy.go.kr 상담사례 게시판 1,745건 수집
206
+ 2. **PDF → 청크 변환**
207
+ - 사람이 검토하는 인터랙티브 청킹 파이프라인. 책의 페이지·절 단위를 우선시하되,
208
+ 의미가 끊기는 곳에서 청크 경계를 두었습니다 (1청크 ≈ 200~600 한국어 어절).
209
+ 3. **Contextual Retrieval 적용**
210
+ - 각 청크에 대해 인접 조항·법조 근거·도식 의미를 자연어로 요약한 `chunk_context`를 LLM으로 생성하고,
211
+ 문서 작성자가 검수.
212
+ 4. **표준화**
213
+ - 본 코퍼스 단위로 공통 필드 10개를 모든 레코드에 도입(원본 필드 유지).
214
+
215
+ ---
216
+
217
+ ## 6. 활용 케이스
218
+
219
+ - **Korean privacy assistant ai 챗봇** — 소상공인·작은병원·학교 등 비전문가용 상담 RAG.
220
+ - **법률 LLM 파인튜닝의 retrieval 평가셋** — `title`/`body`를 질의·정답 쌍으로 변환 가능.
221
+ - **한국어 법률·규제 도메인 retrieval 벤치마크** — 도메인 시프트(일반 ↔ 법률) 평가.
222
+
223
+ ---
224
+
225
+ ## 7. 출처표시
226
+
227
+ 본 데이터셋의 출처는 개인정보보호위원회의 가이드와 개인정보 포털(privacy.go.kr)의
228
+ 상담자료임을 밝힙니다. 본 데이터셋을 사용·인용·재배포할 때는 반드시 다음 출처를
229
+ 명시해 주십시오.
230
+
231
+ ### 원자료 출처
232
+
233
+ 1. 개인정보보호위원회, 「개인정보 질의응답 모음집」 2025.12.
234
+ 2. 개인정보보호위원회, 「소상공인을 위한 개인정보 보호 핸드북」 2024.12.
235
+ 3. 개인정보보호위원회, 「고정형 영상정보처리기기 설치·운영 안내서(공공 및 민간분야 통합본)」 2024.12.
236
+ 4. 개인정보보호위원회·보건복지부 등, 「분야별 개인정보 보호 안내서」 2024.12.
237
+ 5. 개인정보 포털 상담사례 : https://www.privacy.go.kr/front/case/list.do
238
+
239
+ ### 가공
240
+
241
+ Semantic Chunking 및 Contextual Retrieval 기법으로 작성한 맥락 요약, 표준화 스키마는
242
+ 본 데이터셋 기여자의 2차 저작물이며, 위와 동일 조건으로 이용 가능합니다.
243
+
244
+ 원저작자가 전부 또는 일부의 삭제 또는 수정 요청을 할 경우 즉시 조치하겠습니다.
245
+
246
+ 1. Korean Privacy Law RAG Corpus (https://huggingface.co/datasets/scvcoder/korean-privacy-law-corpus)
247
+ 2. 가공내용 : Semantic Chunking, Contextual Retrieval 추가 등
248
+
249
+ ---
250
+
251
+ ## 8. 한계 및 주의사항
252
+
253
+ - **법률 자문이 아닙니다.** 본 데이터는 교육·연구·도구 개발용이며, 구체적 사안에 대한 법적 판단은
254
+ 전문��의 자문을 받아야 합니다.
255
+ - **시점 기준** — 가이드는 발행일(2024.12 / 2025.12) 기준이며, 이후 법 개정이 있을 수 있습니다.
256
+ 변경사항은 현행 법령을 확인하세요.
257
+ - **상담사례** — privacy.go.kr 상담사례는 PIPC가 개별 사안에 회신한 답변으로,
258
+ 유사한 사안에서도 사실관계가 다르면 결론이 달라질 수 있습니다.
259
+
260
+ ---
261
+
262
+ ## 9. 변경 이력
263
+
264
+ | 버전 | 일자 | 내용 |
265
+ |---|---|---|
266
+ | v1.0 | 2026-05-02 | 최초 공개 — 가이드 3종 211청크 + 상담사례 1,745건, 표준화 스키마 적용 |
267
+ | v1.1 | 2026-05-05 | 분야별 개인정보 보호 안내서(2024.12) 의료기관 편까지 청킹 246청크 부분 공개 (인사·노무 32 + 사회복지시설 72 + 의료기관 142). 약국·학원·통계·공공·온라인 경품 5개 편은 추가 작업 후 v1.2에서 공개 예정. 기존 가이드 3종(질의응답·핸드북·CCTV)도 `chunk_context` 보완 |
268
+
269
+ ---
270
+
271
+ *Compiled and curated by [scvcoder](https://huggingface.co/scvcoder).*
docker-compose.yml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KPAA all-in-one — backend(=FastAPI + llama-cpp-python 임베드) + Open WebUI.
2
+ # 외부 추론 데몬·서비스 없는 단일 프로세스 구성.
3
+ #
4
+ # 사용:
5
+ # cp .env.example .env # LAW_OC=... 입력
6
+ # docker compose up -d
7
+ # # → http://localhost:3000 (Open WebUI)
8
+ #
9
+ # 첫 실행 시 backend 컨테이너가 Gemma 4 E2B GGUF (~3.2GB)을 자동으로
10
+ # `kpaa-models` 볼륨에 받음. 5~15분 소요. `docker compose logs -f backend` 로 진행 확인.
11
+
12
+ services:
13
+ backend:
14
+ build:
15
+ context: .
16
+ dockerfile: Dockerfile
17
+ image: kpaa-backend:latest
18
+ restart: unless-stopped
19
+ environment:
20
+ - LAW_OC=${LAW_OC:-}
21
+ - KPAA_HOST=0.0.0.0
22
+ - KPAA_PORT=8000
23
+ # GPU offload 는 *자동 감지*. 강제 override 시 host shell 에서 export:
24
+ # KPAA_N_GPU_LAYERS=-1 docker compose up
25
+ - KPAA_N_GPU_LAYERS=${KPAA_N_GPU_LAYERS:-}
26
+ volumes:
27
+ - kpaa-models:/root/.cache/kpaa # 모델 + 법제처 캐시 영속화
28
+ ports:
29
+ - "8000:8000"
30
+ healthcheck:
31
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/healthz"]
32
+ interval: 30s
33
+ timeout: 5s
34
+ retries: 5
35
+ start_period: 60s # 첫 모델 로드까지 시간 여유
36
+
37
+ open-webui:
38
+ image: ghcr.io/open-webui/open-webui:main
39
+ restart: unless-stopped
40
+ depends_on:
41
+ backend:
42
+ condition: service_started
43
+ environment:
44
+ # backend의 OpenAI-호환 endpoint를 미리 주입 — UI에서 별도 설정 불필요
45
+ - OPENAI_API_BASE_URLS=http://backend:8000/v1
46
+ - OPENAI_API_KEYS=local
47
+ - WEBUI_NAME=KPAA — 개인정보보호법 상담
48
+ # 백엔드 default preset(`gemma-4-e2b-q4`) 과 동일. dropdown 에서
49
+ # 다른 프리셋을 고르면 백엔드 ModelManager 가 자동 전환.
50
+ - DEFAULT_MODELS=개인정보 상담 AI(gemma-4-e2b-q4)
51
+ # OpenWebUI 의 Ollama 자동 감지 차단 — KPAA backend(:8000) 만 사용.
52
+ - ENABLE_OLLAMA_API=false
53
+ - DEFAULT_USER_ROLE=admin
54
+ volumes:
55
+ - open-webui-data:/app/backend/data
56
+ ports:
57
+ - "3000:8080"
58
+
59
+ volumes:
60
+ kpaa-models:
61
+ open-webui-data:
manage.sh ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # KPAA 백엔드 + Open WebUI 통합 관리 스크립트.
3
+ #
4
+ # 사용법:
5
+ # ./manage.sh start KPAA + Open WebUI 백그라운드 기동, ready 대기까지
6
+ # ./manage.sh stop 양쪽 모두 정확한 종료 (PID 파일 기반)
7
+ # ./manage.sh restart stop → start
8
+ # ./manage.sh status 실행 여부 + PID + 포트 (양쪽)
9
+ # ./manage.sh logs KPAA 로그 tail
10
+ # ./manage.sh logs-owui Open WebUI 로그 tail
11
+ #
12
+ # 환경변수 (KPAA):
13
+ # KPAA_HOST 기본 127.0.0.1
14
+ # KPAA_PORT 기본 8000
15
+ # KPAA_LOG_FILE 기본 /tmp/kpaa_serve.log
16
+ # KPAA_PID_FILE 기본 ./.run/kpaa.pid
17
+ # KPAA_READY_TIMEOUT 기본 90 (초)
18
+ #
19
+ # 환경변수 (Open WebUI):
20
+ # KPAA_OPENWEBUI_ENABLED 기본 1 (0 으로 비활성화)
21
+ # KPAA_OPENWEBUI_BIN 기본 ~/.kpaa-owui/bin/open-webui (PATH 폴백)
22
+ # KPAA_OPENWEBUI_HOST 기본 127.0.0.1
23
+ # KPAA_OPENWEBUI_PORT 기본 8080
24
+ # KPAA_OPENWEBUI_LOG_FILE 기본 /tmp/kpaa_openwebui.log
25
+ # KPAA_OPENWEBUI_READY_TIMEOUT 기본 120 (초, 첫 부팅 시 모델 다운로드 등 변수)
26
+ # KPAA_OPENWEBUI_OPENAI_BASE_URLS 기본 http://${KPAA_HOST}:${KPAA_PORT}/v1
27
+ # (`;` 구분으로 다중 endpoint 가능)
28
+ # KPAA_OPENWEBUI_OPENAI_KEYS 기본 local
29
+ # KPAA_OPENWEBUI_DEFAULT_MODELS 기본 kpaa-privacy-ko (UI 첫 default 모델)
30
+ # KPAA_OPENWEBUI_NAME 기본 "KPAA — 개인정보보호법 상담"
31
+ # KPAA_OPENWEBUI_WEBUI_AUTH 기본 false — true 로 두면 본인 이메일/비밀번호 가입·로그인.
32
+ # 기본은 admin@localhost/admin 자동 생성·로그인 (로컬 전용).
33
+
34
+ set -euo pipefail
35
+
36
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
37
+ cd "$ROOT_DIR"
38
+
39
+ # ── KPAA ─────────────────────────────────────────────────────────────
40
+ HOST="${KPAA_HOST:-127.0.0.1}"
41
+ PORT="${KPAA_PORT:-8000}"
42
+ LOG_FILE="${KPAA_LOG_FILE:-/tmp/kpaa_serve.log}"
43
+ PID_DIR="${ROOT_DIR}/.run"
44
+ PID_FILE="${KPAA_PID_FILE:-${PID_DIR}/kpaa.pid}"
45
+ VENV_BIN="${ROOT_DIR}/.venv/bin"
46
+ HEALTH_URL="http://${HOST}:${PORT}/v1/models"
47
+ READY_TIMEOUT_S="${KPAA_READY_TIMEOUT:-90}"
48
+
49
+ # ── Open WebUI ────────────────────────────────────────────────────────
50
+ OWUI_ENABLED="${KPAA_OPENWEBUI_ENABLED:-1}"
51
+ OWUI_BIN_DEFAULT="${HOME}/.kpaa-owui/bin/open-webui"
52
+ OWUI_BIN="${KPAA_OPENWEBUI_BIN:-$OWUI_BIN_DEFAULT}"
53
+ if [[ ! -x "$OWUI_BIN" ]] && command -v open-webui >/dev/null 2>&1; then
54
+ OWUI_BIN="$(command -v open-webui)"
55
+ fi
56
+ OWUI_HOST="${KPAA_OPENWEBUI_HOST:-127.0.0.1}"
57
+ OWUI_PORT="${KPAA_OPENWEBUI_PORT:-8080}"
58
+ OWUI_LOG_FILE="${KPAA_OPENWEBUI_LOG_FILE:-/tmp/kpaa_openwebui.log}"
59
+ OWUI_PID_FILE="${PID_DIR}/openwebui.pid"
60
+ OWUI_HEALTH_URL="http://${OWUI_HOST}:${OWUI_PORT}/health"
61
+ OWUI_READY_TIMEOUT_S="${KPAA_OPENWEBUI_READY_TIMEOUT:-120}"
62
+
63
+ C_GREEN='\033[0;32m'; C_YELLOW='\033[0;33m'; C_RED='\033[0;31m'; C_RESET='\033[0m'
64
+ ok() { printf "${C_GREEN}✓${C_RESET} %s\n" "$*"; }
65
+ warn() { printf "${C_YELLOW}!${C_RESET} %s\n" "$*"; }
66
+ err() { printf "${C_RED}✗${C_RESET} %s\n" "$*" >&2; }
67
+
68
+ # ── 공통 PID 헬퍼 ────────────────────────────────────────────────────
69
+ is_alive() {
70
+ local pid="$1"
71
+ [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
72
+ }
73
+
74
+ read_pid() {
75
+ local f="$1"
76
+ [[ -f "$f" ]] && cat "$f" 2>/dev/null || true
77
+ }
78
+
79
+ current_pid() {
80
+ # PID 파일 우선. 없거나 죽었으면 빈값. 포트로 *추론하지 않음* — Chrome 등
81
+ # 무관한 클라이언트 프로세스를 잘못 죽이는 사고 방지.
82
+ local pid; pid="$(read_pid "$1")"
83
+ if is_alive "$pid"; then
84
+ echo "$pid"
85
+ else
86
+ echo ""
87
+ fi
88
+ }
89
+
90
+ wait_health() {
91
+ local url="$1" timeout="$2" label="$3"
92
+ local deadline=$(( $(date +%s) + timeout ))
93
+ while (( $(date +%s) < deadline )); do
94
+ if curl -fsS --max-time 2 "$url" > /dev/null 2>&1; then
95
+ return 0
96
+ fi
97
+ sleep 1
98
+ done
99
+ return 1
100
+ }
101
+
102
+ # ── KPAA 시작 ────────────────────────────────────────────────────────
103
+ start_kpaa() {
104
+ local existing; existing="$(current_pid "$PID_FILE")"
105
+ if [[ -n "$existing" ]]; then
106
+ warn "[kpaa] 이미 기동 중 (pid=$existing)"
107
+ return 0
108
+ fi
109
+
110
+ if [[ ! -x "${VENV_BIN}/kpaa" ]]; then
111
+ err "${VENV_BIN}/kpaa 가 없습니다. 가상환경을 먼저 활성화/설치하세요."
112
+ return 1
113
+ fi
114
+
115
+ if lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
116
+ err "[kpaa] 포트 ${PORT} 이미 점유."
117
+ lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >&2
118
+ return 1
119
+ fi
120
+
121
+ mkdir -p "$PID_DIR"
122
+ : > "$LOG_FILE"
123
+
124
+ nohup "${VENV_BIN}/kpaa" serve --host "$HOST" --port "$PORT" \
125
+ >> "$LOG_FILE" 2>&1 &
126
+ local pid=$!
127
+ echo "$pid" > "$PID_FILE"
128
+ ok "[kpaa] 기동 시도 (pid=$pid, log=$LOG_FILE)"
129
+
130
+ if wait_health "$HEALTH_URL" "$READY_TIMEOUT_S" "kpaa"; then
131
+ ok "[kpaa] 준비 완료 — ${HEALTH_URL}"
132
+ return 0
133
+ fi
134
+ err "[kpaa] ${READY_TIMEOUT_S}초 내 ready 응답 없음. tail -n 80 $LOG_FILE"
135
+ return 1
136
+ }
137
+
138
+ # ── Open WebUI 시작 ──────────────────────────────────────────────────
139
+ start_owui() {
140
+ if [[ "$OWUI_ENABLED" != "1" ]]; then
141
+ warn "[owui] 비활성화 (KPAA_OPENWEBUI_ENABLED=$OWUI_ENABLED) — 건너뜀"
142
+ return 0
143
+ fi
144
+ if [[ ! -x "$OWUI_BIN" ]]; then
145
+ warn "[owui] 실행 파일을 찾지 못함: $OWUI_BIN"
146
+ warn " 설치: pip install open-webui (전용 venv 권장: ${OWUI_BIN_DEFAULT%/bin/*})"
147
+ warn " 이 기능 영구 비활성화: export KPAA_OPENWEBUI_ENABLED=0"
148
+ return 0
149
+ fi
150
+
151
+ local existing; existing="$(current_pid "$OWUI_PID_FILE")"
152
+ if [[ -n "$existing" ]]; then
153
+ warn "[owui] 이미 기동 중 (pid=$existing)"
154
+ return 0
155
+ fi
156
+
157
+ if lsof -nP -iTCP:"${OWUI_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
158
+ err "[owui] 포트 ${OWUI_PORT} 이미 점유."
159
+ lsof -nP -iTCP:"${OWUI_PORT}" -sTCP:LISTEN >&2
160
+ return 1
161
+ fi
162
+
163
+ mkdir -p "$PID_DIR"
164
+ : > "$OWUI_LOG_FILE"
165
+
166
+ # OpenWebUI 가 KPAA backend 의 OpenAI 호환 endpoint 를 자동으로 부르도록
167
+ # 환경변수 주입 — docker-compose.yml 의 동일 패턴.
168
+ #
169
+ # KPAA 는 llama-cpp-python 임베드 단일 경로라, OpenWebUI 의 외부 추론 데몬
170
+ # 자동 감지(`ENABLE_OLLAMA_API`)는 항상 차단 — KPAA 백엔드만 사용.
171
+ local owui_envs=()
172
+ owui_envs+=("OPENAI_API_BASE_URLS=${KPAA_OPENWEBUI_OPENAI_BASE_URLS:-http://${HOST}:${PORT}/v1}")
173
+ owui_envs+=("OPENAI_API_KEYS=${KPAA_OPENWEBUI_OPENAI_KEYS:-local}")
174
+ owui_envs+=("DEFAULT_MODELS=${KPAA_OPENWEBUI_DEFAULT_MODELS:-kpaa-privacy-ko}")
175
+ owui_envs+=("WEBUI_NAME=${KPAA_OPENWEBUI_NAME:-KPAA — 개인정보보호법 상담}")
176
+ owui_envs+=("ENABLE_OLLAMA_API=false")
177
+ # 로컬 단일 사용자 — 기본 false 시 OpenWebUI 가 admin@localhost/admin 자동 생성·로그인.
178
+ # 본인 이메일/비밀번호로 운영하려면 export KPAA_OPENWEBUI_WEBUI_AUTH=true 후 재시작.
179
+ # 자세한 내용 README "🔐 인증 모드" 참고.
180
+ owui_envs+=("WEBUI_AUTH=${KPAA_OPENWEBUI_WEBUI_AUTH:-false}")
181
+
182
+ # `open-webui serve` 인자: --host / --port 지원. 0.5+ 기준.
183
+ # `env -S` 대신 nohup 앞에 환경변수 prefix — bash 표준 호환.
184
+ nohup env "${owui_envs[@]}" "$OWUI_BIN" serve --host "$OWUI_HOST" --port "$OWUI_PORT" \
185
+ >> "$OWUI_LOG_FILE" 2>&1 &
186
+ local pid=$!
187
+ echo "$pid" > "$OWUI_PID_FILE"
188
+ ok "[owui] 기동 시도 (pid=$pid, log=$OWUI_LOG_FILE)"
189
+ ok " OPENAI_API_BASE_URLS=${KPAA_OPENWEBUI_OPENAI_BASE_URLS:-http://${HOST}:${PORT}/v1}"
190
+
191
+ if wait_health "$OWUI_HEALTH_URL" "$OWUI_READY_TIMEOUT_S" "owui"; then
192
+ ok "[owui] 준비 완료 — ${OWUI_HEALTH_URL}"
193
+ return 0
194
+ fi
195
+ warn "[owui] ${OWUI_READY_TIMEOUT_S}초 내 ready 응답 없음. tail -n 80 $OWUI_LOG_FILE"
196
+ warn " KPAA backend 는 정상 동작 — OpenAI-호환 API 직접 호출 가능: http://${HOST}:${PORT}/v1"
197
+ return 0 # owui 실패는 *전체 start 실패* 로 보지 않음 — KPAA 는 동작
198
+ }
199
+
200
+ # ── 종료 헬퍼 ────────────────────────────────────────────────────────
201
+ stop_one() {
202
+ local label="$1" pid_file="$2"
203
+ local pid; pid="$(current_pid "$pid_file")"
204
+ if [[ -z "$pid" ]]; then
205
+ warn "[$label] 실행 중 아님"
206
+ [[ -f "$pid_file" ]] && rm -f "$pid_file"
207
+ return 0
208
+ fi
209
+ ok "[$label] SIGTERM (pid=$pid)"
210
+ kill "$pid" 2>/dev/null || true
211
+ local deadline=$(( $(date +%s) + 15 ))
212
+ while is_alive "$pid" && (( $(date +%s) < deadline )); do
213
+ sleep 1
214
+ done
215
+ if is_alive "$pid"; then
216
+ warn "[$label] SIGTERM 무응답. SIGKILL."
217
+ kill -9 "$pid" 2>/dev/null || true
218
+ sleep 1
219
+ fi
220
+ if is_alive "$pid"; then
221
+ err "[$label] 여전히 살아있음 (pid=$pid). 수동 점검 필요."
222
+ return 1
223
+ fi
224
+ rm -f "$pid_file"
225
+ ok "[$label] 종료 완료"
226
+ }
227
+
228
+ # ── 통합 명령 ────────────────────────────────────────────────────────
229
+ cmd_start() {
230
+ start_kpaa || return 1
231
+ start_owui || true # owui 실패가 KPAA 부팅을 막지 않게
232
+ }
233
+
234
+ cmd_stop() {
235
+ # 종료 순서: owui 먼저 (자식 클라이언트 → 백엔드 순으로 깨끗)
236
+ stop_one owui "$OWUI_PID_FILE" || true
237
+ stop_one kpaa "$PID_FILE" || true
238
+ }
239
+
240
+ cmd_restart() {
241
+ cmd_stop || true
242
+ cmd_start
243
+ }
244
+
245
+ cmd_status() {
246
+ local kpid opid
247
+ kpid="$(current_pid "$PID_FILE")"
248
+ opid="$(current_pid "$OWUI_PID_FILE")"
249
+
250
+ if [[ -n "$kpid" ]]; then
251
+ ok "[kpaa] RUNNING (pid=$kpid, port=$PORT)"
252
+ if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null 2>&1; then
253
+ ok "[kpaa] 헬스체크 OK — $HEALTH_URL"
254
+ else
255
+ warn "[kpaa] 프로세스 alive 이나 헬스체크 미응답 (부팅 중일 수 있음)"
256
+ fi
257
+ else
258
+ warn "[kpaa] STOPPED"
259
+ if lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
260
+ warn " 다만 포트 ${PORT} 다른 프로세스가 점유:"
261
+ lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN
262
+ fi
263
+ fi
264
+
265
+ if [[ "$OWUI_ENABLED" != "1" ]]; then
266
+ warn "[owui] 비활성화 (KPAA_OPENWEBUI_ENABLED=$OWUI_ENABLED)"
267
+ elif [[ -n "$opid" ]]; then
268
+ ok "[owui] RUNNING (pid=$opid, port=$OWUI_PORT)"
269
+ if curl -fsS --max-time 2 "$OWUI_HEALTH_URL" >/dev/null 2>&1; then
270
+ ok "[owui] 헬스체크 OK — $OWUI_HEALTH_URL"
271
+ else
272
+ warn "[owui] 프로세스 alive 이나 헬스체크 미응답 (부팅 중일 수 있음)"
273
+ fi
274
+ else
275
+ warn "[owui] STOPPED"
276
+ if lsof -nP -iTCP:"${OWUI_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
277
+ warn " 다만 포트 ${OWUI_PORT} 다른 프로세스가 점유:"
278
+ lsof -nP -iTCP:"${OWUI_PORT}" -sTCP:LISTEN
279
+ fi
280
+ fi
281
+
282
+ echo "logs:"
283
+ echo " kpaa : $LOG_FILE"
284
+ echo " owui : $OWUI_LOG_FILE"
285
+ }
286
+
287
+ cmd_logs() {
288
+ if [[ ! -f "$LOG_FILE" ]]; then
289
+ err "로그 파일 없음: $LOG_FILE"
290
+ return 1
291
+ fi
292
+ exec tail -n 200 -f "$LOG_FILE"
293
+ }
294
+
295
+ cmd_logs_owui() {
296
+ if [[ ! -f "$OWUI_LOG_FILE" ]]; then
297
+ err "로그 파일 없음: $OWUI_LOG_FILE"
298
+ return 1
299
+ fi
300
+ exec tail -n 200 -f "$OWUI_LOG_FILE"
301
+ }
302
+
303
+ usage() {
304
+ cat <<EOF
305
+ 사용법: $(basename "$0") {start|stop|restart|status|logs|logs-owui}
306
+
307
+ KPAA 백엔드 + Open WebUI 를 함께 관리합니다.
308
+
309
+ 환경변수 (요약):
310
+ KPAA_HOST / KPAA_PORT 백엔드 (기본 127.0.0.1:8000)
311
+ KPAA_OPENWEBUI_ENABLED 0 으로 두면 OpenWebUI 미동반
312
+ KPAA_OPENWEBUI_HOST / KPAA_OPENWEBUI_PORT (기본 127.0.0.1:8080)
313
+ KPAA_OPENWEBUI_BIN open-webui 실행 경로 override
314
+ EOF
315
+ }
316
+
317
+ case "${1:-}" in
318
+ start) cmd_start ;;
319
+ stop) cmd_stop ;;
320
+ restart) cmd_restart ;;
321
+ status) cmd_status ;;
322
+ logs) cmd_logs ;;
323
+ logs-owui) cmd_logs_owui ;;
324
+ ""|-h|--help|help) usage ;;
325
+ *) usage; exit 2 ;;
326
+ esac
pyproject.toml CHANGED
@@ -48,6 +48,19 @@ hf = [
48
  "accelerate>=0.34",
49
  "spaces>=0.30",
50
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  # 빌드 타임 docling 의존성 — 두 용도가 같은 패키지 셋을 공유:
52
  # 1) PIPC 결정문 별지(이미지) → markdown OCR. 옵트인:
53
  # pip install -e ".[pipc-ocr]"
 
48
  "accelerate>=0.34",
49
  "spaces>=0.30",
50
  ]
51
+ # Hybrid retrieval (BM25 + Dense) + Cross-encoder reranker.
52
+ # - BGE-M3 임베딩 (1024 dim) + bge-reranker-v2-m3 로 한국어 SOTA급.
53
+ # - sqlite-vec 로 별도 벡터 DB 서버 없이 임베디드 검색 (data/embeddings.sqlite).
54
+ # - 노트북·HF Space 양쪽에서 동일 동작. BM25만 쓰는 사용자는 설치 불필요.
55
+ rag = [
56
+ "sentence-transformers>=3.0",
57
+ "sqlite-vec>=0.1.6",
58
+ "torch>=2.4",
59
+ # 주의: python 의 sqlite3 가 extension loading 활성으로 빌드되어 있어야 한다.
60
+ # macOS python.org 인스톨러 빌드는 비활성 — Homebrew/conda/pyenv-from-source 의
61
+ # python 으로 venv 를 만들면 활성. .venv 생성 시 `which python3` 가
62
+ # /opt/homebrew/bin/python3 같은 것을 가리키는지 확인.
63
+ ]
64
  # 빌드 타임 docling 의존성 — 두 용도가 같은 패키지 셋을 공유:
65
  # 1) PIPC 결정문 별지(이미지) → markdown OCR. 옵트인:
66
  # pip install -e ".[pipc-ocr]"
requirements.txt CHANGED
@@ -33,9 +33,3 @@ spaces>=0.30
33
  # HF Spaces 는 requirements.txt 처리 시점에 app 파일이 아직 /home/user/app 에
34
  # mount 되어 있지 않아 `-e .` 가 동작하지 않는다. 대신 app.py 에서
35
  # `src/` 를 sys.path 에 prepend 한다.
36
-
37
- # ── Hybrid retrieval (BM25 + Dense) + Cross-encoder reranker ──
38
- # BGE-M3 임베딩 + bge-reranker-v2-m3 재정렬 + sqlite-vec 벡터 DB.
39
- # torch/transformers 는 위 ZeroGPU 섹션에서 이미 설치됨.
40
- sentence-transformers>=3.0
41
- sqlite-vec>=0.1.6
 
33
  # HF Spaces 는 requirements.txt 처리 시점에 app 파일이 아직 /home/user/app 에
34
  # mount 되어 있지 않아 `-e .` 가 동작하지 않는다. 대신 app.py 에서
35
  # `src/` 를 sys.path 에 prepend 한다.
 
 
 
 
 
 
src/kpaa/cli.py CHANGED
@@ -461,8 +461,10 @@ def main(argv: list[str] | None = None) -> int:
461
  )
462
  else:
463
  import sqlite3
 
464
  from kpaa.cases.index import default_db_path as case_db_path
465
- con = sqlite3.connect(case_db_path()); con.row_factory = sqlite3.Row
 
466
  for h in hits:
467
  ntt_id = h.chunk_id.removeprefix("case_")
468
  row = con.execute("SELECT title, body, summary FROM cases WHERE ntt_id=?", (ntt_id,)).fetchone()
 
461
  )
462
  else:
463
  import sqlite3
464
+
465
  from kpaa.cases.index import default_db_path as case_db_path
466
+ con = sqlite3.connect(case_db_path())
467
+ con.row_factory = sqlite3.Row
468
  for h in hits:
469
  ntt_id = h.chunk_id.removeprefix("case_")
470
  row = con.execute("SELECT title, body, summary FROM cases WHERE ntt_id=?", (ntt_id,)).fetchone()
src/kpaa/config.py CHANGED
@@ -24,9 +24,6 @@ class Settings(BaseSettings):
24
  kpaa_host: str = "127.0.0.1"
25
  kpaa_port: int = 8000
26
 
27
- kpaa_rewrite: bool = False
28
- kpaa_max_context_tokens: int = 16384
29
-
30
  # LLM 백엔드 선택. None=auto: HF Spaces 환경(SPACE_ID 설정)이면 zerogpu,
31
  # 아니면 llama_cpp. 강제 override: "llama_cpp" | "zerogpu".
32
  kpaa_llm_backend: str | None = None
 
24
  kpaa_host: str = "127.0.0.1"
25
  kpaa_port: int = 8000
26
 
 
 
 
27
  # LLM 백엔드 선택. None=auto: HF Spaces 환경(SPACE_ID 설정)이면 zerogpu,
28
  # 아니면 llama_cpp. 강제 override: "llama_cpp" | "zerogpu".
29
  kpaa_llm_backend: str | None = None
src/kpaa/embeddings/embedder.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
  import logging
11
  import os
12
  from functools import cached_property
13
- from typing import ClassVar, TYPE_CHECKING
14
 
15
  if TYPE_CHECKING:
16
  import numpy as np
@@ -39,20 +39,20 @@ def _detect_device() -> str:
39
  class Embedder:
40
  """BGE-M3 (또는 KPAA_EMBEDDER 지정 모델) singleton."""
41
 
42
- _instance: ClassVar["Embedder | None"] = None
43
 
44
  def __init__(self, model_name: str | None = None, device: str | None = None) -> None:
45
  self.model_name = model_name or os.environ.get("KPAA_EMBEDDER", _DEFAULT_MODEL)
46
  self.device = device or _detect_device()
47
 
48
  @classmethod
49
- def default(cls) -> "Embedder":
50
  if cls._instance is None:
51
  cls._instance = cls()
52
  return cls._instance
53
 
54
  @cached_property
55
- def model(self) -> "SentenceTransformer":
56
  from sentence_transformers import SentenceTransformer
57
  logger.info("Loading embedding model %s on %s ...", self.model_name, self.device)
58
  return SentenceTransformer(self.model_name, device=self.device)
@@ -61,7 +61,7 @@ class Embedder:
61
  def dim(self) -> int:
62
  return _DIM_BY_MODEL.get(self.model_name) or self.model.get_sentence_embedding_dimension()
63
 
64
- def encode_chunks(self, texts: list[str], *, batch: int = 32, show_progress: bool = True) -> "np.ndarray":
65
  """문서 측 임베딩. cosine 검색 위해 정규화."""
66
  return self.model.encode(
67
  texts,
@@ -71,7 +71,7 @@ class Embedder:
71
  convert_to_numpy=True,
72
  )
73
 
74
- def encode_query(self, text: str) -> "np.ndarray":
75
  """쿼리 측 임베딩."""
76
  return self.model.encode(
77
  text,
 
10
  import logging
11
  import os
12
  from functools import cached_property
13
+ from typing import TYPE_CHECKING, ClassVar
14
 
15
  if TYPE_CHECKING:
16
  import numpy as np
 
39
  class Embedder:
40
  """BGE-M3 (또는 KPAA_EMBEDDER 지정 모델) singleton."""
41
 
42
+ _instance: ClassVar[Embedder | None] = None
43
 
44
  def __init__(self, model_name: str | None = None, device: str | None = None) -> None:
45
  self.model_name = model_name or os.environ.get("KPAA_EMBEDDER", _DEFAULT_MODEL)
46
  self.device = device or _detect_device()
47
 
48
  @classmethod
49
+ def default(cls) -> Embedder:
50
  if cls._instance is None:
51
  cls._instance = cls()
52
  return cls._instance
53
 
54
  @cached_property
55
+ def model(self) -> SentenceTransformer:
56
  from sentence_transformers import SentenceTransformer
57
  logger.info("Loading embedding model %s on %s ...", self.model_name, self.device)
58
  return SentenceTransformer(self.model_name, device=self.device)
 
61
  def dim(self) -> int:
62
  return _DIM_BY_MODEL.get(self.model_name) or self.model.get_sentence_embedding_dimension()
63
 
64
+ def encode_chunks(self, texts: list[str], *, batch: int = 32, show_progress: bool = True) -> np.ndarray:
65
  """문서 측 임베딩. cosine 검색 위해 정규화."""
66
  return self.model.encode(
67
  texts,
 
71
  convert_to_numpy=True,
72
  )
73
 
74
+ def encode_query(self, text: str) -> np.ndarray:
75
  """쿼리 측 임베딩."""
76
  return self.model.encode(
77
  text,
src/kpaa/embeddings/index.py CHANGED
@@ -13,7 +13,7 @@ from __future__ import annotations
13
  import logging
14
  import sqlite3
15
  from collections.abc import Iterator
16
- from datetime import datetime, timezone
17
  from pathlib import Path
18
  from typing import Literal, NamedTuple
19
 
@@ -150,7 +150,7 @@ def build_embed_index(
150
  texts = [p[3] for p in pending]
151
  vectors = embedder.encode_chunks(texts, batch=batch)
152
 
153
- now = datetime.now(timezone.utc).isoformat(timespec="seconds")
154
  for (cid, doc_id, src, _), vec in zip(pending, vectors):
155
  cur.execute(
156
  "INSERT INTO chunk_vectors(chunk_id, embedding) VALUES (?, ?)",
 
13
  import logging
14
  import sqlite3
15
  from collections.abc import Iterator
16
+ from datetime import UTC, datetime
17
  from pathlib import Path
18
  from typing import Literal, NamedTuple
19
 
 
150
  texts = [p[3] for p in pending]
151
  vectors = embedder.encode_chunks(texts, batch=batch)
152
 
153
+ now = datetime.now(UTC).isoformat(timespec="seconds")
154
  for (cid, doc_id, src, _), vec in zip(pending, vectors):
155
  cur.execute(
156
  "INSERT INTO chunk_vectors(chunk_id, embedding) VALUES (?, ?)",
src/kpaa/guides/extractor.py CHANGED
@@ -124,23 +124,6 @@ def derive_doc_id(pdf_filename: str) -> str:
124
  return name or "guide"
125
 
126
 
127
- def derive_doc_date(pdf_filename: str) -> str:
128
- """파일명 끝의 (YYYY.MM) 또는 (YYYY) 추출. 없으면 빈 문자열."""
129
- import re
130
- m = re.search(r"\((\d{4})(?:[.\-](\d{1,2}))?[.\s]*\)", pdf_filename)
131
- if not m:
132
- # "2026 개인정보 처리방침..." 같이 prefix 에 연도만 있는 경우
133
- m2 = re.search(r"(?:^|\s)(\d{4})(?:\s|$)", pdf_filename)
134
- if m2:
135
- return m2.group(1)
136
- return ""
137
- year = m.group(1)
138
- month = m.group(2)
139
- if month:
140
- return f"{year}.{int(month):02d}"
141
- return year
142
-
143
-
144
  def derive_doc_title(pdf_filename: str) -> str:
145
  """파일명 → 사람이 읽을 한국어 제목 (★, 번호, 확장자 제거)."""
146
  name = pdf_filename
@@ -154,4 +137,4 @@ def derive_doc_title(pdf_filename: str) -> str:
154
  return name
155
 
156
 
157
- __all__ = ["extract", "derive_doc_id", "derive_doc_date", "derive_doc_title"]
 
124
  return name or "guide"
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def derive_doc_title(pdf_filename: str) -> str:
128
  """파일명 → 사람이 읽을 한국어 제목 (★, 번호, 확장자 제거)."""
129
  name = pdf_filename
 
137
  return name
138
 
139
 
140
+ __all__ = ["extract", "derive_doc_id", "derive_doc_title"]
src/kpaa/llm/llama_cpp_backend.py CHANGED
@@ -162,15 +162,15 @@ def _print_env_summary(n_gpu_layers: int, n_threads: int | None) -> None:
162
  )
163
  if plat == "darwin" and n_gpu_layers == 0:
164
  msg += (
165
- f" · 참고: macOS Metal GPU 가속은 *opt-in* 입니다 (Gemma 4 E2B Q4_K_M\n"
166
- f" + Metal 조합 segfault 회귀 회피). 시도하려면 환경변수\n"
167
- f" `KPAA_N_GPU_LAYERS=-1` 후 재시작.\n"
168
  )
169
  if n_gpu_layers == 0 and plat in ("linux", "win32"):
170
  msg += (
171
- f" · 빠르게: GPU 빌드 재설치하면 자동 가속.\n"
172
- f" CMAKE_ARGS='-DGGML_CUDA=on' pip install --force-reinstall \\\n"
173
- f" --no-cache-dir llama-cpp-python\n"
174
  )
175
  if n_threads is not None and cpu and n_threads < cpu:
176
  msg += (
 
162
  )
163
  if plat == "darwin" and n_gpu_layers == 0:
164
  msg += (
165
+ " · 참고: macOS Metal GPU 가속은 *opt-in* 입니다 (Gemma 4 E2B Q4_K_M\n"
166
+ " + Metal 조합 segfault 회귀 회피). 시도하려면 환경변수\n"
167
+ " `KPAA_N_GPU_LAYERS=-1` 후 재시작.\n"
168
  )
169
  if n_gpu_layers == 0 and plat in ("linux", "win32"):
170
  msg += (
171
+ " · 빠르게: GPU 빌드 재설치하면 자동 가속.\n"
172
+ " CMAKE_ARGS='-DGGML_CUDA=on' pip install --force-reinstall \\\n"
173
+ " --no-cache-dir llama-cpp-python\n"
174
  )
175
  if n_threads is not None and cpu and n_threads < cpu:
176
  msg += (
src/kpaa/llm/manager.py CHANGED
@@ -19,7 +19,6 @@ 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
 
19
  import logging
20
  import os
21
  from pathlib import Path
 
22
 
23
  from kpaa.config import get_settings
24
  from kpaa.llm.base import LLMBackend
src/kpaa/llm/presets.py CHANGED
@@ -1,12 +1,8 @@
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
 
@@ -25,15 +21,14 @@ class ModelPreset:
25
  is_default: bool = False
26
 
27
 
28
- # 후보 목록 Unsloth Dynamic Quants 시리즈로 양자화 비트수만 다르게 비교용.
29
- # 모두 같은 가중치(google/gemma-4-E2B-it) GGUF 변환본. HF Space(ZeroGPU)
30
- # 에서 어떤 프리셋을 골라도 hf_repo 의 BF16 transformers 가중치를 로드하므로
31
- # 동일한 답변 — 양자화별 차이는 *로컬(llama-cpp-python)* 에서만 체감됨.
32
  PRESETS: list[ModelPreset] = [
33
  ModelPreset(
34
  # Unsloth Dynamic Quants 2.0 (UD-Q4_K_XL) — 평균 ~4-bit 이지만 층별로
35
  # 중요한 부분은 더 높은 정밀도로 보존해 동일 4-bit 그룹 중 품질 최상.
36
- id="gemma-4-e2b-unsloth-q4",
37
  label="Gemma 4 E2B UD-Q4 (기본·균형)",
38
  short="2B · ~1.7GB · 4-bit Dynamic · 권장 (속도·품질 균형)",
39
  llama_cpp_repo="unsloth/gemma-4-E2B-it-GGUF",
@@ -42,28 +37,6 @@ PRESETS: list[ModelPreset] = [
42
  family="gemma",
43
  is_default=True,
44
  ),
45
- ModelPreset(
46
- # UD-Q3_K_XL — 3-bit Dynamic. 더 작은 RAM/디스크 + 더 빠른 토큰 속도,
47
- # 답변 품질 약간 ↓ (한국어 비문 살짝 증가). 노트북 RAM 부족 환경 권장.
48
- id="gemma-4-e2b-unsloth-q3",
49
- label="Gemma 4 E2B UD-Q3 (3-bit·빠름)",
50
- short="2B · ~1.3GB · 3-bit Dynamic · 메모리 ↓ 속도 ↑ 품질 살짝 ↓",
51
- llama_cpp_repo="unsloth/gemma-4-E2B-it-GGUF",
52
- llama_cpp_file="gemma-4-E2B-it-UD-Q3_K_XL.gguf",
53
- hf_repo="google/gemma-4-E2B-it",
54
- family="gemma",
55
- ),
56
- ModelPreset(
57
- # UD-Q2_K_XL — 2-bit Dynamic. 가장 작고 빠르나 품질 손실 뚜렷.
58
- # 저사양 환경 실험·벤치마킹용. 일반 답변 품질은 권장 안 함.
59
- id="gemma-4-e2b-unsloth-q2",
60
- label="Gemma 4 E2B UD-Q2 (2-bit·실험)",
61
- short="2B · ~1.0GB · 2-bit Dynamic · 가장 빠르나 품질 저하 뚜렷",
62
- llama_cpp_repo="unsloth/gemma-4-E2B-it-GGUF",
63
- llama_cpp_file="gemma-4-E2B-it-UD-Q2_K_XL.gguf",
64
- hf_repo="google/gemma-4-E2B-it",
65
- family="gemma",
66
- ),
67
  ]
68
 
69
 
 
1
+ """모델 프리셋 카탈로그.
2
 
3
  각 프리셋은 *동일 가중치의 두 형식* 을 함께 가진다:
4
  - llama_cpp_repo / llama_cpp_file : 로컬 노트북용 GGUF (Hugging Face 자동 다운로드)
5
  - hf_repo : HF Spaces ZeroGPU 용 transformers 가중치 (옵션)
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
 
21
  is_default: bool = False
22
 
23
 
24
+ # google/gemma-4-E2B-it GGUF 변환본 (Unsloth Dynamic Quants). HF Space(ZeroGPU)
25
+ # 에서는 hf_repo 의 BF16 transformers 가중치 로드 양자화는 로컬
26
+ # (llama-cpp-python) 경로에서 적용된다.
 
27
  PRESETS: list[ModelPreset] = [
28
  ModelPreset(
29
  # Unsloth Dynamic Quants 2.0 (UD-Q4_K_XL) — 평균 ~4-bit 이지만 층별로
30
  # 중요한 부분은 더 높은 정밀도로 보존해 동일 4-bit 그룹 중 품질 최상.
31
+ id="gemma-4-e2b-q4",
32
  label="Gemma 4 E2B UD-Q4 (기본·균형)",
33
  short="2B · ~1.7GB · 4-bit Dynamic · 권장 (속도·품질 균형)",
34
  llama_cpp_repo="unsloth/gemma-4-E2B-it-GGUF",
 
37
  family="gemma",
38
  is_default=True,
39
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  ]
41
 
42
 
src/kpaa/llm/zerogpu_backend.py CHANGED
@@ -131,7 +131,7 @@ class ZeroGPUBackend:
131
  print(f"[kpaa.zerogpu] _gen start, device={device}", flush=True)
132
  if device == "cuda":
133
  model.to(device)
134
- print(f"[kpaa.zerogpu] model moved to cuda", flush=True)
135
  ids = input_ids.to(device)
136
  print(f"[kpaa.zerogpu] input shape={tuple(ids.shape)}, max_new_tokens={opts.max_tokens}", flush=True)
137
 
@@ -156,14 +156,14 @@ class ZeroGPUBackend:
156
  generation_config=gen_cfg,
157
  streamer=streamer,
158
  )
159
- print(f"[kpaa.zerogpu] generate() returned normally", flush=True)
160
  except Exception as e:
161
  print(f"[kpaa.zerogpu] generate() raised: {type(e).__name__}: {e}", flush=True)
162
  raise
163
  finally:
164
  try:
165
  streamer.end()
166
- print(f"[kpaa.zerogpu] streamer.end() called", flush=True)
167
  except Exception as e:
168
  print(f"[kpaa.zerogpu] streamer.end() failed: {e}", flush=True)
169
 
 
131
  print(f"[kpaa.zerogpu] _gen start, device={device}", flush=True)
132
  if device == "cuda":
133
  model.to(device)
134
+ print("[kpaa.zerogpu] model moved to cuda", flush=True)
135
  ids = input_ids.to(device)
136
  print(f"[kpaa.zerogpu] input shape={tuple(ids.shape)}, max_new_tokens={opts.max_tokens}", flush=True)
137
 
 
156
  generation_config=gen_cfg,
157
  streamer=streamer,
158
  )
159
+ print("[kpaa.zerogpu] generate() returned normally", flush=True)
160
  except Exception as e:
161
  print(f"[kpaa.zerogpu] generate() raised: {type(e).__name__}: {e}", flush=True)
162
  raise
163
  finally:
164
  try:
165
  streamer.end()
166
+ print("[kpaa.zerogpu] streamer.end() called", flush=True)
167
  except Exception as e:
168
  print(f"[kpaa.zerogpu] streamer.end() failed: {e}", flush=True)
169
 
src/kpaa/pipeline.py CHANGED
@@ -48,7 +48,7 @@ async def build_context(
48
  *,
49
  client: KoreanLawClient | None = None,
50
  backend: LLMBackend | None = None,
51
- on_progress: "ProgressCB" = None,
52
  ) -> RetrievalResult:
53
  """라우팅(LLM 분류기 1샷) → chain orchestrator → 컨텍스트 빌드.
54
 
@@ -221,7 +221,7 @@ async def generate(
221
  True (기본) — 답변 본문 끝에 "참고한 자료" 섹션을 부착.
222
  Open WebUI 같이 우측 패널이 없는 클라이언트용.
223
  False — 답변 본문은 면책까지만. references는 별도 retrieval
224
- event payload에서 받아서 클라이언트가 표시 (자체 채팅 UI).
225
  """
226
  bk = backend or get_backend()
227
 
 
48
  *,
49
  client: KoreanLawClient | None = None,
50
  backend: LLMBackend | None = None,
51
+ on_progress: ProgressCB = None,
52
  ) -> RetrievalResult:
53
  """라우팅(LLM 분류기 1샷) → chain orchestrator → 컨텍스트 빌드.
54
 
 
221
  True (기본) — 답변 본문 끝에 "참고한 자료" 섹션을 부착.
222
  Open WebUI 같이 우측 패널이 없는 클라이언트용.
223
  False — 답변 본문은 면책까지만. references는 별도 retrieval
224
+ event payload에서 받아서 클라이언트가 표시.
225
  """
226
  bk = backend or get_backend()
227
 
src/kpaa/related_laws.py CHANGED
@@ -31,7 +31,7 @@ SOURCES: tuple[tuple[str, str], ...] = (
31
  # 여기서는 backward-compat 으로 `KNOWN_ALIASES` 이름만 재노출 (스크래이프 + yaml
32
  # 출력 흐름이 이 이름을 사용 중이라 유지). 신규 코드는 `from kpaa.law_api.aliases
33
  # import LAW_ALIASES, normalize_law_name, ...` 를 직접 써라.
34
- from kpaa.law_api.aliases import LAW_ALIASES as KNOWN_ALIASES # noqa: F401
35
 
36
 
37
  def _gen_keywords(name: str) -> list[str]:
 
31
  # 여기서는 backward-compat 으로 `KNOWN_ALIASES` 이름만 재노출 (스크래이프 + yaml
32
  # 출력 흐름이 이 이름을 사용 중이라 유지). 신규 코드는 `from kpaa.law_api.aliases
33
  # import LAW_ALIASES, normalize_law_name, ...` 를 직접 써라.
34
+ from kpaa.law_api.aliases import LAW_ALIASES as KNOWN_ALIASES # noqa: E402, F401
35
 
36
 
37
  def _gen_keywords(name: str) -> list[str]:
src/kpaa/retrieval/chains.py CHANGED
@@ -73,7 +73,7 @@ def _fetchers():
73
  def _make_fetch_call(
74
  source: str,
75
  client: KoreanLawClient,
76
- plan: "RouterPlan",
77
  spec_jo_hints: tuple[str, ...],
78
  on_progress: ProgressCB,
79
  ) -> Awaitable[list[Excerpt]] | None:
@@ -125,7 +125,7 @@ def _make_chain_fn(spec: dict[str, Any]) -> ChainFn:
125
 
126
  async def _run(
127
  client: KoreanLawClient,
128
- plan: "RouterPlan",
129
  *,
130
  on_progress: ProgressCB = None,
131
  ) -> list[Excerpt]:
 
73
  def _make_fetch_call(
74
  source: str,
75
  client: KoreanLawClient,
76
+ plan: RouterPlan,
77
  spec_jo_hints: tuple[str, ...],
78
  on_progress: ProgressCB,
79
  ) -> Awaitable[list[Excerpt]] | None:
 
125
 
126
  async def _run(
127
  client: KoreanLawClient,
128
+ plan: RouterPlan,
129
  *,
130
  on_progress: ProgressCB = None,
131
  ) -> list[Excerpt]:
src/kpaa/retrieval/reranker.py CHANGED
@@ -20,8 +20,9 @@ from __future__ import annotations
20
 
21
  import logging
22
  import os
 
23
  from functools import cached_property
24
- from typing import Callable, ClassVar, TypeVar
25
 
26
  logger = logging.getLogger("kpaa.retrieval.reranker")
27
 
@@ -48,7 +49,7 @@ def _disabled() -> bool:
48
  class Reranker:
49
  """sentence-transformers CrossEncoder wrapper (lazy singleton)."""
50
 
51
- _instance: ClassVar["Reranker | None"] = None
52
  _missing: ClassVar[bool] = False # 한 번 실패하면 retry 안 함
53
 
54
  def __init__(self, model_name: str | None = None, device: str | None = None) -> None:
@@ -56,7 +57,7 @@ class Reranker:
56
  self.device = device or _detect_device()
57
 
58
  @classmethod
59
- def default(cls) -> "Reranker | None":
60
  """싱글톤 인스턴스 — disabled 또는 첫 로드 실패 시 None.
61
 
62
  retriever 가 매 요청마다 호출하므로 None 반환 시 BM25+Dense 결과 그대로 사용.
 
20
 
21
  import logging
22
  import os
23
+ from collections.abc import Callable
24
  from functools import cached_property
25
+ from typing import ClassVar, TypeVar
26
 
27
  logger = logging.getLogger("kpaa.retrieval.reranker")
28
 
 
49
  class Reranker:
50
  """sentence-transformers CrossEncoder wrapper (lazy singleton)."""
51
 
52
+ _instance: ClassVar[Reranker | None] = None
53
  _missing: ClassVar[bool] = False # 한 번 실패하면 retry 안 함
54
 
55
  def __init__(self, model_name: str | None = None, device: str | None = None) -> None:
 
57
  self.device = device or _detect_device()
58
 
59
  @classmethod
60
+ def default(cls) -> Reranker | None:
61
  """싱글톤 인스턴스 — disabled 또는 첫 로드 실패 시 None.
62
 
63
  retriever 가 매 요청마다 호출하므로 None 반환 시 BM25+Dense 결과 그대로 사용.
src/kpaa/retrieval/retriever.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
  import asyncio
21
  import json
22
  import logging
 
23
  import re
24
  from collections.abc import Awaitable, Callable
25
  from datetime import datetime
@@ -27,15 +28,12 @@ from functools import lru_cache
27
  from pathlib import Path
28
  from typing import Any
29
 
30
- import os
31
-
32
  from kpaa.cases import CasesIndex
33
  from kpaa.guides import GuidesIndex
34
  from kpaa.law_api import KoreanLawClient
35
  from kpaa.retrieval.excerpts import Excerpt
36
  from kpaa.retrieval.router import RouterPlan
37
 
38
-
39
  # ─── Hybrid retrieval (BM25 + Dense via sqlite-vec) ────────────────────────
40
  # `kpaa build-embeddings` 로 data/embeddings.sqlite 가 빌드되어 있으면 자동 사용.
41
  # 빌드 안 된 환경 / 실패 시 BM25 단독으로 fallback.
 
20
  import asyncio
21
  import json
22
  import logging
23
+ import os
24
  import re
25
  from collections.abc import Awaitable, Callable
26
  from datetime import datetime
 
28
  from pathlib import Path
29
  from typing import Any
30
 
 
 
31
  from kpaa.cases import CasesIndex
32
  from kpaa.guides import GuidesIndex
33
  from kpaa.law_api import KoreanLawClient
34
  from kpaa.retrieval.excerpts import Excerpt
35
  from kpaa.retrieval.router import RouterPlan
36
 
 
37
  # ─── Hybrid retrieval (BM25 + Dense via sqlite-vec) ────────────────────────
38
  # `kpaa build-embeddings` 로 data/embeddings.sqlite 가 빌드되어 있으면 자동 사용.
39
  # 빌드 안 된 환경 / 실패 시 BM25 단독으로 fallback.
src/kpaa/retrieval/verify.py CHANGED
@@ -30,7 +30,6 @@ from dataclasses import dataclass, field
30
 
31
  from kpaa.retrieval.excerpts import Excerpt
32
 
33
-
34
  # ─────────────────────────────── 정규식 ───────────────────────────────
35
 
36
  # PIPC 결정 번호 — "PIPC 결정 2024-12-345", "개인정보보호위원회 결정 2024-2345"
 
30
 
31
  from kpaa.retrieval.excerpts import Excerpt
32
 
 
33
  # ─────────────────────────────── 정규식 ───────────────────────────────
34
 
35
  # PIPC 결정 번호 — "PIPC 결정 2024-12-345", "개인정보보호위원회 결정 2024-2345"
src/kpaa/server.py CHANGED
@@ -24,7 +24,7 @@ import uuid
24
  from collections.abc import AsyncIterator
25
  from typing import Any, Literal
26
 
27
- from fastapi import FastAPI, HTTPException, Query
28
  from fastapi.responses import HTMLResponse, StreamingResponse
29
  from pydantic import BaseModel, ConfigDict, Field
30
 
@@ -59,8 +59,7 @@ def preset_id_from_model(model: str | None) -> str | None:
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
 
@@ -111,7 +110,7 @@ _last_refs: dict[str, Any] = {
111
  }
112
 
113
 
114
- from kpaa.retrieval.citation_match import (
115
  extract_geungeo_indices as _extract_geungeo_indices,
116
  )
117
 
@@ -179,12 +178,6 @@ class ChatMessage(BaseModel):
179
  content: str
180
 
181
 
182
- class SelectModelReq(BaseModel):
183
- """`/api/select-model` 요청 바디 — preset_id 하나."""
184
-
185
- preset_id: str
186
-
187
-
188
  class ChatRequest(BaseModel):
189
  model_config = ConfigDict(extra="ignore") # 모르는 필드는 무시 (Open WebUI가 보내는 필드 다양)
190
 
@@ -237,13 +230,6 @@ class ModelList(BaseModel):
237
 
238
  # ───────────────────────── helpers ─────────────────────────
239
 
240
- def _last_user_query(messages: list[ChatMessage]) -> str:
241
- for m in reversed(messages):
242
- if m.role == "user" and m.content.strip():
243
- return m.content.strip()
244
- raise HTTPException(400, "no user message with content")
245
-
246
-
247
  def _split_history_and_query(messages: list[ChatMessage]) -> tuple[list[LLMChatMessage], str]:
248
  """ChatRequest.messages → (history, last_user_query).
249
 
@@ -555,7 +541,7 @@ def create_app() -> FastAPI:
555
 
556
  @app.get("/", response_class=HTMLResponse)
557
  async def index() -> str:
558
- # 루트 = Open WebUI + 참고자료 분할 화면. 백엔드 정보 페이지는 /info.
559
  # 페이지 진입(리로드 포함) 시 우측 참고자료 서버 상태를 비움 — 이전 세션
560
  # 잔여 _last_refs 가 폴링에 의해 즉시 렌더되는 것을 방지. HF 백엔드의
561
  # _split_handler 와 동일 정책.
@@ -571,65 +557,14 @@ def create_app() -> FastAPI:
571
  })
572
  return _SPLIT_HTML
573
 
574
- @app.get("/info", response_class=HTMLResponse)
575
- async def info_page() -> str:
576
- return f"""<!doctype html>
577
- <html lang="ko"><head><meta charset="utf-8">
578
- <title>KPAA — 백엔드 정보</title>
579
- <style>
580
- body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
581
- max-width: 720px; margin: 40px auto; padding: 0 16px; color: #222; line-height: 1.6; }}
582
- code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; }}
583
- pre {{ background: #f4f4f4; padding: 12px; border-radius: 6px; overflow-x: auto; }}
584
- a {{ color: #0a66c2; }}
585
- h1 {{ font-size: 1.5rem; }}
586
- h2 {{ font-size: 1.1rem; margin-top: 1.6em; }}
587
- table {{ border-collapse: collapse; width: 100%; }}
588
- td, th {{ border-bottom: 1px solid #eee; padding: 6px 8px; text-align: left; }}
589
- .muted {{ color: #888; font-size: 0.9em; }}
590
- </style></head>
591
- <body>
592
- <h1>KPAA — 개인정보보호법 미니 상담 백엔드</h1>
593
- <p class="muted">버전 {__version__} · 기본 모델 <code>{MODEL_ID}</code></p>
594
- <p class="muted">선택 가능: {", ".join(f"<code>{model_id_for(p)}</code>" for p in list_presets())}</p>
595
-
596
- <p style="background:#0a66c2;color:#fff;padding:14px 16px;border-radius:8px;font-weight:600;">
597
- 👉 <a href="/" style="color:#fff;">Open WebUI + 참고자료 분할 화면 (홈)</a> &nbsp; · &nbsp;
598
- <a href="/chat" style="color:#fff;">자체 채팅 UI</a>
599
- </p>
600
-
601
- <h2>Endpoints</h2>
602
- <table>
603
- <tr><th>Method</th><th>Path</th><th>설명</th></tr>
604
- <tr><td>GET</td><td><a href="/">/</a></td><td><b>홈</b> — Open WebUI + 참고자료 분할 화면</td></tr>
605
- <tr><td>GET</td><td><a href="/chat">/chat</a></td><td>자체 채팅 UI (답변 + 참고자료 좌/우 분할)</td></tr>
606
- <tr><td>GET</td><td>/api/chat?q=…</td><td>SSE 스트림 (자체 UI용)</td></tr>
607
- <tr><td>GET</td><td><a href="/healthz">/healthz</a></td><td>Liveness</td></tr>
608
- <tr><td>GET</td><td><a href="/v1/models">/v1/models</a></td><td>OpenAI-호환 모델 목록</td></tr>
609
- <tr><td>POST</td><td>/v1/chat/completions</td><td>OpenAI-호환 chat (Open WebUI용)</td></tr>
610
- <tr><td>GET</td><td><a href="/docs">/docs</a></td><td>Swagger UI</td></tr>
611
- </table>
612
-
613
- <h2>Open WebUI 연결</h2>
614
- <p>Settings → Connections → <b>OpenAI API</b> → URL <code>http://localhost:8000/v1</code>, Key <code>local</code></p>
615
-
616
- <h2>curl 예시</h2>
617
- <pre>curl -N -X POST http://localhost:8000/v1/chat/completions \\
618
- -H 'Content-Type: application/json' \\
619
- -d '{{"model":"{MODEL_ID}","messages":[{{"role":"user","content":"매장 CCTV 안내문구는?"}}],"stream":true}}'</pre>
620
-
621
- <p class="muted">※ 본 챗봇 답변은 일반적 정보 제공이며 법률 자문이 아닙니다. 신고: KISA 118 / privacy.go.kr</p>
622
- </body></html>"""
623
-
624
  @app.get("/healthz")
625
  async def healthz() -> dict[str, str]:
626
  return {"status": "ok", "version": __version__, "model": MODEL_ID}
627
 
628
  @app.get("/v1/models")
629
  async def list_models() -> ModelList:
630
- # 프리셋별 1개씩 Open WebUI 모델 dropdown 에 동시에 노출.
631
- # 사용자가 dropdown 에서 선택한 모델 이름이 ChatRequest.model 로 전달되며,
632
- # `_switch_to_requested_model` 가 그 이름을 보고 ModelManager 를 전환한다.
633
  now = int(time.time())
634
  return ModelList(
635
  data=[ModelInfo(id=model_id_for(p), created=now) for p in list_presets()]
@@ -697,48 +632,12 @@ def create_app() -> FastAPI:
697
  choices=[ChatChoice(message=ChatChoiceMessage(content=text))],
698
  )
699
 
700
- # ───────────────────────── 자체 채팅 UI (/우 분할) ─────────────────────────
701
-
702
- @app.get("/chat", response_class=HTMLResponse)
703
- async def chat_ui() -> str:
704
- return _CHAT_HTML
705
 
706
  @app.get("/api/last-references")
707
  async def api_last_refs() -> dict[str, Any]:
708
  return dict(_last_refs)
709
 
710
- @app.get("/api/models")
711
- async def api_models() -> dict[str, Any]:
712
- """프리셋 목록 + 현재 선택. 프런트 dropdown 채우기용."""
713
- mgr = get_manager()
714
- return {
715
- "current": mgr.current_id,
716
- "presets": [
717
- {
718
- "id": p.id,
719
- "label": p.label,
720
- "short": p.short,
721
- "family": p.family,
722
- "is_default": p.is_default,
723
- }
724
- for p in list_presets()
725
- ],
726
- }
727
-
728
- @app.post("/api/select-model")
729
- async def api_select_model(req: SelectModelReq) -> dict[str, Any]:
730
- """모델 프리셋 전환 — 다음 답변부터 새 모델로."""
731
- try:
732
- preset = get_manager().set_current(req.preset_id)
733
- except ValueError as e:
734
- raise HTTPException(400, str(e)) from e
735
- return {
736
- "status": "ok",
737
- "current": preset.id,
738
- "label": preset.label,
739
- "short": preset.short,
740
- }
741
-
742
  @app.post("/api/clear-references")
743
  async def api_clear_refs() -> dict[str, str]:
744
  """우측 참고자료 패널 초기화 — Open WebUI 새 채팅 등에서 사용."""
@@ -754,41 +653,6 @@ def create_app() -> FastAPI:
754
  })
755
  return {"status": "cleared"}
756
 
757
- @app.get("/api/chat")
758
- async def api_chat(q: str = Query(..., min_length=1, max_length=2000)):
759
- """SSE: token + references + done events. EventSource 호환."""
760
-
761
- async def gen():
762
- opts = LLMOptions()
763
- refs_sent = False
764
- # 자체 UI는 우측 패널에 참고자료를 별도 표시하므로 답변 본문에는 부착 X
765
- async for evt in generate(q.strip(), options=opts, inline_references=False):
766
- if evt["event"] == "stage":
767
- payload = {"stage": evt["stage"], **(evt.get("payload") or {})}
768
- yield f"event: stage\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
769
- elif evt["event"] == "retrieval":
770
- res = evt["result"]
771
- payload = {
772
- "intents": [i.name for i in res.plan.intents],
773
- "jo_targets": list(res.plan.jo_targets),
774
- "elapsed_ms": res.elapsed_ms,
775
- "excerpts": [_excerpt_to_dict(e) for e in res.excerpts],
776
- }
777
- yield f"event: references\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
778
- refs_sent = True
779
- elif evt["event"] == "token":
780
- yield f"event: token\ndata: {json.dumps({'delta': evt['delta']}, ensure_ascii=False)}\n\n"
781
- elif evt["event"] == "done":
782
- yield f"event: done\ndata: {json.dumps({'answer': evt['answer']}, ensure_ascii=False)}\n\n"
783
- if not refs_sent:
784
- yield "event: references\ndata: {\"excerpts\": []}\n\n"
785
-
786
- return StreamingResponse(
787
- gen(),
788
- media_type="text/event-stream",
789
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
790
- )
791
-
792
  return app
793
 
794
 
@@ -1056,261 +920,3 @@ console.log("[kpaa-parent] message listener attached");
1056
  </script>
1057
  </body></html>"""
1058
 
1059
-
1060
- _CHAT_HTML = """<!doctype html>
1061
- <html lang="ko"><head><meta charset="utf-8">
1062
- <title>KPAA — 개인정보보호법 상담</title>
1063
- <meta name="viewport" content="width=device-width, initial-scale=1">
1064
- <style>
1065
- *, *::before, *::after { box-sizing: border-box; }
1066
- html, body { margin: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Segoe UI", sans-serif; color: #222; background: #fafafa; }
1067
- .app { display: flex; height: 100vh; }
1068
- .pane { display: flex; flex-direction: column; min-width: 0; }
1069
- .left { flex: 1 1 50%; border-right: 1px solid #e5e5e5; background: #fff; }
1070
- .right { flex: 1 1 50%; background: #f6f7f9; overflow-y: auto; }
1071
- header { padding: 12px 18px; border-bottom: 1px solid #e5e5e5; background: #fff; display: flex; align-items: center; gap: 12px; }
1072
- header h1 { margin: 0; font-size: 1.0rem; font-weight: 600; }
1073
- header .muted { color: #888; font-size: 0.85em; }
1074
- .messages { flex: 1; overflow-y: auto; padding: 18px; }
1075
- .msg { margin: 0 0 16px 0; }
1076
- .msg .role { font-size: 0.78em; color: #888; margin-bottom: 4px; }
1077
- .msg.user .role { color: #0a66c2; }
1078
- .msg.bot .role { color: #15833a; }
1079
- .msg .body { white-space: pre-wrap; word-break: break-word; line-height: 1.65; font-size: 0.96rem; }
1080
- .composer { display: flex; padding: 12px 18px; border-top: 1px solid #e5e5e5; gap: 8px; background: #fff; }
1081
- .composer textarea { flex: 1; resize: none; padding: 10px 12px; border-radius: 8px; border: 1px solid #d0d0d0; font: inherit; min-height: 44px; max-height: 160px; }
1082
- .composer button { padding: 0 16px; border-radius: 8px; border: 0; background: #0a66c2; color: #fff; font-weight: 600; cursor: pointer; }
1083
- .composer button:disabled { background: #999; cursor: not-allowed; }
1084
- .right header { background: #f6f7f9; }
1085
- .refs-list { padding: 12px 18px; }
1086
- .refs-empty { color: #888; padding: 24px; text-align: center; }
1087
- .ref { background: #fff; border: 1px solid #e5e5e5; border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; }
1088
- .ref .head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
1089
- .ref .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 0.72em; font-weight: 600; color: #fff; }
1090
- .badge.case { background: #0a66c2; }
1091
- .badge.law { background: #15833a; }
1092
- .badge.related_law { background: #0d8e8a; }
1093
- .badge.guide { background: #107869; }
1094
- .badge.pipc { background: #ad7100; }
1095
- .badge.interpretation { background: #6633bb; }
1096
- .badge.precedent { background: #b03060; }
1097
- .badge.admin_rule { background: #555; }
1098
- .ref .citation { font-weight: 600; font-size: 0.92em; }
1099
- .ref .title { color: #444; font-size: 0.9em; margin-bottom: 6px; }
1100
- .ref .content { font-size: 0.85em; line-height: 1.55; color: #333; background: #fafafa; padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; max-height: 240px; overflow-y: auto; border: 1px solid #eee; }
1101
- .ref .footer { margin-top: 8px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
1102
- .ref a.orig { color: #0a66c2; font-size: 0.85em; text-decoration: none; }
1103
- .ref a.orig:hover { text-decoration: underline; }
1104
- .ref .nolink { color: #888; font-size: 0.78em; font-style: italic; }
1105
- .meta-line { font-size: 0.78em; color: #888; padding: 8px 18px; border-bottom: 1px solid #eee; background: #f0f1f4; }
1106
- .disclaimer { font-size: 0.8em; color: #666; padding: 8px 12px; background: #fff8e1; border-radius: 8px; margin-top: 8px; }
1107
- .typing { display: inline-block; width: 6px; height: 14px; background: #15833a; vertical-align: -3px; animation: blink 1s infinite; }
1108
- @keyframes blink { 50% { opacity: 0; } }
1109
- @media (max-width: 800px) {
1110
- .app { flex-direction: column; }
1111
- .left, .right { flex: 1 1 50%; }
1112
- .left { border-right: 0; border-bottom: 1px solid #e5e5e5; }
1113
- }
1114
- </style></head>
1115
- <body>
1116
- <div class="app">
1117
- <section class="pane left">
1118
- <header>
1119
- <h1>KPAA — 개인정보보호법 상담</h1>
1120
- <select id="model-select" title="답변 LLM 모델 — 변경 시 다음 질문부터 적용"
1121
- style="margin-left:auto; padding:4px 8px; border-radius:6px; border:1px solid #d0d0d0; background:#fff; font-size:0.82em; max-width: 240px;">
1122
- </select>
1123
- </header>
1124
- <div id="model-status" class="meta-line" style="display:none;"></div>
1125
- <div class="messages" id="messages">
1126
- <div class="msg bot">
1127
- <div class="role">상담 도우미</div>
1128
- <div class="body">안녕하세요. 개인 · 소상공인 · 작은 병원의 개인정보보호법 궁금증을 평이한 한국어로 안내해 드립니다. 무엇이 궁금하신가요?</div>
1129
- </div>
1130
- </div>
1131
- <form class="composer" id="form">
1132
- <textarea id="input" placeholder="예: 매장 CCTV 안내문구는 어떻게 작성하나요? (Enter=전송, Shift+Enter=줄바꿈)" rows="2" required></textarea>
1133
- <button type="submit" id="send">보내기</button>
1134
- </form>
1135
- </section>
1136
- <section class="pane right">
1137
- <header>
1138
- <h1>참고한 자료</h1>
1139
- <span class="muted" id="refs-count"></span>
1140
- </header>
1141
- <div class="meta-line" id="meta">질문을 보내면 LLM이 본 근거가 여기에 표시됩니다.</div>
1142
- <div class="refs-list" id="refs">
1143
- <div class="refs-empty">아직 답변이 없습니다.</div>
1144
- </div>
1145
- </section>
1146
- </div>
1147
- <script>
1148
- const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙", constitutional: "헌법재판소", oldnew: "구·신 비교", article_history: "조문 변천" };
1149
- const messagesEl = document.getElementById("messages");
1150
- const refsEl = document.getElementById("refs");
1151
- const refsCountEl = document.getElementById("refs-count");
1152
- const metaEl = document.getElementById("meta");
1153
- const form = document.getElementById("form");
1154
- const input = document.getElementById("input");
1155
- const send = document.getElementById("send");
1156
-
1157
- function escapeHtml(s) {
1158
- return (s || "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","'":"&#39;"}[c]));
1159
- }
1160
-
1161
- function appendMsg(role, html) {
1162
- const div = document.createElement("div");
1163
- div.className = "msg " + (role === "user" ? "user" : "bot");
1164
- const r = role === "user" ? "사용자" : "상담 도우미";
1165
- div.innerHTML = `<div class="role">${r}</div><div class="body"></div>`;
1166
- div.querySelector(".body").innerHTML = html;
1167
- messagesEl.appendChild(div);
1168
- messagesEl.scrollTop = messagesEl.scrollHeight;
1169
- return div.querySelector(".body");
1170
- }
1171
-
1172
- function renderRefs(payload) {
1173
- const excerpts = payload.excerpts || [];
1174
- refsCountEl.textContent = excerpts.length ? `${excerpts.length}건` : "";
1175
- if (payload.intents !== undefined) {
1176
- const intents = (payload.intents.length ? payload.intents.join(", ") : "(매칭 없음)");
1177
- const jo = (payload.jo_targets.length ? payload.jo_targets.join(", ") : "-");
1178
- metaEl.textContent = `의도: ${intents} · 조문 후보: ${jo} · 검색 ${payload.elapsed_ms}ms`;
1179
- }
1180
- refsEl.innerHTML = "";
1181
- if (!excerpts.length) {
1182
- refsEl.innerHTML = '<div class="refs-empty">근거가 검색되지 않았습니다.</div>';
1183
- return;
1184
- }
1185
- for (const e of excerpts) {
1186
- const card = document.createElement("div");
1187
- card.className = "ref";
1188
- const label = LABEL[e.source_type] || e.source_type;
1189
- const link = e.url
1190
- ? `<a class="orig" href="${escapeHtml(e.url)}" target="_blank" rel="noopener noreferrer">원문 페이지 열기 ↗</a>`
1191
- : `<span class="nolink">원문 페이지 미제공 — 아래 본문을 LLM이 직접 참조</span>`;
1192
- card.innerHTML = `
1193
- <div class="head">
1194
- <span class="badge ${e.source_type}">${label}</span>
1195
- <span class="citation">${escapeHtml(e.citation)}</span>
1196
- </div>
1197
- <div class="title">${escapeHtml(e.title || "")}</div>
1198
- <div class="content">${escapeHtml(e.content || "")}</div>
1199
- <div class="footer">${link}</div>`;
1200
- refsEl.appendChild(card);
1201
- }
1202
- }
1203
-
1204
- let activeStream = null;
1205
-
1206
- form.addEventListener("submit", (ev) => {
1207
- ev.preventDefault();
1208
- const q = input.value.trim();
1209
- if (!q || activeStream) return;
1210
-
1211
- appendMsg("user", escapeHtml(q));
1212
- const botBody = appendMsg("bot", '<span class="typing"></span>');
1213
- refsEl.innerHTML = '<div class="refs-empty">법령 검색 중…</div>';
1214
- refsCountEl.textContent = "";
1215
- metaEl.textContent = "검색 중…";
1216
-
1217
- send.disabled = true;
1218
- input.value = "";
1219
-
1220
- const es = new EventSource("/api/chat?q=" + encodeURIComponent(q));
1221
- activeStream = es;
1222
- let acc = "";
1223
-
1224
- es.addEventListener("references", (e) => {
1225
- try { renderRefs(JSON.parse(e.data)); } catch (_) {}
1226
- });
1227
-
1228
- es.addEventListener("token", (e) => {
1229
- try {
1230
- const { delta } = JSON.parse(e.data);
1231
- acc += delta;
1232
- botBody.textContent = acc;
1233
- messagesEl.scrollTop = messagesEl.scrollHeight;
1234
- } catch (_) {}
1235
- });
1236
-
1237
- es.addEventListener("done", (e) => {
1238
- try {
1239
- const { answer } = JSON.parse(e.data);
1240
- botBody.textContent = answer;
1241
- } catch (_) {}
1242
- es.close();
1243
- activeStream = null;
1244
- send.disabled = false;
1245
- input.focus();
1246
- });
1247
-
1248
- es.onerror = () => {
1249
- if (!acc) botBody.textContent = "(응답 오류)";
1250
- es.close();
1251
- activeStream = null;
1252
- send.disabled = false;
1253
- };
1254
- });
1255
-
1256
- // ─ 모델 선택 dropdown (자체 chat UI) ─
1257
- const modelSelect = document.getElementById("model-select");
1258
- const modelStatus = document.getElementById("model-status");
1259
- function showModelStatus(msg, ok) {
1260
- modelStatus.textContent = msg;
1261
- modelStatus.style.display = "block";
1262
- modelStatus.style.color = ok ? "#15833a" : "#c0392b";
1263
- setTimeout(() => { modelStatus.style.display = "none"; }, 4000);
1264
- }
1265
- async function loadModels() {
1266
- try {
1267
- const r = await fetch("/api/models", { cache: "no-store" });
1268
- if (!r.ok) return;
1269
- const data = await r.json();
1270
- modelSelect.innerHTML = "";
1271
- for (const p of data.presets || []) {
1272
- const opt = document.createElement("option");
1273
- opt.value = p.id;
1274
- opt.textContent = p.label;
1275
- opt.title = p.short;
1276
- if (p.id === data.current) opt.selected = true;
1277
- modelSelect.appendChild(opt);
1278
- }
1279
- } catch (_) {}
1280
- }
1281
- modelSelect.addEventListener("change", async () => {
1282
- const preset_id = modelSelect.value;
1283
- modelSelect.disabled = true;
1284
- try {
1285
- const r = await fetch("/api/select-model", {
1286
- method: "POST",
1287
- headers: { "Content-Type": "application/json" },
1288
- body: JSON.stringify({ preset_id }),
1289
- });
1290
- if (!r.ok) {
1291
- const txt = await r.text();
1292
- showModelStatus(`모델 변경 실패: ${txt}`, false);
1293
- return;
1294
- }
1295
- const data = await r.json();
1296
- showModelStatus(`✅ 모델 변경됨 — ${data.label} (다음 질문부터 적용 · 첫 사용 시 다운로드)`, true);
1297
- } catch (e) {
1298
- showModelStatus(`네트워크 오류: ${e}`, false);
1299
- } finally {
1300
- modelSelect.disabled = false;
1301
- }
1302
- });
1303
- loadModels();
1304
-
1305
- input.addEventListener("keydown", (e) => {
1306
- // 일반 채팅 UX: Enter = 전송, Shift+Enter = 줄바꿈.
1307
- // 한국어 IME 조합 중 Enter(글자 확정)는 무시.
1308
- if (e.key !== "Enter") return;
1309
- if (e.shiftKey) return; // 줄바꿈
1310
- if (e.isComposing || e.keyCode === 229) return; // IME 글자 확정
1311
- e.preventDefault();
1312
- form.requestSubmit();
1313
- });
1314
- input.focus();
1315
- </script>
1316
- </body></html>"""
 
24
  from collections.abc import AsyncIterator
25
  from typing import Any, Literal
26
 
27
+ from fastapi import FastAPI, HTTPException
28
  from fastapi.responses import HTMLResponse, StreamingResponse
29
  from pydantic import BaseModel, ConfigDict, Field
30
 
 
59
  return m.group("id") if m else None
60
 
61
 
62
+ # 기본 모델 ID — `/healthz` 응답 테스트 호환용. 항상 default_preset() 동기.
 
63
  MODEL_ID = model_id_for(default_preset())
64
 
65
 
 
110
  }
111
 
112
 
113
+ from kpaa.retrieval.citation_match import ( # noqa: E402
114
  extract_geungeo_indices as _extract_geungeo_indices,
115
  )
116
 
 
178
  content: str
179
 
180
 
 
 
 
 
 
 
181
  class ChatRequest(BaseModel):
182
  model_config = ConfigDict(extra="ignore") # 모르는 필드는 무시 (Open WebUI가 보내는 필드 다양)
183
 
 
230
 
231
  # ───────────────────────── helpers ─────────────────────────
232
 
 
 
 
 
 
 
 
233
  def _split_history_and_query(messages: list[ChatMessage]) -> tuple[list[LLMChatMessage], str]:
234
  """ChatRequest.messages → (history, last_user_query).
235
 
 
541
 
542
  @app.get("/", response_class=HTMLResponse)
543
  async def index() -> str:
544
+ # 루트 = Open WebUI + 참고자료 분할 화면.
545
  # 페이지 진입(리로드 포함) 시 우측 참고자료 서버 상태를 비움 — 이전 세션
546
  # 잔여 _last_refs 가 폴링에 의해 즉시 렌더되는 것을 방지. HF 백엔드의
547
  # _split_handler 와 동일 정책.
 
557
  })
558
  return _SPLIT_HTML
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  @app.get("/healthz")
561
  async def healthz() -> dict[str, str]:
562
  return {"status": "ok", "version": __version__, "model": MODEL_ID}
563
 
564
  @app.get("/v1/models")
565
  async def list_models() -> ModelList:
566
+ # 프리셋별 1개씩. 사용자가 Open WebUI dropdown 에 고르면 그 이름이
567
+ # ChatRequest.model 로 들어오고 `_switch_to_requested_model` 가 매니저를 환.
 
568
  now = int(time.time())
569
  return ModelList(
570
  data=[ModelInfo(id=model_id_for(p), created=now) for p in list_presets()]
 
632
  choices=[ChatChoice(message=ChatChoiceMessage(content=text))],
633
  )
634
 
635
+ # ─── 분할 화면 (`/`) JS 폴링이 사용하는 API ─────────────────────────
 
 
 
 
636
 
637
  @app.get("/api/last-references")
638
  async def api_last_refs() -> dict[str, Any]:
639
  return dict(_last_refs)
640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  @app.post("/api/clear-references")
642
  async def api_clear_refs() -> dict[str, str]:
643
  """우측 참고자료 패널 초기화 — Open WebUI 새 채팅 등에서 사용."""
 
653
  })
654
  return {"status": "cleared"}
655
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  return app
657
 
658
 
 
920
  </script>
921
  </body></html>"""
922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/kpaa/ui/__init__.py CHANGED
@@ -1,5 +1,4 @@
1
  """KPAA UI 패키지.
2
 
3
  - `gradio` 모듈 — HF Spaces 데모 (Gradio Blocks).
4
- - 로컬 노트북용 자체 채팅 UI 는 `kpaa.server` 의 `/chat` 엔드포인트.
5
  """
 
1
  """KPAA UI 패키지.
2
 
3
  - `gradio` 모듈 — HF Spaces 데모 (Gradio Blocks).
 
4
  """
tasks.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-platform task runner. Run via `invoke <task>` after `pip install invoke`.
2
+
3
+ Replaces the role of a Makefile so Windows/Mac/Linux users get the same UX.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from invoke import task
8
+
9
+
10
+ @task
11
+ def install(c):
12
+ """Editable install with dev + llm extras."""
13
+ c.run('pip install -e ".[dev,llm]"')
14
+
15
+
16
+ @task
17
+ def lint(c):
18
+ c.run("ruff check src/ tests/")
19
+
20
+
21
+ @task
22
+ def fmt(c):
23
+ c.run("ruff format src/ tests/")
24
+
25
+
26
+ @task
27
+ def test(c):
28
+ c.run("pytest tests/ -v")
29
+
30
+
31
+ @task(help={"q": "Question to send through the pipeline"})
32
+ def smoke(c, q="매장 CCTV 안내문구 어떻게 써요?"):
33
+ """Run a single end-to-end smoke through the RAG pipeline."""
34
+ c.run(f'python -m kpaa smoke "{q}"')
35
+
36
+
37
+ @task
38
+ def evalq(c):
39
+ """Run golden-question evaluation."""
40
+ c.run("python -m kpaa eval")
41
+
42
+
43
+ @task
44
+ def serve(c):
45
+ """Start the FastAPI backend on :8000."""
46
+ c.run("python -m kpaa serve")
47
+
48
+
49
+ @task
50
+ def up(c):
51
+ c.run("docker compose up -d")
52
+
53
+
54
+ @task
55
+ def down(c):
56
+ c.run("docker compose down")
57
+
58
+
59
+ @task(help={"since": "YYYY-MM-DD; only fetch cases registered after this date"})
60
+ def refresh_cases(c, since=""):
61
+ cmd = "python -m kpaa refresh-cases"
62
+ if since:
63
+ cmd += f" --since {since}"
64
+ c.run(cmd)