Compare commits

..

24 Commits

Author SHA1 Message Date
shinalok ab437d5d2e UI/UX D3: dark mode toggle, chat export, accessibility, onboarding modal
- app.py: _JS with dark mode toggle (localStorage + system preference) (D3-17)
- app.py: dark mode CSS overrides for custom chat bubble colors (D3-17)
- app.py: export_chat() -> gr.File .md download button in controls row (D3-18)
- app.py: JS aria-label/role/aria-live injection for chatbot and inputs (D3-19)
- app.py: :focus-visible CSS 3px blue outline for keyboard navigation (D3-19)
- app.py: first-visit onboarding modal with localStorage guard (D3-20)
- app.py: js=_JS wired into gr.Blocks()
- ROADMAP.md: mark all D3 items complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:45:49 +09:00
shinalok fb438864b1 UI/UX D2: branded header, custom theme, bubble styles, streaming animation, responsive
- app.py: styled HTML header with robot icon + subtitle (D2-11)
- app.py: gr.themes.Soft(blue/indigo/slate) applied to gr.Blocks (D2-12)
- app.py: user_selector moved to header Row, right-aligned scale=0 (D2-13)
- app.py: .message-wrap .user/.bot custom background + border-radius CSS (D2-14)
- app.py: streaming-indicator blink animation on _live_html (D2-15)
- app.py: @media max-width:768px responsive CSS (D2-16)
- ROADMAP.md: mark all D2 items complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:41:51 +09:00
shinalok c1a28bfdcc UI/UX D1: examples, image accordion, layout improvements, table select, button size
- app.py: add gr.Examples with 3 sample questions (D1-5)
- app.py: wrap image input in gr.Accordion(open=False) (D1-6)
- app.py: checkboxes left Column(scale=3), reset btn right Column(scale=1) (D1-7)
- app.py: msg_box lines=2, send_btn .send-btn CSS min-height 80px (D1-8)
- app.py: doc_table.select -> select_doc_row auto-fills delete_source (D1-9)
- app.py: ingest_btn scale=0 min_width=200 to avoid full-width stretch (D1-10)
- ROADMAP.md: mark all D1 items complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:28:21 +09:00
shinalok e08b43c785 UI/UX D0: fix Textbox label, hide audio accordion, hide footer, hide ingest result box
- app.py: show_label=False on msg_box to remove "Textbox" label
- app.py: wrap audio input in gr.Accordion(open=False) to hide microphone error on load
- app.py: add CSS to hide Gradio footer branding
- app.py: ingest_status starts visible=False; ingest_files returns gr.update(visible=True)
- ROADMAP.md: add UI/UX design improvement section (D0–D3) and mark D0 complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:25:54 +09:00
shinalok 20f385e2a0 Merge remote-tracking branch 'origin/main' 2026-06-02 14:12:45 +09:00
shinalok 4565980915 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.env.example
2026-06-02 14:09:54 +09:00
shinalok b13e3dfdd7 Add .env.example file with API and voice configuration templates 2026-06-02 14:09:40 +09:00
shinalok 0803479438 Fix: move image_input to separate row for cleaner layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:04:01 +09:00
shinalok 7f50333bdb Phase 17: Add image upload to chat UI
- app.py: image_input gr.Image component, respond() accepts image_path,
  all yields updated to 7 outputs
- api_client.py: chat(image_path=None), base64-encodes image for API
- services/chat.py: chat(image_path=None) passes through to api_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:52:21 +09:00
shinalok 974bab7cd8 Phase 28: P3 — Pydantic Settings, dependency-injector IoC, tenacity retry
- config.py: dataclasses → pydantic-settings BaseSettings (flat AppConfig,
  env vars auto-loaded from .env, type-safe validation)
- api_client.py: HTTPAPIClient takes AppConfig directly (APIConfig removed);
  tenacity retry on 5 methods (reset/ingest/list/delete/feedback) —
  retries on 5xx + TransportError, 3 attempts, exponential backoff 1-8s
- container.py: manual DI → dependency_injector DeclarativeContainer with
  providers.Singleton; Container() needs no args
- app.py: container.X → container.X() calls, remove AppConfig import
- requirements.txt: add pydantic-settings, tenacity, dependency-injector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 05:59:23 +09:00
shinalok 148211e236 Phase 27: P2 quality improvements — logging, httpx pooling, validation, tests
- app.py: replace print() with logging, basicConfig with LOG_LEVEL env var
- api_client.py: shared AsyncClient instance (connection pooling), URL-encode
  delete_document path parameter, aclose() for cleanup
- services/document.py: validate file exists and extension before ingest
- tests/: ChatService (4) + DocumentService (6) unit tests via pytest-asyncio
- pyproject.toml: asyncio_mode = auto
- requirements-dev.txt: pytest, pytest-asyncio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:52:43 +09:00
shinalok 511c87b290 docs: update ROADMAP to reflect P0/P1 completion and services/ package structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:46:53 +09:00
shinalok 79f2abe7cf chore: exclude .claude/ from git tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:41:17 +09:00
shinalok 7eed70d7f7 chore: add .gitignore rules for IDE and cache files, remove tracked artifacts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:40:08 +09:00
shinalok 1e93def909 Refactor: split services.py into services/ package
ChatService → services/chat.py
DocumentService → services/document.py
TTSService → services/tts.py
services/__init__.py re-exports all three for backward-compatible imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:39:52 +09:00
shinalok d81a2f5888 Phase 26: P1 architecture refactor — DI container, service layer, async callbacks
- config.py: APIConfig + AppConfig dataclasses, env vars centralized
- api_client.py: APIClientProtocol (Protocol) + HTTPAPIClient class, remove module-level globals
- services.py: ChatService, DocumentService, TTSService (TTS moved from app.py)
- container.py: manual DI container with lazy singleton properties
- app.py: all callbacks converted to async, asyncio.run() fully removed, container wired in
- .env.example: add TTS_EDGE_VOICE entry
- ROADMAP.md: P0/P1 checklist updated to reflect completed work

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:36:35 +09:00
shinalok be4b7c40cb Merge remote-tracking branch 'origin/main' 2026-06-01 16:42:58 +09:00
shinalok 5ea7948ed3 Add project roadmap outlining improvements and priorities 2026-06-01 16:42:37 +09:00
shinalok 38d2edeeec Phase 25: Separate RAG sources into collapsible box below chatbot
- Add source_box gr.HTML component below chatbot
- Add _sources_html() helper rendering <details> expand/collapse
- Handle __sources token in respond(): update source_box independently of thinking_box
- Reset both thinking_box and source_box on each new message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:15:06 +09:00
shinalok 55ea69d902 Fix details close-on-update: use div during streaming, details on complete
During streaming: _live_html (plain div) shows only the current line —
no DOM reset, no closing issue. __thinking shows last non-empty line,
__meta shows the full trimmed message.
On completion: _thinking_html (<details>) shows all accumulated content
collapsed, expands on click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:50:21 +09:00
shinalok 18609a4f7d Collapsible thinking box with details/summary, thinking on by default
- Thinking box uses <details>/<summary> — collapsed by default, expands on click
- Simple __status header shown before content arrives (no expand needed)
- show_thinking checkbox default changed to True

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:34:47 +09:00
shinalok 5cf8bdabfd Handle __status tokens for instant thinking box feedback
__status tokens show immediately in the thinking box but do not
accumulate in thinking_acc. When real content (__meta/__thinking)
arrives it overwrites the status message naturally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:08:30 +09:00
shinalok 4956ab7085 Replace gr.Markdown thinking box with gr.HTML for reliable streaming
gr.Markdown visible toggling is unreliable in Gradio streaming generators.
Switched to gr.HTML with inline styles — empty string hides the element,
HTML string shows the styled box. No visibility state needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 11:30:57 +09:00
shinalok 2348f17791 Move progress logs into thinking box alongside LLM reasoning
Both __meta (LangGraph/search progress) and __thinking (LLM reasoning)
tokens now stream into the thinking box instead of the chatbot.
Chatbot shows only the final answer. Thinking box shows the full
analysis pipeline: [LangGraph → ...], 문서 검색 중, thinking content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:56:01 +09:00
17 changed files with 1114 additions and 215 deletions
+11
View File
@@ -0,0 +1,11 @@
# 율봇 API 서버 주소
YOULBOT_API_URL=http://localhost:8000
# API 토큰 (youlbot/.env의 API_TOKEN과 동일하게 설정. 빈 값이면 개발 모드)
YOULBOT_API_TOKEN=youlbot-ai-token!!@@1234
# 음성 설정 (로컬 실행)
WHISPER_MODEL_SIZE=small
TTS_VOICE=Yuna
TTS_EDGE_VOICE=ko-KR-SunHiNeural
LOG_LEVEL=INFO
+5
View File
@@ -1 +1,6 @@
/.env
.idea/
.claude/
__pycache__/
*.pyc
*.pyo
+384
View File
@@ -0,0 +1,384 @@
# youlbot-webui 개선 로드맵
## 현황 요약
| 항목 | 현재 상태 | 심각도 |
|------|---------|--------|
| 아키텍처 모듈화 | ~~2파일 혼재~~ → config / api_client / services/ / container / app 5모듈 분리 | ✅ 완료 |
| Windows 호환성 | ~~TTS `say` 명령어 — macOS 전용~~ → 크로스플랫폼 구현 완료 | ✅ 완료 |
| Gradio Chatbot 타입 | ~~`type="messages"` 누락~~ → Gradio 6.x 기본 포맷 사용 | ✅ 완료 |
| JSON yield 타입 불일치 | ~~`JSONDecodeError` 시 타입 혼용~~`str()` 변환 적용 | ✅ 완료 |
| run_id 인덱싱 버그 | ~~`history` / `run_ids` 동기화 취약~~ → 방어 로직 추가 | ✅ 완료 |
| RAG 출처 표시 | ~~thinking 박스에 혼재~~ → 답변 하단 `📄 출처` 전용 박스로 분리 | ✅ 완료 |
| async/sync 혼용 | ~~`asyncio.run()` 5곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 |
| 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 |
| 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 |
| 테스트 가능성 | ~~모킹 불가능~~ → pytest-asyncio 단위 테스트 10개 작성 | ✅ 완료 |
| 로깅 | ~~`print()` 만 사용~~`logging` 모듈, `LOG_LEVEL` 환경변수 | ✅ 완료 |
---
## IoC / 의존성 주입 전략
전용 IoC 프레임워크(`dependency-injector` 등)는 현재 규모에 **과도한 복잡도**를 유발합니다.
대신 **수동 DI 패턴**을 적용합니다.
- `Protocol` 기반 인터페이스로 `api_client` 추상화 → 테스트 모킹 가능
- `Container` 클래스가 서비스 인스턴스 생성·생명주기 관리
- 향후 규모가 커질 경우 프레임워크로 전환 용이
---
## 목표 아키텍처
```
┌──────────────────────────────────────────┐
│ app.py (Gradio UI) │
│ 콜백 함수는 service 메서드만 호출 │
└──────────────┬───────────────────────────┘
│ 주입받음
┌──────────────▼───────────────────────────┐
│ container.py (Container) │
│ chat_service, document_service 제공 │
└──────────────┬───────────────────────────┘
│ 생성
┌──────────────▼───────────────────────────┐
│ services/ │
│ chat.py / document.py / tts.py │
└──────────────┬───────────────────────────┘
│ 사용
┌──────────────▼───────────────────────────┐
│ api_client.py (APIClientProtocol) │
│ HTTPAPIClient (실구현) │
└──────────────┬───────────────────────────┘
│ 설정 읽음
┌──────────────▼───────────────────────────┐
│ config.py (AppConfig / APIConfig) │
│ 환경변수 일원화 │
└──────────────────────────────────────────┘
```
---
## P0 — 즉시 수정 (버그·호환성)
> 현재 Windows 환경에서 실행 불가 또는 런타임 오류 유발 항목
### 1. 크로스플랫폼 TTS 구현 (`app.py:47-61`)
- **문제**: `subprocess.run(["say", ...])` 는 macOS 전용 → Windows에서 동작 불가.
- **플랫폼별 우선순위**:
- **macOS**: `say`(오프라인, 내장) → `edge-tts`(온라인 폴백) → `pyttsx3`(최종 폴백)
- **Windows**: `edge-tts`(온라인) → `pyttsx3`(오프라인 폴백)
| 우선순위 | 라이브러리 | 방식 | 품질 | Windows | macOS |
|---------|-----------|------|------|---------|-------|
| macOS 1순위 | `say` | 오프라인(내장) | ⭐⭐⭐⭐ | ❌ | ✅ |
| macOS 2순위 / Windows 1순위 | `edge-tts` | 온라인(MS Edge) | ⭐⭐⭐⭐⭐ | ✅ | ✅ |
| 최종 폴백 | `pyttsx3` | 오프라인(SAPI5/NSSpeech) | ⭐⭐⭐ | ✅ | ✅ |
```python
async def tts_speak(text: str) -> str | None:
"""크로스플랫폼 TTS — 플랫폼별 우선순위 적용."""
if not text:
return None
# macOS: say 우선 (오프라인, 자연스러운 한국어)
if platform.system() == "Darwin":
try:
tmp = tempfile.NamedTemporaryFile(suffix=".aiff", delete=False)
tmp.close()
await asyncio.to_thread(
subprocess.run,
["say", "-v", _TTS_VOICE, "-o", tmp.name, text],
check=True, capture_output=True,
)
return tmp.name
except Exception:
pass
# Windows 1순위 / macOS say 실패 시: edge-tts (온라인)
try:
import edge_tts
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
tmp.close()
await edge_tts.Communicate(text, _TTS_EDGE_VOICE).save(tmp.name)
return tmp.name
except Exception:
pass
# 최종 폴백: pyttsx3 (오프라인)
try:
import pyttsx3
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
def _save():
engine = pyttsx3.init()
engine.save_to_file(text, tmp.name)
engine.runAndWait()
await asyncio.to_thread(_save)
return tmp.name
except Exception:
return None
```
- **전역 변수** (`app.py` 상단 추가):
```python
_TTS_VOICE = os.getenv("TTS_VOICE", "Yuna") # macOS say
_TTS_EDGE_VOICE = os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural") # edge-tts
```
- **출력 포맷**: macOS say → `.aiff` (기존 유지), edge-tts → `.mp3`, pyttsx3 → `.wav`
- **requirements.txt 추가**: `edge-tts>=6.1.9`, `pyttsx3>=2.90`
- **주요 edge-tts 한국어 보이스**: `ko-KR-SunHiNeural`(여성), `ko-KR-InJoonNeural`(남성)
### 2. Gradio Chatbot `type="messages"` 누락 (`app.py:186`)
- **문제**: Gradio 4.x에서 `{"role": ..., "content": ...}` 딕셔너리 포맷 사용 시
`type="messages"` 를 명시하지 않으면 경고 또는 오류 발생.
- **수정**: `gr.Chatbot(type="messages", ...)` 추가.
### 3. JSON yield 타입 불일치 (`api_client.py:45-46`)
- **문제**: `JSONDecodeError` 발생 시 `raw`(bytes 또는 str)를 그대로 yield →
반환 타입 `tuple[str, str | None]` 불일치.
- **수정**: `yield str(raw), None` 으로 명시적 변환.
### 4. run_id 인덱싱 방어 로직 (`app.py:107-108`)
- **문제**: `history` 길이와 `run_ids` 길이가 어긋나면 인덱스 오류 발생 가능.
- **수정**: `asst_turn` 계산 후 범위 초과 시 `None` 반환하는 방어 코드 보강.
---
## P1 — 1주일 내 (구조 개선)
### 1. 설정 분리 → `config.py` 신규 생성
```python
# config.py
from dataclasses import dataclass, field
import os
@dataclass
class APIConfig:
url: str = field(default_factory=lambda: os.getenv("YOULBOT_API_URL", "http://localhost:8000"))
token: str = field(default_factory=lambda: os.getenv("YOULBOT_API_TOKEN", ""))
timeout: int = 180
@dataclass
class AppConfig:
api: APIConfig = field(default_factory=APIConfig)
whisper_model_size: str = field(default_factory=lambda: os.getenv("WHISPER_MODEL_SIZE", "small"))
tts_voice: str = field(default_factory=lambda: os.getenv("TTS_VOICE", "Yuna"))
server_host: str = "0.0.0.0"
server_port: int = 7860
```
### 2. Protocol 인터페이스 + HTTPAPIClient 분리 (`api_client.py` 리팩터링)
```python
# api_client.py
from typing import Protocol, AsyncIterator, runtime_checkable
@runtime_checkable
class APIClientProtocol(Protocol):
async def chat(self, message: str, user_id: str, show_thinking: bool) -> AsyncIterator[tuple[str, str | None]]: ...
async def reset(self, user_id: str) -> None: ...
async def ingest(self, file_path: str) -> dict: ...
async def list_documents(self) -> list[str]: ...
async def delete_document(self, source: str) -> None: ...
async def save_feedback(self, user_id: str, user_msg: str, asst_msg: str, rating: int, run_id: str | None) -> None: ...
class HTTPAPIClient:
def __init__(self, config: APIConfig): ...
# 기존 함수들을 메서드로 이전
```
### 3. 서비스 레이어 분리 → `services/` 패키지 신규 생성
```
services/
├── __init__.py — ChatService, DocumentService, TTSService 재익스포트
├── chat.py — ChatService: chat, reset, save_feedback
├── document.py — DocumentService: ingest, list_documents, delete_document
└── tts.py — TTSService: speak (플랫폼 분기)
```
### 4. 수동 DI 컨테이너 → `container.py` 신규 생성
```python
# container.py
class Container:
def __init__(self, config: AppConfig):
self._config = config
self._api_client: HTTPAPIClient | None = None
self._chat_service: ChatService | None = None
self._document_service: DocumentService | None = None
@property
def api_client(self) -> HTTPAPIClient: ...
@property
def chat_service(self) -> ChatService: ...
@property
def document_service(self) -> DocumentService: ...
```
### 5. Async 콜백 통일 (`app.py`)
- `handle_feedback`, `reset_chat`, `ingest_files`, `list_docs`, `delete_doc` →
모두 `async def` 로 전환
- `asyncio.get_event_loop().run_until_complete()` 패턴 완전 제거
- Gradio 4.x async 콜백 지원 활용
---
## P2 — 2주일 내 (품질 개선)
| # | 항목 | 설명 |
|---|------|------|
| 1 | 로깅 시스템 | `print()` → `logging` 모듈, 구조적 로그 포맷 |
| 2 | httpx 연결 풀 공유 | `HTTPAPIClient`에서 `AsyncClient` 인스턴스 재사용 |
| 3 | 입력 검증 | 파일 경로 sanitize, URL path parameter 검증 |
| 4 | 단위 테스트 | `tests/` 폴더 생성, `ChatService` / `DocumentService` Mock 테스트 |
---
## P3 — 선택 사항 (장기)
| # | 항목 | 설명 |
|---|------|------|
| 1 | 재시도 로직 | `tenacity` 라이브러리 또는 수동 exponential backoff |
| 2 | Pydantic Settings | `pydantic-settings` 로 타입 안전 환경변수 관리 |
| 3 | IoC 프레임워크 전환 | 규모 확장 시 `dependency-injector` 도입 검토 |
---
## 최종 파일 구조 (목표)
```
youlbot-webui/
├── app.py # Gradio UI 전용 — 콜백만 존재, 비즈니스 로직 없음
├── container.py # 수동 DI 컨테이너
├── services/
│ ├── __init__.py # 재익스포트
│ ├── chat.py # ChatService
│ ├── document.py # DocumentService
│ └── tts.py # TTSService
├── api_client.py # APIClientProtocol + HTTPAPIClient
├── config.py # AppConfig, APIConfig dataclass
├── tests/
│ ├── test_chat_service.py
│ └── test_document_service.py
├── requirements.txt
├── .env.example
└── ROADMAP.md
```
---
## UI/UX 디자인 개선
> 실제 UI 스크린샷 분석(2026-06-02) 기반 — 우선순위 순 정렬
### D0 — 즉시 수정 (노출 결함)
| # | 위치 | 문제 | 개선 방안 |
|---|------|------|-----------|
| 1 | 대화 탭 · 입력창 | `"Textbox"` 레이블이 입력창 위에 그대로 노출 | `gr.Textbox(label="", show_label=False, ...)` 또는 `label=None` |
| 2 | 대화 탭 · 음성 영역 | `"마이크를 찾을 수 ..."` 오류 메시지가 항상 노출 | 마이크 미사용 시 해당 텍스트 숨김 처리, 또는 `visible=False` 기본값 |
| 3 | 전체 탭 · 푸터 | `"Gradio로 제작됨 🎉"` Gradio 브랜딩 노출 | `css="footer { display: none; }"` 추가 또는 `gr.Blocks(show_footer=False)` |
| 4 | 문서 등록 탭 · 결과 박스 | 업로드 전에도 빈 `결과` 박스가 항상 노출 | 초기 `visible=False`, 수집 완료 시에만 `visible=True` 반환 |
### D1 — 1주일 내 (레이아웃 개선)
| # | 위치 | 문제 | 개선 방안 |
|---|------|------|-----------|
| 5 | 대화 탭 · 채팅 영역 | 초기 화면이 빈 흰 공간으로 시작 — 어색한 첫 인상 | 웰컴 메시지 또는 예시 질문 버블(`gr.Examples`) 추가 |
| 6 | 대화 탭 · 이미지 첨부 | 이미지 업로드 영역이 항상 전체 크기로 펼쳐짐 | `gr.Accordion("이미지 첨부 (선택)", open=False)` 로 접이식 변경 |
| 7 | 대화 탭 · 하단 컨트롤 | `사고 과정 표시`, `TTS`, `대화 초기화` 배치가 불규칙 | `gr.Row`로 균등 3분할, `대화 초기화`는 오른쪽 정렬 |
| 8 | 대화 탭 · 입력 영역 | 텍스트 입력창과 `전송` 버튼이 시각적으로 분리됨 | 입력창 높이를 `lines=2`로 통일, 버튼 높이 CSS로 맞춤 |
| 9 | 문서 관리 탭 · 삭제 UX | 경로를 테이블에서 복사해 입력 필드에 붙여넣기 해야 함 | 테이블 행 클릭 → 입력 필드 자동 채움 (`select` 이벤트 활용) |
| 10 | 문서 등록 탭 · 버튼 | `문서 수집` 버튼이 전체 너비를 차지해 무게감 과도 | `scale=0` 또는 `min_width=200` 으로 적정 크기 조절 |
### D2 — 2주일 내 (시각 품질)
| # | 항목 | 설명 |
|---|------|------|
| 11 | 헤더 브랜딩 | 텍스트 전용 헤더 → 아이콘/로고 이미지 + 서브타이틀 레이아웃으로 개선 |
| 12 | 커스텀 테마 | Gradio 기본 보라색 → `gr.themes.Soft()` 또는 커스텀 `primary_hue` 색상 지정 |
| 13 | 사용자 선택 위치 | `사용자` 드롭다운이 채팅 위에 있어 흐름 방해 → 헤더 우측 또는 사이드바로 이동 |
| 14 | 채팅 버블 스타일 | Gradio 기본 스타일 → CSS로 사용자/봇 버블 배경색·radius 차별화 |
| 15 | 응답 로딩 표시 | 스트리밍 중 시각적 피드백 없음 → 입력 비활성화 + 스피너 CSS 추가 |
| 16 | 반응형 레이아웃 | 좁은 뷰포트에서 요소 겹침 → `gr.Column`/`gr.Row` 비율 재조정 |
### D3 — 선택 사항 (장기)
| # | 항목 | 설명 |
|---|------|------|
| 17 | 다크 모드 | `gr.themes.Base()` + CSS 변수로 다크/라이트 토글 지원 |
| 18 | 채팅 내보내기 | 대화 내용을 `.txt`/`.md`로 다운로드하는 버튼 추가 |
| 19 | 접근성 | `aria-label` 속성 추가, 키보드 포커스 표시 개선 |
| 20 | 온보딩 투어 | 첫 방문 사용자 대상 단계별 기능 안내 (JS 오버레이) |
### D 체크리스트
#### D0
- [x] `gr.Textbox(show_label=False)` — `"Textbox"` 레이블 제거
- [x] 음성 오류 메시지 기본 숨김 처리 — `gr.Accordion("🎤 음성으로 질문하기", open=False)` 로 기본 접힘
- [x] Gradio 푸터 CSS 숨김 — `footer { display: none !important; }` 추가
- [x] `결과` 박스 초기 `visible=False` — `ingest_files`에서 `gr.update(visible=True)` 반환
#### D1
- [x] 웰컴 메시지 또는 예시 질문 버블 추가 — `gr.Examples` 3개 예시 질문
- [x] 이미지 첨부 영역 `gr.Accordion`으로 접이식 변경 — `open=False` 기본 접힘
- [x] 하단 컨트롤 `gr.Row` 균등 배치 — 체크박스 좌측 `Column(scale=3)`, 초기화 버튼 우측 `Column(scale=1)`
- [x] 입력창 `lines=2` + 전송 버튼 높이 CSS 맞춤 — `.send-btn { min-height: 80px }`
- [x] 문서 관리 탭 — 테이블 행 클릭 → 삭제 경로 자동 채움 — `doc_table.select` + `select_doc_row`
- [x] `문서 수집` 버튼 크기 적정화 — `scale=0, min_width=200`
#### D2
- [x] 헤더 아이콘/로고 추가 — `gr.HTML` 🤖 아이콘 + 타이틀/서브타이틀 레이아웃
- [x] 커스텀 테마 적용 — `gr.themes.Soft(primary_hue="blue", secondary_hue="indigo", neutral_hue="slate")`
- [x] 사용자 드롭다운 위치 이동 — 탭 내부 → 헤더 Row 우측 (`scale=0, min_width=160`)
- [x] 채팅 버블 커스텀 CSS — `.message-wrap .user` 파란 배경, `.message-wrap .bot` 회색 배경
- [x] 스트리밍 로딩 표시 — `_live_html`에 `streaming-indicator` blink 애니메이션
- [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS
#### D3
- [x] 다크 모드 토글 — 헤더 🌙/☀️ 버튼 + `localStorage` 기억 + 시스템 설정 자동 감지
- [x] 채팅 내보내기 — `export_chat` 함수, `.md` 파일 다운로드, `gr.File` 출력
- [x] 접근성 개선 — `aria-label`/`role`/`aria-live` JS 주입, `:focus-visible` 파란 테두리
- [x] 온보딩 투어 — 첫 방문 모달 (`localStorage` 체크, "시작하기" 버튼으로 닫기)
---
## 진행 체크리스트
### P0
- [x] `tts_speak()` 크로스플랫폼 구현 (macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3)
- [x] `requirements.txt` — `edge-tts>=6.1.9`, `pyttsx3>=2.90` 추가
- [x] `.env.example` — `TTS_EDGE_VOICE=ko-KR-SunHiNeural` 항목 추가 (`TTS_VOICE=Yuna` 유지)
- [x] `gr.Chatbot` — Gradio 6.x 기본 dict 포맷 사용 (`type` 파라미터 불필요, 제거)
- [x] `api_client.py` JSON yield 타입 수정
- [x] `run_id` 인덱싱 방어 로직 추가
- [x] RAG 출처 전용 박스 분리 — `source_box` gr.HTML + `_sources_html()` + `__sources` 토큰 처리
### P1
- [x] `config.py` 작성 (APIConfig, AppConfig)
- [x] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리
- [x] `services/` 패키지 작성 (chat.py, document.py, tts.py + __init__.py 재익스포트)
- [x] `container.py` 작성 (lazy singleton 프로퍼티)
- [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거)
### P2
- [x] `logging` 모듈 도입 — `basicConfig` + `LOG_LEVEL` 환경변수, `print()` 제거
- [x] httpx `AsyncClient` 재사용 — `HTTPAPIClient.__init__`에서 공유 클라이언트 생성, `aclose()` 추가
- [x] 입력 검증 추가 — `DocumentService.ingest` 파일 존재·확장자 검증, `delete_document` URL 인코딩
- [x] `tests/` 단위 테스트 작성 — `pytest-asyncio`, `ChatService` 4개 / `DocumentService` 6개 (10/10 통과)
### P3
- [x] 재시도 로직 — `tenacity` / 5xx·TransportError에만 최대 3회, 지수 백오프(1→8s) / `chat` 제외 5개 메서드 적용
- [x] Pydantic Settings — `config.py` dataclass → `BaseSettings` (flat `AppConfig`), `.env` 자동 로드
- [x] IoC 프레임워크 전환 — 수동 DI → `dependency-injector` `DeclarativeContainer` + `providers.Singleton`
+85 -53
View File
@@ -1,39 +1,74 @@
"""율봇 API 클라이언트 — youlbot REST API(Phase 22)를 httpx로 호출."""
"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현."""
import json
import os
from typing import AsyncIterator
import urllib.parse
from typing import AsyncIterator, Protocol, runtime_checkable
import httpx
from dotenv import load_dotenv
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
load_dotenv()
_API_URL = os.getenv("YOULBOT_API_URL", "http://localhost:8000").rstrip("/")
_API_TOKEN = os.getenv("YOULBOT_API_TOKEN", "")
from config import AppConfig
def _headers() -> dict:
if _API_TOKEN:
return {"Authorization": f"Bearer {_API_TOKEN}"}
return {}
def _is_transient(exc: Exception) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code >= 500
return isinstance(exc, httpx.TransportError)
async def chat(
_retry = retry(
retry=retry_if_exception(_is_transient),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
reraise=True,
)
@runtime_checkable
class APIClientProtocol(Protocol):
def chat(
self, message: str, user_id: str, show_thinking: bool
) -> AsyncIterator[tuple[str, str | None]]: ...
async def reset(self, user_id: str) -> None: ...
async def ingest(self, file_path: str) -> dict: ...
async def list_documents(self) -> list[str]: ...
async def delete_document(self, source: str) -> None: ...
async def save_feedback(
self, user_id: str, user_msg: str, asst_msg: str, rating: int, run_id: str | None
) -> None: ...
class HTTPAPIClient:
def __init__(self, config: AppConfig):
self._url = config.youlbot_api_url.rstrip("/")
self._timeout = config.youlbot_api_timeout
self._client = httpx.AsyncClient(
headers={"Authorization": f"Bearer {config.youlbot_api_token}"}
if config.youlbot_api_token else {},
)
async def chat(
self,
message: str,
user_id: str = "default",
show_thinking: bool = False,
) -> AsyncIterator[tuple[str, str | None]]:
"""SSE 스트림을 읽어 (token, run_id) 튜플을 yield.
- 일반 토큰: (token_str, None)
- 스트림 종료: ("", run_id_or_None) ← __done 이벤트
"""
async with httpx.AsyncClient(timeout=180) as client:
async with client.stream(
image_path: str | None = None,
) -> AsyncIterator[tuple[str, str | None]]:
payload: dict = {"message": message, "user_id": user_id, "show_thinking": show_thinking}
if image_path:
import base64
with open(image_path, "rb") as f:
payload["image_base64"] = base64.b64encode(f.read()).decode()
async with self._client.stream(
"POST",
f"{_API_URL}/chat",
json={"message": message, "user_id": user_id, "show_thinking": show_thinking},
headers=_headers(),
f"{self._url}/chat",
json=payload,
timeout=self._timeout,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
@@ -50,56 +85,50 @@ async def chat(
return
yield payload, None
async def reset(user_id: str = "default") -> None:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{_API_URL}/reset",
@_retry
async def reset(self, user_id: str = "default") -> None:
r = await self._client.post(
f"{self._url}/reset",
params={"user_id": user_id},
headers=_headers(),
timeout=30,
)
r.raise_for_status()
async def ingest(file_path: str) -> dict:
async with httpx.AsyncClient(timeout=300) as client:
@_retry
async def ingest(self, file_path: str) -> dict:
with open(file_path, "rb") as f:
filename = os.path.basename(file_path)
r = await client.post(
f"{_API_URL}/ingest",
r = await self._client.post(
f"{self._url}/ingest",
files={"file": (filename, f, "application/octet-stream")},
headers=_headers(),
timeout=300,
)
r.raise_for_status()
return r.json()
async def list_documents() -> list[str]:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"{_API_URL}/documents", headers=_headers())
@_retry
async def list_documents(self) -> list[str]:
r = await self._client.get(f"{self._url}/documents", timeout=30)
r.raise_for_status()
return r.json().get("documents", [])
async def delete_document(source: str) -> None:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.delete(
f"{_API_URL}/documents/{source}",
headers=_headers(),
)
@_retry
async def delete_document(self, source: str) -> None:
encoded = urllib.parse.quote(source, safe="")
r = await self._client.delete(f"{self._url}/documents/{encoded}", timeout=30)
r.raise_for_status()
async def save_feedback(
@_retry
async def save_feedback(
self,
user_id: str,
user_msg: str,
asst_msg: str,
rating: int,
run_id: str | None = None,
) -> None:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{_API_URL}/feedback",
) -> None:
r = await self._client.post(
f"{self._url}/feedback",
json={
"user_id": user_id,
"user_msg": user_msg,
@@ -107,6 +136,9 @@ async def save_feedback(
"rating": rating,
"run_id": run_id,
},
headers=_headers(),
timeout=30,
)
r.raise_for_status()
async def aclose(self) -> None:
await self._client.aclose()
+368 -149
View File
@@ -7,34 +7,38 @@
YOULBOT_API_URL=http://localhost:8000
YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값
"""
import asyncio
import html as _html
import logging
import os
import platform
import subprocess
import tempfile
import gradio as gr
from dotenv import load_dotenv
load_dotenv()
import api_client
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-8s %(name)s%(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
from container import Container
container = Container()
USER_LABELS = ["아록", "근혜", "도율", "하율"]
DEFAULT_USER = "아록"
# ── STT (Whisper) — 로컬 실행 유지 ──────────────────────────────
_whisper_model = None
_WHISPER_SIZE = os.getenv("WHISPER_MODEL_SIZE", "small")
_TTS_VOICE = os.getenv("TTS_VOICE", "Yuna") # macOS say 보이스
_TTS_EDGE_VOICE = os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural") # edge-tts 보이스
def _get_whisper():
global _whisper_model
if _whisper_model is None:
import whisper
_whisper_model = whisper.load_model(_WHISPER_SIZE)
_whisper_model = whisper.load_model(container.config().whisper_model_size)
return _whisper_model
@@ -46,112 +50,88 @@ def transcribe_audio(filepath: str) -> str:
return result["text"].strip()
async def tts_speak(text: str) -> str | None:
"""크로스플랫폼 TTS. macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3"""
if not text:
return None
# macOS: say 우선 (오프라인, 내장 한국어)
if platform.system() == "Darwin":
try:
tmp = tempfile.NamedTemporaryFile(suffix=".aiff", delete=False)
tmp.close()
await asyncio.to_thread(
subprocess.run,
["say", "-v", _TTS_VOICE, "-o", tmp.name, text],
check=True,
capture_output=True,
)
return tmp.name
except Exception:
pass
# Windows 1순위 / macOS say 실패 시: edge-tts (온라인)
try:
import edge_tts
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
tmp.close()
await edge_tts.Communicate(text, _TTS_EDGE_VOICE).save(tmp.name)
return tmp.name
except Exception:
pass
# 최종 폴백: pyttsx3 (오프라인)
try:
import pyttsx3
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
def _save():
engine = pyttsx3.init()
engine.save_to_file(text, tmp.name)
engine.runAndWait()
await asyncio.to_thread(_save)
return tmp.name
except Exception:
return None
# ── 채팅 ─────────────────────────────────────────────────────────
async def respond(message, history, show_thinking, user_id, use_tts, run_ids):
if not message.strip():
yield history, "", None, run_ids, gr.update()
async def respond(message, history, show_thinking, user_id, use_tts, run_ids, image_path):
if not message.strip() and not image_path:
yield history, "", None, run_ids, "", "", None
return
history = list(history)
run_ids = list(run_ids)
history.append({"role": "user", "content": message})
display_msg = message
if image_path:
display_msg = f"🖼️ [이미지 첨부]\n{message}" if message.strip() else "🖼️ [이미지 첨부]"
history.append({"role": "user", "content": display_msg})
history.append({"role": "assistant", "content": ""})
yield history, "", None, run_ids, gr.update(value="", visible=False)
yield history, "", None, run_ids, "", "", None # boxes 초기화 + 이미지 초기화
collected_run_id: str | None = None
tts_text = "" # 순수 답변만 누적 (TTS용)
thinking_acc = "" # 사고 과정 누적
thinking_active = False
tts_text = ""
thinking_acc = ""
thinking_text = ""
thinking_finalized = False
source_box_html = ""
try:
async for token, run_id in api_client.chat(message, user_id, show_thinking):
async for token, run_id in container.chat_service().chat(
message or "이 이미지를 분석해줘.", user_id, show_thinking, image_path=image_path
):
if run_id is not None:
collected_run_id = run_id
break
if isinstance(token, dict) and "__thinking" in token:
thinking_active = True
thinking_acc += token["__thinking"]
thinking_md = f"🤔 **사고 중...**\n\n{thinking_acc}"
yield history, "", None, run_ids, gr.update(value=thinking_md, visible=True)
# 즉시 상태 — thinking_acc에 누적 안 함
if isinstance(token, dict) and "__status" in token:
if not thinking_acc:
yield history, "", None, run_ids, _status_html(token["__status"]), gr.update(), gr.update()
continue
if thinking_active:
# 첫 답변 토큰 도착 — 사고 완료 표시
thinking_active = False
yield history, "", None, run_ids, gr.update(
value=f"💭 **사고 완료**\n\n{thinking_acc}", visible=True
)
# 사고 과정(LLM thinking) — 현재 줄만 live_html로 표시
if isinstance(token, dict) and "__thinking" in token:
thinking_text += token["__thinking"]
thinking_acc += token["__thinking"]
yield history, "", None, run_ids, _live_html(_last_line(thinking_text)), gr.update(), gr.update()
continue
# 진행 로그(LangGraph, 검색 등) — 메시지 전체를 live_html로 표시
if isinstance(token, dict) and "__meta" in token:
display_token = token["__meta"]
else:
display_token = token
tts_text += display_token
history[-1]["content"] += display_token
yield history, "", None, run_ids, gr.update()
thinking_acc += token["__meta"]
live = token["__meta"].strip()
if live:
yield history, "", None, run_ids, _live_html(live), gr.update(), gr.update()
continue
# RAG 출처 — 별도 source_box로 표시
if isinstance(token, dict) and "__sources" in token:
source_box_html = _sources_html(token["__sources"])
yield history, "", None, run_ids, gr.update(), source_box_html, gr.update()
continue
# 첫 답변 토큰 도착 — 전체를 details로 전환 (접힌 상태)
if thinking_acc and not thinking_finalized:
thinking_finalized = True
yield history, "", None, run_ids, _thinking_html(thinking_acc), gr.update(), gr.update()
tts_text += token
history[-1]["content"] += token
yield history, "", None, run_ids, gr.update(), gr.update(), gr.update()
except Exception as e:
history[-1]["content"] += f"\n\n[오류: {e}]"
yield history, "", None, run_ids, gr.update()
yield history, "", None, run_ids, gr.update(), gr.update(), gr.update()
return
run_ids.append(collected_run_id)
if use_tts:
audio_path = await tts_speak(tts_text)
yield history, "", audio_path, run_ids, gr.update()
audio_path = await container.tts_service().speak(tts_text)
yield history, "", audio_path, run_ids, gr.update(), gr.update(), gr.update()
else:
yield history, "", None, run_ids, gr.update()
yield history, "", None, run_ids, gr.update(), gr.update(), gr.update()
def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
idx = like_data.index
if isinstance(idx, (list, tuple)):
idx = idx[0]
@@ -159,7 +139,6 @@ def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
return
if history[idx].get("role") != "assistant":
return
# idx 위치까지 등장한 assistant 메시지 수 = 이 메시지의 0-based 턴 번호
asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant")
run_id = run_ids[asst_turn] if run_ids and asst_turn < len(run_ids) else None
@@ -168,118 +147,357 @@ def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
rating = 1 if like_data.liked else -1
try:
asyncio.run(api_client.save_feedback(user_id, user_msg, asst_msg, rating, run_id))
await container.chat_service().save_feedback(user_id, user_msg, asst_msg, rating, run_id)
except Exception as e:
print(f"[Feedback] 저장 실패: {e}")
logger.error("피드백 저장 실패: %s", e)
def switch_user(user_id):
return [], []
def reset_chat(user_id):
async def reset_chat(user_id):
try:
asyncio.run(api_client.reset(user_id))
await container.chat_service().reset(user_id)
except Exception as e:
print(f"[Reset] 실패: {e}")
logger.error("대화 초기화 실패: %s", e)
return [], []
async def export_chat(history):
if not history:
return gr.update(visible=False)
from datetime import datetime
import tempfile
lines = [f"# 율봇 대화 내보내기\n_내보낸 시각: {datetime.now().strftime('%Y-%m-%d %H:%M')}_\n\n"]
for msg in history:
role = "👤 사용자" if msg["role"] == "user" else "🤖 율봇"
content = str(msg.get("content") or "")
lines.append(f"### {role}\n\n{content}\n\n---\n\n")
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".md", delete=False, encoding="utf-8", prefix="youlbot_chat_"
)
tmp.write("".join(lines))
tmp.close()
return gr.update(value=tmp.name, visible=True)
# ── 문서 관리 ─────────────────────────────────────────────────────
def ingest_files(files):
async def ingest_files(files):
if not files:
return "파일을 선택해주세요."
return gr.update(value="파일을 선택해주세요.", visible=True)
paths = [f if isinstance(f, str) else f.name for f in files]
results = []
for path in paths:
try:
result = asyncio.run(api_client.ingest(path))
result = await container.document_service().ingest(path)
name = os.path.basename(path)
results.append(f"{name}{result.get('chunks', '?')}개 청크")
except Exception as e:
results.append(f"{os.path.basename(path)} 오류: {e}")
return "\n".join(results)
return gr.update(value="\n".join(results), visible=True)
def list_docs():
async def list_docs():
try:
sources = asyncio.run(api_client.list_documents())
sources = await container.document_service().list_documents()
return [[os.path.basename(s), s] for s in sources]
except Exception as e:
return [[f"오류: {e}", ""]]
def delete_doc(source):
if not source.strip():
return "삭제할 파일 경로를 입력하세요.", list_docs()
def select_doc_row(evt: gr.SelectData, doc_data):
row = evt.index[0]
try:
asyncio.run(api_client.delete_document(source.strip()))
return f"삭제 완료: {os.path.basename(source.strip())}", list_docs()
if hasattr(doc_data, "iloc"):
return str(doc_data.iloc[row, 1])
return str(doc_data[row][1])
except Exception:
return gr.update()
async def delete_doc(source):
if not source.strip():
return "삭제할 파일 경로를 입력하세요.", await list_docs()
try:
await container.document_service().delete_document(source.strip())
return f"삭제 완료: {os.path.basename(source.strip())}", await list_docs()
except Exception as e:
return f"오류: {e}", list_docs()
return f"오류: {e}", await list_docs()
# ── UI 구성 ──────────────────────────────────────────────────────
_THINKING_CSS = """
.thinking-box {
background: #f9f9f9;
border-left: 3px solid #bbb;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 6px;
max-height: 220px;
overflow-y: auto;
font-size: 0.85em;
color: #555;
white-space: pre-wrap;
_BOX_STYLE = (
"background:#f9f9f9;border-left:3px solid #bbb;border-radius:6px;"
"padding:8px 14px;margin-bottom:6px;"
)
_CONTENT_STYLE = (
"margin-top:6px;white-space:pre-wrap;font-size:0.85em;"
"color:#555;max-height:160px;overflow-y:auto;"
)
def _last_line(text: str) -> str:
"""현재 진행 중인 마지막 비어있지 않은 줄 반환."""
lines = [l for l in text.split("\n") if l.strip()]
return lines[-1] if lines else text.strip()
def _live_html(text: str) -> str:
"""스트리밍 중 현재 줄만 보여주는 단순 div (details 미사용 → 닫힘 현상 없음)."""
return (
f'<div style="{_BOX_STYLE}">'
f'<strong class="streaming-indicator">⏳ 분석 중...</strong>'
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)} ▌</div>'
f'</div>'
)
def _thinking_html(text: str) -> str:
"""완료 후 전체 내용을 접기/펼치기로 표시."""
return (
f'<details style="{_BOX_STYLE}">'
f'<summary style="cursor:pointer;font-weight:bold;">💭 분석 완료</summary>'
f'<div style="{_CONTENT_STYLE}">{_html.escape(text)}</div>'
f'</details>'
)
def _status_html(status: str) -> str:
"""내용 없이 상태만 표시하는 단순 헤더."""
return (
f'<div style="{_BOX_STYLE}">'
f'<strong>🤔 {_html.escape(status)}</strong>'
f'</div>'
)
def _sources_html(sources: list) -> str:
"""RAG 출처 목록을 접기/펼치기로 표시."""
items = "".join(
f"<li>{_html.escape(s['filename'])}"
+ (f"{s['page']}페이지" if "page" in s else "")
+ "</li>"
for s in sources
)
return (
f'<details style="{_BOX_STYLE}">'
f'<summary style="cursor:pointer;font-weight:bold;">📄 출처 ({len(sources)}개)</summary>'
f'<ul style="margin:6px 0;padding-left:18px;font-size:0.85em;color:#555;">{items}</ul>'
f'</details>'
)
_JS = """
() => {
const htmlEl = document.documentElement;
// D3-17: 다크 모드 토글
window.toggleDarkMode = function() {
htmlEl.classList.toggle('dark');
const isDark = htmlEl.classList.contains('dark');
localStorage.setItem('youlbot_theme', isDark ? 'dark' : 'light');
const btn = document.getElementById('dark-mode-btn');
if (btn) btn.textContent = isDark ? '☀️' : '🌙';
};
const saved = localStorage.getItem('youlbot_theme');
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
htmlEl.classList.add('dark');
}
// D3-19: aria 레이블 설정
function addAriaLabels() {
const ta = document.querySelector('textarea[placeholder*="질문"]');
if (ta) ta.setAttribute('aria-label', '질문 입력창 (Enter 키로 전송)');
document.querySelectorAll('.send-btn').forEach(b => b.setAttribute('aria-label', '메시지 전송'));
const cb = document.getElementById('main-chatbot');
if (cb) { cb.setAttribute('role', 'log'); cb.setAttribute('aria-live', 'polite'); cb.setAttribute('aria-label', '대화 내용'); }
}
// D3-20: 첫 방문 온보딩 모달
function showOnboarding() {
if (localStorage.getItem('youlbot_onboarded')) return;
const modal = document.createElement('div');
modal.id = 'youlbot-onboarding';
modal.innerHTML = `
<div style="position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:20px;padding:36px 32px;max-width:400px;width:100%;box-shadow:0 24px 64px rgba(0,0,0,.25);">
<div style="font-size:2.4rem;text-align:center;margin-bottom:12px;">🤖</div>
<h2 style="margin:0 0 6px;font-size:1.3rem;font-weight:700;text-align:center;color:#1e293b;">율봇에 오신 것을 환영합니다!</h2>
<p style="margin:0 0 20px;text-align:center;color:#64748b;font-size:.875rem;">육아·금융 전문 AI 상담 도우미</p>
<ul style="list-style:none;padding:0;margin:0 0 24px;display:flex;flex-direction:column;gap:10px;">
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>💬</span> 아래 입력창에 질문을 입력하세요</li>
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>💡</span> 예시 질문 클릭으로 빠르게 시작</li>
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>📄</span> 문서 등록 탭에서 RAG 문서 추가</li>
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>🎤</span> 음성으로도 질문 가능</li>
</ul>
<button onclick="window.closeOnboarding()" style="width:100%;padding:13px;background:#3b82f6;color:#fff;border:none;border-radius:10px;font-size:.95rem;font-weight:600;cursor:pointer;" onmouseover="this.style.background='#2563eb'" onmouseout="this.style.background='#3b82f6'">시작하기</button>
</div>
</div>`;
document.body.appendChild(modal);
}
window.closeOnboarding = function() {
const m = document.getElementById('youlbot-onboarding');
if (m) m.remove();
localStorage.setItem('youlbot_onboarded', '1');
};
setTimeout(function() {
addAriaLabels();
showOnboarding();
const btn = document.getElementById('dark-mode-btn');
if (btn && htmlEl.classList.contains('dark')) btn.textContent = '☀️';
}, 1500);
}
"""
with gr.Blocks(title="율봇", css=_THINKING_CSS) as demo:
gr.Markdown("# 율봇\n육아·금융 전문 AI 상담 도우미")
_CUSTOM_CSS = """
footer { display: none !important; }
/* 입력 영역 */
.send-btn { min-height: 80px !important; align-self: stretch !important; }
/* 헤더 (D2-11) */
.app-header {
align-items: center !important;
padding-bottom: 12px !important;
border-bottom: 1px solid var(--border-color-primary);
margin-bottom: 4px !important;
}
.app-header > .wrap, .app-header > div > .wrap {
padding: 0 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* 채팅 버블 스타일 (D2-14) */
.message-wrap .user {
background: #dbeafe !important;
border-color: #93c5fd !important;
border-bottom-right-radius: 4px !important;
}
.message-wrap .bot {
background: #f8fafc !important;
border-color: #e2e8f0 !important;
border-bottom-left-radius: 4px !important;
}
/* 응답 스트리밍 애니메이션 (D2-15) */
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.streaming-indicator { animation: blink 1.2s ease-in-out infinite; display: inline-block; }
/* 반응형 (D2-16) */
@media (max-width: 768px) {
.send-btn { min-height: 56px !important; }
.app-header { flex-wrap: wrap; gap: 8px; }
.message-wrap .user, .message-wrap .bot { max-width: 92% !important; }
}
/* 접근성: 포커스 표시 강화 (D3-19) */
:focus-visible {
outline: 3px solid #3b82f6 !important;
outline-offset: 2px !important;
border-radius: 4px;
}
/* 다크 모드 커스텀 요소 오버라이드 (D3-17) */
.dark .message-wrap .user { background: #1e3a5f !important; border-color: #2563eb !important; }
.dark .message-wrap .bot { background: #1e293b !important; border-color: #334155 !important; }
.dark .app-header { border-color: #334155 !important; }
"""
_THEME = gr.themes.Soft(
primary_hue="blue",
secondary_hue="indigo",
neutral_hue="slate",
)
with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo:
with gr.Row(elem_classes=["app-header"]):
gr.HTML("""
<div style="display:flex;align-items:center;gap:14px;padding:4px 0;">
<span style="font-size:2.2rem;line-height:1;">🤖</span>
<div>
<div style="font-size:1.6rem;font-weight:700;color:#1e293b;line-height:1.15;">율봇</div>
<div style="font-size:0.85rem;color:#64748b;margin-top:3px;">육아·금융 전문 AI 상담 도우미</div>
</div>
</div>
""")
gr.HTML("""
<div style="display:flex;justify-content:flex-end;align-items:center;height:100%;">
<button id="dark-mode-btn" onclick="toggleDarkMode()" title="다크/라이트 모드 전환"
style="background:none;border:1.5px solid #e2e8f0;border-radius:8px;cursor:pointer;
font-size:1.2rem;padding:6px 10px;line-height:1;color:#64748b;transition:all .15s;"
onmouseover="this.style.borderColor='#94a3b8'"
onmouseout="this.style.borderColor='#e2e8f0'">🌙</button>
</div>
""", scale=0, min_width=60)
user_selector = gr.Dropdown(
choices=USER_LABELS,
value=DEFAULT_USER,
label="사용자",
scale=0,
min_width=160,
)
user_state = gr.State(DEFAULT_USER)
run_ids_state = gr.State([])
with gr.Tab("대화"):
with gr.Row():
user_selector = gr.Dropdown(
choices=USER_LABELS,
value=DEFAULT_USER,
label="사용자",
scale=1,
)
thinking_box = gr.Markdown(
value="",
visible=False,
elem_classes=["thinking-box"],
)
chatbot = gr.Chatbot(label="율봇", height=500)
thinking_box = gr.HTML(value="")
chatbot = gr.Chatbot(label="율봇", height=500, elem_id="main-chatbot")
source_box = gr.HTML(value="")
with gr.Row():
msg_box = gr.Textbox(
placeholder="질문을 입력하세요... (Enter로 전송)",
label="",
show_label=False,
lines=2,
scale=5,
autofocus=True,
)
send_btn = gr.Button("전송", variant="primary", scale=1)
send_btn = gr.Button("전송", variant="primary", scale=1, elem_classes=["send-btn"])
gr.Examples(
examples=[
["육아휴직 급여 신청 방법을 알려주세요"],
["어린이집 입소 대기 기간은 얼마나 걸리나요?"],
["아이 의료비 세금 공제는 어떻게 하나요?"],
],
inputs=[msg_box],
label="💡 예시 질문",
)
with gr.Accordion("📷 이미지 첨부 (선택)", open=False):
image_input = gr.Image(
type="filepath",
show_label=False,
sources=["upload", "clipboard"],
height=160,
)
with gr.Accordion("🎤 음성으로 질문하기", open=False):
with gr.Row():
audio_input = gr.Audio(
sources=["microphone"],
type="filepath",
label="음성으로 질문하기",
show_label=False,
scale=4,
)
transcribe_btn = gr.Button("음성 → 텍스트 변환", scale=1)
with gr.Row():
show_thinking = gr.Checkbox(label="사고 과정 표시", value=False)
with gr.Column(scale=3):
with gr.Row():
show_thinking = gr.Checkbox(label="사고 과정 표시", value=True)
use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False)
reset_btn = gr.Button("대화 초기화", size="sm")
with gr.Column(scale=2, min_width=240):
with gr.Row():
export_btn = gr.Button("💾 내보내기", size="sm", min_width=100)
reset_btn = gr.Button("대화 초기화", size="sm", min_width=100)
export_file = gr.File(label="내보내기 파일", visible=False)
tts_output = gr.Audio(label="음성 답변", autoplay=True, visible=False)
use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output])
@@ -294,17 +512,13 @@ with gr.Blocks(title="율봇", css=_THINKING_CSS) as demo:
transcribe_btn.click(transcribe_audio, inputs=[audio_input], outputs=[msg_box])
send_btn.click(
respond,
inputs=[msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state],
outputs=[chatbot, msg_box, tts_output, run_ids_state, thinking_box],
)
msg_box.submit(
respond,
inputs=[msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state],
outputs=[chatbot, msg_box, tts_output, run_ids_state, thinking_box],
)
_respond_inputs = [msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state, image_input]
_respond_outputs = [chatbot, msg_box, tts_output, run_ids_state, thinking_box, source_box, image_input]
send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs)
msg_box.submit(respond, inputs=_respond_inputs, outputs=_respond_outputs)
reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state])
export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file])
chatbot.like(
handle_feedback,
@@ -319,8 +533,9 @@ with gr.Blocks(title="율봇", css=_THINKING_CSS) as demo:
file_count="multiple",
label="파일 선택",
)
ingest_btn = gr.Button("문서 수집", variant="primary")
ingest_status = gr.Textbox(label="결과", interactive=False)
with gr.Row():
ingest_btn = gr.Button("문서 수집", variant="primary", scale=0, min_width=200)
ingest_status = gr.Textbox(label="결과", interactive=False, visible=False)
ingest_btn.click(ingest_files, inputs=[file_input], outputs=[ingest_status])
with gr.Tab("문서 관리"):
@@ -343,8 +558,12 @@ with gr.Blocks(title="율봇", css=_THINKING_CSS) as demo:
refresh_btn.click(list_docs, outputs=[doc_table])
delete_btn.click(delete_doc, inputs=[delete_source], outputs=[delete_status, doc_table])
doc_table.select(select_doc_row, inputs=[doc_table], outputs=[delete_source])
demo.load(list_docs, outputs=[doc_table])
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())
demo.launch(
server_name=container.config().server_host,
server_port=container.config().server_port,
)
+18
View File
@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppConfig(BaseSettings):
# API (env: YOULBOT_API_URL, YOULBOT_API_TOKEN, YOULBOT_API_TIMEOUT)
youlbot_api_url: str = "http://localhost:8000"
youlbot_api_token: str = ""
youlbot_api_timeout: int = 180
# STT/TTS (env: WHISPER_MODEL_SIZE, TTS_VOICE, TTS_EDGE_VOICE)
whisper_model_size: str = "small"
tts_voice: str = "Yuna"
tts_edge_voice: str = "ko-KR-SunHiNeural"
# 서버 / 로깅
log_level: str = "INFO"
server_host: str = "0.0.0.0"
server_port: int = 7860
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+15
View File
@@ -0,0 +1,15 @@
from dependency_injector import containers, providers
from api_client import HTTPAPIClient
from config import AppConfig
from services import ChatService, DocumentService, TTSService
class Container(containers.DeclarativeContainer):
config = providers.Singleton(AppConfig)
api_client = providers.Singleton(HTTPAPIClient, config=config)
chat_service = providers.Singleton(ChatService, api_client=api_client)
document_service = providers.Singleton(DocumentService, api_client=api_client)
tts_service = providers.Singleton(TTSService, config=config)
+2
View File
@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
+2
View File
@@ -0,0 +1,2 @@
pytest>=8.0.0
pytest-asyncio>=0.23.0
+3
View File
@@ -4,3 +4,6 @@ python-dotenv>=1.0.0
openai-whisper>=20231117
edge-tts>=6.1.9
pyttsx3>=2.90
pydantic-settings>=2.0.0
tenacity>=8.0.0
dependency-injector>=4.41.0
+5
View File
@@ -0,0 +1,5 @@
from services.chat import ChatService
from services.document import DocumentService
from services.tts import TTSService
__all__ = ["ChatService", "DocumentService", "TTSService"]
+30
View File
@@ -0,0 +1,30 @@
from typing import AsyncIterator
from api_client import APIClientProtocol
class ChatService:
def __init__(self, api_client: APIClientProtocol):
self._api = api_client
def chat(
self,
message: str,
user_id: str,
show_thinking: bool,
image_path: str | None = None,
) -> AsyncIterator[tuple[str, str | None]]:
return self._api.chat(message, user_id, show_thinking, image_path=image_path)
async def reset(self, user_id: str) -> None:
await self._api.reset(user_id)
async def save_feedback(
self,
user_id: str,
user_msg: str,
asst_msg: str,
rating: int,
run_id: str | None,
) -> None:
await self._api.save_feedback(user_id, user_msg, asst_msg, rating, run_id)
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
from api_client import APIClientProtocol
_ALLOWED_EXTENSIONS = {".pdf", ".txt"}
class DocumentService:
def __init__(self, api_client: APIClientProtocol):
self._api = api_client
async def ingest(self, file_path: str) -> dict:
path = Path(file_path)
if not path.is_file():
raise ValueError(f"파일을 찾을 수 없습니다: {file_path}")
if path.suffix.lower() not in _ALLOWED_EXTENSIONS:
raise ValueError(f"지원하지 않는 파일 형식입니다: {path.suffix} (허용: pdf, txt)")
return await self._api.ingest(file_path)
async def list_documents(self) -> list[str]:
return await self._api.list_documents()
async def delete_document(self, source: str) -> None:
await self._api.delete_document(source)
+53
View File
@@ -0,0 +1,53 @@
import asyncio
import platform
import subprocess
import tempfile
from config import AppConfig
class TTSService:
def __init__(self, config: AppConfig):
self._voice = config.tts_voice
self._edge_voice = config.tts_edge_voice
async def speak(self, text: str) -> str | None:
"""크로스플랫폼 TTS. macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3"""
if not text:
return None
if platform.system() == "Darwin":
try:
tmp = tempfile.NamedTemporaryFile(suffix=".aiff", delete=False)
tmp.close()
await asyncio.to_thread(
subprocess.run,
["say", "-v", self._voice, "-o", tmp.name, text],
check=True,
capture_output=True,
)
return tmp.name
except Exception:
pass
try:
import edge_tts
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
tmp.close()
await edge_tts.Communicate(text, self._edge_voice).save(tmp.name)
return tmp.name
except Exception:
pass
try:
import pyttsx3
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
def _save():
engine = pyttsx3.init()
engine.save_to_file(text, tmp.name)
engine.runAndWait()
await asyncio.to_thread(_save)
return tmp.name
except Exception:
return None
View File
+40
View File
@@ -0,0 +1,40 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from services.chat import ChatService
@pytest.fixture
def mock_api():
api = MagicMock()
api.reset = AsyncMock()
api.save_feedback = AsyncMock()
return api
@pytest.fixture
def service(mock_api):
return ChatService(mock_api)
async def test_reset_delegates_to_api(service, mock_api):
await service.reset("user1")
mock_api.reset.assert_awaited_once_with("user1")
async def test_save_feedback_delegates_to_api(service, mock_api):
await service.save_feedback("user1", "질문", "답변", 1, "run-123")
mock_api.save_feedback.assert_awaited_once_with("user1", "질문", "답변", 1, "run-123")
async def test_save_feedback_with_no_run_id(service, mock_api):
await service.save_feedback("user1", "질문", "답변", -1, None)
mock_api.save_feedback.assert_awaited_once_with("user1", "질문", "답변", -1, None)
def test_chat_returns_api_iterator(service, mock_api):
sentinel = object()
mock_api.chat = MagicMock(return_value=sentinel)
result = service.chat("안녕", "user1", False)
assert result is sentinel
mock_api.chat.assert_called_once_with("안녕", "user1", False)
+56
View File
@@ -0,0 +1,56 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from services.document import DocumentService
@pytest.fixture
def mock_api():
api = MagicMock()
api.ingest = AsyncMock(return_value={"chunks": 5})
api.list_documents = AsyncMock(return_value=["docs/report.pdf"])
api.delete_document = AsyncMock()
return api
@pytest.fixture
def service(mock_api):
return DocumentService(mock_api)
async def test_ingest_valid_pdf(service, mock_api, tmp_path):
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF-1.4")
result = await service.ingest(str(pdf))
assert result == {"chunks": 5}
mock_api.ingest.assert_awaited_once_with(str(pdf))
async def test_ingest_valid_txt(service, mock_api, tmp_path):
txt = tmp_path / "notes.txt"
txt.write_text("내용")
result = await service.ingest(str(txt))
assert result == {"chunks": 5}
async def test_ingest_nonexistent_file_raises(service):
with pytest.raises(ValueError, match="파일을 찾을 수 없습니다"):
await service.ingest("/nonexistent/file.pdf")
async def test_ingest_unsupported_extension_raises(service, tmp_path):
docx = tmp_path / "doc.docx"
docx.write_bytes(b"data")
with pytest.raises(ValueError, match="지원하지 않는 파일 형식"):
await service.ingest(str(docx))
async def test_list_documents_delegates(service, mock_api):
result = await service.list_documents()
assert result == ["docs/report.pdf"]
mock_api.list_documents.assert_awaited_once()
async def test_delete_document_delegates(service, mock_api):
await service.delete_document("/path/to/doc.pdf")
mock_api.delete_document.assert_awaited_once_with("/path/to/doc.pdf")