Cleanup: dead code, route deletion (/info, /chat, /api/*), comment polish, auth mode docs, URL rename
Browse files- .dockerignore +26 -0
- .gitattributes +9 -38
- .gitignore +30 -0
- Dockerfile +50 -0
- Dockerfile.ui +41 -0
- README.md +242 -32
- README_backend.md +56 -0
- README_ui.md +42 -0
- app.py +47 -140
- app_backend.py +183 -0
- data/hf_dataset/README.md +271 -0
- docker-compose.yml +61 -0
- manage.sh +326 -0
- pyproject.toml +13 -0
- requirements.txt +0 -6
- src/kpaa/cli.py +3 -1
- src/kpaa/config.py +0 -3
- src/kpaa/embeddings/embedder.py +6 -6
- src/kpaa/embeddings/index.py +2 -2
- src/kpaa/guides/extractor.py +1 -18
- src/kpaa/llm/llama_cpp_backend.py +6 -6
- src/kpaa/llm/manager.py +0 -1
- src/kpaa/llm/presets.py +5 -32
- src/kpaa/llm/zerogpu_backend.py +3 -3
- src/kpaa/pipeline.py +2 -2
- src/kpaa/related_laws.py +1 -1
- src/kpaa/retrieval/chains.py +2 -2
- src/kpaa/retrieval/reranker.py +4 -3
- src/kpaa/retrieval/retriever.py +1 -3
- src/kpaa/retrieval/verify.py +0 -1
- src/kpaa/server.py +7 -401
- src/kpaa/ui/__init__.py +0 -1
- tasks.py +64 -0
.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 |
-
*
|
| 2 |
-
|
| 3 |
-
*.
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.
|
| 8 |
-
*.
|
| 9 |
-
*.
|
| 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: "5.20.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
short_description:
|
| 11 |
hardware: zero-a10g
|
| 12 |
license: mit
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
한국 개인정보보호법
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
```
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
```
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
|
| 36 |
|---|---|---|
|
| 37 |
-
|
|
| 38 |
-
|
|
| 39 |
-
|
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
```bash
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
```
|
| 50 |
|
| 51 |
-
##
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
- mount_gradio_app + manual uvicorn does NOT activate ZeroGPU.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
on each request.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
"""
|
|
|
|
|
|
|
| 15 |
import os
|
| 16 |
import sys
|
| 17 |
-
import time
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
# HF Spaces: src/ on sys.path
|
| 24 |
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
| 25 |
|
| 26 |
|
| 27 |
-
# ─── monkey-patch:
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
#
|
| 56 |
-
#
|
| 57 |
-
|
| 58 |
-
#
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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 |
-
|
| 163 |
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
server_name="0.0.0.0",
|
| 170 |
-
server_port=
|
|
|
|
| 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 |
-
|
| 182 |
-
|
| 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())
|
|
|
|
| 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
|
| 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[
|
| 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) ->
|
| 50 |
if cls._instance is None:
|
| 51 |
cls._instance = cls()
|
| 52 |
return cls._instance
|
| 53 |
|
| 54 |
@cached_property
|
| 55 |
-
def model(self) ->
|
| 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) ->
|
| 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) ->
|
| 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
|
| 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(
|
| 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", "
|
|
|
|
| 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 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
)
|
| 169 |
if n_gpu_layers == 0 and plat in ("linux", "win32"):
|
| 170 |
msg += (
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 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 |
-
"""모델 프리셋 카탈로그
|
| 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 |
-
#
|
| 29 |
-
#
|
| 30 |
-
# 에서
|
| 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-
|
| 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(
|
| 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(
|
| 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(
|
| 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:
|
| 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에서 받아서 클라이언트가 표시
|
| 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:
|
| 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:
|
| 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
|
| 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[
|
| 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) ->
|
| 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
|
| 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`
|
| 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 + 참고자료 분할 화면.
|
| 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> ·
|
| 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개씩
|
| 631 |
-
#
|
| 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 |
-
# ───
|
| 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 => ({"&":"&","<":"<",">":">","\\"":""","'":"'"}[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)
|