commit a16c972dbb939ed81a10aaa3a899bfb07b166366
Author: airkjw
Date: Thu Mar 26 12:49:43 2026 +0900
Initial commit: import from sinmb79/Gov-chat-bot
Co-Authored-By: Claude Sonnet 4.6
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4c74a16
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,44 @@
+# ================================================================
+# SmartBot KR 환경 설정
+# 이 파일을 .env로 복사한 후 값을 수정하세요: cp .env.example .env
+# ================================================================
+
+# !! 보안 경고: SECRET_KEY를 반드시 변경하세요 !!
+# python -c "import secrets; print(secrets.token_hex(32))" 로 생성
+SECRET_KEY=change-this-in-production-32chars-or-longer
+
+# JWT 토큰 만료 시간 (시간 단위)
+JWT_EXPIRE_HOURS=24
+
+# ── 데이터베이스
+DATABASE_URL=postgresql+asyncpg://botuser:botpass@db:5432/smartbot
+REDIS_URL=redis://redis:6379
+
+# ── 벡터DB
+VECTOR_DB=chromadb
+CHROMA_HOST=chromadb
+CHROMA_PORT=8000
+
+# ── LLM Provider 설정
+# 기본값: none (LLM 미사용 — Tier A/B만 동작, 외부 API 비용 없음)
+LLM_PROVIDER=none
+
+# LLM 활성화 예시 (주석 해제하여 사용):
+# LLM_PROVIDER=anthropic
+# ANTHROPIC_API_KEY=sk-ant-...
+#
+# LLM_PROVIDER=openai
+# OPENAI_API_KEY=sk-...
+
+# ── 임베딩 설정 (한국어 특화 모델 — 첫 실행 시 자동 다운로드)
+EMBEDDING_PROVIDER=local
+EMBEDDING_MODEL=jhgan/ko-sroberta-multitask
+
+# ── 대화 이력 보관 기간 (일)
+CHAT_LOG_RETENTION_DAYS=30
+
+# ── Idempotency TTL (초) — 중복 요청 방지
+IDEMPOTENCY_TTL_SECONDS=60
+
+# ── CORS 허용 오리진 (쉼표 구분)
+ALLOWED_ORIGINS=["http://localhost","http://localhost:80","http://localhost:3000"]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6b8fac8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,99 @@
+# ============================================================
+# GovBot KR — .gitignore
+# ============================================================
+
+# 환경 설정 (민감정보 포함 — 절대 커밋 금지)
+.env
+.env.local
+.env.*.local
+*.env
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+*.egg
+*.egg-info/
+dist/
+build/
+.eggs/
+.venv/
+venv/
+env/
+pip-wheel-metadata/
+*.pyo
+
+# pytest / coverage
+.pytest_cache/
+.coverage
+.coverage.*
+coverage.xml
+htmlcov/
+*.lcov
+nosetests.xml
+test-results/
+
+# mypy / type checkers
+.mypy_cache/
+.dmypy.json
+.pyre/
+
+# Jupyter
+.ipynb_checkpoints/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# Node.js / Frontend
+node_modules/
+frontend/dist/
+frontend/.vite/
+frontend/build/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# Docker
+*.override.yml
+
+# 데이터베이스 / 벡터DB
+*.db
+*.sqlite
+*.sqlite3
+chroma_data/
+postgres_data/
+redis_data/
+
+# 로그
+*.log
+logs/
+
+# 업로드된 파일 (운영 환경)
+uploads/
+media/
+
+# Alembic 자동생성 제외 대상
+# (versions/ 는 포함)
+alembic/versions/*.pyc
+
+# 모델 캐시 (허깅페이스 등 — 용량 큼)
+.cache/
+models/
+*.bin
+*.safetensors
+sentence_transformers/
+
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..32174b1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,21 @@
+# Gov-chat-bot
+
+이 파일은 Claude Code가 어느 경로에서 실행되든 자동으로 로드합니다.
+
+
+
+## 저장소
+- Git 서버: Gitea (자체 NAS 운영)
+- Gitea URL: http://nas.gru.farm:3001
+- 계정: airkjw
+- 저장소: convai
+- Remote: http://nas.gru.farm:3001/airkjw/Gov-chat-bot
+- 토큰: de09579b48152cea8d68b896516a43f5dede578c
+
+## NAS ssh 공개키
+- 아이디: airkjw
+- 공개키: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICkbFPXF3CHi91UsWIrIsjG8srqceVm1wKrL3K1doM1V
+- 주소: nas.gru.farm:22
+- 내부 IP: 192.168.0.17
+- Docker 명령: sudo /usr/local/bin/docker (NOPASSWD)
+- Docker Compose: sudo /usr/local/bin/docker compose
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8492c44
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 22B Labs
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6dc636d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,245 @@
+# 🤖 SmartBot KR — 누구나 쓰는 AI 챗봇 플랫폼
+
+> **코딩 몰라도 됩니다.** 설치부터 운영까지 한 번에.
+
+카카오톡 채팅, 홈페이지 위젯으로 고객 질문에 AI가 자동으로 답변합니다.
+지자체·음식점·쇼핑몰·병원·학원 등 **어떤 조직이든 바로 사용 가능**합니다.
+
+---
+
+## 이런 분께 딱 맞습니다
+
+| 사용처 | 활용 예시 |
+|--------|-----------|
+| 🏛️ **지자체·공공기관** | 민원 안내, 서류 발급 방법, 담당부서 연결 |
+| 🍽️ **음식점·카페** | 메뉴 문의, 영업시간, 예약, 주차 안내 |
+| 🛍️ **쇼핑몰·온라인몰** | 배송 조회, 교환·환불 정책, 상품 문의 |
+| 🏥 **병원·의원·약국** | 진료시간, 예약 방법, 보험 안내 |
+| 🎓 **학원·교육기관** | 수업 일정, 수강료, 입학 상담 |
+| 🏢 **일반 기업** | 고객 CS 자동화, 사내 FAQ 봇 |
+
+---
+
+## ✨ 주요 기능
+
+| 기능 | 설명 |
+|------|------|
+| 💬 **카카오톡 연동** | 카카오 i 오픈빌더 스킬 서버로 바로 연결 |
+| 🌐 **홈페이지 위젯** | 코드 한 줄로 어느 홈페이지에나 삽입 |
+| ❓ **FAQ 자동 답변** | 등록한 FAQ를 AI가 유사도로 매칭해 즉시 답변 |
+| 📄 **문서 학습** | PDF·Word 파일 업로드 → AI가 자동으로 학습 |
+| 🤖 **LLM 연동** | Claude·GPT 연결 시 더 자연스러운 답변 (선택) |
+| 📊 **관리 대시보드** | 통계·FAQ·문서·대화이력 한눈에 관리 |
+| 🔒 **개인정보 보호** | 고객 발화 자동 마스킹, 원문 미저장 |
+| 🏢 **멀티 조직 지원** | 하나의 서버로 여러 매장/지점 각각 운영 |
+
+---
+
+## 🚀 5분 설치
+
+### Linux / macOS
+
+```bash
+git clone https://github.com/sinmb79/Gov-chat-bot.git
+cd Gov-chat-bot
+chmod +x install.sh
+./install.sh
+```
+
+> **Windows 사용자** → [Windows 설치 가이드](docs/WSL2_가이드.md)
+
+### 설치 후 접속
+
+```
+관리자 화면: http://localhost:3000
+API 문서: http://localhost:8000/docs
+```
+
+---
+
+## 📋 시스템 요구사항
+
+| 항목 | 최소 |
+|------|------|
+| OS | Ubuntu 20.04+ / macOS 13+ / Windows 11 (WSL2) |
+| RAM | 4GB |
+| 디스크 | 20GB |
+| Docker | 24.x 이상 |
+| 인터넷 | 설치 시 필요 (이후 오프라인 운영 가능) |
+
+---
+
+## 📖 사용 방법
+
+### 1단계 — 관리자 계정 만들기
+
+```bash
+docker compose exec backend python -m app.scripts.create_admin
+```
+
+조직 ID(영문), 이메일, 비밀번호를 입력하면 완료.
+
+### 2단계 — FAQ 등록하기
+
+1. http://localhost:3000 에서 로그인
+2. **FAQ 관리** → **+ FAQ 추가**
+3. 카테고리, 질문, 답변 입력 → 저장
+
+> **팁**: 같은 뜻이지만 표현이 다른 질문을 여러 개 등록할수록 인식률이 높아집니다.
+> - "영업시간이요?" / "몇시에 열어요?" / "오늘 언제까지 해요?" → 같은 답변으로 등록
+
+### 3단계 — 문서 업로드하기 (선택)
+
+자주 묻는 내용이 담긴 PDF, Word 파일을 올리면 AI가 자동으로 학습합니다.
+
+1. **문서 관리** → **+ 문서 업로드**
+2. 파일 선택 (PDF · Word · 텍스트)
+3. 처리 완료 후 **승인** 클릭
+
+### 4단계 — 테스트하기
+
+**시뮬레이터** 메뉴에서 실제 질문을 입력해 답변을 미리 확인할 수 있습니다.
+
+### 5단계 — 홈페이지에 위젯 달기
+
+홈페이지 HTML에 아래 코드를 붙여넣기만 하면 됩니다.
+
+```html
+
+```
+
+---
+
+## ⚙️ 환경 설정
+
+```bash
+# 설정 파일 복사
+cp .env.example .env
+
+# 텍스트 에디터로 열어서 수정
+nano .env # 또는 메모장으로 열기
+```
+
+**반드시 바꿔야 할 항목:**
+
+| 항목 | 설명 |
+|------|------|
+| `SECRET_KEY` | 보안 키 (아무 긴 문자열, 32자 이상) |
+
+**선택 항목 (LLM 연결):**
+
+| 항목 | 설명 |
+|------|------|
+| `LLM_PROVIDER=anthropic` | Claude AI 사용 |
+| `ANTHROPIC_API_KEY=...` | Claude API 키 |
+| `LLM_PROVIDER=openai` | ChatGPT 사용 |
+| `OPENAI_API_KEY=...` | OpenAI API 키 |
+
+> LLM 없이도 FAQ + 문서 기반 답변이 충분히 잘 동작합니다. 처음에는 LLM 없이 시작하세요.
+
+---
+
+## 🏗️ 동작 원리
+
+고객이 질문하면 아래 순서로 가장 적합한 답변을 찾습니다.
+
+```
+고객 질문
+ │
+ ▼
+① FAQ 검색 (등록된 FAQ와 유사도 비교)
+ │ 비슷한 FAQ 있음 → 즉시 답변
+ │ 없음 ↓
+ ▼
+② 문서 검색 (업로드한 파일에서 관련 내용 추출)
+ │ 관련 내용 있음 + LLM 연결됨 → AI가 자연스럽게 재서술
+ │ 관련 내용 있음 + LLM 없음 → 문서 내용 그대로 안내
+ │ 없음 ↓
+ ▼
+③ 담당자 안내 (설정한 연락처로 안내)
+```
+
+---
+
+## 📁 파일 구조
+
+```
+Gov-chat-bot/
+├── backend/ # AI 서버 (자동 관리, 직접 수정 불필요)
+├── frontend/ # 관리자 화면 (자동 관리)
+│ └── widget/ # 홈페이지 위젯 파일
+├── docs/
+│ ├── 운영가이드.md # 상세 운영 설명서
+│ └── WSL2_가이드.md # Windows 설치 방법
+├── docker-compose.yml # 서비스 실행 설정
+├── install.sh # 자동 설치 스크립트
+└── .env.example # 환경 설정 예시
+```
+
+---
+
+## 🔧 자주 묻는 질문 (설치·운영)
+
+**Q. 클라우드 서버가 있어야 하나요?**
+A. 아니요. 사무실 컴퓨터(윈도우·맥·리눅스)에서 바로 실행됩니다. 외부에서 접속하려면 공인 IP나 클라우드 서버가 필요합니다.
+
+**Q. 카카오톡 계정이 있어야 하나요?**
+A. 카카오톡 연동은 선택 사항입니다. 홈페이지 위젯만으로도 운영할 수 있습니다.
+
+**Q. LLM(Claude·ChatGPT) API 비용이 걱정돼요.**
+A. LLM 연결 없이도 FAQ + 문서 기반 답변이 잘 작동합니다. LLM은 나중에 필요할 때 추가하면 됩니다.
+
+**Q. 고객 대화 내용이 외부로 나가나요?**
+A. LLM을 사용하지 않으면 외부 API 호출이 전혀 없습니다. 모든 데이터가 내 서버 안에만 있습니다.
+
+**Q. 여러 매장을 동시에 운영할 수 있나요?**
+A. 네. 매장마다 별도의 조직 ID를 만들면 데이터가 완전히 분리되어 관리됩니다.
+
+**Q. 문제가 생기면 어떻게 하나요?**
+A. 로그 확인: `docker compose logs backend`
+ [Issues](https://github.com/sinmb79/Gov-chat-bot/issues)에 남겨주시면 도움드립니다.
+
+---
+
+## 🔒 개인정보 보호
+
+- 고객 발화에서 **주민번호·전화번호·이메일·카드번호** 자동 감지 후 마스킹
+- 원문은 서버에 **절대 저장되지 않음**
+- 사용자 ID는 **복원 불가능한 해시값**으로만 저장
+- 관리자도 원문 열람 불가 (마스킹 상태로만 표시)
+
+---
+
+## 🧪 개발자 정보
+
+```bash
+cd backend
+pip install -r requirements.txt
+pytest tests/ -v
+# 127 tests passing
+```
+
+**기술 스택**: Python · FastAPI · PostgreSQL · Redis · ChromaDB · React · Docker
+
+---
+
+## 🤝 기여 방법
+
+버그 제보, 기능 제안, 코드 기여 모두 환영합니다!
+
+1. 저장소 Fork
+2. 브랜치 생성: `git checkout -b feature/기능명`
+3. 커밋: `git commit -m "feat: 설명"`
+4. Pull Request 생성
+
+---
+
+## 📄 라이선스
+
+[MIT License](LICENSE) — **무료**, 상업적 이용 가능, 수정·재배포 자유
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..534cbc6
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..642a96b
--- /dev/null
+++ b/backend/alembic.ini
@@ -0,0 +1,38 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..65ccc2e
--- /dev/null
+++ b/backend/alembic/env.py
@@ -0,0 +1,63 @@
+import os
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config, pool
+
+from alembic import context
+
+# 모델 임포트 (테이블 메타데이터 등록)
+from app.core.database import Base
+import app.models.tenant
+import app.models.admin
+import app.models.knowledge
+import app.models.complaint
+import app.models.moderation
+import app.models.audit
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def get_sync_url() -> str:
+ """settings의 DATABASE_URL에서 asyncpg → psycopg2 변환."""
+ from app.core.config import settings
+ url = settings.DATABASE_URL
+ # asyncpg → psycopg2 (Alembic은 동기 연결 사용)
+ return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
+
+
+def run_migrations_offline() -> None:
+ url = get_sync_url()
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ cfg = config.get_section(config.config_ini_section) or {}
+ cfg["sqlalchemy.url"] = get_sync_url()
+
+ connectable = engine_from_config(
+ cfg,
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako
new file mode 100644
index 0000000..fbc4b07
--- /dev/null
+++ b/backend/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..62aa9b9
--- /dev/null
+++ b/backend/alembic/versions/0001_initial.py
@@ -0,0 +1,165 @@
+"""Initial schema: 10 tables
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-03-26
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0001"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "tenants",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("name", sa.String(100), nullable=False),
+ sa.Column("slug", sa.String(50), unique=True, nullable=False),
+ sa.Column("is_active", sa.Boolean(), default=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "system_admins",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("email", sa.String(200), unique=True, nullable=False),
+ sa.Column("hashed_pw", sa.String(200), nullable=False),
+ sa.Column("is_active", sa.Boolean(), default=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "tenant_configs",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("key", sa.String(100), nullable=False),
+ sa.Column("value", sa.Text()),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "admin_users",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("email", sa.String(200), nullable=False),
+ sa.Column("hashed_pw", sa.String(200), nullable=False),
+ sa.Column("role", sa.String(20), nullable=False, default="viewer"),
+ sa.Column("is_active", sa.Boolean(), default=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ sa.UniqueConstraint("tenant_id", "email", name="uq_admin_users_tenant_email"),
+ )
+
+ op.create_table(
+ "faqs",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("category", sa.String(100)),
+ sa.Column("question", sa.Text(), nullable=False),
+ sa.Column("answer", sa.Text(), nullable=False),
+ sa.Column("keywords", sa.JSON()),
+ sa.Column("hit_count", sa.Integer(), default=0),
+ sa.Column("is_active", sa.Boolean(), default=True),
+ sa.Column("created_by", sa.String(36)),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "documents",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("filename", sa.String(255), nullable=False),
+ sa.Column("source_type", sa.String(50)),
+ sa.Column("source_url", sa.Text()),
+ sa.Column("published_at", sa.DateTime()),
+ sa.Column("effective_date", sa.DateTime()),
+ sa.Column("expires_at", sa.DateTime()),
+ sa.Column("is_active", sa.Boolean(), default=False),
+ sa.Column("approved_by", sa.String(36)),
+ sa.Column("approved_at", sa.DateTime()),
+ sa.Column("version", sa.Integer(), default=1),
+ sa.Column("supersedes_id", sa.String(36), sa.ForeignKey("documents.id")),
+ sa.Column("chunk_count", sa.Integer(), default=0),
+ sa.Column("status", sa.String(50), default="pending"),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "crawler_urls",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("url", sa.Text(), nullable=False),
+ sa.Column("url_type", sa.String(50)),
+ sa.Column("interval_hours", sa.Integer(), default=24),
+ sa.Column("is_active", sa.Boolean(), default=True),
+ sa.Column("last_crawled", sa.DateTime()),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "complaint_logs",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("user_key", sa.String(64)),
+ sa.Column("utterance_masked", sa.String(1000)),
+ sa.Column("utterance_vec_id", sa.String(36)),
+ sa.Column("channel", sa.String(20), default="kakao"),
+ sa.Column("request_id", sa.String(36)),
+ sa.Column("response_tier", sa.String(1)),
+ sa.Column("response_source", sa.String(20)),
+ sa.Column("faq_id", sa.String(36), sa.ForeignKey("faqs.id")),
+ sa.Column("doc_id", sa.String(36), sa.ForeignKey("documents.id")),
+ sa.Column("response_ms", sa.Integer()),
+ sa.Column("is_timeout", sa.Boolean(), default=False),
+ sa.Column("is_promoted", sa.Boolean(), default=False),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+ op.create_table(
+ "user_restrictions",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("user_key", sa.String(64), nullable=False),
+ sa.Column("level", sa.Integer(), default=0),
+ sa.Column("reason", sa.String(500)),
+ sa.Column("applied_by", sa.String(36)),
+ sa.Column("auto_applied", sa.Boolean(), default=True),
+ sa.Column("expires_at", sa.DateTime()),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
+ sa.UniqueConstraint("tenant_id", "user_key", name="uq_user_restrictions_tenant_user"),
+ )
+
+ op.create_table(
+ "audit_logs",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("tenant_id", sa.String(36), sa.ForeignKey("tenants.id"), nullable=False),
+ sa.Column("actor_id", sa.String(36), nullable=False),
+ sa.Column("actor_type", sa.String(20), nullable=False),
+ sa.Column("action", sa.String(50), nullable=False),
+ sa.Column("target_type", sa.String(50)),
+ sa.Column("target_id", sa.String(36)),
+ sa.Column("diff", sa.JSON()),
+ sa.Column("ip_address", sa.String(45)),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("audit_logs")
+ op.drop_table("user_restrictions")
+ op.drop_table("complaint_logs")
+ op.drop_table("crawler_urls")
+ op.drop_table("documents")
+ op.drop_table("faqs")
+ op.drop_table("admin_users")
+ op.drop_table("tenant_configs")
+ op.drop_table("system_admins")
+ op.drop_table("tenants")
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 0000000..5696873
--- /dev/null
+++ b/backend/app/core/config.py
@@ -0,0 +1,65 @@
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+
+class Settings(BaseSettings):
+ # 보안
+ SECRET_KEY: str = "change-this-in-production-32chars"
+ JWT_EXPIRE_HOURS: int = 24
+
+ # 데이터베이스
+ DATABASE_URL: str = "postgresql+asyncpg://botuser:botpass@db:5432/smartbot"
+ REDIS_URL: str = "redis://redis:6379"
+
+ # 벡터DB
+ VECTOR_DB: str = "chromadb"
+ CHROMA_HOST: str = "chromadb"
+ CHROMA_PORT: int = 8000
+
+ # Provider 기본값 (테넌트별 TenantConfig로 오버라이드 가능)
+ LLM_PROVIDER: str = "none"
+ EMBEDDING_PROVIDER: str = "local"
+ EMBEDDING_MODEL: str = "jhgan/ko-sroberta-multitask"
+
+ # 개인정보
+ CHAT_LOG_RETENTION_DAYS: int = 30
+
+ # Idempotency
+ IDEMPOTENCY_TTL_SECONDS: int = 60
+
+ # Admin 초기값
+ ADMIN_DEFAULT_EMAIL: str = "admin@smartbot.kr"
+ ADMIN_DEFAULT_PASSWORD: str = "changeme123!"
+
+ # CORS
+ ALLOWED_ORIGINS: list[str] = ["http://localhost"]
+
+ class Config:
+ env_file = ".env"
+ case_sensitive = True
+
+
+settings = Settings()
+
+# 설정 우선순위:
+# 1 (최고) TenantConfig DB 값 → 해당 테넌트에만 적용
+# 2 환경변수 (.env) → 서버 전체 기본값
+# 3 (최저) 코드 하드코딩 → 폴백
+
+
+async def get_tenant_config(tenant_id: str, db) -> dict:
+ """TenantConfig에서 테넌트별 설정 로드. 없으면 전역 settings 사용."""
+ from app.models.tenant import TenantConfig
+ from sqlalchemy import select
+
+ result = await db.execute(
+ select(TenantConfig).where(TenantConfig.tenant_id == tenant_id)
+ )
+ configs = result.scalars().all()
+
+ base = settings.model_dump()
+ if not configs:
+ return base
+
+ overrides = {cfg.key: cfg.value for cfg in configs}
+ return {**base, **overrides}
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
new file mode 100644
index 0000000..871ce43
--- /dev/null
+++ b/backend/app/core/database.py
@@ -0,0 +1,28 @@
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+# Phase 0B에서 settings로 교체 예정 — 현재는 하드코딩 허용
+DATABASE_URL = "sqlite+aiosqlite:///:memory:"
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+engine = create_async_engine(DATABASE_URL, echo=False)
+AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def get_db():
+ async with AsyncSessionLocal() as session:
+ yield session
+
+
+async def init_db():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+
+async def startup_hook():
+ """Phase 0B startup 훅 — DB 초기화 + 추가 작업 예정."""
+ await init_db()
diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py
new file mode 100644
index 0000000..72682b2
--- /dev/null
+++ b/backend/app/core/deps.py
@@ -0,0 +1,52 @@
+"""
+FastAPI 공통 의존성.
+JWT 인증, 역할 검증.
+"""
+from typing import Optional
+
+from fastapi import Depends, HTTPException, Header, status
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from app.core.database import get_db
+from app.core.security import decode_token
+from app.models.admin import AdminUser, AdminRole, SystemAdmin
+
+
+async def get_current_admin(
+ authorization: Optional[str] = Header(default=None),
+ db: AsyncSession = Depends(get_db),
+) -> AdminUser:
+ """JWT Bearer 토큰에서 AdminUser 추출."""
+ if not authorization or not authorization.startswith("Bearer "):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+
+ token = authorization.removeprefix("Bearer ").strip()
+ payload = decode_token(token)
+ if not payload:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+ if payload.get("type") == "system_admin":
+ # SystemAdmin은 별도 처리 — 여기선 tenant-scoped API 접근 거부
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Use system admin endpoints")
+
+ user_id = payload.get("sub")
+ result = await db.execute(select(AdminUser).where(AdminUser.id == user_id, AdminUser.is_active.is_(True)))
+ user = result.scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
+ return user
+
+
+def require_role(*roles: AdminRole):
+ """역할 검증 의존성 팩토리."""
+ async def _check(user: AdminUser = Depends(get_current_admin)) -> AdminUser:
+ if user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
+ return user
+ return _check
+
+
+# 편의 의존성
+require_editor = require_role(AdminRole.admin, AdminRole.editor)
+require_admin = require_role(AdminRole.admin)
diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py
new file mode 100644
index 0000000..24c81a5
--- /dev/null
+++ b/backend/app/core/middleware.py
@@ -0,0 +1,49 @@
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+from sqlalchemy import select
+
+EXEMPT_PATHS = {"/health", "/ready", "/engine/query", "/api/docs", "/openapi.json", "/redoc"}
+EXEMPT_PREFIXES = ("/skill/", "/api/admin/") # 채널 API + 관리자 API (JWT로 tenant 검증)
+
+
+class TenantMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ path = request.url.path
+ if path in EXEMPT_PATHS or any(path.startswith(p) for p in EXEMPT_PREFIXES):
+ request.state.tenant_id = None
+ return await call_next(request)
+
+ tenant_id = await self._resolve_tenant(request)
+ if not tenant_id:
+ return JSONResponse({"error": "tenant_required"}, status_code=400)
+
+ request.state.tenant_id = tenant_id
+ return await call_next(request)
+
+ async def _resolve_tenant(self, request: Request):
+ # 현재는 X-Tenant-Slug 헤더만 처리 (Phase 0B에서 JWT 추가)
+ slug = request.headers.get("X-Tenant-Slug")
+ if slug:
+ return slug
+ return None
+
+
+def tenanted_query(query, model, tenant_id):
+ """
+ 주의: model 파라미터가 두 번째 인자다. (v2.0 오류 수정)
+ tenant_id가 None 또는 빈 문자열이면 RuntimeError 발생.
+ 사용 예: tenanted_query(select(FAQ), FAQ, request.state.tenant_id)
+ """
+ if not tenant_id:
+ raise RuntimeError(
+ f"tenant_id is required for {model.__tablename__} queries. "
+ "Check TenantMiddleware is applied."
+ )
+ return query.where(model.tenant_id == tenant_id)
+
+
+def system_query(query):
+ """SystemAdmin 전용 쿼리. tenant 필터 없음. 일반 서비스에서 호출 금지."""
+ return query
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..7190674
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,44 @@
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+import bcrypt
+import jwt
+
+from app.core.config import settings
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
+
+
+def create_admin_token(user_id: str, tenant_id: str, role: str) -> str:
+ payload = {
+ "sub": user_id,
+ "tenant_id": tenant_id,
+ "role": role,
+ "type": "admin_user",
+ "exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS),
+ }
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
+
+
+def create_system_token(sys_admin_id: str) -> str:
+ payload = {
+ "sub": sys_admin_id,
+ "tenant_id": None,
+ "role": "system",
+ "type": "system_admin",
+ "exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS),
+ }
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
+
+
+def decode_token(token: str) -> Optional[dict]:
+ try:
+ return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
+ except Exception:
+ return None
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..edb22c6
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,70 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+from app.core.config import settings
+from app.core.database import startup_hook
+from app.core.middleware import TenantMiddleware
+from app.providers import get_embedding_provider, get_llm_provider, get_vectordb_provider
+from app.routers import health
+from app.routers import engine, skill
+from app.routers import admin_docs, admin_crawler
+from app.routers import admin_faq, admin_moderation, admin_complaints
+from app.routers import admin_auth, admin_metrics
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await startup_hook()
+
+ # 전역 설정 로드
+ cfg = settings.model_dump()
+
+ # Provider 초기화
+ embedding = get_embedding_provider(cfg)
+ llm = get_llm_provider(cfg)
+ vectordb = get_vectordb_provider(cfg)
+
+ # 임베딩 워밍업 (시작 시 모델 로드)
+ try:
+ await embedding.warmup()
+ except Exception:
+ pass # 실패해도 서버는 기동 — /ready에서 상태 노출
+
+ app.state.providers = {
+ "embedding": embedding,
+ "llm": llm,
+ "vectordb": vectordb,
+ }
+ app.state.tenant_configs = {} # 캐시: {tenant_slug: config_dict}
+ app.state.redis = None # Phase 1: Redis 연결은 선택적
+
+ # Redis 연결 시도
+ try:
+ import redis.asyncio as aioredis
+ r = aioredis.from_url(settings.REDIS_URL, socket_connect_timeout=2)
+ await r.ping()
+ app.state.redis = r
+ except Exception:
+ pass # Redis 없이도 동작 (Idempotency 비활성)
+
+ yield
+
+ # 종료 시 Redis 연결 해제
+ if app.state.redis:
+ await app.state.redis.aclose()
+
+
+app = FastAPI(title="SmartBot KR", version="1.0.0", lifespan=lifespan)
+
+app.add_middleware(TenantMiddleware)
+app.include_router(health.router)
+app.include_router(engine.router)
+app.include_router(skill.router)
+app.include_router(admin_docs.router)
+app.include_router(admin_crawler.router)
+app.include_router(admin_auth.router)
+app.include_router(admin_faq.router)
+app.include_router(admin_moderation.router)
+app.include_router(admin_complaints.router)
+app.include_router(admin_metrics.router)
diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py
new file mode 100644
index 0000000..b5bd597
--- /dev/null
+++ b/backend/app/providers/__init__.py
@@ -0,0 +1,42 @@
+from app.providers.llm import LLMProvider, NullLLMProvider
+from app.providers.embedding import EmbeddingProvider, NotImplementedEmbeddingProvider
+from app.providers.vectordb import VectorDBProvider
+
+# 워밍업 상태 전역 플래그
+_embedding_warmed_up = False
+
+
+def get_llm_provider(config: dict) -> LLMProvider:
+ provider = config.get("LLM_PROVIDER", "none")
+ if provider == "none":
+ return NullLLMProvider()
+ if provider == "anthropic":
+ from app.providers.llm_anthropic import AnthropicLLMProvider
+ return AnthropicLLMProvider(
+ api_key=config.get("ANTHROPIC_API_KEY", ""),
+ model=config.get("LLM_MODEL", "claude-haiku-4-5-20251001"),
+ )
+ if provider == "openai":
+ from app.providers.llm_anthropic import OpenAILLMProvider
+ return OpenAILLMProvider(
+ api_key=config.get("OPENAI_API_KEY", ""),
+ model=config.get("LLM_MODEL", "gpt-4o-mini"),
+ )
+ raise ValueError(f"Unknown LLM provider: {provider}")
+
+
+def get_embedding_provider(config: dict) -> EmbeddingProvider:
+ provider = config.get("EMBEDDING_PROVIDER", "none")
+ if provider == "local":
+ from app.providers.local_embedding import LocalEmbeddingProvider
+ model = config.get("EMBEDDING_MODEL", "jhgan/ko-sroberta-multitask")
+ return LocalEmbeddingProvider(model_name=model)
+ return NotImplementedEmbeddingProvider()
+
+
+def get_vectordb_provider(config: dict) -> VectorDBProvider:
+ from app.providers.chroma import ChromaVectorDBProvider
+ return ChromaVectorDBProvider(
+ host=config.get("CHROMA_HOST", "chromadb"),
+ port=int(config.get("CHROMA_PORT", 8000)),
+ )
diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py
new file mode 100644
index 0000000..31a6fe1
--- /dev/null
+++ b/backend/app/providers/base.py
@@ -0,0 +1,10 @@
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass
+class SearchResult:
+ text: str
+ doc_id: str
+ score: float
+ metadata: dict = field(default_factory=dict)
diff --git a/backend/app/providers/chroma.py b/backend/app/providers/chroma.py
new file mode 100644
index 0000000..ce5b121
--- /dev/null
+++ b/backend/app/providers/chroma.py
@@ -0,0 +1,91 @@
+from typing import Optional
+
+from app.providers.base import SearchResult
+from app.providers.vectordb import VectorDBProvider
+
+
+class ChromaVectorDBProvider(VectorDBProvider):
+ """
+ ChromaDB 기반 벡터 검색.
+ 컬렉션명 = tenant_{tenant_id} (테넌트 격리)
+ """
+
+ def __init__(self, host: str = "chromadb", port: int = 8000):
+ self.host = host
+ self.port = port
+ self._client = None
+
+ def _get_client(self):
+ if self._client is None:
+ import chromadb
+ self._client = chromadb.HttpClient(host=self.host, port=self.port)
+ return self._client
+
+ def _collection_name(self, tenant_id: str) -> str:
+ return f"tenant_{tenant_id}"
+
+ async def upsert(
+ self,
+ tenant_id: str,
+ doc_id: str,
+ chunks: list[str],
+ embeddings: list[list[float]],
+ metadatas: list[dict],
+ ) -> int:
+ client = self._get_client()
+ collection = client.get_or_create_collection(self._collection_name(tenant_id))
+ ids = [f"{doc_id}_{i}" for i in range(len(chunks))]
+ collection.upsert(ids=ids, documents=chunks, embeddings=embeddings, metadatas=metadatas)
+ return len(chunks)
+
+ async def search(
+ self,
+ tenant_id: str,
+ query_vec: list[float],
+ top_k: int = 3,
+ threshold: float = 0.70,
+ ) -> list[SearchResult]:
+ client = self._get_client()
+ collection_name = self._collection_name(tenant_id)
+ try:
+ collection = client.get_collection(collection_name)
+ except Exception:
+ return []
+
+ results = collection.query(
+ query_embeddings=[query_vec],
+ n_results=min(top_k, collection.count()),
+ include=["documents", "metadatas", "distances"],
+ )
+
+ search_results = []
+ if not results["ids"] or not results["ids"][0]:
+ return []
+
+ for i, doc_id in enumerate(results["ids"][0]):
+ # Chroma distances: 1 - cosine_similarity (낮을수록 유사)
+ distance = results["distances"][0][i]
+ score = 1.0 - distance # cosine similarity로 변환
+ if score >= threshold:
+ search_results.append(
+ SearchResult(
+ text=results["documents"][0][i],
+ doc_id=doc_id,
+ score=score,
+ metadata=results["metadatas"][0][i] or {},
+ )
+ )
+ return search_results
+
+ async def delete(self, tenant_id: str, doc_id: str) -> None:
+ client = self._get_client()
+ collection_name = self._collection_name(tenant_id)
+ try:
+ collection = client.get_collection(collection_name)
+ # doc_id로 시작하는 모든 청크 삭제
+ all_ids = collection.get()["ids"]
+ ids_to_delete = [id_ for id_ in all_ids if id_.startswith(f"{doc_id}_")]
+ if ids_to_delete:
+ collection.delete(ids=ids_to_delete)
+ except Exception:
+ pass
diff --git a/backend/app/providers/embedding.py b/backend/app/providers/embedding.py
new file mode 100644
index 0000000..cd17f6a
--- /dev/null
+++ b/backend/app/providers/embedding.py
@@ -0,0 +1,30 @@
+from abc import ABC, abstractmethod
+
+
+class EmbeddingProvider(ABC):
+ @abstractmethod
+ async def embed(self, texts: list[str]) -> list[list[float]]:
+ ...
+
+ @abstractmethod
+ async def warmup(self) -> None:
+ ...
+
+ @property
+ @abstractmethod
+ def dimension(self) -> int:
+ ...
+
+
+class NotImplementedEmbeddingProvider(EmbeddingProvider):
+ """Phase 1에서 LocalEmbeddingProvider로 교체 예정"""
+
+ async def embed(self, texts: list[str]) -> list:
+ raise NotImplementedError("Embedding provider not configured. Set EMBEDDING_PROVIDER.")
+
+ async def warmup(self) -> None:
+ pass # 예외 없이 통과
+
+ @property
+ def dimension(self) -> int:
+ return 768
diff --git a/backend/app/providers/llm.py b/backend/app/providers/llm.py
new file mode 100644
index 0000000..a08b907
--- /dev/null
+++ b/backend/app/providers/llm.py
@@ -0,0 +1,28 @@
+from abc import ABC, abstractmethod
+from typing import Optional
+
+
+class LLMProvider(ABC):
+ @abstractmethod
+ async def generate(
+ self,
+ system_prompt: str,
+ user_message: str,
+ context_chunks: list,
+ max_tokens: int = 512,
+ ) -> Optional[str]:
+ """실패 시 None 반환. 예외 raise 금지."""
+ ...
+
+
+class NullLLMProvider(LLMProvider):
+ """LLM_PROVIDER=none 기본값"""
+
+ async def generate(
+ self,
+ system_prompt: str = "",
+ user_message: str = "",
+ context_chunks: list = None,
+ max_tokens: int = 512,
+ ) -> None:
+ return None
diff --git a/backend/app/providers/llm_anthropic.py b/backend/app/providers/llm_anthropic.py
new file mode 100644
index 0000000..fec3d20
--- /dev/null
+++ b/backend/app/providers/llm_anthropic.py
@@ -0,0 +1,82 @@
+"""
+Anthropic Claude LLM Provider.
+근거(context_chunks)가 있을 때만 호출.
+할루시네이션 방지: 근거 없으면 None 반환.
+"""
+from typing import Optional
+
+from app.providers.llm import LLMProvider
+
+SYSTEM_PROMPT_TEMPLATE = """당신은 {tenant_name}AI 안내 도우미입니다.
+반드시 아래 근거 문서에 있는 내용만을 바탕으로 답변하세요.
+근거 없는 내용은 절대 추측하거나 생성하지 마세요.
+
+근거 문서:
+{context}
+
+규칙:
+1. 근거 문서에 없는 내용은 "담당자에게 문의해 주세요"로 안내
+2. 답변은 간결하고 명확하게 (3문장 이내)
+3. 전문 용어는 쉬운 말로 바꿔 설명
+"""
+
+
+class AnthropicLLMProvider(LLMProvider):
+ def __init__(self, api_key: str, model: str = "claude-haiku-4-5-20251001"):
+ self.api_key = api_key
+ self.model = model
+
+ async def generate(
+ self,
+ system_prompt: str,
+ user_message: str,
+ context_chunks: list,
+ max_tokens: int = 512,
+ ) -> Optional[str]:
+ """근거 없으면 None 반환. 예외 발생 시 None 반환."""
+ if not context_chunks:
+ return None # 할루시네이션 방지 — 근거 없으면 LLM 미호출
+
+ try:
+ import anthropic
+ client = anthropic.AsyncAnthropic(api_key=self.api_key)
+ message = await client.messages.create(
+ model=self.model,
+ max_tokens=max_tokens,
+ system=system_prompt,
+ messages=[{"role": "user", "content": user_message}],
+ )
+ return message.content[0].text if message.content else None
+ except Exception:
+ return None # 실패 시 None — 호출자가 Tier D로 폴백
+
+
+class OpenAILLMProvider(LLMProvider):
+ def __init__(self, api_key: str, model: str = "gpt-4o-mini"):
+ self.api_key = api_key
+ self.model = model
+
+ async def generate(
+ self,
+ system_prompt: str,
+ user_message: str,
+ context_chunks: list,
+ max_tokens: int = 512,
+ ) -> Optional[str]:
+ if not context_chunks:
+ return None
+
+ try:
+ import openai
+ client = openai.AsyncOpenAI(api_key=self.api_key)
+ response = await client.chat.completions.create(
+ model=self.model,
+ max_tokens=max_tokens,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message},
+ ],
+ )
+ return response.choices[0].message.content
+ except Exception:
+ return None
diff --git a/backend/app/providers/local_embedding.py b/backend/app/providers/local_embedding.py
new file mode 100644
index 0000000..26d31f1
--- /dev/null
+++ b/backend/app/providers/local_embedding.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from app.providers.embedding import EmbeddingProvider
+
+import app.providers as providers_module
+
+
+class LocalEmbeddingProvider(EmbeddingProvider):
+ """
+ jhgan/ko-sroberta-multitask 기반 로컬 임베딩.
+ sentence-transformers 패키지 필요.
+ """
+
+ def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"):
+ self.model_name = model_name
+ self._model = None
+
+ async def warmup(self) -> None:
+ """모델 로드. 최초 1회 실행."""
+ from sentence_transformers import SentenceTransformer
+ self._model = SentenceTransformer(self.model_name)
+ providers_module._embedding_warmed_up = True
+
+ async def embed(self, texts: list[str]) -> list[list[float]]:
+ if self._model is None:
+ await self.warmup()
+ embeddings = self._model.encode(texts, convert_to_numpy=True)
+ return embeddings.tolist()
+
+ @property
+ def dimension(self) -> int:
+ return 768
diff --git a/backend/app/providers/vectordb.py b/backend/app/providers/vectordb.py
new file mode 100644
index 0000000..cb08820
--- /dev/null
+++ b/backend/app/providers/vectordb.py
@@ -0,0 +1,30 @@
+from abc import ABC, abstractmethod
+
+from app.providers.base import SearchResult
+
+
+class VectorDBProvider(ABC):
+ @abstractmethod
+ async def upsert(
+ self,
+ tenant_id: str,
+ doc_id: str,
+ chunks: list[str],
+ embeddings: list[list[float]],
+ metadatas: list[dict],
+ ) -> int:
+ ...
+
+ @abstractmethod
+ async def search(
+ self,
+ tenant_id: str,
+ query_vec: list[float],
+ top_k: int = 3,
+ threshold: float = 0.70,
+ ) -> list[SearchResult]:
+ ...
+
+ @abstractmethod
+ async def delete(self, tenant_id: str, doc_id: str) -> None:
+ ...
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/routers/admin_auth.py b/backend/app/routers/admin_auth.py
new file mode 100644
index 0000000..c66c3ad
--- /dev/null
+++ b/backend/app/routers/admin_auth.py
@@ -0,0 +1,47 @@
+"""
+관리자 인증 API.
+POST /api/admin/auth/login — 로그인 (JWT 발급)
+"""
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import verify_password, create_admin_token
+from app.models.admin import AdminUser
+
+router = APIRouter(prefix="/api/admin/auth", tags=["admin-auth"])
+
+
+class LoginRequest(BaseModel):
+ tenant_id: str
+ email: str
+ password: str
+
+
+class LoginResponse(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+ role: str
+
+
+@router.post("/login", response_model=LoginResponse)
+async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(
+ select(AdminUser).where(
+ AdminUser.tenant_id == body.tenant_id,
+ AdminUser.email == body.email,
+ AdminUser.is_active == True,
+ )
+ )
+ user = result.scalar_one_or_none()
+
+ if user is None or not verify_password(body.password, user.hashed_pw):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="이메일 또는 비밀번호가 올바르지 않습니다.",
+ )
+
+ token = create_admin_token(user.id, user.tenant_id, user.role.value)
+ return LoginResponse(access_token=token, role=user.role.value)
diff --git a/backend/app/routers/admin_complaints.py b/backend/app/routers/admin_complaints.py
new file mode 100644
index 0000000..83795e3
--- /dev/null
+++ b/backend/app/routers/admin_complaints.py
@@ -0,0 +1,72 @@
+"""
+민원 이력 조회 API — viewer 이상.
+GET /api/admin/complaints — 민원 이력 목록 (마스킹 상태로 노출)
+"""
+from typing import Optional
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel
+from sqlalchemy import select, desc
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.deps import get_current_admin
+from app.models.admin import AdminUser
+from app.models.complaint import ComplaintLog
+
+router = APIRouter(prefix="/api/admin/complaints", tags=["admin-complaints"])
+
+
+class ComplaintOut(BaseModel):
+ id: str
+ user_key: str # 해시값만 노출
+ utterance_masked: Optional[str] = None # 마스킹된 발화
+ channel: Optional[str] = None
+ response_tier: Optional[str] = None
+ response_source: Optional[str] = None
+ response_ms: Optional[int] = None
+ is_timeout: bool
+ created_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+@router.get("", response_model=list[ComplaintOut])
+async def list_complaints(
+ limit: int = Query(default=50, le=200),
+ tier: Optional[str] = Query(default=None, description="A|B|C|D"),
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(get_current_admin),
+):
+ """
+ 민원 이력 조회.
+ - utterance는 마스킹 상태로만 노출 (관리자도 원문 열람 불가)
+ - user_key는 해시값만 노출
+ """
+ query = (
+ select(ComplaintLog)
+ .where(ComplaintLog.tenant_id == current_user.tenant_id)
+ .order_by(desc(ComplaintLog.created_at))
+ .limit(limit)
+ )
+ if tier:
+ query = query.where(ComplaintLog.response_tier == tier)
+
+ result = await db.execute(query)
+ logs = result.scalars().all()
+ return [
+ ComplaintOut(
+ id=log.id,
+ user_key=log.user_key or "",
+ utterance_masked=log.utterance_masked,
+ channel=log.channel,
+ response_tier=log.response_tier,
+ response_source=log.response_source,
+ response_ms=log.response_ms,
+ is_timeout=bool(log.is_timeout),
+ created_at=log.created_at,
+ )
+ for log in logs
+ ]
diff --git a/backend/app/routers/admin_crawler.py b/backend/app/routers/admin_crawler.py
new file mode 100644
index 0000000..48c43df
--- /dev/null
+++ b/backend/app/routers/admin_crawler.py
@@ -0,0 +1,165 @@
+"""
+크롤러 관리 API.
+POST /api/admin/crawler/urls — URL 등록
+GET /api/admin/crawler/urls — URL 목록
+POST /api/admin/crawler/run/{url_id} — 수동 크롤링 실행
+DELETE /api/admin/crawler/urls/{id} — URL 삭제
+"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.deps import require_editor
+from app.models.admin import AdminUser
+from app.models.knowledge import CrawlerURL, Document
+from app.services.crawler import CrawlerService
+from app.services.document_processor import DocumentProcessor
+from app.services.audit import log_action
+
+router = APIRouter(prefix="/api/admin/crawler", tags=["admin-crawler"])
+
+
+class CrawlerURLCreate(BaseModel):
+ url: str
+ url_type: str = "page"
+ interval_hours: int = 24
+
+
+class CrawlerURLOut(BaseModel):
+ id: str
+ url: str
+ url_type: str
+ interval_hours: int
+ is_active: bool
+ last_crawled: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+@router.post("/urls", status_code=status.HTTP_201_CREATED)
+async def register_url(
+ body: CrawlerURLCreate,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """크롤러 URL 등록."""
+ crawler_url = CrawlerURL(
+ tenant_id=current_user.tenant_id,
+ url=body.url,
+ url_type=body.url_type,
+ interval_hours=body.interval_hours,
+ is_active=True,
+ )
+ db.add(crawler_url)
+ await db.commit()
+ await db.refresh(crawler_url)
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="crawler.approve",
+ target_type="crawler_url",
+ target_id=crawler_url.id,
+ diff={"url": body.url},
+ )
+
+ return {"id": crawler_url.id, "url": crawler_url.url}
+
+
+@router.get("/urls", response_model=list[CrawlerURLOut])
+async def list_urls(
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ result = await db.execute(
+ select(CrawlerURL).where(CrawlerURL.tenant_id == current_user.tenant_id)
+ )
+ return result.scalars().all()
+
+
+@router.post("/run/{url_id}")
+async def run_crawl(
+ url_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """수동 크롤링 실행 → 텍스트 추출 → 문서로 저장."""
+ tenant_id = current_user.tenant_id
+ result = await db.execute(
+ select(CrawlerURL).where(CrawlerURL.id == url_id, CrawlerURL.tenant_id == tenant_id)
+ )
+ crawler_url = result.scalar_one_or_none()
+ if not crawler_url:
+ raise HTTPException(status_code=404, detail="Crawler URL not found")
+
+ service = CrawlerService(db)
+ text = await service.run(crawler_url, tenant_id)
+
+ if not text:
+ raise HTTPException(status_code=422, detail="Failed to crawl or robots.txt disallowed")
+
+ # 크롤링 결과를 문서로 저장
+ from urllib.parse import urlparse
+ parsed = urlparse(crawler_url.url)
+ filename = parsed.netloc + parsed.path.replace("/", "_") + ".txt"
+
+ doc = Document(
+ tenant_id=tenant_id,
+ filename=filename,
+ source_type="crawler",
+ source_url=crawler_url.url,
+ is_active=False, # 편집장 검토 후 승인
+ status="pending",
+ )
+ db.add(doc)
+ await db.flush()
+
+ providers = getattr(request.app.state, "providers", {})
+ processor = DocumentProcessor(
+ embedding_provider=providers.get("embedding"),
+ vectordb_provider=providers.get("vectordb"),
+ db=db,
+ )
+ chunk_count = await processor.process(tenant_id, doc, text.encode("utf-8"))
+
+ return {
+ "doc_id": doc.id,
+ "url": crawler_url.url,
+ "chunk_count": chunk_count,
+ "status": doc.status,
+ }
+
+
+@router.delete("/urls/{url_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_url(
+ url_id: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ result = await db.execute(
+ select(CrawlerURL).where(CrawlerURL.id == url_id, CrawlerURL.tenant_id == current_user.tenant_id)
+ )
+ crawler_url = result.scalar_one_or_none()
+ if not crawler_url:
+ raise HTTPException(status_code=404, detail="Crawler URL not found")
+
+ await db.delete(crawler_url)
+ await db.commit()
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="crawler.reject",
+ target_type="crawler_url",
+ target_id=url_id,
+ )
diff --git a/backend/app/routers/admin_docs.py b/backend/app/routers/admin_docs.py
new file mode 100644
index 0000000..d59106e
--- /dev/null
+++ b/backend/app/routers/admin_docs.py
@@ -0,0 +1,190 @@
+"""
+문서 관리 API — 편집장(editor) 이상 접근.
+POST /api/admin/documents/upload — 문서 업로드
+POST /api/admin/documents/{id}/approve — 문서 승인 (is_active=True)
+GET /api/admin/documents — 문서 목록
+DELETE /api/admin/documents/{id} — 문서 삭제
+"""
+from typing import Optional
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.deps import require_editor, require_admin, get_current_admin
+from app.models.admin import AdminUser
+from app.models.knowledge import Document
+from app.services.document_processor import DocumentProcessor
+from app.services.audit import log_action
+
+router = APIRouter(prefix="/api/admin/documents", tags=["admin-documents"])
+
+
+class DocumentOut(BaseModel):
+ id: str
+ filename: str
+ status: str
+ is_active: bool
+ chunk_count: int
+ version: int
+ published_at: Optional[datetime] = None
+ created_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+@router.post("/upload", status_code=status.HTTP_201_CREATED)
+async def upload_document(
+ request: Request,
+ file: UploadFile = File(...),
+ published_at: Optional[str] = Form(default=None),
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """문서 업로드 → 파싱·임베딩 → VectorDB 저장. is_active=False (승인 대기)."""
+ tenant_id = current_user.tenant_id
+ content = await file.read()
+
+ # 지원 형식 검사
+ ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
+ SUPPORTED = {"txt", "md", "html", "htm", "docx", "pdf"}
+ if ext not in SUPPORTED:
+ raise HTTPException(status_code=400, detail=f"Unsupported file type: .{ext}")
+
+ # Document 레코드 생성
+ published = None
+ if published_at:
+ try:
+ published = datetime.fromisoformat(published_at)
+ except ValueError:
+ pass
+
+ doc = Document(
+ tenant_id=tenant_id,
+ filename=file.filename,
+ source_type="upload",
+ is_active=False, # 편집장 승인 전
+ status="pending",
+ published_at=published,
+ approved_by=None,
+ )
+ db.add(doc)
+ await db.flush() # id 확보
+
+ # 문서 처리
+ providers = getattr(request.app.state, "providers", {})
+ processor = DocumentProcessor(
+ embedding_provider=providers.get("embedding"),
+ vectordb_provider=providers.get("vectordb"),
+ db=db,
+ )
+ chunk_count = await processor.process(tenant_id, doc, content)
+
+ # 감사 로그
+ await log_action(
+ db=db,
+ tenant_id=tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="doc.upload",
+ target_type="document",
+ target_id=doc.id,
+ diff={"filename": file.filename, "chunk_count": chunk_count},
+ )
+
+ return {"id": doc.id, "filename": doc.filename, "status": doc.status, "chunk_count": chunk_count}
+
+
+@router.post("/{doc_id}/approve")
+async def approve_document(
+ doc_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """문서 승인 → is_active=True."""
+ tenant_id = current_user.tenant_id
+ result = await db.execute(
+ select(Document).where(Document.id == doc_id, Document.tenant_id == tenant_id)
+ )
+ doc = result.scalar_one_or_none()
+ if not doc:
+ raise HTTPException(status_code=404, detail="Document not found")
+ if doc.status not in ("processed", "embedding_unavailable"):
+ raise HTTPException(status_code=400, detail=f"Cannot approve document with status: {doc.status}")
+
+ doc.is_active = True
+ doc.approved_by = current_user.id
+ doc.approved_at = datetime.utcnow()
+ await db.commit()
+
+ await log_action(
+ db=db,
+ tenant_id=tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="doc.approve",
+ target_type="document",
+ target_id=doc.id,
+ )
+
+ return {"id": doc.id, "is_active": True}
+
+
+@router.get("", response_model=list[DocumentOut])
+async def list_documents(
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(get_current_admin),
+):
+ """문서 목록 조회."""
+ result = await db.execute(
+ select(Document)
+ .where(Document.tenant_id == current_user.tenant_id)
+ .order_by(Document.created_at.desc())
+ )
+ return result.scalars().all()
+
+
+@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_document(
+ doc_id: str,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """문서 삭제 (DB + VectorDB)."""
+ tenant_id = current_user.tenant_id
+ result = await db.execute(
+ select(Document).where(Document.id == doc_id, Document.tenant_id == tenant_id)
+ )
+ doc = result.scalar_one_or_none()
+ if not doc:
+ raise HTTPException(status_code=404, detail="Document not found")
+
+ # VectorDB 청크 삭제
+ providers = getattr(request.app.state, "providers", {})
+ vectordb = providers.get("vectordb")
+ if vectordb:
+ processor = DocumentProcessor(
+ embedding_provider=providers.get("embedding"),
+ vectordb_provider=vectordb,
+ db=db,
+ )
+ await processor.delete(tenant_id, doc_id)
+
+ await db.delete(doc)
+ await db.commit()
+
+ await log_action(
+ db=db,
+ tenant_id=tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="doc.delete",
+ target_type="document",
+ target_id=doc_id,
+ )
diff --git a/backend/app/routers/admin_faq.py b/backend/app/routers/admin_faq.py
new file mode 100644
index 0000000..7f0976b
--- /dev/null
+++ b/backend/app/routers/admin_faq.py
@@ -0,0 +1,189 @@
+"""
+FAQ CRUD API — 편집장(editor) 이상.
+POST /api/admin/faqs — FAQ 생성
+GET /api/admin/faqs — FAQ 목록
+PUT /api/admin/faqs/{id} — FAQ 수정
+DELETE /api/admin/faqs/{id} — FAQ 삭제
+POST /api/admin/faqs/{id}/index — FAQ 벡터 색인
+"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.deps import require_editor, get_current_admin
+from app.models.admin import AdminUser
+from app.models.knowledge import FAQ
+from app.services.audit import log_action
+from app.services.faq_search import FAQSearchService
+
+router = APIRouter(prefix="/api/admin/faqs", tags=["admin-faq"])
+
+
+class FAQCreate(BaseModel):
+ category: Optional[str] = None
+ question: str
+ answer: str
+ keywords: Optional[list[str]] = None
+
+
+class FAQUpdate(BaseModel):
+ category: Optional[str] = None
+ question: Optional[str] = None
+ answer: Optional[str] = None
+ keywords: Optional[list[str]] = None
+ is_active: Optional[bool] = None
+
+
+class FAQOut(BaseModel):
+ id: str
+ category: Optional[str] = None
+ question: str
+ answer: str
+ keywords: Optional[list] = None
+ hit_count: int
+ is_active: bool
+
+ class Config:
+ from_attributes = True
+
+
+@router.post("", status_code=status.HTTP_201_CREATED, response_model=FAQOut)
+async def create_faq(
+ body: FAQCreate,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ faq = FAQ(
+ tenant_id=current_user.tenant_id,
+ category=body.category,
+ question=body.question,
+ answer=body.answer,
+ keywords=body.keywords,
+ created_by=current_user.id,
+ is_active=True,
+ )
+ db.add(faq)
+ await db.flush()
+
+ # 벡터 색인
+ providers = getattr(request.app.state, "providers", {})
+ if providers.get("embedding") and providers.get("vectordb"):
+ service = FAQSearchService(providers["embedding"], providers["vectordb"], db)
+ try:
+ await service.index_faq(current_user.tenant_id, faq)
+ except Exception:
+ pass # 색인 실패해도 FAQ 저장은 성공
+
+ await db.commit()
+ await db.refresh(faq)
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="faq.create",
+ target_type="faq",
+ target_id=faq.id,
+ diff={"question": body.question},
+ )
+ return faq
+
+
+@router.get("", response_model=list[FAQOut])
+async def list_faqs(
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(get_current_admin),
+):
+ result = await db.execute(
+ select(FAQ)
+ .where(FAQ.tenant_id == current_user.tenant_id)
+ .order_by(FAQ.created_at.desc())
+ )
+ return result.scalars().all()
+
+
+@router.put("/{faq_id}", response_model=FAQOut)
+async def update_faq(
+ faq_id: str,
+ body: FAQUpdate,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ result = await db.execute(
+ select(FAQ).where(FAQ.id == faq_id, FAQ.tenant_id == current_user.tenant_id)
+ )
+ faq = result.scalar_one_or_none()
+ if not faq:
+ raise HTTPException(status_code=404, detail="FAQ not found")
+
+ diff = {}
+ if body.question is not None:
+ diff["question"] = {"before": faq.question, "after": body.question}
+ faq.question = body.question
+ if body.answer is not None:
+ diff["answer"] = "updated"
+ faq.answer = body.answer
+ if body.category is not None:
+ faq.category = body.category
+ if body.keywords is not None:
+ faq.keywords = body.keywords
+ if body.is_active is not None:
+ faq.is_active = body.is_active
+
+ # 재색인
+ providers = getattr(request.app.state, "providers", {})
+ if providers.get("embedding") and providers.get("vectordb") and faq.is_active:
+ service = FAQSearchService(providers["embedding"], providers["vectordb"], db)
+ try:
+ await service.index_faq(current_user.tenant_id, faq)
+ except Exception:
+ pass
+
+ await db.commit()
+ await db.refresh(faq)
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="faq.update",
+ target_type="faq",
+ target_id=faq_id,
+ diff=diff,
+ )
+ return faq
+
+
+@router.delete("/{faq_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_faq(
+ faq_id: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ result = await db.execute(
+ select(FAQ).where(FAQ.id == faq_id, FAQ.tenant_id == current_user.tenant_id)
+ )
+ faq = result.scalar_one_or_none()
+ if not faq:
+ raise HTTPException(status_code=404, detail="FAQ not found")
+
+ await db.delete(faq)
+ await db.commit()
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="faq.delete",
+ target_type="faq",
+ target_id=faq_id,
+ )
diff --git a/backend/app/routers/admin_metrics.py b/backend/app/routers/admin_metrics.py
new file mode 100644
index 0000000..dc81352
--- /dev/null
+++ b/backend/app/routers/admin_metrics.py
@@ -0,0 +1,85 @@
+"""
+메트릭 조회 API — viewer 이상.
+GET /api/admin/metrics — Tier별 응답 통계 (ComplaintLog 기반, Redis 선택적)
+"""
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.deps import get_current_admin
+from app.models.admin import AdminUser
+from app.models.complaint import ComplaintLog
+
+router = APIRouter(prefix="/api/admin/metrics", tags=["admin-metrics"])
+
+
+@router.get("")
+async def get_metrics(
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(get_current_admin),
+):
+ """
+ Tier별 응답 통계.
+ Redis가 있으면 MetricsCollector, 없으면 DB 집계로 fallback.
+ """
+ tenant_id = current_user.tenant_id
+
+ # Redis MetricsCollector 우선
+ redis = getattr(request.app.state, "redis", None)
+ if redis is not None:
+ try:
+ from app.services.metrics import MetricsCollector
+ collector = MetricsCollector(redis)
+ overview = await collector.get_overview(tenant_id)
+ counts = overview.get("counts", {})
+ total = counts.get("total_count", 0)
+ return {
+ "total_count": total,
+ "tier_counts": {
+ "A": counts.get("faq_hit_count", 0),
+ "B": counts.get("rag_hit_count", 0),
+ "C": counts.get("llm_hit_count", 0),
+ "D": counts.get("fallback_count", 0),
+ },
+ "timeout_count": counts.get("timeout_count", 0),
+ "avg_ms": overview.get("avg_ms", 0),
+ "p95_ms": overview.get("p95_ms", 0),
+ }
+ except Exception:
+ pass
+
+ # DB fallback: ComplaintLog 집계
+ total_result = await db.execute(
+ select(func.count()).where(ComplaintLog.tenant_id == tenant_id)
+ )
+ total = total_result.scalar() or 0
+
+ tier_result = await db.execute(
+ select(ComplaintLog.response_tier, func.count())
+ .where(ComplaintLog.tenant_id == tenant_id)
+ .group_by(ComplaintLog.response_tier)
+ )
+ tier_counts = {row[0]: row[1] for row in tier_result.all() if row[0]}
+
+ timeout_result = await db.execute(
+ select(func.count()).where(
+ ComplaintLog.tenant_id == tenant_id,
+ ComplaintLog.is_timeout == True,
+ )
+ )
+ timeout_count = timeout_result.scalar() or 0
+
+ return {
+ "total_count": total,
+ "tier_counts": {
+ "A": tier_counts.get("A", 0),
+ "B": tier_counts.get("B", 0),
+ "C": tier_counts.get("C", 0),
+ "D": tier_counts.get("D", 0),
+ },
+ "timeout_count": timeout_count,
+ "avg_ms": 0,
+ "p95_ms": 0,
+ }
diff --git a/backend/app/routers/admin_moderation.py b/backend/app/routers/admin_moderation.py
new file mode 100644
index 0000000..140abde
--- /dev/null
+++ b/backend/app/routers/admin_moderation.py
@@ -0,0 +1,106 @@
+"""
+악성 유저 관리 API — 편집장(editor) 이상.
+GET /api/admin/moderation — 제한 유저 목록
+POST /api/admin/moderation/{user_key}/release — 수동 해제
+POST /api/admin/moderation/{user_key}/escalate — 수동 레벨 상승
+"""
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.deps import require_editor, require_admin
+from app.models.admin import AdminUser
+from app.models.moderation import UserRestriction
+from app.services.moderation import ModerationService
+from app.services.audit import log_action
+
+router = APIRouter(prefix="/api/admin/moderation", tags=["admin-moderation"])
+
+
+class RestrictionOut(BaseModel):
+ id: str
+ user_key: str
+ level: int
+ reason: str | None = None
+ auto_applied: bool
+ expires_at: str | None = None
+
+ class Config:
+ from_attributes = True
+
+
+class EscalateRequest(BaseModel):
+ reason: str = "수동 조치"
+
+
+@router.get("", response_model=list[RestrictionOut])
+async def list_restrictions(
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """제한(level ≥ 1) 유저 목록."""
+ result = await db.execute(
+ select(UserRestriction).where(
+ UserRestriction.tenant_id == current_user.tenant_id,
+ UserRestriction.level > 0,
+ )
+ )
+ restrictions = result.scalars().all()
+ return [
+ RestrictionOut(
+ id=r.id,
+ user_key=r.user_key,
+ level=r.level,
+ reason=r.reason,
+ auto_applied=r.auto_applied,
+ expires_at=r.expires_at.isoformat() if r.expires_at else None,
+ )
+ for r in restrictions
+ ]
+
+
+@router.post("/{user_key}/release")
+async def release_restriction(
+ user_key: str,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """수동 해제 (Level 4+ 포함)."""
+ service = ModerationService(db)
+ await service.release(current_user.tenant_id, user_key, current_user.id)
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="user.unblock",
+ target_type="user_restriction",
+ diff={"user_key": user_key},
+ )
+ return {"user_key": user_key, "released": True}
+
+
+@router.post("/{user_key}/escalate")
+async def escalate_restriction(
+ user_key: str,
+ body: EscalateRequest,
+ db: AsyncSession = Depends(get_db),
+ current_user: AdminUser = Depends(require_editor),
+):
+ """수동 레벨 상승."""
+ service = ModerationService(db)
+ new_level = await service.escalate(current_user.tenant_id, user_key, body.reason)
+
+ await log_action(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ actor_id=current_user.id,
+ actor_type="admin_user",
+ action="user.restrict",
+ target_type="user_restriction",
+ diff={"user_key": user_key, "new_level": new_level, "reason": body.reason},
+ )
+ return {"user_key": user_key, "new_level": new_level}
diff --git a/backend/app/routers/engine.py b/backend/app/routers/engine.py
new file mode 100644
index 0000000..09e2bcd
--- /dev/null
+++ b/backend/app/routers/engine.py
@@ -0,0 +1,112 @@
+"""
+POST /engine/query — 채널 공통 엔진 API (웹 시뮬레이터)
+"""
+import uuid
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.config import settings
+from app.services.idempotency import IdempotencyCache
+from app.services.routing import ResponseRouter
+from app.services.complaint_logger import log_complaint
+from app.services.moderation import ModerationService
+
+router = APIRouter()
+
+
+class EngineRequest(BaseModel):
+ tenant: str
+ utterance: str
+ user_key: str
+ channel: str = "web"
+ request_id: Optional[str] = None
+
+
+class EngineResponse(BaseModel):
+ answer: str
+ tier: str
+ source: str
+ citations: list[dict] = []
+ request_id: Optional[str] = None
+ elapsed_ms: int = 0
+ is_timeout: bool = False
+
+
+@router.post("/engine/query", response_model=EngineResponse)
+async def engine_query(
+ body: EngineRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ tenant_id = body.tenant
+ request_id = body.request_id or str(uuid.uuid4())
+
+ # Idempotency 캐시 확인
+ redis_client = getattr(request.app.state, "redis", None)
+ if redis_client:
+ cache = IdempotencyCache(redis_client)
+ cached = await cache.get(tenant_id, request_id)
+ if cached:
+ return EngineResponse(**cached)
+
+ # 악성 감지
+ mod_service = ModerationService(db)
+ mod_result = await mod_service.check(tenant_id, body.user_key)
+ if not mod_result.allowed:
+ return EngineResponse(
+ answer=mod_result.message or "이용이 제한되었습니다. 담당 부서에 문의해 주세요.",
+ tier="D",
+ source="fallback",
+ request_id=request_id,
+ )
+
+ # 라우터 실행
+ providers = getattr(request.app.state, "providers", {})
+ tenant_config = getattr(request.app.state, "tenant_configs", {}).get(
+ tenant_id, settings.model_dump()
+ )
+ router_svc = ResponseRouter(tenant_config=tenant_config, providers=providers)
+ result = await router_svc.route(
+ tenant_id=tenant_id,
+ utterance=body.utterance,
+ user_key=body.user_key,
+ request_id=request_id,
+ db=db,
+ )
+
+ # 경고 메시지 추가 (Level 1)
+ if mod_result.message and mod_result.level == 1:
+ result.answer = f"{mod_result.message}\n\n{result.answer}"
+
+ resp_dict = result.to_dict()
+
+ # Idempotency 캐시 저장
+ if redis_client:
+ await cache.set(tenant_id, request_id, resp_dict)
+
+ # 민원 이력 저장 (fire-and-forget: 실패해도 응답 영향 없음)
+ try:
+ await log_complaint(
+ db=db,
+ tenant_id=tenant_id,
+ raw_utterance=body.utterance,
+ raw_user_id=body.user_key,
+ result=result,
+ channel=body.channel,
+ )
+ except Exception:
+ pass
+
+ return EngineResponse(
+ answer=result.answer,
+ tier=result.tier,
+ source=result.source,
+ citations=resp_dict.get("citations", []),
+ request_id=request_id,
+ elapsed_ms=result.elapsed_ms,
+ is_timeout=result.is_timeout,
+ )
diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py
new file mode 100644
index 0000000..4a5cb12
--- /dev/null
+++ b/backend/app/routers/health.py
@@ -0,0 +1,48 @@
+import redis.asyncio as aioredis
+from fastapi import APIRouter, Response
+from sqlalchemy import text
+
+import app.providers as providers_module
+from app.core.config import settings
+from app.core.database import AsyncSessionLocal
+
+router = APIRouter()
+
+
+@router.get("/health")
+async def health():
+ return {"status": "ok", "phase": "0B", "version": "0.2.0"}
+
+
+@router.get("/ready")
+async def ready(response: Response):
+ checks = {}
+ all_ok = True
+
+ # DB 확인
+ try:
+ async with AsyncSessionLocal() as session:
+ await session.execute(text("SELECT 1"))
+ checks["db"] = "ok"
+ except Exception as e:
+ checks["db"] = f"error: {e}"
+ all_ok = False
+
+ # Redis 확인
+ try:
+ r = aioredis.from_url(settings.REDIS_URL, socket_connect_timeout=2)
+ await r.ping()
+ await r.aclose()
+ checks["redis"] = "ok"
+ except Exception as e:
+ checks["redis"] = f"error: {e}"
+ all_ok = False
+
+ # Embedding 워밍업 상태
+ checks["embedding"] = "warmed_up" if providers_module._embedding_warmed_up else "not_warmed_up"
+
+ if all_ok:
+ return {"ready": True, "checks": checks}
+ else:
+ response.status_code = 503
+ return {"ready": False, "checks": checks}
diff --git a/backend/app/routers/skill.py b/backend/app/routers/skill.py
new file mode 100644
index 0000000..1e7bee1
--- /dev/null
+++ b/backend/app/routers/skill.py
@@ -0,0 +1,116 @@
+"""
+POST /skill/{tenant_slug} — 카카오 스킬 API
+"""
+import uuid
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.config import settings
+from app.services.masking import mask_text, hash_user_key
+from app.services.idempotency import IdempotencyCache
+from app.services.routing import ResponseRouter
+from app.services.complaint_logger import log_complaint
+from app.services.moderation import ModerationService
+
+router = APIRouter()
+
+
+class KakaoUserRequest(BaseModel):
+ utterance: str
+ user: Optional[dict] = None
+
+
+class KakaoSkillRequest(BaseModel):
+ userRequest: KakaoUserRequest
+ action: Optional[dict] = None
+
+
+def build_kakao_response(answer: str, doc_name: Optional[str] = None) -> dict:
+ quick_replies = []
+ if doc_name:
+ quick_replies.append({
+ "label": "출처 보기",
+ "action": "message",
+ "messageText": f"출처: {doc_name}",
+ })
+ response = {
+ "version": "2.0",
+ "template": {
+ "outputs": [{"simpleText": {"text": answer}}],
+ },
+ }
+ if quick_replies:
+ response["template"]["quickReplies"] = quick_replies
+ return response
+
+
+@router.post("/skill/{tenant_slug}")
+async def kakao_skill(
+ tenant_slug: str,
+ body: KakaoSkillRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ utterance = body.userRequest.utterance
+ raw_user_id = (body.userRequest.user or {}).get("id", "anonymous")
+
+ masked_utterance = mask_text(utterance)
+ user_key = hash_user_key(raw_user_id)
+
+ action_params = (body.action or {}).get("params", {})
+ request_id = action_params.get("request_id") or str(uuid.uuid4())
+
+ # Idempotency 캐시 확인
+ redis_client = getattr(request.app.state, "redis", None)
+ if redis_client:
+ cache = IdempotencyCache(redis_client)
+ cached = await cache.get(tenant_slug, request_id)
+ if cached:
+ return build_kakao_response(cached.get("answer", ""), cached.get("doc_name"))
+
+ # 악성 감지
+ mod_service = ModerationService(db)
+ mod_result = await mod_service.check(tenant_slug, user_key)
+ if not mod_result.allowed:
+ answer = mod_result.message or "이용이 제한되었습니다. 운영자에게 문의해 주세요."
+ return build_kakao_response(answer)
+
+ # 라우터 실행
+ providers = getattr(request.app.state, "providers", {})
+ tenant_config = getattr(request.app.state, "tenant_configs", {}).get(
+ tenant_slug, settings.model_dump()
+ )
+ router_svc = ResponseRouter(tenant_config=tenant_config, providers=providers)
+ result = await router_svc.route(
+ tenant_id=tenant_slug,
+ utterance=utterance,
+ user_key=user_key,
+ request_id=request_id,
+ db=db,
+ )
+
+ if mod_result.message and mod_result.level == 1:
+ result.answer = f"{mod_result.message}\n\n{result.answer}"
+
+ # Idempotency 캐시 저장
+ if redis_client:
+ await cache.set(tenant_slug, request_id, result.to_dict())
+
+ # 민원 이력 저장
+ try:
+ await log_complaint(
+ db=db,
+ tenant_id=tenant_slug,
+ raw_utterance=utterance,
+ raw_user_id=raw_user_id,
+ result=result,
+ channel="kakao",
+ )
+ except Exception:
+ pass
+
+ return build_kakao_response(result.answer, result.doc_name)
diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/scripts/create_admin.py b/backend/app/scripts/create_admin.py
new file mode 100644
index 0000000..fb3546e
--- /dev/null
+++ b/backend/app/scripts/create_admin.py
@@ -0,0 +1,83 @@
+"""
+관리자 계정 초기 생성 스크립트.
+사용법: python -m app.scripts.create_admin
+"""
+import asyncio
+import sys
+
+
+async def main():
+ print("=== SmartBot KR 관리자 계정 생성 ===\n")
+
+ tenant_id = input("테넌트 ID (예: dongducheon): ").strip()
+ if not tenant_id:
+ print("오류: 테넌트 ID를 입력하세요.")
+ sys.exit(1)
+
+ email = input("관리자 이메일: ").strip()
+ if not email:
+ print("오류: 이메일을 입력하세요.")
+ sys.exit(1)
+
+ import getpass
+ password = getpass.getpass("비밀번호 (8자 이상): ")
+ if len(password) < 8:
+ print("오류: 비밀번호는 8자 이상이어야 합니다.")
+ sys.exit(1)
+
+ from app.core.database import engine, Base
+ from app.models.tenant import Tenant, TenantConfig
+ from app.models.admin import AdminUser, AdminRole
+ from app.core.security import hash_password
+ from sqlalchemy.ext.asyncio import AsyncSession
+ from sqlalchemy import select
+ import uuid
+
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ async with AsyncSession(engine) as db:
+ # 테넌트 확인 / 생성
+ result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
+ tenant = result.scalar_one_or_none()
+ if not tenant:
+ tenant = Tenant(id=tenant_id, name=tenant_id, slug=tenant_id)
+ db.add(tenant)
+ await db.flush()
+ config = TenantConfig(
+ id=str(uuid.uuid4()),
+ tenant_id=tenant_id,
+ key="tenant_name",
+ value=tenant_id,
+ )
+ db.add(config)
+ print(f"테넌트 생성: {tenant_id}")
+
+ # 관리자 생성
+ result = await db.execute(
+ select(AdminUser).where(
+ AdminUser.tenant_id == tenant_id,
+ AdminUser.email == email,
+ )
+ )
+ existing = result.scalar_one_or_none()
+ if existing:
+ print(f"이미 존재하는 계정입니다: {email}")
+ else:
+ admin = AdminUser(
+ id=str(uuid.uuid4()),
+ tenant_id=tenant_id,
+ email=email,
+ hashed_pw=hash_password(password),
+ role=AdminRole.admin,
+ )
+ db.add(admin)
+ print(f"관리자 계정 생성: {email} (role: admin)")
+
+ await db.commit()
+
+ print("\n✅ 완료! 대시보드에서 로그인하세요.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py
new file mode 100644
index 0000000..e1768fa
--- /dev/null
+++ b/backend/app/services/audit.py
@@ -0,0 +1,39 @@
+"""
+감사 로그 기록 헬퍼.
+표준 action: faq.create|faq.update|faq.delete
+ doc.upload|doc.approve|doc.delete
+ user.restrict|user.unblock
+ crawler.approve|crawler.reject
+ config.update
+"""
+from typing import Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.audit import AuditLog
+
+
+async def log_action(
+ db: AsyncSession,
+ tenant_id: str,
+ actor_id: str,
+ actor_type: str, # 'admin_user' | 'system_admin'
+ action: str,
+ target_type: Optional[str] = None,
+ target_id: Optional[str] = None,
+ diff: Optional[dict] = None,
+ ip_address: Optional[str] = None,
+) -> AuditLog:
+ entry = AuditLog(
+ tenant_id=tenant_id,
+ actor_id=actor_id,
+ actor_type=actor_type,
+ action=action,
+ target_type=target_type,
+ target_id=target_id,
+ diff=diff,
+ ip_address=ip_address,
+ )
+ db.add(entry)
+ await db.commit()
+ return entry
diff --git a/backend/app/services/complaint_logger.py b/backend/app/services/complaint_logger.py
new file mode 100644
index 0000000..ac8100e
--- /dev/null
+++ b/backend/app/services/complaint_logger.py
@@ -0,0 +1,44 @@
+"""
+민원 이력 DB 저장.
+개인정보 원칙: utterance_masked(마스킹), user_key(SHA-256 해시 16자리).
+원문 미저장.
+"""
+from typing import Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.complaint import ComplaintLog
+from app.services.masking import mask_text, hash_user_key
+from app.services.routing import RoutingResult
+
+
+async def log_complaint(
+ db: AsyncSession,
+ tenant_id: str,
+ raw_utterance: str,
+ raw_user_id: str,
+ result: RoutingResult,
+ channel: str = "kakao",
+) -> ComplaintLog:
+ """
+ 민원 이력을 ComplaintLog에 기록.
+ - utterance: 마스킹 후 저장
+ - user_key: SHA-256 해시 16자리
+ - 원문 미저장
+ """
+ entry = ComplaintLog(
+ tenant_id=tenant_id,
+ user_key=hash_user_key(raw_user_id),
+ utterance_masked=mask_text(raw_utterance)[:1000],
+ channel=channel,
+ request_id=result.request_id,
+ response_tier=result.tier,
+ response_source=result.source,
+ faq_id=result.faq_id,
+ doc_id=result.doc_id,
+ response_ms=result.elapsed_ms,
+ is_timeout=result.is_timeout,
+ )
+ db.add(entry)
+ await db.commit()
+ return entry
diff --git a/backend/app/services/crawler.py b/backend/app/services/crawler.py
new file mode 100644
index 0000000..1b21aa9
--- /dev/null
+++ b/backend/app/services/crawler.py
@@ -0,0 +1,92 @@
+"""
+웹 크롤러 — httpx + BeautifulSoup4.
+robots.txt 준수. CrawlerURL 기반.
+"""
+from typing import Optional
+from datetime import datetime, timezone
+
+import httpx
+from bs4 import BeautifulSoup
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.knowledge import CrawlerURL, Document
+
+
+CRAWLER_HEADERS = {
+ "User-Agent": "SmartBot-KR/1.0 (+https://github.com/sinmb79/Gov-chat-bot)",
+}
+CRAWL_TIMEOUT = 15 # 초
+
+
+async def check_robots_txt(base_url: str, target_path: str) -> bool:
+ """robots.txt 확인. 크롤링 허용 여부 반환."""
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(base_url)
+ robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
+ async with httpx.AsyncClient(timeout=5) as client:
+ resp = await client.get(robots_url, headers=CRAWLER_HEADERS)
+ if resp.status_code != 200:
+ return True # robots.txt 없으면 허용으로 간주
+ content = resp.text.lower()
+ # 간단한 User-agent: * Disallow 체크
+ lines = content.splitlines()
+ in_block = False
+ for line in lines:
+ line = line.strip()
+ if line.startswith("user-agent:"):
+ agent = line.split(":", 1)[1].strip()
+ in_block = agent in ("*", "smartbot-kr")
+ elif in_block and line.startswith("disallow:"):
+ disallowed = line.split(":", 1)[1].strip()
+ if disallowed and target_path.startswith(disallowed):
+ return False
+ return True
+ except Exception:
+ return True # 확인 불가 시 허용
+
+
+async def crawl_url(url: str) -> Optional[str]:
+ """URL 크롤링 → 텍스트 추출. 실패 시 None."""
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(url)
+ target_path = parsed.path or "/"
+
+ if not await check_robots_txt(url, target_path):
+ return None # robots.txt 불허
+
+ async with httpx.AsyncClient(
+ timeout=CRAWL_TIMEOUT,
+ follow_redirects=True,
+ headers=CRAWLER_HEADERS,
+ ) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+
+ content_type = resp.headers.get("content-type", "")
+ if "html" in content_type:
+ soup = BeautifulSoup(resp.content, "html.parser")
+ for tag in soup(["script", "style", "nav", "footer", "header"]):
+ tag.decompose()
+ return soup.get_text(separator="\n", strip=True)
+ else:
+ return resp.text
+
+ except Exception:
+ return None
+
+
+class CrawlerService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def run(self, crawler_url: CrawlerURL, tenant_id: str) -> Optional[str]:
+ """크롤러 URL 실행 → 텍스트 반환."""
+ text = await crawl_url(crawler_url.url)
+
+ # last_crawled 업데이트
+ crawler_url.last_crawled = datetime.now(timezone.utc)
+ await self.db.commit()
+
+ return text
diff --git a/backend/app/services/document_processor.py b/backend/app/services/document_processor.py
new file mode 100644
index 0000000..fd60a6c
--- /dev/null
+++ b/backend/app/services/document_processor.py
@@ -0,0 +1,88 @@
+"""
+문서 처리 파이프라인:
+파싱 → 청킹 → 임베딩 → VectorDB 저장 → Document 레코드 업데이트
+"""
+from typing import Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.knowledge import Document
+from app.providers.embedding import EmbeddingProvider
+from app.providers.vectordb import VectorDBProvider
+from app.services.parsers.text_parser import extract_text, chunk_text
+
+
+class DocumentProcessor:
+ def __init__(
+ self,
+ embedding_provider: EmbeddingProvider,
+ vectordb_provider: VectorDBProvider,
+ db: AsyncSession,
+ ):
+ self.embedding = embedding_provider
+ self.vectordb = vectordb_provider
+ self.db = db
+
+ async def process(self, tenant_id: str, doc: Document, content: bytes) -> int:
+ """
+ 문서를 파싱·청킹·임베딩하여 VectorDB에 저장.
+ chunk_count 반환. 실패 시 0.
+ """
+ # 1. 텍스트 추출
+ text = extract_text(content, doc.filename)
+ if not text or not text.strip():
+ doc.status = "parse_failed"
+ await self.db.commit()
+ return 0
+
+ # 2. 청킹
+ chunks = chunk_text(text)
+ if not chunks:
+ doc.status = "parse_failed"
+ await self.db.commit()
+ return 0
+
+ # 3. 임베딩
+ try:
+ embeddings = await self.embedding.embed(chunks)
+ except NotImplementedError:
+ doc.status = "embedding_unavailable"
+ await self.db.commit()
+ return 0
+ except Exception:
+ doc.status = "embedding_failed"
+ await self.db.commit()
+ return 0
+
+ # 4. 메타데이터 구성
+ published = doc.published_at.strftime("%Y.%m") if doc.published_at else ""
+ metadatas = [
+ {
+ "doc_id": doc.id,
+ "filename": doc.filename,
+ "chunk_idx": i,
+ "published_at": published,
+ "tenant_id": tenant_id,
+ }
+ for i in range(len(chunks))
+ ]
+
+ # 5. VectorDB 저장
+ await self.vectordb.upsert(
+ tenant_id=tenant_id,
+ doc_id=doc.id,
+ chunks=chunks,
+ embeddings=embeddings,
+ metadatas=metadatas,
+ )
+
+ # 6. Document 레코드 업데이트
+ doc.chunk_count = len(chunks)
+ doc.status = "processed"
+ await self.db.commit()
+
+ return len(chunks)
+
+ async def delete(self, tenant_id: str, doc_id: str) -> None:
+ """VectorDB에서 문서 청크 삭제."""
+ await self.vectordb.delete(tenant_id=tenant_id, doc_id=doc_id)
diff --git a/backend/app/services/faq_search.py b/backend/app/services/faq_search.py
new file mode 100644
index 0000000..d7afcc9
--- /dev/null
+++ b/backend/app/services/faq_search.py
@@ -0,0 +1,94 @@
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.knowledge import FAQ
+from app.providers.embedding import EmbeddingProvider
+from app.providers.vectordb import VectorDBProvider
+from app.providers.base import SearchResult
+
+FAQ_SIMILARITY_THRESHOLD = 0.85 # Tier A 기준
+
+
+class FAQSearchService:
+ """
+ Tier A — FAQ 임베딩 유사도 검색.
+ 임베딩 유사도 ≥ 0.85 시 등록 FAQ 반환.
+ """
+
+ def __init__(
+ self,
+ embedding_provider: EmbeddingProvider,
+ vectordb_provider: VectorDBProvider,
+ db: AsyncSession,
+ ):
+ self.embedding = embedding_provider
+ self.vectordb = vectordb_provider
+ self.db = db
+
+ async def search(
+ self, tenant_id: str, utterance: str
+ ) -> Optional[tuple[FAQ, float]]:
+ """
+ 발화를 임베딩 → 벡터DB 검색 → 0.85 이상이면 FAQ 반환.
+ 없으면 None.
+ """
+ try:
+ vecs = await self.embedding.embed([utterance])
+ except NotImplementedError:
+ return None
+
+ query_vec = vecs[0]
+ results = await self.vectordb.search(
+ tenant_id=tenant_id,
+ query_vec=query_vec,
+ top_k=1,
+ threshold=FAQ_SIMILARITY_THRESHOLD,
+ )
+
+ if not results:
+ return None
+
+ top: SearchResult = results[0]
+ faq_id = top.metadata.get("faq_id")
+ if not faq_id:
+ return None
+
+ faq = await self._load_faq(tenant_id, faq_id)
+ if faq is None:
+ return None
+
+ return faq, top.score
+
+ async def _load_faq(self, tenant_id: str, faq_id: str) -> Optional[FAQ]:
+ result = await self.db.execute(
+ select(FAQ).where(
+ FAQ.tenant_id == tenant_id,
+ FAQ.id == faq_id,
+ FAQ.is_active.is_(True),
+ )
+ )
+ return result.scalar_one_or_none()
+
+ async def increment_hit(self, faq_id: str) -> None:
+ """FAQ hit_count 증가."""
+ faq = await self.db.get(FAQ, faq_id)
+ if faq:
+ faq.hit_count = (faq.hit_count or 0) + 1
+ await self.db.commit()
+
+ async def index_faq(self, tenant_id: str, faq: FAQ) -> None:
+ """FAQ를 벡터DB에 색인."""
+ text = f"{faq.question}\n{faq.answer}"
+ try:
+ vecs = await self.embedding.embed([text])
+ except NotImplementedError:
+ return
+ await self.vectordb.upsert(
+ tenant_id=tenant_id,
+ doc_id=faq.id,
+ chunks=[text],
+ embeddings=vecs,
+ metadatas=[{"faq_id": faq.id, "question": faq.question}],
+ )
diff --git a/backend/app/services/idempotency.py b/backend/app/services/idempotency.py
new file mode 100644
index 0000000..82d0df5
--- /dev/null
+++ b/backend/app/services/idempotency.py
@@ -0,0 +1,28 @@
+import json
+from typing import Optional
+
+
+class IdempotencyCache:
+ def __init__(self, redis_client):
+ self.redis = redis_client
+ self.ttl = 60 # 기본 TTL (Phase 0B에서 settings로 교체 예정)
+
+ def _key(self, tenant_id: str, request_id: str) -> str:
+ return f"idempotency:{tenant_id}:{request_id}"
+
+ async def get(self, tenant_id: str, request_id: Optional[str]) -> Optional[dict]:
+ if request_id is None:
+ return None
+ raw = await self.redis.get(self._key(tenant_id, request_id))
+ if raw is None:
+ return None
+ return json.loads(raw)
+
+ async def set(self, tenant_id: str, request_id: Optional[str], result_dict: dict) -> None:
+ if request_id is None:
+ return
+ await self.redis.setex(
+ self._key(tenant_id, request_id),
+ self.ttl,
+ json.dumps(result_dict),
+ )
diff --git a/backend/app/services/masking.py b/backend/app/services/masking.py
new file mode 100644
index 0000000..e83cfec
--- /dev/null
+++ b/backend/app/services/masking.py
@@ -0,0 +1,34 @@
+import hashlib
+import re
+
+# 마스킹 패턴 정의
+_PATTERNS = [
+ # 주민등록번호: 6자리-1~4자리+6자리 (숫자 금액과 구분: 앞에 비숫자 또는 시작, 뒤에 비숫자 또는 끝)
+ (re.compile(r"(? str:
+ """텍스트에서 개인정보 패턴을 마스킹하여 반환."""
+ for pattern, replacement in _PATTERNS:
+ text = pattern.sub(replacement, text)
+ return text
+
+
+def hash_user_key(kakao_id: str) -> str:
+ """SHA-256 해시 앞 16자리 반환."""
+ return hashlib.sha256(kakao_id.encode()).hexdigest()[:16]
+
+
+def has_sensitive_data(text: str) -> bool:
+ """텍스트에 개인정보 패턴이 포함되어 있으면 True."""
+ for pattern, _ in _PATTERNS:
+ if pattern.search(text):
+ return True
+ return False
diff --git a/backend/app/services/metrics.py b/backend/app/services/metrics.py
new file mode 100644
index 0000000..7e8da1a
--- /dev/null
+++ b/backend/app/services/metrics.py
@@ -0,0 +1,95 @@
+import json
+from typing import Optional
+
+from app.services.routing import RoutingResult
+
+METRIC_KEYS = [
+ "total_count",
+ "faq_hit_count",
+ "rag_hit_count",
+ "llm_hit_count",
+ "fallback_count",
+ "timeout_count",
+ "response_ms_sum",
+ "blocked_attempts",
+]
+
+P95_SORTED_SET = "response_ms_p95_buf"
+P95_MAX_SIZE = 10000
+
+_SOURCE_TO_KEY = {
+ "faq": "faq_hit_count",
+ "rag": "rag_hit_count",
+ "llm": "llm_hit_count",
+ "fallback": "fallback_count",
+}
+
+
+class MetricsCollector:
+ def __init__(self, redis_client):
+ self.redis = redis_client
+
+ def _prefix(self, tenant_id: str) -> str:
+ return f"tenant:{tenant_id}:metrics"
+
+ def _p95_key(self, tenant_id: str) -> str:
+ return f"tenant:{tenant_id}:{P95_SORTED_SET}"
+
+ async def record(self, tenant_id: str, result: RoutingResult) -> None:
+ prefix = self._prefix(tenant_id)
+ p95_key = self._p95_key(tenant_id)
+
+ pipe = self.redis.pipeline()
+ pipe.hincrby(prefix, "total_count", 1)
+
+ source_key = _SOURCE_TO_KEY.get(result.source)
+ if source_key:
+ pipe.hincrby(prefix, source_key, 1)
+
+ if result.is_timeout:
+ pipe.hincrby(prefix, "timeout_count", 1)
+
+ pipe.hincrby(prefix, "response_ms_sum", result.elapsed_ms)
+
+ # p95 sorted set — score=elapsed_ms, member=unique id
+ import time
+ member = f"{time.time_ns()}"
+ pipe.zadd(p95_key, {member: result.elapsed_ms})
+ pipe.zremrangebyrank(p95_key, 0, -(P95_MAX_SIZE + 1))
+
+ await pipe.execute()
+
+ async def get_overview(self, tenant_id: str) -> dict:
+ prefix = self._prefix(tenant_id)
+ p95_key = self._p95_key(tenant_id)
+
+ raw = await self.redis.hgetall(prefix)
+ counts = {k: int(v) for k, v in raw.items()} if raw else {}
+
+ total = counts.get("total_count", 0)
+ avg_ms = counts.get("response_ms_sum", 0) // max(total, 1)
+
+ # p95 계산
+ p95_ms = 0
+ buf_size = await self.redis.zcard(p95_key)
+ if buf_size > 0:
+ p95_idx = max(0, int(buf_size * 0.95) - 1)
+ p95_items = await self.redis.zrange(p95_key, p95_idx, p95_idx, withscores=True)
+ if p95_items:
+ p95_ms = int(p95_items[0][1])
+
+ rates = {}
+ if total > 0:
+ rates["faq_hit_rate"] = round(counts.get("faq_hit_count", 0) / total * 100, 2)
+ rates["rag_hit_rate"] = round(counts.get("rag_hit_count", 0) / total * 100, 2)
+ rates["fallback_rate"] = round(counts.get("fallback_count", 0) / total * 100, 2)
+ rates["timeout_rate"] = round(counts.get("timeout_count", 0) / total * 100, 2)
+ else:
+ rates = {"faq_hit_rate": 0.0, "rag_hit_rate": 0.0, "fallback_rate": 0.0, "timeout_rate": 0.0}
+
+ return {
+ "counts": counts,
+ "rates": rates,
+ "avg_ms": avg_ms,
+ "p95_ms": p95_ms,
+ }
diff --git a/backend/app/services/moderation.py b/backend/app/services/moderation.py
new file mode 100644
index 0000000..a5839f2
--- /dev/null
+++ b/backend/app/services/moderation.py
@@ -0,0 +1,175 @@
+"""
+악성·반복 민원 제한 서비스.
+
+Level 상태 자동 조치 해제
+0 정상 없음 자동
+1 주의 경고 메시지 자동
+2 경고 30초 응답 지연 자동 24h
+3 제한 10회/일 제한 자동 72h
+4 임시 차단 24시간 차단 편집장 수동 확인 필요
+5 영구 제한 차단 유지 편집장 수동 해제만
+
+원칙: 자동 영구 차단 없음. Level 4+ 는 편집장 수동 확인.
+"""
+import asyncio
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.moderation import UserRestriction, RestrictionLevel
+
+# 레벨별 자동 만료 시간
+LEVEL_EXPIRY = {
+ RestrictionLevel.WARNING: timedelta(hours=24),
+ RestrictionLevel.LIMITED: timedelta(hours=72),
+ RestrictionLevel.SUSPENDED: timedelta(hours=24), # 편집장 확인 전 임시
+}
+
+# 레벨 3 일별 제한 횟수
+DAILY_LIMIT = 10
+
+# 레벨 1 경고 메시지
+WARNING_MESSAGE = "⚠️ 동일 문의가 반복되고 있습니다. 잠시 후 다시 시도해 주세요."
+
+
+class ModerationResult:
+ def __init__(
+ self,
+ allowed: bool,
+ level: int = 0,
+ message: Optional[str] = None,
+ delay_seconds: int = 0,
+ ):
+ self.allowed = allowed
+ self.level = level
+ self.message = message # 경고 메시지 (Level 1)
+ self.delay_seconds = delay_seconds # 응답 지연 (Level 2)
+
+
+class ModerationService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def check(self, tenant_id: str, user_key: str) -> ModerationResult:
+ """
+ user_key의 제한 레벨 확인.
+ 만료된 제한은 자동 해제.
+ """
+ restriction = await self._get_restriction(tenant_id, user_key)
+
+ if restriction is None:
+ return ModerationResult(allowed=True, level=0)
+
+ # 만료 확인
+ if restriction.expires_at and restriction.expires_at < datetime.now(timezone.utc):
+ if restriction.level < RestrictionLevel.SUSPENDED:
+ await self._reset(restriction)
+ return ModerationResult(allowed=True, level=0)
+
+ level = restriction.level
+
+ if level == RestrictionLevel.BLOCKED:
+ return ModerationResult(allowed=False, level=level)
+
+ if level == RestrictionLevel.SUSPENDED:
+ # Level 4: 편집장 확인 전 차단
+ return ModerationResult(allowed=False, level=level)
+
+ if level == RestrictionLevel.LIMITED:
+ # Level 3: 일별 10회 제한 (Redis 카운터 없이 단순 차단)
+ return ModerationResult(
+ allowed=False,
+ level=level,
+ message="일일 문의 한도에 도달했습니다. 내일 다시 시도해 주세요.",
+ )
+
+ if level == RestrictionLevel.WARNING:
+ # Level 2: 30초 지연
+ return ModerationResult(
+ allowed=True,
+ level=level,
+ delay_seconds=30,
+ message="요청이 지연되고 있습니다.",
+ )
+
+ if level == RestrictionLevel.NORMAL + 1: # Level 1 (WARNING 사용하기 전 단계는 없으므로 1 = 주의)
+ return ModerationResult(
+ allowed=True,
+ level=level,
+ message=WARNING_MESSAGE,
+ )
+
+ return ModerationResult(allowed=True, level=level)
+
+ async def escalate(
+ self,
+ tenant_id: str,
+ user_key: str,
+ reason: str = "자동 감지",
+ ) -> int:
+ """
+ 레벨 1단계 상승. Level 4+ 는 편집장 수동 확인.
+ 반환: 새 레벨.
+ """
+ restriction = await self._get_restriction(tenant_id, user_key)
+
+ if restriction is None:
+ restriction = UserRestriction(
+ tenant_id=tenant_id,
+ user_key=user_key,
+ level=RestrictionLevel.NORMAL,
+ auto_applied=True,
+ )
+ self.db.add(restriction)
+
+ current = restriction.level
+ if current >= RestrictionLevel.SUSPENDED:
+ # Level 4+ 는 자동 상승 금지
+ return current
+
+ new_level = min(current + 1, RestrictionLevel.SUSPENDED)
+ restriction.level = new_level
+ restriction.reason = reason
+ restriction.auto_applied = True
+
+ # 만료 시간 설정
+ expiry_delta = LEVEL_EXPIRY.get(new_level)
+ if expiry_delta:
+ restriction.expires_at = datetime.now(timezone.utc) + expiry_delta
+ else:
+ restriction.expires_at = None
+
+ await self.db.commit()
+ return new_level
+
+ async def release(
+ self,
+ tenant_id: str,
+ user_key: str,
+ applied_by: str,
+ ) -> None:
+ """수동 해제 (편집장 이상)."""
+ restriction = await self._get_restriction(tenant_id, user_key)
+ if restriction:
+ await self._reset(restriction, applied_by=applied_by)
+
+ async def _get_restriction(
+ self, tenant_id: str, user_key: str
+ ) -> Optional[UserRestriction]:
+ result = await self.db.execute(
+ select(UserRestriction).where(
+ UserRestriction.tenant_id == tenant_id,
+ UserRestriction.user_key == user_key,
+ )
+ )
+ return result.scalar_one_or_none()
+
+ async def _reset(self, restriction: UserRestriction, applied_by: Optional[str] = None) -> None:
+ restriction.level = RestrictionLevel.NORMAL
+ restriction.expires_at = None
+ restriction.auto_applied = applied_by is None
+ if applied_by:
+ restriction.applied_by = applied_by
+ await self.db.commit()
diff --git a/backend/app/services/parsers/__init__.py b/backend/app/services/parsers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/parsers/text_parser.py b/backend/app/services/parsers/text_parser.py
new file mode 100644
index 0000000..031120f
--- /dev/null
+++ b/backend/app/services/parsers/text_parser.py
@@ -0,0 +1,96 @@
+"""
+문서 파서 — 1차 정식 지원 형식:
+TXT · MD · DOCX · 텍스트 PDF · HTML
+"""
+import io
+from typing import Optional
+
+
+def parse_txt(content: bytes, encoding: str = "utf-8") -> str:
+ return content.decode(encoding, errors="replace")
+
+
+def parse_md(content: bytes) -> str:
+ return content.decode("utf-8", errors="replace")
+
+
+def parse_html(content: bytes) -> str:
+ from bs4 import BeautifulSoup
+ soup = BeautifulSoup(content, "html.parser")
+ # script/style 제거
+ for tag in soup(["script", "style"]):
+ tag.decompose()
+ return soup.get_text(separator="\n", strip=True)
+
+
+def parse_docx(content: bytes) -> str:
+ from docx import Document
+ doc = Document(io.BytesIO(content))
+ return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
+
+
+def parse_pdf(content: bytes) -> str:
+ try:
+ import pdfplumber
+ with pdfplumber.open(io.BytesIO(content)) as pdf:
+ pages = []
+ for page in pdf.pages:
+ text = page.extract_text()
+ if text:
+ pages.append(text)
+ return "\n".join(pages)
+ except Exception:
+ return ""
+
+
+PARSERS = {
+ "txt": parse_txt,
+ "md": parse_md,
+ "html": parse_html,
+ "htm": parse_html,
+ "docx": parse_docx,
+ "pdf": parse_pdf,
+}
+
+
+def extract_text(content: bytes, filename: str) -> Optional[str]:
+ """파일 확장자에 따라 적절한 파서 선택."""
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
+ parser = PARSERS.get(ext)
+ if not parser:
+ return None
+ try:
+ return parser(content)
+ except Exception:
+ return None
+
+
+def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
+ """
+ 문단 단위 청킹 (약 chunk_size 토큰).
+ overlap: 이전 청크 끝 글자를 다음 청크 시작에 포함.
+ """
+ paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
+ chunks = []
+ current = []
+ current_len = 0
+
+ for para in paragraphs:
+ para_len = len(para)
+ if current_len + para_len > chunk_size and current:
+ chunk_text_ = "\n".join(current)
+ chunks.append(chunk_text_)
+ # overlap: 마지막 문단 유지
+ if overlap > 0 and current:
+ current = [current[-1]]
+ current_len = len(current[-1])
+ else:
+ current = []
+ current_len = 0
+ current.append(para)
+ current_len += para_len
+
+ if current:
+ chunks.append("\n".join(current))
+
+ return chunks if chunks else [text[:chunk_size]]
diff --git a/backend/app/services/rag_search.py b/backend/app/services/rag_search.py
new file mode 100644
index 0000000..6da46a4
--- /dev/null
+++ b/backend/app/services/rag_search.py
@@ -0,0 +1,109 @@
+"""
+Tier B — RAG 검색.
+임베딩 유사도 ≥ 0.70 + 근거 문서 존재 → 문서 기반 템플릿 응답.
+"""
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.knowledge import Document
+from app.providers.base import SearchResult
+from app.providers.embedding import EmbeddingProvider
+from app.providers.vectordb import VectorDBProvider
+
+RAG_SIMILARITY_THRESHOLD = 0.70 # Tier B 기준
+
+
+class RAGSearchResult:
+ def __init__(self, chunk_text: str, doc: Document, score: float):
+ self.chunk_text = chunk_text
+ self.doc = doc
+ self.score = score
+
+ @property
+ def doc_name(self) -> str:
+ return self.doc.filename
+
+ @property
+ def doc_date(self) -> str:
+ if self.doc.published_at:
+ return self.doc.published_at.strftime("%Y.%m")
+ return ""
+
+
+class RAGSearchService:
+ def __init__(
+ self,
+ embedding_provider: EmbeddingProvider,
+ vectordb_provider: VectorDBProvider,
+ db: AsyncSession,
+ ):
+ self.embedding = embedding_provider
+ self.vectordb = vectordb_provider
+ self.db = db
+
+ async def search(
+ self, tenant_id: str, utterance: str, top_k: int = 3
+ ) -> Optional[list[RAGSearchResult]]:
+ """
+ 발화를 임베딩 → 벡터DB 검색 → 0.70 이상 문서 청크 반환.
+ 결과 없으면 None.
+ """
+ try:
+ vecs = await self.embedding.embed([utterance])
+ except NotImplementedError:
+ return None
+
+ query_vec = vecs[0]
+ results = await self.vectordb.search(
+ tenant_id=tenant_id,
+ query_vec=query_vec,
+ top_k=top_k,
+ threshold=RAG_SIMILARITY_THRESHOLD,
+ )
+
+ if not results:
+ return None
+
+ # 중복 doc_id 제거 (같은 문서의 여러 청크 중 최고 점수만)
+ seen_docs: dict[str, SearchResult] = {}
+ for r in results:
+ doc_id = r.metadata.get("doc_id", r.doc_id.rsplit("_", 1)[0])
+ if doc_id not in seen_docs or r.score > seen_docs[doc_id].score:
+ seen_docs[doc_id] = r
+
+ # Document 레코드 로드 (is_active=True만)
+ rag_results = []
+ for doc_id, sr in seen_docs.items():
+ doc = await self._load_doc(tenant_id, doc_id)
+ if doc:
+ rag_results.append(RAGSearchResult(sr.text, doc, sr.score))
+
+ return rag_results if rag_results else None
+
+ async def _load_doc(self, tenant_id: str, doc_id: str) -> Optional[Document]:
+ result = await self.db.execute(
+ select(Document).where(
+ Document.tenant_id == tenant_id,
+ Document.id == doc_id,
+ Document.is_active.is_(True),
+ )
+ )
+ return result.scalar_one_or_none()
+
+ def build_answer(self, utterance: str, rag_results: list[RAGSearchResult]) -> str:
+ """
+ 문서 기반 템플릿 응답 생성.
+ 출처 2단계 포맷 (간단형).
+ """
+ # 근거 문단 합치기 (최대 2개)
+ contexts = [r.chunk_text[:300] for r in rag_results[:2]]
+ context_str = "\n---\n".join(contexts)
+
+ best = rag_results[0]
+ citation = f"📎 출처: {best.doc_name}"
+ if best.doc_date:
+ citation += f" ({best.doc_date})"
+
+ return f"{context_str}\n\n{citation}"
diff --git a/backend/app/services/routing.py b/backend/app/services/routing.py
new file mode 100644
index 0000000..667f59c
--- /dev/null
+++ b/backend/app/services/routing.py
@@ -0,0 +1,243 @@
+import asyncio
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+@dataclass
+class RoutingResult:
+ answer: str
+ tier: str # 'A'|'B'|'C'|'D'
+ source: str # 'faq'|'rag'|'llm'|'fallback'
+ faq_id: Optional[str] = None
+ doc_id: Optional[str] = None
+ doc_name: Optional[str] = None
+ doc_date: Optional[str] = None
+ score: float = 0.0
+ elapsed_ms: int = 0
+ is_timeout: bool = False
+ request_id: Optional[str] = None
+
+ def to_dict(self) -> dict:
+ citations = []
+ if self.doc_name:
+ citations.append({"doc": self.doc_name, "date": self.doc_date or ""})
+ return {
+ "answer": self.answer,
+ "tier": self.tier,
+ "source": self.source,
+ "faq_id": self.faq_id,
+ "doc_id": self.doc_id,
+ "score": self.score,
+ "elapsed_ms": self.elapsed_ms,
+ "is_timeout": self.is_timeout,
+ "request_id": self.request_id,
+ "citations": citations,
+ }
+
+
+class ResponseRouter:
+ TIMEOUT_MS = 4500 # 4.5초 — 카카오 5초 한계 - 500ms
+
+ def __init__(self, tenant_config: dict, providers: dict):
+ self.tenant_config = tenant_config
+ self.providers = providers
+
+ async def route(
+ self,
+ tenant_id: str,
+ utterance: str,
+ user_key: str,
+ request_id: Optional[str] = None,
+ db=None,
+ ) -> RoutingResult:
+ import time
+ start = time.monotonic()
+ try:
+ return await asyncio.wait_for(
+ self._try_tiers(tenant_id, utterance, user_key, request_id, db, start),
+ timeout=self.TIMEOUT_MS / 1000,
+ )
+ except asyncio.TimeoutError:
+ elapsed = int((time.monotonic() - start) * 1000)
+ return self._tier_d(tenant_id, elapsed, is_timeout=True, request_id=request_id)
+
+ async def _try_tiers(
+ self,
+ tenant_id: str,
+ utterance: str,
+ user_key: str,
+ request_id: Optional[str],
+ db,
+ start: float,
+ ) -> RoutingResult:
+ import time
+
+ # Tier A — FAQ 임베딩 유사도 검색
+ tier_a = await self._try_tier_a(tenant_id, utterance, db)
+ if tier_a is not None:
+ tier_a.elapsed_ms = int((time.monotonic() - start) * 1000)
+ tier_a.request_id = request_id
+ return tier_a
+
+ # Tier C — LLM 기반 재서술 (RAG 근거 있음 + LLM 활성화)
+ # Tier B보다 먼저 시도: LLM 활성 시 템플릿 대신 재서술
+ tier_c = await self._try_tier_c(tenant_id, utterance, db)
+ if tier_c is not None:
+ tier_c.elapsed_ms = int((time.monotonic() - start) * 1000)
+ tier_c.request_id = request_id
+ return tier_c
+
+ # Tier B — RAG 문서 검색 (LLM 비활성 또는 Tier C 실패 시)
+ tier_b = await self._try_tier_b(tenant_id, utterance, db)
+ if tier_b is not None:
+ tier_b.elapsed_ms = int((time.monotonic() - start) * 1000)
+ tier_b.request_id = request_id
+ return tier_b
+
+ elapsed = int((time.monotonic() - start) * 1000)
+ return self._tier_d(tenant_id, elapsed, request_id=request_id)
+
+ async def _try_tier_a(self, tenant_id: str, utterance: str, db) -> Optional[RoutingResult]:
+ """Tier A — FAQ 임베딩 유사도 ≥ 0.85."""
+ embedding_provider = self.providers.get("embedding")
+ vectordb_provider = self.providers.get("vectordb")
+
+ if embedding_provider is None or vectordb_provider is None or db is None:
+ return None
+
+ from app.services.faq_search import FAQSearchService
+ service = FAQSearchService(embedding_provider, vectordb_provider, db)
+ match = await service.search(tenant_id, utterance)
+
+ if match is None:
+ return None
+
+ faq, score = match
+ # hit_count 비동기 증가 (fire-and-forget)
+ await service.increment_hit(faq.id)
+
+ citation_date = (
+ faq.updated_at.strftime("%Y.%m") if faq.updated_at else ""
+ )
+
+ return RoutingResult(
+ answer=faq.answer,
+ tier="A",
+ source="faq",
+ faq_id=faq.id,
+ doc_name=f"FAQ: {faq.question[:30]}",
+ doc_date=citation_date,
+ score=score,
+ )
+
+ async def _try_tier_c(self, tenant_id: str, utterance: str, db) -> Optional[RoutingResult]:
+ """Tier C — RAG 근거 있음 + LLM 활성화 → 근거 기반 재서술."""
+ llm_provider = self.providers.get("llm")
+ embedding_provider = self.providers.get("embedding")
+ vectordb_provider = self.providers.get("vectordb")
+
+ if llm_provider is None or embedding_provider is None or vectordb_provider is None or db is None:
+ return None
+
+ # NullLLMProvider → None 즉시 반환
+ from app.providers.llm import NullLLMProvider
+ if isinstance(llm_provider, NullLLMProvider):
+ return None
+
+ # RAG 검색 (Tier B와 동일 임계값)
+ from app.services.rag_search import RAGSearchService
+ rag_service = RAGSearchService(embedding_provider, vectordb_provider, db)
+ rag_results = await rag_service.search(tenant_id, utterance)
+
+ if not rag_results:
+ return None # 근거 없으면 LLM 미호출 (P6 할루시네이션 방지)
+
+ # 근거 기반 LLM 재서술
+ context_chunks = [r.chunk_text for r in rag_results[:3]]
+ context_str = "\n---\n".join(context_chunks)
+ tenant_name = self.tenant_config.get("tenant_name", "")
+ name_prefix = f"{tenant_name}의 " if tenant_name else ""
+
+ system_prompt = (
+ f"당신은 {name_prefix}AI 안내 도우미입니다.\n"
+ f"반드시 아래 근거 문서의 내용만을 바탕으로 답변하세요.\n"
+ f"근거 없는 내용은 절대 추측하지 마세요.\n\n"
+ f"근거 문서:\n{context_str}"
+ )
+
+ answer = await llm_provider.generate(
+ system_prompt=system_prompt,
+ user_message=utterance,
+ context_chunks=context_chunks,
+ )
+
+ if answer is None:
+ return None # LLM 실패 → Tier D로 폴백
+
+ best = rag_results[0]
+ return RoutingResult(
+ answer=answer,
+ tier="C",
+ source="llm",
+ doc_id=best.doc.id,
+ doc_name=best.doc_name,
+ doc_date=best.doc_date,
+ score=best.score,
+ )
+
+ async def _try_tier_b(self, tenant_id: str, utterance: str, db) -> Optional[RoutingResult]:
+ """Tier B — RAG 유사도 ≥ 0.70 + 근거 문서 존재."""
+ embedding_provider = self.providers.get("embedding")
+ vectordb_provider = self.providers.get("vectordb")
+
+ if embedding_provider is None or vectordb_provider is None or db is None:
+ return None
+
+ from app.services.rag_search import RAGSearchService
+ service = RAGSearchService(embedding_provider, vectordb_provider, db)
+ results = await service.search(tenant_id, utterance)
+
+ if not results:
+ return None
+
+ best = results[0]
+ answer = service.build_answer(utterance, results)
+
+ return RoutingResult(
+ answer=answer,
+ tier="B",
+ source="rag",
+ doc_id=best.doc.id,
+ doc_name=best.doc_name,
+ doc_date=best.doc_date,
+ score=best.score,
+ )
+
+ def _tier_d(
+ self,
+ tenant_id: str,
+ elapsed_ms: int,
+ is_timeout: bool = False,
+ request_id: Optional[str] = None,
+ ) -> RoutingResult:
+ # DB 조회 없이 tenant_config 메모리에서 직접 읽음 (~5ms)
+ phone = self.tenant_config.get("phone_number", "")
+ contact = self.tenant_config.get("fallback_dept", "")
+ name = self.tenant_config.get("tenant_name", "")
+
+ if phone and contact:
+ answer = f"해당 문의는 {name} {contact}({phone})로 연락해 주세요."
+ elif phone:
+ answer = f"해당 문의는 {name}({phone})로 연락해 주세요." if name else f"해당 문의는 {phone}로 연락해 주세요."
+ elif name:
+ answer = f"죄송합니다. {name}에 직접 문의해 주세요."
+ else:
+ answer = "죄송합니다. 해당 내용을 찾을 수 없습니다. 담당자에게 직접 문의해 주세요."
+ return RoutingResult(
+ answer=answer,
+ tier="D",
+ source="fallback",
+ elapsed_ms=elapsed_ms,
+ is_timeout=is_timeout,
+ request_id=request_id,
+ )
diff --git a/backend/pytest.ini b/backend/pytest.ini
new file mode 100644
index 0000000..2f4c80e
--- /dev/null
+++ b/backend/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+asyncio_mode = auto
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..c4800d8
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,20 @@
+fastapi==0.115.0
+uvicorn[standard]==0.30.6
+pydantic==2.8.2
+pydantic-settings==2.4.0
+sqlalchemy[asyncio]==2.0.35
+pytest==8.3.3
+pytest-asyncio==0.24.0
+httpx==0.27.2
+aiosqlite==0.20.0
+asyncpg==0.29.0
+alembic==1.13.2
+redis[asyncio]==5.1.1
+bcrypt==4.2.0
+pyjwt==2.9.0
+python-multipart==0.0.9
+sentence-transformers==3.1.1
+chromadb==0.5.18
+beautifulsoup4==4.12.3
+python-docx==1.1.2
+pdfplumber==0.11.4
diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..66bff52
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,56 @@
+import pytest
+import pytest_asyncio
+from httpx import AsyncClient, ASGITransport
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.orm import sessionmaker
+
+from app.core.database import Base, get_db
+from app.main import app
+
+# 모든 모델을 명시적으로 임포트하여 Base.metadata에 등록 (FK 순서 보장)
+from app.models import tenant as _m1 # noqa: F401
+from app.models import admin as _m2 # noqa: F401
+from app.models import knowledge as _m3 # noqa: F401
+from app.models import complaint as _m4 # noqa: F401
+from app.models import moderation as _m5 # noqa: F401
+from app.models import audit as _m6 # noqa: F401
+
+TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
+
+
+@pytest_asyncio.fixture(scope="function")
+async def db_engine():
+ engine = create_async_engine(TEST_DATABASE_URL, echo=False)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield engine
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.drop_all)
+ await engine.dispose()
+
+
+@pytest_asyncio.fixture(scope="function")
+async def db_session(db_engine):
+ async_session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
+ async with async_session() as session:
+ yield session
+
+
+@pytest_asyncio.fixture(scope="function")
+async def client(db_engine):
+ async_session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
+
+ async def override_get_db():
+ async with async_session() as session:
+ yield session
+
+ app.dependency_overrides[get_db] = override_get_db
+
+ async with AsyncClient(
+ transport=ASGITransport(app=app), base_url="http://test"
+ ) as ac:
+ # app 참조를 client에 노출 (테스트에서 app.state 패치 용도)
+ ac.app = app
+ yield ac
+
+ app.dependency_overrides.clear()
diff --git a/backend/tests/test_admin_auth.py b/backend/tests/test_admin_auth.py
new file mode 100644
index 0000000..3566aaa
--- /dev/null
+++ b/backend/tests/test_admin_auth.py
@@ -0,0 +1,114 @@
+"""
+관리자 인증 API 테스트.
+POST /api/admin/auth/login
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+
+from app.models.admin import AdminUser, AdminRole
+from app.core.security import hash_password
+
+
+def make_user(tenant_id: str = "t1", email: str = "admin@test.com") -> AdminUser:
+ user = AdminUser()
+ user.id = str(uuid4())
+ user.tenant_id = tenant_id
+ user.email = email
+ user.hashed_pw = hash_password("secret123")
+ user.role = AdminRole.admin
+ user.is_active = True
+ return user
+
+
+@pytest.mark.asyncio
+async def test_login_success(client):
+ """올바른 자격증명 → access_token + role 반환."""
+ user = make_user()
+
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none = MagicMock(return_value=user)
+
+ from app.core.database import get_db
+
+ async def override_db():
+ db = AsyncMock()
+ db.execute = AsyncMock(return_value=mock_result)
+ yield db
+
+ client.app.dependency_overrides[get_db] = override_db
+
+ try:
+ res = await client.post("/api/admin/auth/login", json={
+ "tenant_id": user.tenant_id,
+ "email": user.email,
+ "password": "secret123",
+ })
+ assert res.status_code == 200
+ data = res.json()
+ assert "access_token" in data
+ assert data["token_type"] == "bearer"
+ assert data["role"] == "admin"
+ finally:
+ client.app.dependency_overrides.pop(get_db, None)
+
+
+@pytest.mark.asyncio
+async def test_login_wrong_password(client):
+ """틀린 비밀번호 → 401."""
+ user = make_user()
+
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none = MagicMock(return_value=user)
+
+ from app.core.database import get_db
+
+ async def override_db():
+ db = AsyncMock()
+ db.execute = AsyncMock(return_value=mock_result)
+ yield db
+
+ client.app.dependency_overrides[get_db] = override_db
+
+ try:
+ res = await client.post("/api/admin/auth/login", json={
+ "tenant_id": user.tenant_id,
+ "email": user.email,
+ "password": "wrong-password",
+ })
+ assert res.status_code == 401
+ finally:
+ client.app.dependency_overrides.pop(get_db, None)
+
+
+@pytest.mark.asyncio
+async def test_login_user_not_found(client):
+ """존재하지 않는 사용자 → 401."""
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none = MagicMock(return_value=None)
+
+ from app.core.database import get_db
+
+ async def override_db():
+ db = AsyncMock()
+ db.execute = AsyncMock(return_value=mock_result)
+ yield db
+
+ client.app.dependency_overrides[get_db] = override_db
+
+ try:
+ res = await client.post("/api/admin/auth/login", json={
+ "tenant_id": "nonexistent",
+ "email": "nobody@test.com",
+ "password": "any",
+ })
+ assert res.status_code == 401
+ finally:
+ client.app.dependency_overrides.pop(get_db, None)
+
+
+@pytest.mark.asyncio
+async def test_login_missing_fields(client):
+ """필수 필드 누락 → 422."""
+ res = await client.post("/api/admin/auth/login", json={"email": "x@x.com"})
+ assert res.status_code == 422
diff --git a/backend/tests/test_admin_metrics.py b/backend/tests/test_admin_metrics.py
new file mode 100644
index 0000000..03b6317
--- /dev/null
+++ b/backend/tests/test_admin_metrics.py
@@ -0,0 +1,79 @@
+"""
+메트릭 조회 API 테스트.
+GET /api/admin/metrics
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+
+from app.models.admin import AdminUser, AdminRole
+from app.core.security import create_admin_token, hash_password
+
+
+def make_user(tenant_id: str = "t1") -> AdminUser:
+ user = AdminUser()
+ user.id = str(uuid4())
+ user.tenant_id = tenant_id
+ user.email = "admin@test.com"
+ user.hashed_pw = hash_password("pw")
+ user.role = AdminRole.admin
+ user.is_active = True
+ return user
+
+
+def auth_headers(user: AdminUser) -> dict:
+ token = create_admin_token(user.id, user.tenant_id, user.role.value)
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.mark.asyncio
+async def test_metrics_no_auth_returns_401(client):
+ """인증 없이 접근 → 401."""
+ res = await client.get("/api/admin/metrics")
+ assert res.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_metrics_db_fallback(client):
+ """Redis 없을 때 DB 집계로 메트릭 반환."""
+ user = make_user()
+
+ call_count = 0
+
+ async def fake_execute(query):
+ nonlocal call_count
+ call_count += 1
+ mock = MagicMock()
+ if call_count == 1:
+ # get_current_admin: AdminUser 조회
+ mock.scalar_one_or_none = MagicMock(return_value=user)
+ return mock
+ sql = str(query)
+ if "response_tier" in sql or "group_by" in sql.lower():
+ mock.all = MagicMock(return_value=[("A", 5), ("B", 3), ("D", 2)])
+ elif "is_timeout" in sql:
+ mock.scalar = MagicMock(return_value=1)
+ else:
+ mock.scalar = MagicMock(return_value=10)
+ return mock
+
+ from app.core.database import get_db
+
+ async def override_db():
+ db = AsyncMock()
+ db.execute = AsyncMock(side_effect=fake_execute)
+ yield db
+
+ client.app.dependency_overrides[get_db] = override_db
+ client.app.state.redis = None
+
+ try:
+ res = await client.get("/api/admin/metrics", headers=auth_headers(user))
+ assert res.status_code == 200
+ data = res.json()
+ assert "total_count" in data
+ assert "tier_counts" in data
+ assert "timeout_count" in data
+ assert set(data["tier_counts"].keys()) == {"A", "B", "C", "D"}
+ finally:
+ client.app.dependency_overrides.pop(get_db, None)
diff --git a/backend/tests/test_audit_log.py b/backend/tests/test_audit_log.py
new file mode 100644
index 0000000..d8e2b89
--- /dev/null
+++ b/backend/tests/test_audit_log.py
@@ -0,0 +1,115 @@
+"""
+감사 로그 단위 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock, call
+
+from app.services.audit import log_action
+from app.models.audit import AuditLog
+
+
+@pytest.mark.asyncio
+async def test_log_action_creates_audit_entry():
+ """log_action 호출 시 AuditLog 추가 및 커밋."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ entry = await log_action(
+ db=db,
+ tenant_id="tenant-1",
+ actor_id="user-1",
+ actor_type="admin_user",
+ action="doc.upload",
+ target_type="document",
+ target_id="doc-1",
+ diff={"filename": "guide.txt"},
+ )
+
+ db.add.assert_called_once()
+ db.commit.assert_called_once()
+ assert entry.action == "doc.upload"
+ assert entry.tenant_id == "tenant-1"
+ assert entry.actor_id == "user-1"
+
+
+@pytest.mark.asyncio
+async def test_log_action_doc_approve():
+ """doc.approve 액션 기록."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ entry = await log_action(
+ db=db,
+ tenant_id="t1",
+ actor_id="editor-1",
+ actor_type="admin_user",
+ action="doc.approve",
+ target_type="document",
+ target_id="doc-2",
+ )
+
+ assert entry.action == "doc.approve"
+ assert entry.target_id == "doc-2"
+
+
+@pytest.mark.asyncio
+async def test_log_action_includes_diff():
+ """diff 필드가 저장됨."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ diff = {"before": "pending", "after": "processed"}
+ entry = await log_action(
+ db=db,
+ tenant_id="t1",
+ actor_id="user-1",
+ actor_type="admin_user",
+ action="config.update",
+ diff=diff,
+ )
+
+ assert entry.diff == diff
+
+
+@pytest.mark.asyncio
+async def test_log_action_without_target():
+ """target_type/target_id 없어도 정상 기록."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ entry = await log_action(
+ db=db,
+ tenant_id="t1",
+ actor_id="user-1",
+ actor_type="system_admin",
+ action="config.update",
+ )
+
+ assert entry.target_type is None
+ assert entry.target_id is None
+
+
+@pytest.mark.asyncio
+async def test_log_crawler_approve():
+ """crawler.approve 감사 로그."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ entry = await log_action(
+ db=db,
+ tenant_id="t1",
+ actor_id="editor-1",
+ actor_type="admin_user",
+ action="crawler.approve",
+ target_type="crawler_url",
+ target_id="url-1",
+ diff={"url": "https://www.dongducheon.go.kr"},
+ )
+
+ assert entry.action == "crawler.approve"
+ assert entry.diff["url"] == "https://www.dongducheon.go.kr"
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
new file mode 100644
index 0000000..e5baf7a
--- /dev/null
+++ b/backend/tests/test_auth.py
@@ -0,0 +1,65 @@
+from datetime import datetime, timedelta, timezone
+
+import jwt
+import pytest
+
+from app.core.security import (
+ hash_password,
+ verify_password,
+ create_admin_token,
+ create_system_token,
+ decode_token,
+)
+from app.core.config import settings
+
+
+def test_hash_and_verify_password():
+ """해싱 후 verify True, 다른 비밀번호는 False."""
+ hashed = hash_password("secret123")
+ assert verify_password("secret123", hashed) is True
+ assert verify_password("wrong", hashed) is False
+
+
+def test_create_admin_token_has_tenant_id():
+ """decode 시 payload['tenant_id'] == 'tenant-id', type == 'admin_user'."""
+ token = create_admin_token("user-1", "tenant-id", "admin")
+ payload = decode_token(token)
+ assert payload is not None
+ assert payload["tenant_id"] == "tenant-id"
+ assert payload["type"] == "admin_user"
+
+
+def test_create_system_token_has_no_tenant_id():
+ """decode 시 payload['tenant_id'] is None, type == 'system_admin'."""
+ token = create_system_token("sys-admin-1")
+ payload = decode_token(token)
+ assert payload is not None
+ assert payload["tenant_id"] is None
+ assert payload["type"] == "system_admin"
+
+
+def test_decode_invalid_token_returns_none():
+ """decode_token('invalid.token') == None."""
+ assert decode_token("invalid.token") is None
+
+
+def test_decode_expired_token_returns_none():
+ """만료 토큰 생성 후 decode == None."""
+ payload = {
+ "sub": "user-1",
+ "tenant_id": "tenant-1",
+ "exp": datetime.now(timezone.utc) - timedelta(seconds=1),
+ }
+ expired_token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
+ assert decode_token(expired_token) is None
+
+
+def test_admin_and_system_token_different_type():
+ """두 토큰의 type 필드 값 다름."""
+ admin_token = create_admin_token("user-1", "tenant-1", "admin")
+ system_token = create_system_token("sys-1")
+
+ admin_payload = decode_token(admin_token)
+ system_payload = decode_token(system_token)
+
+ assert admin_payload["type"] != system_payload["type"]
diff --git a/backend/tests/test_complaint_logger.py b/backend/tests/test_complaint_logger.py
new file mode 100644
index 0000000..8063c6a
--- /dev/null
+++ b/backend/tests/test_complaint_logger.py
@@ -0,0 +1,128 @@
+"""
+민원 이력 저장 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+from app.services.complaint_logger import log_complaint
+from app.services.routing import RoutingResult
+
+
+def make_result(tier="D", source="fallback", request_id="req-1") -> RoutingResult:
+ return RoutingResult(
+ answer="답변",
+ tier=tier,
+ source=source,
+ elapsed_ms=100,
+ is_timeout=False,
+ request_id=request_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_log_complaint_masks_utterance():
+ """민원 이력에 원문 미저장, 마스킹 후 저장."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ raw_utterance = "제 전화번호는 010-1234-5678 입니다"
+ result = make_result()
+
+ entry = await log_complaint(
+ db=db,
+ tenant_id="t1",
+ raw_utterance=raw_utterance,
+ raw_user_id="kakao-user-123",
+ result=result,
+ )
+
+ # 원문 미저장
+ assert "010-1234-5678" not in (entry.utterance_masked or "")
+ # 마스킹 처리됨
+ assert "***-****-****" in (entry.utterance_masked or "")
+
+
+@pytest.mark.asyncio
+async def test_log_complaint_hashes_user_id():
+ """user_key는 해시값(16자리)으로 저장."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ result = make_result()
+ entry = await log_complaint(
+ db=db,
+ tenant_id="t1",
+ raw_utterance="일반 민원",
+ raw_user_id="real-kakao-id-12345",
+ result=result,
+ )
+
+ assert entry.user_key != "real-kakao-id-12345"
+ assert len(entry.user_key) == 16
+
+
+@pytest.mark.asyncio
+async def test_log_complaint_stores_tier_and_source():
+ """response_tier, response_source 저장."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ result = make_result(tier="A", source="faq")
+ entry = await log_complaint(
+ db=db,
+ tenant_id="t1",
+ raw_utterance="질문",
+ raw_user_id="user-1",
+ result=result,
+ )
+
+ assert entry.response_tier == "A"
+ assert entry.response_source == "faq"
+
+
+@pytest.mark.asyncio
+async def test_log_complaint_stores_request_id():
+ """request_id 저장 (Idempotency 추적)."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ result = make_result(request_id="unique-req-abc")
+ entry = await log_complaint(
+ db=db,
+ tenant_id="t1",
+ raw_utterance="질문",
+ raw_user_id="user-1",
+ result=result,
+ )
+
+ assert entry.request_id == "unique-req-abc"
+
+
+@pytest.mark.asyncio
+async def test_log_complaint_timeout_flag():
+ """타임아웃 시 is_timeout=True 저장."""
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+
+ result = RoutingResult(
+ answer="타임아웃",
+ tier="D",
+ source="fallback",
+ elapsed_ms=4500,
+ is_timeout=True,
+ )
+
+ entry = await log_complaint(
+ db=db,
+ tenant_id="t1",
+ raw_utterance="질문",
+ raw_user_id="user-1",
+ result=result,
+ )
+
+ assert entry.is_timeout is True
diff --git a/backend/tests/test_crawler.py b/backend/tests/test_crawler.py
new file mode 100644
index 0000000..abb5145
--- /dev/null
+++ b/backend/tests/test_crawler.py
@@ -0,0 +1,92 @@
+"""
+크롤러 서비스 단위 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from app.services.crawler import crawl_url, check_robots_txt, CrawlerService
+from app.services.parsers.text_parser import extract_text
+
+
+@pytest.mark.asyncio
+async def test_crawl_url_extracts_text():
+ """정상 HTML 응답 → 텍스트 추출."""
+ html_str = "동두천시 여권 안내
"
+ html = html_str.encode("utf-8")
+
+ mock_response = MagicMock()
+ mock_response.content = html
+ mock_response.text = html_str
+ mock_response.headers = {"content-type": "text/html"}
+ mock_response.raise_for_status = MagicMock()
+
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client.get = AsyncMock(return_value=mock_response)
+
+ with patch("app.services.crawler.check_robots_txt", return_value=True), \
+ patch("httpx.AsyncClient", return_value=mock_client):
+ result = await crawl_url("https://www.example.com/guide")
+
+ assert result is not None
+ assert "동두천시" in result
+ assert "bad()" not in result
+
+
+@pytest.mark.asyncio
+async def test_crawl_url_returns_none_on_robots_disallow():
+ """robots.txt 불허 → None 반환."""
+ with patch("app.services.crawler.check_robots_txt", return_value=False):
+ result = await crawl_url("https://www.example.com/private")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_crawl_url_returns_none_on_network_error():
+ """네트워크 오류 → None 반환 (예외 미전파)."""
+ import httpx
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+ mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
+
+ with patch("app.services.crawler.check_robots_txt", return_value=True), \
+ patch("httpx.AsyncClient", return_value=mock_client):
+ result = await crawl_url("https://unreachable.example.com")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_crawler_service_updates_last_crawled():
+ """run() 후 last_crawled 업데이트."""
+ db = AsyncMock()
+ db.commit = AsyncMock()
+
+ crawler_url = MagicMock()
+ crawler_url.url = "https://www.example.com"
+ crawler_url.last_crawled = None
+
+ with patch("app.services.crawler.crawl_url", return_value="크롤링된 내용"):
+ service = CrawlerService(db)
+ result = await service.run(crawler_url, "tenant-1")
+
+ assert result == "크롤링된 내용"
+ assert crawler_url.last_crawled is not None
+ db.commit.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_crawler_service_returns_none_on_fail():
+ """크롤링 실패 → None 반환."""
+ db = AsyncMock()
+ crawler_url = MagicMock()
+ crawler_url.url = "https://fail.example.com"
+
+ with patch("app.services.crawler.crawl_url", return_value=None):
+ service = CrawlerService(db)
+ result = await service.run(crawler_url, "tenant-1")
+
+ assert result is None
diff --git a/backend/tests/test_document_processor.py b/backend/tests/test_document_processor.py
new file mode 100644
index 0000000..6e2af00
--- /dev/null
+++ b/backend/tests/test_document_processor.py
@@ -0,0 +1,123 @@
+"""
+DocumentProcessor + 텍스트 파서 단위 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+from app.services.parsers.text_parser import extract_text, chunk_text
+from app.services.document_processor import DocumentProcessor
+
+
+# ── 파서 테스트 ─────────────────────────────────────────────────────
+
+def test_extract_text_txt():
+ content = "안녕하세요\n여권 발급 안내입니다".encode("utf-8")
+ result = extract_text(content, "guide.txt")
+ assert "여권" in result
+
+
+def test_extract_text_md():
+ content = "# 제목\n본문 내용".encode("utf-8")
+ result = extract_text(content, "readme.md")
+ assert "본문" in result
+
+
+def test_extract_text_html():
+ content = "여권 안내
".encode("utf-8")
+ result = extract_text(content, "page.html")
+ assert "여권" in result
+ assert "alert" not in result
+
+
+def test_extract_text_unsupported_returns_none():
+ result = extract_text(b"binary", "file.exe")
+ assert result is None
+
+
+def test_chunk_text_splits_correctly():
+ text = "\n".join(["문장 " + str(i) for i in range(50)])
+ chunks = chunk_text(text, chunk_size=100)
+ assert len(chunks) > 1
+ for chunk in chunks:
+ assert len(chunk) > 0
+
+
+def test_chunk_text_small_text_single_chunk():
+ text = "짧은 텍스트"
+ chunks = chunk_text(text, chunk_size=500)
+ assert len(chunks) == 1
+ assert "짧은 텍스트" in chunks[0]
+
+
+# ── DocumentProcessor 테스트 ────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_processor_stores_chunks_in_vectordb():
+ """파싱 성공 → VectorDB에 upsert 호출."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768, [0.2] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.upsert = AsyncMock(return_value=2)
+
+ db = AsyncMock()
+ db.commit = AsyncMock()
+
+ doc = MagicMock()
+ doc.id = "doc-1"
+ doc.filename = "test.txt"
+ doc.published_at = None
+ doc.status = "pending"
+ doc.chunk_count = 0
+
+ processor = DocumentProcessor(embedding, vectordb, db)
+ content = "첫 번째 문단입니다.\n두 번째 문단입니다.".encode("utf-8")
+ count = await processor.process("tenant-1", doc, content)
+
+ assert count > 0
+ assert vectordb.upsert.called
+ assert doc.status == "processed"
+
+
+@pytest.mark.asyncio
+async def test_processor_fails_on_unsupported_format():
+ """지원하지 않는 파일 형식 → chunk_count=0, status=parse_failed."""
+ embedding = AsyncMock()
+ vectordb = AsyncMock()
+ db = AsyncMock()
+ db.commit = AsyncMock()
+
+ doc = MagicMock()
+ doc.id = "doc-2"
+ doc.filename = "file.exe"
+ doc.published_at = None
+ doc.status = "pending"
+
+ processor = DocumentProcessor(embedding, vectordb, db)
+ count = await processor.process("tenant-1", doc, b"binary data")
+
+ assert count == 0
+ assert doc.status == "parse_failed"
+
+
+@pytest.mark.asyncio
+async def test_processor_handles_embedding_not_implemented():
+ """임베딩 미구성 → status=embedding_unavailable."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(side_effect=NotImplementedError)
+
+ vectordb = AsyncMock()
+ db = AsyncMock()
+ db.commit = AsyncMock()
+
+ doc = MagicMock()
+ doc.id = "doc-3"
+ doc.filename = "guide.txt"
+ doc.published_at = None
+ doc.status = "pending"
+
+ processor = DocumentProcessor(embedding, vectordb, db)
+ count = await processor.process("tenant-1", doc, "본문 내용".encode("utf-8"))
+
+ assert count == 0
+ assert doc.status == "embedding_unavailable"
diff --git a/backend/tests/test_engine_api.py b/backend/tests/test_engine_api.py
new file mode 100644
index 0000000..8c97bb8
--- /dev/null
+++ b/backend/tests/test_engine_api.py
@@ -0,0 +1,109 @@
+"""
+POST /engine/query 엔드포인트 테스트 (웹 시뮬레이터).
+"""
+import pytest
+from unittest.mock import AsyncMock, patch
+
+
+@pytest.mark.asyncio
+async def test_engine_query_returns_correct_structure(client):
+ """POST /engine/query → 필수 필드 포함 응답."""
+ response = await client.post(
+ "/engine/query",
+ json={
+ "tenant": "test-tenant",
+ "utterance": "여권 발급 방법 알려주세요",
+ "user_key": "test-user-key",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "answer" in data
+ assert "tier" in data
+ assert "source" in data
+ assert "citations" in data
+ assert "request_id" in data
+
+
+@pytest.mark.asyncio
+async def test_engine_query_tier_d_when_no_faq(client):
+ """FAQ 없으면 Tier D 폴백 반환."""
+ response = await client.post(
+ "/engine/query",
+ json={
+ "tenant": "test-tenant",
+ "utterance": "알 수 없는 질문",
+ "user_key": "user-1",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["tier"] == "D"
+ assert data["source"] == "fallback"
+
+
+@pytest.mark.asyncio
+async def test_engine_query_idempotency_deduplication(client):
+ """같은 request_id로 두 번 요청 → 동일 응답 (캐시 HIT)."""
+ payload = {
+ "tenant": "test-tenant",
+ "utterance": "중복 요청 테스트",
+ "user_key": "user-1",
+ "request_id": "dup-req-001",
+ }
+
+ # Mock Redis for idempotency
+ mock_redis = AsyncMock()
+ cached_result = {
+ "answer": "캐시된 응답",
+ "tier": "D",
+ "source": "fallback",
+ "citations": [],
+ "request_id": "dup-req-001",
+ "elapsed_ms": 10,
+ "is_timeout": False,
+ }
+ import json
+ mock_redis.get = AsyncMock(return_value=json.dumps(cached_result).encode())
+ mock_redis.setex = AsyncMock()
+
+ # app.state에 직접 설정 (lifespan 미실행 상태)
+ client.app.state.redis = mock_redis
+ try:
+ resp1 = await client.post("/engine/query", json=payload)
+ finally:
+ client.app.state.redis = None
+
+ assert resp1.status_code == 200
+ assert resp1.json()["answer"] == "캐시된 응답"
+
+
+@pytest.mark.asyncio
+async def test_engine_query_request_id_auto_generated(client):
+ """request_id 미전송 시 자동 생성."""
+ response = await client.post(
+ "/engine/query",
+ json={
+ "tenant": "test-tenant",
+ "utterance": "테스트",
+ "user_key": "user-1",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["request_id"] is not None
+ assert len(data["request_id"]) > 0
+
+
+@pytest.mark.asyncio
+async def test_engine_query_channel_default_web(client):
+ """channel 미전송 시 기본값 web 사용 — 응답은 정상."""
+ response = await client.post(
+ "/engine/query",
+ json={
+ "tenant": "test-tenant",
+ "utterance": "테스트",
+ "user_key": "user-1",
+ },
+ )
+ assert response.status_code == 200
diff --git a/backend/tests/test_faq_search.py b/backend/tests/test_faq_search.py
new file mode 100644
index 0000000..ff26a42
--- /dev/null
+++ b/backend/tests/test_faq_search.py
@@ -0,0 +1,142 @@
+"""
+FAQSearchService 단위 테스트.
+임베딩/벡터DB는 Mock 사용.
+"""
+import pytest
+import pytest_asyncio
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+
+from app.services.faq_search import FAQSearchService, FAQ_SIMILARITY_THRESHOLD
+from app.providers.base import SearchResult
+
+
+def make_faq(tenant_id: str, question: str = "Q", answer: str = "A") -> MagicMock:
+ faq = MagicMock()
+ faq.id = str(uuid4())
+ faq.tenant_id = tenant_id
+ faq.question = question
+ faq.answer = answer
+ faq.is_active = True
+ faq.hit_count = 0
+ faq.updated_at = None
+ return faq
+
+
+@pytest.mark.asyncio
+async def test_faq_search_above_threshold_returns_faq():
+ """유사도 ≥ 0.85 → FAQ 반환."""
+ faq = make_faq("t1")
+ faq_id = faq.id
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.92, metadata={"faq_id": faq_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=faq)
+ db.execute.return_value = scalar
+
+ service = FAQSearchService(embedding, vectordb, db)
+ result = await service.search("t1", "여권 발급")
+
+ assert result is not None
+ found_faq, score = result
+ assert found_faq.id == faq_id
+ assert score >= FAQ_SIMILARITY_THRESHOLD
+
+
+@pytest.mark.asyncio
+async def test_faq_search_below_threshold_returns_none():
+ """유사도 < 0.85 → None 반환."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[]) # threshold 미달로 빈 결과
+
+ db = AsyncMock()
+
+ service = FAQSearchService(embedding, vectordb, db)
+ result = await service.search("t1", "모르는 질문")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_faq_search_tenant_isolation():
+ """다른 테넌트의 FAQ가 반환되지 않는다."""
+ faq_tenant_b = make_faq("tenant-B")
+ faq_id = faq_tenant_b.id
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.95, metadata={"faq_id": faq_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ # tenant-A로 조회하면 None (다른 테넌트 FAQ)
+ scalar.scalar_one_or_none = MagicMock(return_value=None)
+ db.execute.return_value = scalar
+
+ service = FAQSearchService(embedding, vectordb, db)
+ result = await service.search("tenant-A", "테스트 질문") # tenant-A로 검색
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_faq_search_hit_count_increment():
+ """FAQ 검색 성공 시 hit_count가 증가한다."""
+ faq = make_faq("t1")
+ faq.hit_count = 5
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="Q\nA", doc_id=f"{faq.id}_0", score=0.90,
+ metadata={"faq_id": faq.id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=faq)
+ db.execute.return_value = scalar
+ db.get = AsyncMock(return_value=faq)
+ db.commit = AsyncMock()
+
+ service = FAQSearchService(embedding, vectordb, db)
+ result = await service.search("t1", "질문")
+ assert result is not None
+
+ await service.increment_hit(faq.id)
+ assert faq.hit_count == 6
+
+
+@pytest.mark.asyncio
+async def test_faq_search_embedding_not_implemented_returns_none():
+ """EmbeddingProvider가 NotImplementedError → None 반환 (graceful)."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(side_effect=NotImplementedError("not configured"))
+
+ vectordb = AsyncMock()
+ db = AsyncMock()
+
+ service = FAQSearchService(embedding, vectordb, db)
+ result = await service.search("t1", "질문")
+
+ assert result is None
diff --git a/backend/tests/test_idempotency.py b/backend/tests/test_idempotency.py
new file mode 100644
index 0000000..dfc8b33
--- /dev/null
+++ b/backend/tests/test_idempotency.py
@@ -0,0 +1,49 @@
+import pytest
+
+from app.services.idempotency import IdempotencyCache
+
+
+class FakeRedis:
+ def __init__(self):
+ self._store = {}
+
+ async def get(self, key):
+ return self._store.get(key)
+
+ async def setex(self, key, ttl, value):
+ self._store[key] = value
+
+
+@pytest.mark.asyncio
+async def test_cache_miss_returns_none():
+ """get('tenant-1', 'req-1') == None (캐시 없음)."""
+ cache = IdempotencyCache(FakeRedis())
+ result = await cache.get("tenant-1", "req-1")
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_set_and_get_returns_same_data():
+ """set 후 get → 동일 데이터."""
+ cache = IdempotencyCache(FakeRedis())
+ data = {"answer": "test", "tier": "A"}
+ await cache.set("tenant-1", "req-1", data)
+ result = await cache.get("tenant-1", "req-1")
+ assert result == data
+
+
+@pytest.mark.asyncio
+async def test_different_tenant_same_request_id_no_collision():
+ """tenant-A에 저장, tenant-B에서 같은 request_id get → None."""
+ cache = IdempotencyCache(FakeRedis())
+ await cache.set("tenant-A", "req-1", {"answer": "A"})
+ result = await cache.get("tenant-B", "req-1")
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_no_request_id_returns_none():
+ """get(tenant_id, None) == None."""
+ cache = IdempotencyCache(FakeRedis())
+ result = await cache.get("tenant-1", None)
+ assert result is None
diff --git a/backend/tests/test_llm_providers.py b/backend/tests/test_llm_providers.py
new file mode 100644
index 0000000..13618eb
--- /dev/null
+++ b/backend/tests/test_llm_providers.py
@@ -0,0 +1,60 @@
+"""
+LLM Provider 인터페이스 테스트.
+"""
+import pytest
+from app.providers.llm import NullLLMProvider
+from app.providers.llm_anthropic import AnthropicLLMProvider, OpenAILLMProvider
+from app.providers import get_llm_provider
+
+
+@pytest.mark.asyncio
+async def test_null_provider_always_returns_none():
+ provider = NullLLMProvider()
+ result = await provider.generate("sys", "user", ["ctx"])
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_null_provider_returns_none_without_context():
+ provider = NullLLMProvider()
+ result = await provider.generate("sys", "user", [])
+ assert result is None
+
+
+def test_get_llm_provider_none():
+ provider = get_llm_provider({"LLM_PROVIDER": "none"})
+ assert isinstance(provider, NullLLMProvider)
+
+
+def test_get_llm_provider_anthropic():
+ provider = get_llm_provider({"LLM_PROVIDER": "anthropic", "ANTHROPIC_API_KEY": "test"})
+ assert isinstance(provider, AnthropicLLMProvider)
+
+
+def test_get_llm_provider_unknown_raises():
+ with pytest.raises(ValueError):
+ get_llm_provider({"LLM_PROVIDER": "unknown_xyz"})
+
+
+@pytest.mark.asyncio
+async def test_anthropic_provider_no_context_returns_none():
+ """근거 없으면 API 호출 없이 None."""
+ provider = AnthropicLLMProvider(api_key="test-key")
+ result = await provider.generate("sys", "user", context_chunks=[])
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_anthropic_provider_api_failure_returns_none():
+ """API 오류 → None (예외 미전파)."""
+ from unittest.mock import patch, AsyncMock
+ provider = AnthropicLLMProvider(api_key="invalid-key")
+
+ with patch("anthropic.AsyncAnthropic") as mock_cls:
+ mock_client = AsyncMock()
+ mock_client.messages.create = AsyncMock(side_effect=Exception("API error"))
+ mock_cls.return_value = mock_client
+
+ result = await provider.generate("sys", "user", context_chunks=["근거"])
+
+ assert result is None
diff --git a/backend/tests/test_masking.py b/backend/tests/test_masking.py
new file mode 100644
index 0000000..df443c4
--- /dev/null
+++ b/backend/tests/test_masking.py
@@ -0,0 +1,60 @@
+import pytest
+
+from app.services.masking import mask_text, hash_user_key, has_sensitive_data
+
+
+def test_rrn_full_masking():
+ """주민번호 마스킹."""
+ result = mask_text("주민번호: 901225-1234567")
+ assert result == "주민번호: ######-*######"
+
+
+def test_phone_masking():
+ """전화번호 마스킹."""
+ result = mask_text("전화: 010-1234-5678")
+ assert result == "전화: ***-****-****"
+
+
+def test_email_masking():
+ """이메일 마스킹."""
+ result = mask_text("user@example.com")
+ assert result == "***@***.***"
+
+
+def test_card_masking():
+ """카드번호 마스킹."""
+ result = mask_text("4242-4242-4242-4242")
+ assert result == "****-****-****-****"
+
+
+def test_no_false_positive_number():
+ """금액 숫자는 마스킹되지 않아야 함."""
+ original = "예산액: 1,234,567원"
+ result = mask_text(original)
+ assert result == original
+
+
+def test_multiple_patterns_in_one_text():
+ """전화번호와 이메일이 같이 있는 문장에서 둘 다 마스킹."""
+ text = "연락처: 010-1234-5678, 이메일: admin@gov.kr"
+ result = mask_text(text)
+ assert "010-1234-5678" not in result
+ assert "admin@gov.kr" not in result
+ assert "***-****-****" in result
+ assert "***@***.***" in result
+
+
+def test_hash_user_key_length():
+ """hash_user_key 결과 길이 == 16."""
+ assert len(hash_user_key("any_id")) == 16
+
+
+def test_hash_user_key_deterministic():
+ """같은 입력에 항상 같은 결과."""
+ assert hash_user_key("test_user") == hash_user_key("test_user")
+
+
+def test_has_sensitive_data_detection():
+ """민감 데이터 감지."""
+ assert has_sensitive_data("010-1234-5678") is True
+ assert has_sensitive_data("일반 민원 내용입니다") is False
diff --git a/backend/tests/test_metrics.py b/backend/tests/test_metrics.py
new file mode 100644
index 0000000..7c990ca
--- /dev/null
+++ b/backend/tests/test_metrics.py
@@ -0,0 +1,114 @@
+import pytest
+import pytest_asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from app.services.metrics import MetricsCollector
+from app.services.routing import RoutingResult
+
+
+def make_result(source="faq", elapsed_ms=100, is_timeout=False):
+ return RoutingResult(
+ answer="test",
+ tier="A",
+ source=source,
+ elapsed_ms=elapsed_ms,
+ is_timeout=is_timeout,
+ )
+
+
+class FakePipeline:
+ def __init__(self):
+ self._data = {}
+ self._sets = {}
+ self._calls = []
+
+ def hincrby(self, key, field, amount):
+ if key not in self._data:
+ self._data[key] = {}
+ self._data[key][field] = self._data[key].get(field, 0) + amount
+ return self
+
+ def zadd(self, key, mapping):
+ if key not in self._sets:
+ self._sets[key] = {}
+ self._sets[key].update(mapping)
+ return self
+
+ def zremrangebyrank(self, key, start, stop):
+ return self
+
+ async def execute(self):
+ return []
+
+
+class FakeRedis:
+ def __init__(self):
+ self._data = {}
+ self._sets = {}
+ self._pipeline = FakePipeline()
+
+ def pipeline(self):
+ return self._pipeline
+
+ async def hgetall(self, key):
+ return {k: str(v) for k, v in self._pipeline._data.get(key, {}).items()}
+
+ async def zcard(self, key):
+ return len(self._pipeline._sets.get(key, {}))
+
+ async def zrange(self, key, start, stop, withscores=False):
+ items = sorted(self._pipeline._sets.get(key, {}).items(), key=lambda x: x[1])
+ slice_ = items[start:stop + 1 if stop >= 0 else None]
+ if withscores:
+ return [(k.encode(), v) for k, v in slice_]
+ return [k.encode() for k, _ in slice_]
+
+
+@pytest.mark.asyncio
+async def test_record_increments_total_count():
+ """record 후 total_count == 1."""
+ redis = FakeRedis()
+ collector = MetricsCollector(redis)
+ result = make_result(source="faq")
+ await collector.record("tenant-1", result)
+ assert redis._pipeline._data.get("tenant:tenant-1:metrics", {}).get("total_count", 0) == 1
+
+
+@pytest.mark.asyncio
+async def test_record_faq_increments_faq_hit_count():
+ """source='faq'인 result record 후 faq_hit_count == 1."""
+ redis = FakeRedis()
+ collector = MetricsCollector(redis)
+ await collector.record("tenant-1", make_result(source="faq"))
+ counts = redis._pipeline._data.get("tenant:tenant-1:metrics", {})
+ assert counts.get("faq_hit_count", 0) == 1
+
+
+@pytest.mark.asyncio
+async def test_record_timeout_increments_timeout_count():
+ """is_timeout=True인 result record 후 timeout_count == 1."""
+ redis = FakeRedis()
+ collector = MetricsCollector(redis)
+ await collector.record("tenant-1", make_result(is_timeout=True))
+ counts = redis._pipeline._data.get("tenant:tenant-1:metrics", {})
+ assert counts.get("timeout_count", 0) == 1
+
+
+@pytest.mark.asyncio
+async def test_get_overview_returns_rates():
+ """total=10, faq=4 → faq_hit_rate == 40.0."""
+ redis = FakeRedis()
+ collector = MetricsCollector(redis)
+
+ # 수동으로 pipeline 데이터 설정
+ redis._pipeline._data["tenant:tenant-1:metrics"] = {
+ "total_count": 10,
+ "faq_hit_count": 4,
+ "rag_hit_count": 2,
+ "fallback_count": 3,
+ "timeout_count": 1,
+ "response_ms_sum": 1000,
+ }
+
+ overview = await collector.get_overview("tenant-1")
+ assert overview["rates"]["faq_hit_rate"] == 40.0
diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py
new file mode 100644
index 0000000..a2b025a
--- /dev/null
+++ b/backend/tests/test_middleware.py
@@ -0,0 +1,68 @@
+import pytest
+import pytest_asyncio
+from httpx import AsyncClient, ASGITransport
+from sqlalchemy import select
+
+from app.core.middleware import tenanted_query, system_query, TenantMiddleware
+from app.models.knowledge import FAQ
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_health_exempt_no_tenant_required(client):
+ """GET /health → 200."""
+ response = await client.get("/health")
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_token_endpoint_exempt(client):
+ """POST /api/admin/token → 400이 아닌 응답 (tenant 오류 아님)."""
+ response = await client.post("/api/admin/token")
+ # 엔드포인트가 없으면 404, 있으면 422/200 — 어떤 경우든 400(tenant_required)이 아님
+ assert response.status_code != 400 or response.json().get("error") != "tenant_required"
+
+
+@pytest.mark.asyncio
+async def test_api_request_without_auth_returns_401(client):
+ """GET /api/admin/faqs (인증 없음) → 401 (admin 경로는 미들웨어 exempt, JWT 필요)."""
+ response = await client.get("/api/admin/faqs")
+ assert response.status_code == 401
+
+
+def test_tenanted_query_without_tenant_id_raises_runtime_error():
+ """tenanted_query(select(FAQ), FAQ, None) → RuntimeError 발생."""
+ with pytest.raises(RuntimeError):
+ tenanted_query(select(FAQ), FAQ, None)
+
+
+def test_tenanted_query_with_tenant_id_adds_filter():
+ """생성된 SQL에 'tenant_id' 포함 확인."""
+ query = tenanted_query(select(FAQ), FAQ, "test-tenant-id")
+ sql = str(query.compile())
+ assert "tenant_id" in sql
+
+
+def test_tenanted_query_with_empty_string_raises():
+ """tenanted_query(select(FAQ), FAQ, '') → RuntimeError 발생."""
+ with pytest.raises(RuntimeError):
+ tenanted_query(select(FAQ), FAQ, "")
+
+
+def test_system_query_has_no_tenant_filter():
+ """system_query(q) == q (변경 없음)."""
+ q = select(FAQ)
+ result = system_query(q)
+ assert result is q
+
+
+@pytest.mark.asyncio
+async def test_request_state_has_tenant_id_after_middleware(client):
+ """X-Tenant-Slug 헤더 포함 요청 시 request.state.tenant_id 주입 확인."""
+ # 미들웨어가 tenant_id를 설정하면 400 대신 다른 응답 코드가 나와야 함
+ response = await client.get(
+ "/api/admin/faqs",
+ headers={"X-Tenant-Slug": "test-tenant"}
+ )
+ # tenant_required 오류가 아니어야 함 (404 또는 다른 응답)
+ assert response.status_code != 400 or response.json().get("error") != "tenant_required"
diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py
new file mode 100644
index 0000000..70bc5c8
--- /dev/null
+++ b/backend/tests/test_models.py
@@ -0,0 +1,95 @@
+import pytest
+from sqlalchemy import inspect, String, VARCHAR
+
+from app.models.tenant import Tenant, TenantConfig
+from app.models.admin import SystemAdmin, AdminUser, AdminRole
+from app.models.knowledge import FAQ, Document, CrawlerURL
+from app.models.complaint import ComplaintLog
+from app.models.moderation import UserRestriction, RestrictionLevel
+from app.models.audit import AuditLog
+
+TENANT_REQUIRED_MODELS = [
+ TenantConfig, FAQ, Document, CrawlerURL, ComplaintLog, UserRestriction, AuditLog, AdminUser
+]
+
+
+def get_column(model, col_name):
+ """모델에서 컬럼 객체 반환."""
+ for col in model.__table__.columns:
+ if col.name == col_name:
+ return col
+ return None
+
+
+def test_all_tenant_models_have_tenant_id():
+ """8개 모델 모두 tenant_id 컬럼 포함 확인."""
+ for model in TENANT_REQUIRED_MODELS:
+ col = get_column(model, "tenant_id")
+ assert col is not None, f"{model.__tablename__} must have tenant_id column"
+
+
+def test_system_admin_has_no_tenant_id():
+ """SystemAdmin 테이블에 tenant_id 없음 확인."""
+ col = get_column(SystemAdmin, "tenant_id")
+ assert col is None, "SystemAdmin must NOT have tenant_id column"
+
+
+def test_all_pk_are_uuid_string():
+ """모든 모델 PK 컬럼 타입이 VARCHAR(36) 또는 String."""
+ all_models = [Tenant, TenantConfig, SystemAdmin, AdminUser, FAQ, Document,
+ CrawlerURL, ComplaintLog, UserRestriction, AuditLog]
+ for model in all_models:
+ pk_cols = [c for c in model.__table__.columns if c.primary_key]
+ assert len(pk_cols) > 0, f"{model.__tablename__} has no PK"
+ for col in pk_cols:
+ col_type = str(col.type)
+ assert "VARCHAR" in col_type or "CHAR" in col_type or isinstance(col.type, String), \
+ f"{model.__tablename__}.{col.name} PK must be String/VARCHAR, got {col_type}"
+
+
+def test_admin_user_email_not_globally_unique():
+ """AdminUser.email 컬럼의 unique=False 확인."""
+ col = get_column(AdminUser, "email")
+ assert col is not None
+ assert not col.unique, "AdminUser.email must NOT be globally unique (use tenant-scoped constraint)"
+
+
+def test_system_admin_email_globally_unique():
+ """SystemAdmin.email 컬럼의 unique=True 확인."""
+ col = get_column(SystemAdmin, "email")
+ assert col is not None
+ assert col.unique, "SystemAdmin.email must be globally unique"
+
+
+def test_document_is_active_default_false():
+ """Document.is_active 기본값이 False 확인."""
+ col = get_column(Document, "is_active")
+ assert col is not None
+ assert col.default.arg is False, "Document.is_active default must be False"
+
+
+def test_complaint_log_has_request_id():
+ """ComplaintLog에 request_id 컬럼 존재 확인."""
+ col = get_column(ComplaintLog, "request_id")
+ assert col is not None, "ComplaintLog must have request_id column"
+
+
+def test_audit_log_has_required_columns():
+ """AuditLog에 actor_id, actor_type, action, tenant_id 확인."""
+ required = ["actor_id", "actor_type", "action", "tenant_id"]
+ for col_name in required:
+ col = get_column(AuditLog, col_name)
+ assert col is not None, f"AuditLog must have {col_name} column"
+
+
+def test_user_restriction_level_range():
+ """RestrictionLevel.NORMAL==0, BLOCKED==5."""
+ assert RestrictionLevel.NORMAL == 0
+ assert RestrictionLevel.BLOCKED == 5
+
+
+def test_admin_role_values():
+ """AdminRole 값 집합 == {'admin','editor','viewer','readonly_api'}."""
+ expected = {"admin", "editor", "viewer", "readonly_api"}
+ actual = {role.value for role in AdminRole}
+ assert actual == expected
diff --git a/backend/tests/test_moderation.py b/backend/tests/test_moderation.py
new file mode 100644
index 0000000..15f0305
--- /dev/null
+++ b/backend/tests/test_moderation.py
@@ -0,0 +1,141 @@
+"""
+악성·반복 민원 제한 서비스 테스트.
+"""
+import pytest
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock
+
+from app.services.moderation import ModerationService, ModerationResult
+from app.models.moderation import UserRestriction, RestrictionLevel
+
+
+def make_db(restriction=None):
+ db = AsyncMock()
+ db.add = MagicMock()
+ db.commit = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=restriction)
+ db.execute.return_value = scalar
+ return db
+
+
+@pytest.mark.asyncio
+async def test_check_normal_user_is_allowed():
+ """제한 없는 사용자 → 허용."""
+ db = make_db(restriction=None)
+ service = ModerationService(db)
+ result = await service.check("t1", "user-1")
+ assert result.allowed is True
+ assert result.level == 0
+
+
+@pytest.mark.asyncio
+async def test_check_blocked_user_is_denied():
+ """Level 5 (BLOCKED) → 차단."""
+ r = MagicMock()
+ r.level = RestrictionLevel.BLOCKED
+ r.expires_at = None
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ result = await service.check("t1", "user-block")
+ assert result.allowed is False
+ assert result.level == RestrictionLevel.BLOCKED
+
+
+@pytest.mark.asyncio
+async def test_check_suspended_user_is_denied():
+ """Level 4 (SUSPENDED) → 편집장 확인 전 차단."""
+ r = MagicMock()
+ r.level = RestrictionLevel.SUSPENDED
+ r.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ result = await service.check("t1", "user-sus")
+ assert result.allowed is False
+
+
+@pytest.mark.asyncio
+async def test_check_warning_user_has_delay():
+ """Level 2 (WARNING) → 허용 + 30초 지연."""
+ r = MagicMock()
+ r.level = RestrictionLevel.WARNING
+ r.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ result = await service.check("t1", "user-warn")
+ assert result.allowed is True
+ assert result.delay_seconds == 30
+
+
+@pytest.mark.asyncio
+async def test_escalate_increases_level():
+ """레벨 상승: NORMAL → 다음 레벨."""
+ r = MagicMock(spec=UserRestriction)
+ r.level = RestrictionLevel.NORMAL
+ r.expires_at = None
+ r.auto_applied = True
+
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ new_level = await service.escalate("t1", "user-1", "반복 민원")
+
+ assert new_level == RestrictionLevel.NORMAL + 1
+ db.commit.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_escalate_stops_at_suspended():
+ """Level 4 이상 자동 상승 금지."""
+ r = MagicMock(spec=UserRestriction)
+ r.level = RestrictionLevel.SUSPENDED
+ r.expires_at = None
+
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ new_level = await service.escalate("t1", "user-1")
+
+ # Level 4에서 멈춤
+ assert new_level == RestrictionLevel.SUSPENDED
+
+
+@pytest.mark.asyncio
+async def test_release_resets_level():
+ """수동 해제 → level 0."""
+ r = MagicMock(spec=UserRestriction)
+ r.level = RestrictionLevel.SUSPENDED
+ r.expires_at = None
+ r.auto_applied = True
+ r.applied_by = None
+
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ await service.release("t1", "user-1", applied_by="editor-1")
+
+ assert r.level == RestrictionLevel.NORMAL
+ assert r.applied_by == "editor-1"
+ db.commit.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_check_expired_restriction_resets():
+ """만료된 제한 → 자동 해제."""
+ r = MagicMock(spec=UserRestriction)
+ r.level = RestrictionLevel.WARNING # Level 2
+ r.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # 만료
+ r.auto_applied = True
+ r.applied_by = None
+
+ db = make_db(restriction=r)
+
+ service = ModerationService(db)
+ result = await service.check("t1", "user-expired")
+
+ assert result.allowed is True
+ assert result.level == 0
diff --git a/backend/tests/test_providers.py b/backend/tests/test_providers.py
new file mode 100644
index 0000000..24930cf
--- /dev/null
+++ b/backend/tests/test_providers.py
@@ -0,0 +1,40 @@
+import pytest
+
+from app.providers.llm import NullLLMProvider
+from app.providers.embedding import NotImplementedEmbeddingProvider
+from app.providers import get_llm_provider, get_embedding_provider
+
+
+@pytest.mark.asyncio
+async def test_null_llm_provider_returns_none():
+ """NullLLMProvider().generate(...) == None."""
+ provider = NullLLMProvider()
+ result = await provider.generate("system", "user", [])
+ assert result is None
+
+
+def test_get_llm_provider_none_config():
+ """get_llm_provider({'LLM_PROVIDER':'none'}) == NullLLMProvider 인스턴스."""
+ provider = get_llm_provider({"LLM_PROVIDER": "none"})
+ assert isinstance(provider, NullLLMProvider)
+
+
+def test_get_llm_provider_unknown_raises():
+ """get_llm_provider({'LLM_PROVIDER':'xyz'}) → ValueError."""
+ with pytest.raises(ValueError):
+ get_llm_provider({"LLM_PROVIDER": "xyz"})
+
+
+@pytest.mark.asyncio
+async def test_not_implemented_embedding_raises_on_embed():
+ """NotImplementedEmbeddingProvider().embed(['test']) → NotImplementedError."""
+ provider = NotImplementedEmbeddingProvider()
+ with pytest.raises(NotImplementedError):
+ await provider.embed(["test"])
+
+
+@pytest.mark.asyncio
+async def test_not_implemented_embedding_warmup_is_noop():
+ """.warmup() 호출 시 예외 없이 통과."""
+ provider = NotImplementedEmbeddingProvider()
+ await provider.warmup() # 예외 없어야 함
diff --git a/backend/tests/test_rag_search.py b/backend/tests/test_rag_search.py
new file mode 100644
index 0000000..d081139
--- /dev/null
+++ b/backend/tests/test_rag_search.py
@@ -0,0 +1,150 @@
+"""
+RAGSearchService 단위 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+from datetime import datetime
+
+from app.services.rag_search import RAGSearchService, RAG_SIMILARITY_THRESHOLD
+from app.providers.base import SearchResult
+
+
+def make_doc(tenant_id: str, filename: str = "안내문.txt") -> MagicMock:
+ doc = MagicMock()
+ doc.id = str(uuid4())
+ doc.tenant_id = tenant_id
+ doc.filename = filename
+ doc.is_active = True
+ doc.published_at = datetime(2026, 3, 1)
+ return doc
+
+
+@pytest.mark.asyncio
+async def test_rag_search_returns_results_above_threshold():
+ """유사도 ≥ 0.70 → RAGSearchResult 반환."""
+ doc = make_doc("t1")
+ doc_id = doc.id
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(
+ text="여권은 민원과에서 발급합니다.",
+ doc_id=f"{doc_id}_0",
+ score=0.78,
+ metadata={"doc_id": doc_id, "filename": "여권안내.txt"},
+ )
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ service = RAGSearchService(embedding, vectordb, db)
+ results = await service.search("t1", "여권 발급")
+
+ assert results is not None
+ assert len(results) > 0
+ assert results[0].score >= RAG_SIMILARITY_THRESHOLD
+
+
+@pytest.mark.asyncio
+async def test_rag_search_returns_none_when_no_match():
+ """매칭 없으면 None."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[])
+
+ db = AsyncMock()
+
+ service = RAGSearchService(embedding, vectordb, db)
+ results = await service.search("t1", "알 수 없는 질문")
+
+ assert results is None
+
+
+@pytest.mark.asyncio
+async def test_rag_search_excludes_inactive_docs():
+ """is_active=False 문서 → 결과에서 제외."""
+ doc_id = str(uuid4())
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.85,
+ metadata={"doc_id": doc_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=None) # is_active 필터로 None
+ db.execute.return_value = scalar
+
+ service = RAGSearchService(embedding, vectordb, db)
+ results = await service.search("t1", "질문")
+
+ assert results is None
+
+
+def test_rag_build_answer_includes_citation():
+ """응답에 출처(문서명·날짜) 포함."""
+ doc = make_doc("t1", "여권발급안내.txt")
+
+ embedding = AsyncMock()
+ vectordb = AsyncMock()
+ db = AsyncMock()
+
+ from app.services.rag_search import RAGSearchResult
+ rag_result = RAGSearchResult(
+ chunk_text="여권 발급은 민원과에서 처리합니다.",
+ doc=doc,
+ score=0.78,
+ )
+
+ service = RAGSearchService(embedding, vectordb, db)
+ answer = service.build_answer("여권 질문", [rag_result])
+
+ assert "출처" in answer
+ assert "여권발급안내.txt" in answer
+
+
+@pytest.mark.asyncio
+async def test_rag_deduplicates_same_doc_chunks():
+ """같은 문서의 여러 청크 → 최고 점수 1개만."""
+ doc_id = str(uuid4())
+ doc = make_doc("t1")
+ doc.id = doc_id
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="청크1", doc_id=f"{doc_id}_0", score=0.80,
+ metadata={"doc_id": doc_id}),
+ SearchResult(text="청크2", doc_id=f"{doc_id}_1", score=0.75,
+ metadata={"doc_id": doc_id}),
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ service = RAGSearchService(embedding, vectordb, db)
+ results = await service.search("t1", "질문")
+
+ # 같은 문서는 1개만
+ doc_ids = [r.doc.id for r in results]
+ assert len(doc_ids) == len(set(doc_ids))
diff --git a/backend/tests/test_ready.py b/backend/tests/test_ready.py
new file mode 100644
index 0000000..2bef2ec
--- /dev/null
+++ b/backend/tests/test_ready.py
@@ -0,0 +1,40 @@
+import pytest
+from unittest.mock import AsyncMock, patch, MagicMock
+
+
+@pytest.mark.asyncio
+async def test_ready_ok_when_all_services_up(client):
+ """DB/Redis mock 정상 → GET /ready → 200, ready=True."""
+ mock_redis = AsyncMock()
+ mock_redis.ping = AsyncMock(return_value=True)
+ mock_redis.aclose = AsyncMock()
+
+ with patch("app.routers.health.aioredis.from_url", return_value=mock_redis):
+ response = await client.get("/ready")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["ready"] is True
+ assert data["checks"]["db"] == "ok"
+
+
+@pytest.mark.asyncio
+async def test_ready_503_when_db_down(client):
+ """DB mock 오류 → GET /ready → 503, ready=False."""
+ mock_redis = AsyncMock()
+ mock_redis.ping = AsyncMock(return_value=True)
+ mock_redis.aclose = AsyncMock()
+
+ # DB 연결 실패 시뮬레이션
+ mock_session = AsyncMock()
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+ mock_session.execute = AsyncMock(side_effect=Exception("DB connection failed"))
+
+ with patch("app.routers.health.aioredis.from_url", return_value=mock_redis), \
+ patch("app.routers.health.AsyncSessionLocal", return_value=mock_session):
+ response = await client.get("/ready")
+
+ assert response.status_code == 503
+ data = response.json()
+ assert data["ready"] is False
diff --git a/backend/tests/test_skill_api.py b/backend/tests/test_skill_api.py
new file mode 100644
index 0000000..11d6e84
--- /dev/null
+++ b/backend/tests/test_skill_api.py
@@ -0,0 +1,83 @@
+"""
+POST /skill/{tenant_slug} — 카카오 스킬 API 테스트.
+"""
+import pytest
+
+
+def make_kakao_body(utterance: str, user_id: str = "kakao-user-1") -> dict:
+ return {
+ "userRequest": {
+ "utterance": utterance,
+ "user": {"id": user_id},
+ },
+ "action": {"params": {}},
+ }
+
+
+@pytest.mark.asyncio
+async def test_skill_returns_kakao_format(client):
+ """스킬 응답이 카카오 포맷(version, template.outputs)을 포함한다."""
+ response = await client.post(
+ "/skill/test-tenant",
+ json=make_kakao_body("여권 발급 방법"),
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["version"] == "2.0"
+ assert "template" in data
+ assert "outputs" in data["template"]
+ assert len(data["template"]["outputs"]) > 0
+ assert "simpleText" in data["template"]["outputs"][0]
+
+
+@pytest.mark.asyncio
+async def test_skill_tier_d_fallback_returns_200(client):
+ """FAQ 없어도 Tier D 응답으로 200 반환."""
+ response = await client.post(
+ "/skill/test-tenant",
+ json=make_kakao_body("이상한 질문"),
+ )
+ assert response.status_code == 200
+ data = response.json()
+ text = data["template"]["outputs"][0]["simpleText"]["text"]
+ assert len(text) > 0
+
+
+@pytest.mark.asyncio
+async def test_skill_utterance_text_in_response(client):
+ """응답 text 필드가 비어있지 않다."""
+ response = await client.post(
+ "/skill/dongducheon",
+ json=make_kakao_body("담당부서 알려주세요"),
+ )
+ assert response.status_code == 200
+ text = response.json()["template"]["outputs"][0]["simpleText"]["text"]
+ assert isinstance(text, str)
+ assert len(text) > 0
+
+
+@pytest.mark.asyncio
+async def test_skill_user_key_is_hashed(client):
+ """user_key는 원본 ID가 아닌 해시값(16자리)으로 처리된다 (응답에 노출 안 됨)."""
+ # 개인정보(원본 카카오 ID)가 응답 바디에 노출되지 않는지 확인
+ user_id = "real-kakao-id-12345"
+ response = await client.post(
+ "/skill/test-tenant",
+ json=make_kakao_body("질문", user_id=user_id),
+ )
+ assert response.status_code == 200
+ response_text = response.text
+ assert user_id not in response_text
+
+
+@pytest.mark.asyncio
+async def test_skill_masked_pii_not_in_answer(client):
+ """개인정보가 포함된 발화도 정상 처리 (마스킹 후 라우팅)."""
+ response = await client.post(
+ "/skill/test-tenant",
+ json=make_kakao_body("전화번호 010-1234-5678로 연락해주세요"),
+ )
+ assert response.status_code == 200
+ # 마스킹된 발화로 처리되므로 응답은 정상
+ data = response.json()
+ assert "version" in data
diff --git a/backend/tests/test_tier_a_routing.py b/backend/tests/test_tier_a_routing.py
new file mode 100644
index 0000000..b1f4008
--- /dev/null
+++ b/backend/tests/test_tier_a_routing.py
@@ -0,0 +1,151 @@
+"""
+Tier A 라우팅 통합 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+
+from app.services.routing import ResponseRouter, RoutingResult
+from app.providers.base import SearchResult
+
+
+def make_router(embedding=None, vectordb=None) -> ResponseRouter:
+ providers = {}
+ if embedding:
+ providers["embedding"] = embedding
+ if vectordb:
+ providers["vectordb"] = vectordb
+ return ResponseRouter(
+ tenant_config={
+ "phone_number": "031-860-2000",
+ "fallback_dept": "민원과",
+ "tenant_name": "동두천시",
+ },
+ providers=providers,
+ )
+
+
+@pytest.mark.asyncio
+async def test_tier_a_returns_when_faq_found():
+ """벡터DB에서 FAQ 매칭 → Tier A 반환."""
+ faq_id = str(uuid4())
+ faq = MagicMock()
+ faq.id = faq_id
+ faq.answer = "여권은 민원과에서 발급합니다."
+ faq.question = "여권 발급 방법"
+ faq.is_active = True
+ faq.hit_count = 0
+ faq.updated_at = None
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="여권 발급 방법\n여권은 민원과에서 발급합니다.",
+ doc_id=f"{faq_id}_0", score=0.92, metadata={"faq_id": faq_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=faq)
+ db.execute.return_value = scalar
+ db.get = AsyncMock(return_value=faq)
+ db.commit = AsyncMock()
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "여권 발급 방법", "user-1", db=db)
+
+ assert result.tier == "A"
+ assert result.source == "faq"
+ assert result.answer == faq.answer
+
+
+@pytest.mark.asyncio
+async def test_tier_a_skipped_when_no_match():
+ """매칭 없으면 Tier D 반환."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[]) # 빈 결과
+
+ db = AsyncMock()
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "모르는 질문", "user-1", db=db)
+
+ assert result.tier == "D"
+ assert result.source == "fallback"
+
+
+@pytest.mark.asyncio
+async def test_routing_result_has_faq_id():
+ """Tier A 결과에 faq_id가 포함된다."""
+ faq_id = str(uuid4())
+ faq = MagicMock()
+ faq.id = faq_id
+ faq.answer = "답변"
+ faq.question = "질문"
+ faq.is_active = True
+ faq.hit_count = 0
+ faq.updated_at = None
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.90,
+ metadata={"faq_id": faq_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=faq)
+ db.execute.return_value = scalar
+ db.get = AsyncMock(return_value=faq)
+ db.commit = AsyncMock()
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ assert result.faq_id == faq_id
+
+
+@pytest.mark.asyncio
+async def test_routing_result_tier_d_has_fallback_info():
+ """Tier D 결과에 담당부서·전화번호가 포함된다."""
+ router = make_router()
+ result = await router.route("tenant-1", "알 수 없는 질문", "user-1", db=None)
+
+ assert result.tier == "D"
+ assert "031-860-2000" in result.answer
+ assert "민원과" in result.answer
+
+
+@pytest.mark.asyncio
+async def test_timeout_returns_tier_d_is_timeout():
+ """타임아웃 발생 시 Tier D + is_timeout=True."""
+ import asyncio
+
+ async def slow_embed(texts):
+ await asyncio.sleep(10) # 타임아웃보다 긴 대기
+ return [[0.0] * 768]
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(side_effect=slow_embed)
+
+ vectordb = AsyncMock()
+
+ # 타임아웃을 매우 짧게 설정
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ router.TIMEOUT_MS = 50 # 50ms
+
+ db = AsyncMock()
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ assert result.tier == "D"
+ assert result.is_timeout is True
diff --git a/backend/tests/test_tier_b_routing.py b/backend/tests/test_tier_b_routing.py
new file mode 100644
index 0000000..3a22d75
--- /dev/null
+++ b/backend/tests/test_tier_b_routing.py
@@ -0,0 +1,144 @@
+"""
+Tier B RAG 라우팅 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+from datetime import datetime
+
+from app.services.routing import ResponseRouter
+from app.providers.base import SearchResult
+
+
+def make_router(embedding=None, vectordb=None) -> ResponseRouter:
+ providers = {}
+ if embedding:
+ providers["embedding"] = embedding
+ if vectordb:
+ providers["vectordb"] = vectordb
+ return ResponseRouter(
+ tenant_config={
+ "phone_number": "031-860-2000",
+ "fallback_dept": "민원과",
+ "tenant_name": "동두천시",
+ },
+ providers=providers,
+ )
+
+
+def make_doc(doc_id: str) -> MagicMock:
+ doc = MagicMock()
+ doc.id = doc_id
+ doc.filename = "안내문.txt"
+ doc.is_active = True
+ doc.published_at = datetime(2026, 3, 1)
+ return doc
+
+
+@pytest.mark.asyncio
+async def test_tier_b_returns_when_doc_found():
+ """문서 RAG 매칭 → Tier B 반환."""
+ doc_id = str(uuid4())
+ doc = make_doc(doc_id)
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="여권은 민원과에서 발급합니다.", doc_id=f"{doc_id}_0",
+ score=0.75, metadata={"doc_id": doc_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "여권 발급 방법", "user-1", db=db)
+
+ assert result.tier == "B"
+ assert result.source == "rag"
+
+
+@pytest.mark.asyncio
+async def test_tier_b_skipped_when_no_doc_match():
+ """문서 매칭 없으면 Tier D."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[])
+
+ db = AsyncMock()
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "모르는 질문", "user-1", db=db)
+
+ assert result.tier == "D"
+
+
+@pytest.mark.asyncio
+async def test_tier_b_result_has_doc_name():
+ """Tier B 결과에 doc_name 포함."""
+ doc_id = str(uuid4())
+ doc = make_doc(doc_id)
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.72,
+ metadata={"doc_id": doc_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ assert result.doc_name is not None
+
+
+@pytest.mark.asyncio
+async def test_tier_a_takes_priority_over_tier_b():
+ """FAQ 유사도 ≥ 0.85 → Tier A (Tier B 미실행)."""
+ faq_id = str(uuid4())
+ faq = MagicMock()
+ faq.id = faq_id
+ faq.answer = "FAQ 답변"
+ faq.question = "질문"
+ faq.is_active = True
+ faq.hit_count = 0
+ faq.updated_at = None
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ # FAQ 검색: 높은 유사도
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.92,
+ metadata={"faq_id": faq_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=faq)
+ db.execute.return_value = scalar
+ db.get = AsyncMock(return_value=faq)
+ db.commit = AsyncMock()
+
+ router = make_router(embedding=embedding, vectordb=vectordb)
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ # FAQ가 먼저 매칭되면 Tier A
+ assert result.tier == "A"
diff --git a/backend/tests/test_tier_c_routing.py b/backend/tests/test_tier_c_routing.py
new file mode 100644
index 0000000..6ad286a
--- /dev/null
+++ b/backend/tests/test_tier_c_routing.py
@@ -0,0 +1,164 @@
+"""
+Tier C — LLM 기반 재서술 라우팅 테스트.
+"""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+from datetime import datetime
+
+from app.services.routing import ResponseRouter
+from app.providers.llm import NullLLMProvider
+from app.providers.base import SearchResult
+
+
+def make_doc(doc_id: str) -> MagicMock:
+ doc = MagicMock()
+ doc.id = doc_id
+ doc.filename = "안내문.txt"
+ doc.is_active = True
+ doc.published_at = datetime(2026, 3, 1)
+ return doc
+
+
+def make_router(embedding=None, vectordb=None, llm=None) -> ResponseRouter:
+ providers = {}
+ if embedding:
+ providers["embedding"] = embedding
+ if vectordb:
+ providers["vectordb"] = vectordb
+ if llm:
+ providers["llm"] = llm
+ return ResponseRouter(
+ tenant_config={
+ "phone_number": "031-860-2000",
+ "fallback_dept": "민원과",
+ "tenant_name": "동두천시",
+ },
+ providers=providers,
+ )
+
+
+@pytest.mark.asyncio
+async def test_tier_c_returns_llm_answer():
+ """LLM 활성화 + RAG 근거 있음 → Tier C 반환."""
+ doc_id = str(uuid4())
+ doc = make_doc(doc_id)
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="여권은 민원과에서 발급합니다.", doc_id=f"{doc_id}_0",
+ score=0.75, metadata={"doc_id": doc_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ llm = AsyncMock()
+ llm.generate = AsyncMock(return_value="여권 발급은 민원과(031-860-2000)에서 신청하시면 됩니다.")
+
+ router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
+ result = await router.route("tenant-1", "여권 어디서 받아요", "user-1", db=db)
+
+ assert result.tier == "C"
+ assert result.source == "llm"
+ assert "여권" in result.answer
+
+
+@pytest.mark.asyncio
+async def test_tier_c_skipped_when_llm_is_null():
+ """NullLLMProvider → Tier C 스킵 → Tier B 또는 D."""
+ doc_id = str(uuid4())
+ doc = make_doc(doc_id)
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.75,
+ metadata={"doc_id": doc_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ llm = NullLLMProvider() # none 기본값
+
+ router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ # NullLLM → Tier C 스킵 → Tier B (RAG 매칭 있으므로)
+ assert result.tier in ("B", "D")
+ assert result.tier != "C"
+
+
+@pytest.mark.asyncio
+async def test_tier_c_skipped_when_no_rag_context():
+ """RAG 근거 없음 → LLM 미호출 (할루시네이션 방지)."""
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[]) # 근거 없음
+
+ db = AsyncMock()
+
+ llm = AsyncMock()
+ llm.generate = AsyncMock(return_value="LLM 답변")
+
+ router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ # 근거 없으면 LLM 미호출 → Tier D
+ assert result.tier == "D"
+ llm.generate.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_tier_c_falls_back_to_d_when_llm_fails():
+ """LLM 실패(None 반환) → Tier D 폴백."""
+ doc_id = str(uuid4())
+ doc = make_doc(doc_id)
+
+ embedding = AsyncMock()
+ embedding.embed = AsyncMock(return_value=[[0.1] * 768])
+
+ vectordb = AsyncMock()
+ vectordb.search = AsyncMock(return_value=[
+ SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.75,
+ metadata={"doc_id": doc_id})
+ ])
+
+ db = AsyncMock()
+ db.execute = AsyncMock()
+ scalar = MagicMock()
+ scalar.scalar_one_or_none = MagicMock(return_value=doc)
+ db.execute.return_value = scalar
+
+ llm = AsyncMock()
+ llm.generate = AsyncMock(return_value=None) # LLM 실패
+
+ router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
+ result = await router.route("tenant-1", "질문", "user-1", db=db)
+
+ # LLM None → Tier C 없음 → Tier B (RAG 있으므로)
+ assert result.tier in ("B", "D")
+ assert result.tier != "C"
+
+
+@pytest.mark.asyncio
+async def test_llm_not_called_without_context_chunks():
+ """AnthropicLLMProvider: context_chunks 비어있으면 None."""
+ from app.providers.llm_anthropic import AnthropicLLMProvider
+ provider = AnthropicLLMProvider(api_key="test-key")
+ result = await provider.generate("system", "user", context_chunks=[])
+ assert result is None
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..7556a00
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,55 @@
+version: "3.9"
+
+services:
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: botuser
+ POSTGRES_PASSWORD: botpass
+ POSTGRES_DB: smartbot
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U botuser"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis_data:/data
+
+ chromadb:
+ image: chromadb/chroma:latest
+ environment:
+ ANONYMIZED_TELEMETRY: "false"
+ volumes:
+ - chroma_data:/chroma/chroma
+
+ backend:
+ build: ./backend
+ env_file: .env
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ chromadb:
+ condition: service_started
+ ports:
+ - "8000:8000"
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ ports:
+ - "3000:80"
+ depends_on:
+ - backend
+
+volumes:
+ postgres_data:
+ redis_data:
+ chroma_data:
diff --git a/docs/WSL2_가이드.md b/docs/WSL2_가이드.md
new file mode 100644
index 0000000..41fff37
--- /dev/null
+++ b/docs/WSL2_가이드.md
@@ -0,0 +1,28 @@
+# Windows 설치 빠른 시작 가이드
+
+SmartBot KR을 Windows에서 실행하는 가장 빠른 방법입니다.
+
+## 5분 설치 순서
+
+**1. PowerShell(관리자)** 열기 → `wsl --install` 실행 → 컴퓨터 재시작
+
+**2. Ubuntu** 실행 → 사용자 이름·비밀번호 설정
+
+**3. [Docker Desktop](https://www.docker.com/products/docker-desktop/) 설치**
+ - 설치 후: Settings → Resources → WSL Integration → Ubuntu 켜기 → Apply
+
+**4. Ubuntu 터미널에서:**
+
+```bash
+git clone https://github.com/sinmb79/Gov-chat-bot.git
+cd Gov-chat-bot
+chmod +x install.sh && ./install.sh
+```
+
+**5. 브라우저에서 접속:**
+- 관리자 화면: http://localhost:3000
+- API 문서: http://localhost:8000/docs
+
+---
+
+자세한 내용은 [운영가이드.md](./운영가이드.md) 13장을 참고하세요.
diff --git a/docs/운영가이드.md b/docs/운영가이드.md
new file mode 100644
index 0000000..659563c
--- /dev/null
+++ b/docs/운영가이드.md
@@ -0,0 +1,429 @@
+# SmartBot KR 운영 가이드
+
+> 코딩을 몰라도 설치·운영할 수 있도록 작성된 한글 가이드입니다.
+> 지자체, 소상공인, 기업 모두 동일하게 사용할 수 있습니다.
+
+---
+
+## 목차
+
+1. [시스템 요구사항](#1-시스템-요구사항)
+2. [설치 방법](#2-설치-방법)
+3. [초기 설정](#3-초기-설정)
+4. [대시보드 사용법](#4-대시보드-사용법)
+5. [FAQ 관리](#5-faq-관리)
+6. [문서 관리 (자료 학습)](#6-문서-관리-자료-학습)
+7. [문의 이력 조회](#7-문의-이력-조회)
+8. [악성 감지 · 이용 제한](#8-악성-감지--이용-제한)
+9. [홈페이지 위젯 삽입](#9-홈페이지-위젯-삽입)
+10. [LLM 연동 설정](#10-llm-연동-설정)
+11. [백업 · 복구](#11-백업--복구)
+12. [문제 해결](#12-문제-해결)
+13. [Windows 설치 가이드](#13-windows-설치-가이드)
+14. [조직 유형별 활용 팁](#14-조직-유형별-활용-팁)
+
+---
+
+## 1. 시스템 요구사항
+
+| 항목 | 최소 | 권장 |
+|------|------|------|
+| OS | Ubuntu 20.04 / macOS 13 | Ubuntu 22.04 LTS |
+| CPU | 2코어 | 4코어 이상 |
+| RAM | 4GB | 8GB 이상 |
+| 디스크 | 20GB | 50GB 이상 |
+| Docker | 24.x 이상 | 최신 버전 |
+
+> **Windows 사용자**: [13장 Windows 설치 가이드](#13-windows-설치-가이드)를 먼저 확인하세요.
+
+---
+
+## 2. 설치 방법
+
+### 방법 A — 자동 설치 스크립트 (권장)
+
+```bash
+git clone https://github.com/sinmb79/Gov-chat-bot.git
+cd Gov-chat-bot
+chmod +x install.sh
+./install.sh
+```
+
+스크립트가 자동으로 처리하는 항목:
+- Docker 설치 확인
+- 설정 파일(`.env`) 생성
+- 서비스 빌드 및 시작
+- 데이터베이스 초기화
+
+### 방법 B — 수동 설치
+
+```bash
+cp .env.example .env
+# .env 파일을 편집하여 설정 입력
+
+docker compose up -d
+docker compose exec backend alembic upgrade head
+```
+
+---
+
+## 3. 초기 설정
+
+### 3-1. 관리자 계정 만들기
+
+```bash
+docker compose exec backend python -m app.scripts.create_admin
+```
+
+입력 항목:
+- **조직 ID**: 영문 소문자 (예: `my-cafe`, `city-hall`, `my-shop`)
+- **이메일**: 로그인에 사용할 이메일
+- **비밀번호**: 8자 이상
+
+### 3-2. 조직 기본 정보 설정 (폴백 메시지용)
+
+AI가 답을 못 찾을 때 표시할 안내 메시지를 설정합니다.
+API를 통해 TenantConfig에 다음 값을 등록하세요:
+
+| 키 | 설명 | 예시 |
+|----|------|------|
+| `tenant_name` | 조직 이름 | 맛있는 식당, 동두천시 |
+| `phone_number` | 대표 전화번호 | 031-000-0000 |
+| `fallback_dept` | 문의 담당 (선택) | 고객센터, 민원과 |
+
+---
+
+## 4. 대시보드 사용법
+
+브라우저에서 `http://서버주소:3000` 접속 후 로그인.
+
+| 메뉴 | 설명 |
+|------|------|
+| 📊 대시보드 | 문의 통계, 자동응답률 현황 |
+| ❓ FAQ 관리 | 자주 묻는 질문 등록·수정·삭제 |
+| 📄 문서 관리 | 자료 파일 업로드·승인·삭제 |
+| 📋 문의 이력 | 처리된 문의 조회 (개인정보 마스킹) |
+| 🚫 악성 감지 | 도배·욕설 사용자 관리 |
+| 💬 시뮬레이터 | AI 응답 미리 테스트 |
+
+---
+
+## 5. FAQ 관리
+
+FAQ는 가장 빠르고 정확한 답변 방식입니다. 유사도가 높은 질문이 들어오면 즉시 답변합니다.
+
+### 효과적인 FAQ 작성 방법
+
+**같은 의미를 여러 표현으로 등록**하면 인식률이 크게 올라갑니다.
+
+```
+질문: "영업시간이 어떻게 되나요?"
+질문: "몇 시에 열어요?" ← 별도 FAQ로 추가
+질문: "오늘 언제까지 하나요?" ← 별도 FAQ로 추가
+→ 세 개 모두 같은 답변으로 연결
+```
+
+**답변 작성 팁:**
+- 짧고 명확하게 (200자 이내 권장)
+- 전화번호, 주소 등 실용 정보 포함
+- 줄바꿈으로 읽기 쉽게
+
+### 카테고리 예시
+
+| 업종 | 카테고리 예시 |
+|------|---------------|
+| 음식점 | 메뉴, 예약, 위치, 영업시간 |
+| 쇼핑몰 | 배송, 교환환불, 결제, 회원 |
+| 병원 | 진료, 예약, 보험, 주차 |
+| 지자체 | 민원, 복지, 세금, 교통 |
+| 학원 | 수강, 수업, 환불, 일정 |
+
+---
+
+## 6. 문서 관리 (자료 학습)
+
+PDF, Word 파일을 올리면 AI가 내용을 자동으로 학습하여 질문에 활용합니다.
+
+### 지원 파일 형식
+
+| 형식 | 확장자 |
+|------|--------|
+| PDF | `.pdf` (이미지 스캔 PDF 불가) |
+| Word | `.docx` |
+| 텍스트 | `.txt` |
+| 마크다운 | `.md` |
+
+### 업로드 절차
+
+1. **문서 관리** → **+ 문서 업로드** → 파일 선택
+2. 상태가 `processed`(처리 완료)로 바뀔 때까지 대기
+3. **승인** 버튼 클릭 → AI가 학습 시작
+
+> **승인하지 않으면 학습되지 않습니다.** 반드시 승인 후 시뮬레이터로 테스트하세요.
+
+### 어떤 자료를 올리면 좋을까요?
+
+- 업무 매뉴얼, 안내 책자
+- 상품 설명서, 이용약관
+- 공지사항, 자주 묻는 질문 모음
+- 가격표, 서비스 안내문
+
+---
+
+## 7. 문의 이력 조회
+
+처리된 모든 문의를 조회할 수 있습니다.
+
+- 고객 발화: **개인정보 자동 마스킹** 처리 후 표시
+- 사용자 식별: 해시값만 표시 (원본 저장 안 됨)
+- Tier 필터로 분류별 조회 가능
+
+---
+
+## 8. 악성 감지 · 이용 제한
+
+반복 욕설, 도배 등 악성 사용자를 단계적으로 제한합니다.
+
+| 레벨 | 이름 | 의미 |
+|------|------|------|
+| 0 | 정상 | 제한 없음 |
+| 1 | 경고 | 경고 메시지 표시 |
+| 2 | 제한 | 응답 지연 |
+| 3 | 일시 정지 | 관리자가 해제해야 함 |
+| 4 | 차단 | 관리자만 해제 가능 |
+| 5 | 영구 차단 | — |
+
+---
+
+## 9. 홈페이지 위젯 삽입
+
+홈페이지 HTML에 아래 코드를 `
+
+
+ ` 태그 바로 앞에 붙여넣으세요.
+
+```html
+
+```
+
+| 속성 | 설명 | 예시 |
+|------|------|------|
+| `data-tenant` | 조직 ID | `my-cafe` |
+| `data-api` | 서버 주소 | `https://bot.myshop.com` |
+| `data-title` | 위젯 제목 | `주문 도우미` |
+| `data-color` | 버튼 색상 | `#e53e3e` (빨강) |
+
+---
+
+## 10. LLM 연동 설정
+
+기본값은 LLM 없이 FAQ + 문서 기반 응답만 사용합니다.
+더 자연스러운 답변을 원하면 AI API를 연결할 수 있습니다.
+
+`.env` 파일을 열어 아래 내용을 수정합니다:
+
+**Claude (Anthropic) 사용:**
+```env
+LLM_PROVIDER=anthropic
+ANTHROPIC_API_KEY=sk-ant-api03-...
+```
+
+**ChatGPT (OpenAI) 사용:**
+```env
+LLM_PROVIDER=openai
+OPENAI_API_KEY=sk-...
+```
+
+설정 후 재시작:
+```bash
+docker compose restart backend
+```
+
+> **비용 주의**: LLM API는 질문 수에 따라 요금이 발생합니다.
+> 처음에는 LLM 없이 시작하고, FAQ와 문서를 충분히 등록한 후 연결하세요.
+
+---
+
+## 11. 백업 · 복구
+
+### 데이터베이스 백업
+
+```bash
+# 백업 (날짜 포함 파일명으로 저장)
+docker compose exec db pg_dump -U botuser smartbot > backup_$(date +%Y%m%d).sql
+
+# 복구
+docker compose exec -T db psql -U botuser smartbot < backup_20260101.sql
+```
+
+### 정기 백업 자동화
+
+```bash
+# crontab -e 에 추가 (매일 새벽 2시)
+0 2 * * * cd /경로/Gov-chat-bot && docker compose exec db pg_dump -U botuser smartbot > /백업경로/backup_$(date +\%Y\%m\%d).sql
+```
+
+---
+
+## 12. 문제 해결
+
+### 서비스가 안 켜질 때
+
+```bash
+docker compose logs backend # 오류 로그 확인
+docker compose logs db # DB 오류 확인
+docker compose restart # 재시작
+```
+
+### 포트 충돌 오류
+
+```bash
+# docker-compose.yml 파일에서 포트 변경
+# "8000:8000" → "8080:8000"
+# "3000:80" → "3001:80"
+```
+
+### 답변이 엉뚱할 때
+
+- **시뮬레이터**에서 테스트하며 FAQ를 추가하거나 수정
+- 문서 내용이 명확한지 확인 (스캔 이미지 PDF는 인식 불가)
+- FAQ에 더 다양한 표현으로 질문을 추가
+
+### AI 모델 로드 실패
+
+처음 실행 시 한국어 AI 모델(약 400MB)이 자동 다운로드됩니다.
+
+```bash
+# 인터넷 연결 확인
+docker compose exec backend curl -I https://huggingface.co
+
+# 모델 캐시 초기화 후 재시작
+docker compose exec backend rm -rf /root/.cache/huggingface
+docker compose restart backend
+```
+
+---
+
+## 13. Windows 설치 가이드
+
+Windows에서는 WSL2(리눅스 가상환경)를 통해 설치합니다.
+
+### 1단계 — WSL2 설치
+
+PowerShell을 **관리자 권한**으로 실행 후:
+
+```powershell
+wsl --install
+```
+
+컴퓨터를 재시작하면 Ubuntu 창이 뜹니다. 사용자 이름과 비밀번호를 설정하세요.
+
+### 2단계 — Docker Desktop 설치
+
+1. [Docker Desktop](https://www.docker.com/products/docker-desktop/) 다운로드 및 설치
+2. Settings → Resources → WSL Integration → Ubuntu 켜기
+3. Docker Desktop 재시작
+
+### 3단계 — SmartBot KR 설치
+
+Ubuntu 터미널에서:
+
+```bash
+git clone https://github.com/sinmb79/Gov-chat-bot.git
+cd Gov-chat-bot
+chmod +x install.sh && ./install.sh
+```
+
+### 4단계 — 브라우저 접속
+
+Windows 브라우저에서 바로 접속 가능합니다:
+- 관리자 화면: http://localhost:3000
+- API 문서: http://localhost:8000/docs
+
+---
+
+## 14. 조직 유형별 활용 팁
+
+### 🍽️ 음식점 · 카페
+
+**FAQ 등록 우선 항목:**
+- 영업시간 (요일별, 공휴일 포함)
+- 메뉴 안내 (가격 포함)
+- 예약 방법 및 연락처
+- 주차 가능 여부, 위치
+- 포장·배달 가능 여부
+
+**문서 업로드 추천:**
+- 메뉴판 (텍스트 PDF)
+- 이벤트·프로모션 안내문
+
+---
+
+### 🛍️ 쇼핑몰 · 온라인 판매
+
+**FAQ 등록 우선 항목:**
+- 배송 기간 및 방법
+- 교환·환불 정책 (기간, 조건)
+- 결제 수단
+- 회원가입·포인트 안내
+- AS 문의 방법
+
+**문서 업로드 추천:**
+- 이용약관
+- 배송·환불 정책 전문
+
+---
+
+### 🏥 병원 · 의원 · 약국
+
+**FAQ 등록 우선 항목:**
+- 진료 시간 (요일별)
+- 진료 과목
+- 예약 방법
+- 주차, 대중교통
+- 건강보험 적용 여부
+
+> **주의**: 구체적인 의학 상담 답변은 등록하지 마세요. 진료 예약 안내만 다루는 것이 안전합니다.
+
+---
+
+### 🎓 학원 · 교육기관
+
+**FAQ 등록 우선 항목:**
+- 수업 시간표
+- 수강료 및 납부 방법
+- 등록·상담 방법
+- 환불 규정
+- 교재·준비물 안내
+
+---
+
+### 🏛️ 지자체 · 공공기관
+
+**FAQ 등록 우선 항목:**
+- 주요 민원 처리 방법
+- 서류 발급 절차
+- 담당 부서 연락처
+- 이용 시간 및 휴관일
+
+**문서 업로드 추천:**
+- 민원 안내 책자
+- 주요 사업 안내문
+
+---
+
+## 부록: 주요 API 엔드포인트
+
+| 주소 | 설명 |
+|------|------|
+| `GET /health` | 서비스 정상 여부 확인 |
+| `POST /skill/{조직ID}` | 카카오톡 스킬 연결 주소 |
+| `POST /api/admin/auth/login` | 관리자 로그인 |
+| `GET /docs` | API 전체 문서 |
+
+---
+
+*SmartBot KR · MIT License · 오픈소스 무료 사용*
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..42b54a9
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..2c2cd7e
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+