diff --git a/.bkit/runtime/agent-state.json b/.bkit/runtime/agent-state.json index 4b48ae7..c7c7e82 100644 --- a/.bkit/runtime/agent-state.json +++ b/.bkit/runtime/agent-state.json @@ -6,8 +6,8 @@ "pdcaPhase": "plan", "orchestrationPattern": "leader", "ctoAgent": "opus", - "startedAt": "2026-04-23T07:16:29.093Z", - "lastUpdated": "2026-04-23T07:20:19.184Z", + "startedAt": "2026-04-24T15:37:17.072Z", + "lastUpdated": "2026-04-24T15:43:34.747Z", "teammates": [], "progress": { "totalTasks": 0, @@ -17,5 +17,5 @@ "pendingTasks": 0 }, "recentMessages": [], - "sessionId": "447cbd58-776c-45d1-bb67-4864a4449066" + "sessionId": "af6ef06c-c555-4583-a13d-5cd68e1b1a37" } \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7b998a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# LLM 모델 설정 +MODEL_ID=mlx-community/Qwen2.5-7B-Instruct-4bit +MAX_TOKENS=1024 +MAX_HISTORY_TURNS=30 +COMPACT_THRESHOLD=40 + +# MySQL 설정 (미설정 시 DB 기능 비활성화) +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=youlbot +DB_USER= +DB_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1693312 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +*.pyo +.idea/ diff --git a/.idea/misc.xml b/.idea/misc.xml index c73a9de..632c4af 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,8 @@ + + diff --git a/chat.py b/chat.py index 0646bfb..458f96d 100644 --- a/chat.py +++ b/chat.py @@ -4,7 +4,7 @@ MODEL_ID = "mlx-community/Qwen2.5-7B-Instruct-4bit" MAX_TOKENS = 1024 MAX_HISTORY_TURNS = 10 # 최근 10턴만 유지해 메모리 절약 -SYSTEM_PROMPT = """당신은 친절하고 따뜻한 한국어 상담 도우미입니다. +SYSTEM_PROMPT = """당신의 이름은 '율봇'입니다. 친절하고 따뜻한 한국어 상담 도우미입니다. 육아와 금융 두 분야를 전문으로 합니다. - 육아: 아이 발달, 이유식, 수면, 훈육, 교육 등 부모가 궁금해하는 모든 것을 도와드립니다. diff --git a/config.py b/config.py new file mode 100644 index 0000000..dde3e65 --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Config(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + frozen=True, + ) + + # LLM + model_id: str = "mlx-community/Qwen2.5-7B-Instruct-4bit" + max_tokens: int = 1024 + max_history_turns: int = 10 + compact_threshold: int = 20 + + # MySQL + db_host: str = "localhost" + db_port: int = 3306 + db_name: str = "youlbot" + db_user: str = "" + db_password: str = "" + + system_prompt: str = """당신의 이름은 '율봇'입니다. 친절하고 따뜻한 한국어 상담 도우미입니다. +육아와 금융 두 분야를 전문으로 합니다. + +- 육아: 아이 발달, 이유식, 수면, 훈육, 교육 등 부모가 궁금해하는 모든 것을 도와드립니다. +- 금융: 저축, 투자, 보험, 대출, 세금 등 생활 금융 관련 질문에 답변드립니다. + +항상 쉽고 친근한 말투로 설명하고, 전문 용어는 풀어서 설명합니다. +의학적 진단이나 법적 판단이 필요한 경우에는 반드시 전문가 상담을 권유합니다.""" diff --git a/container.py b/container.py new file mode 100644 index 0000000..4c9e12a --- /dev/null +++ b/container.py @@ -0,0 +1,64 @@ +from dependency_injector import containers, providers + +from config import Config +from services.model.mlx_model import MlxModelService +from services.chat.history_service import HistoryService +from services.chat.chat_service import ChatService +from services.chat.compact_service import CompactService +from services.db.mysql_service import DatabaseService +from services.db.conversation_repository import ConversationRepository +from services.ui.cli_service import CliUiService +from services.events.event_bus import EventBus +from services.events.handlers import StreamTokenHandler, StreamEndHandler + + +class Container(containers.DeclarativeContainer): + config = providers.Singleton(Config) + + event_bus = providers.Singleton(EventBus) + + model_service = providers.Singleton( + MlxModelService, + model_id=providers.Callable(lambda c: c.model_id, config), + ) + + compact_service = providers.Singleton( + CompactService, + model=model_service, + ) + + db_service = providers.Singleton( + DatabaseService, + host=providers.Callable(lambda c: c.db_host, config), + port=providers.Callable(lambda c: c.db_port, config), + db=providers.Callable(lambda c: c.db_name, config), + user=providers.Callable(lambda c: c.db_user, config), + password=providers.Callable(lambda c: c.db_password, config), + ) + + conversation_repository = providers.Singleton( + ConversationRepository, + db=db_service, + ) + + history_service = providers.Factory( + HistoryService, + system_prompt=providers.Callable(lambda c: c.system_prompt, config), + max_turns=providers.Callable(lambda c: c.max_history_turns, config), + compact_threshold=providers.Callable(lambda c: c.compact_threshold, config), + repository=conversation_repository, + compact_service=compact_service, + ) + + chat_service = providers.Factory( + ChatService, + model=model_service, + history=history_service, + event_bus=event_bus, + max_tokens=providers.Callable(lambda c: c.max_tokens, config), + ) + + ui_service = providers.Singleton(CliUiService) + + stream_token_handler = providers.Singleton(StreamTokenHandler) + stream_end_handler = providers.Singleton(StreamEndHandler) diff --git a/docs/01-plan/features/ioc-base-structure.plan.md b/docs/01-plan/features/ioc-base-structure.plan.md new file mode 100644 index 0000000..d8643a8 --- /dev/null +++ b/docs/01-plan/features/ioc-base-structure.plan.md @@ -0,0 +1,155 @@ +--- +template: plan +version: 1.3 +feature: ioc-base-structure +date: 2026-04-24 +author: sal +project: youlbot +status: Draft +--- + +# ioc-base-structure Planning Document + +> **Summary**: 절차형 chat.py를 IoC 컨테이너 기반 클래스 구조로 리팩토링하여 재사용성과 확장성을 확보한다. +> +> **Project**: youlbot +> **Author**: sal +> **Date**: 2026-04-24 +> **Status**: Draft + +--- + +## Executive Summary + +| Perspective | Content | +|-------------|---------| +| **Problem** | 단일 파일 절차형 스크립트로 서비스 교체·테스트·확장이 어렵다 | +| **Solution** | dependency-injector 기반 IoC 컨테이너로 서비스 분리 및 DI 적용 | +| **Function/UX Effect** | LLM 백엔드 교체(MLX↔OpenAI↔Ollama) 및 DB 연동이 코드 변경 없이 가능 | +| **Core Value** | 재사용 가능한 서비스 계층 + 디자인 패턴 적용으로 장기 유지보수성 확보 | + +--- + +## Context Anchor + +| Key | Value | +|-----|-------| +| **WHY** | 절차형 코드의 결합도를 낮춰 기능 추가·교체를 안전하게 하기 위함 | +| **WHO** | 개발자 (sal) — 단독 개발, 추후 팀 확장 가능성 있음 | +| **RISK** | MLX 모델 로딩이 무거워 Singleton 관리 실수 시 메모리 이슈 | +| **SUCCESS** | 각 서비스가 독립적으로 교체/테스트 가능하고 Container 설정만으로 행동 변경 | +| **SCOPE** | Phase 1: 기본 구조 + MLX 서비스 / Phase 2: DB(MySQL) 연동 | + +--- + +## 1. Overview + +### 1.1 Purpose +현재 `chat.py`는 모델 로딩, 히스토리 관리, 스트림 생성, CLI 루프가 한 파일에 혼재한다. +IoC 컨테이너와 디자인 패턴을 적용해 각 책임을 분리하고, 의존성을 주입으로 연결한다. + +### 1.2 Background +- 육아·금융 상담 챗봇 '율봇'의 초기 구조 정립 +- LLM 백엔드를 MLX로 시작하되 OpenAI / Ollama 전환이 설정 변경만으로 가능해야 함 +- MySQL DB 연동이 예정되어 있어 DB 서비스 레이어 선제 설계 필요 + +--- + +## 2. Scope + +### 2.1 In Scope +- [ ] IoC Container 설정 (`app/container.py`) +- [ ] Strategy 패턴으로 LLM 백엔드 추상화 (`AbstractModelService` + `MlxModelService`) +- [ ] `ChatService` — 대화 오케스트레이션 +- [ ] `HistoryService` — 히스토리 관리 (trim 포함) +- [ ] `DatabaseService` — MySQL 연결 (mysqlclient) +- [ ] Observer/EventBus — 스트림 토큰 이벤트 발행 +- [ ] `CliUiService` — CLI 입출력 +- [ ] `main.py` — 컨테이너 부트스트랩 진입점 +- [ ] `config.py` — 환경변수 기반 설정 + +### 2.2 Out of Scope +- 웹 API (FastAPI 등) 레이어 +- 인증/세션 관리 +- 테스트 코드 (별도 Phase) + +--- + +## 3. Requirements + +### 3.1 Functional Requirements + +| ID | Requirement | Priority | +|----|-------------|----------| +| FR-01 | IoC Container에서 모든 서비스를 Singleton/Factory로 등록·해석 | High | +| FR-02 | LLM 백엔드를 Strategy 인터페이스로 추상화, 구현체 교체 가능 | High | +| FR-03 | ChatService가 HistoryService·ModelService·EventBus를 주입받아 동작 | High | +| FR-04 | 스트림 토큰 출력을 Observer 패턴으로 처리 (핸들러 교체 가능) | Medium | +| FR-05 | DatabaseService가 MySQL 연결을 캡슐화, 다른 서비스에서 주입받아 사용 | Medium | +| FR-06 | 설정은 환경변수 + config.py에서 중앙 관리 | High | + +### 3.2 Non-Functional Requirements + +| Category | Criteria | +|----------|----------| +| 확장성 | 새 LLM 백엔드 추가 시 AbstractModelService 구현만으로 완성 | +| 결합도 | 서비스 간 직접 import 없이 Container 통해서만 의존 | +| 메모리 | MLX 모델은 Singleton으로 1회만 로딩 | + +--- + +## 4. Success Criteria + +- [ ] `python -m app.main` 실행 시 기존 chat.py와 동일하게 동작 +- [ ] `MlxModelService`를 `MockModelService`로 교체해도 나머지 코드 변경 없음 +- [ ] `Container` 설정만 바꿔서 CLI → 다른 UI로 전환 가능한 구조 + +--- + +## 5. Risks + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| MLX Singleton 초기화 시간 (~수초) | Medium | High | 로딩 중 진행 표시 유지 | +| dependency-injector 버전 충돌 | Low | Low | requirements.txt 버전 고정 | +| MySQL 연결 설정 누락 | Medium | Medium | 미설정 시 graceful skip | + +--- + +## 6. Architecture Decisions + +| Decision | Selected | Rationale | +|----------|----------|-----------| +| IoC 라이브러리 | dependency-injector | DeclarativeContainer, Singleton/Factory 지원 | +| LLM 추상화 | Strategy 패턴 | 백엔드 교체 시 코드 변경 최소화 | +| 스트림 처리 | Observer/EventBus | 출력 핸들러(CLI, 파일, WebSocket)를 분리 | +| DB | MySQL (mysqlclient) | 단순 Service로 캡슐화, 추후 ORM 추가 가능 | + +--- + +## 7. Directory Structure + +``` +youlbot/ +├── app/ +│ ├── __init__.py +│ ├── config.py # 환경변수 기반 설정 +│ ├── container.py # IoC DeclarativeContainer +│ ├── main.py # 부트스트랩 진입점 +│ └── services/ +│ ├── model/ +│ │ ├── base.py # AbstractModelService (Strategy 인터페이스) +│ │ └── mlx_model.py # MlxModelService (Strategy 구현체) +│ ├── chat/ +│ │ ├── chat_service.py +│ │ └── history_service.py +│ ├── db/ +│ │ └── mysql_service.py +│ ├── ui/ +│ │ └── cli_service.py +│ └── events/ +│ ├── event_bus.py # Observer EventBus +│ └── handlers.py # StreamTokenHandler 등 +├── chat.py # 기존 파일 (보존) +└── requirements.txt +``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..12673b1 --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +from container import Container +from services.chat.chat_service import ChatService + + +def main() -> None: + container = Container() + + ui = container.ui_service() + model = container.model_service() + bus = container.event_bus() + db = container.db_service() + repo = container.conversation_repository() + + bus.subscribe(ChatService.EVENT_TOKEN, container.stream_token_handler()) + bus.subscribe(ChatService.EVENT_END, container.stream_end_handler()) + + ui.show_banner(container.config().model_id) + model.load() + db.connect() + db.init_schema() + + chat = container.chat_service() + + while True: + try: + user_input = ui.prompt_user() + except (EOFError, KeyboardInterrupt): + print("\n대화를 종료합니다.") + break + + if not user_input: + continue + + if ui.is_exit_command(user_input): + print("대화를 종료합니다.") + break + + if ui.is_reset_command(user_input): + repo.create_conversation() + chat = container.chat_service() + print("\n[대화가 초기화되었습니다.]\n") + continue + + ui.show_assistant_prefix() + chat.respond(user_input) + + db.close() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index f8a69ec..aa2b00c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ mlx-lm>=0.19.0 +dependency-injector>=4.41.0 +PyMySQL>=1.1.0 +pydantic-settings>=2.0.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/chat/__init__.py b/services/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/chat/chat_service.py b/services/chat/chat_service.py new file mode 100644 index 0000000..aa587ae --- /dev/null +++ b/services/chat/chat_service.py @@ -0,0 +1,35 @@ +from services.model.base import AbstractModelService +from services.chat.history_service import HistoryService +from services.events.event_bus import EventBus + + +class ChatService: + """대화 오케스트레이션 서비스.""" + + EVENT_TOKEN = "stream.token" + EVENT_END = "stream.end" + + def __init__( + self, + model: AbstractModelService, + history: HistoryService, + event_bus: EventBus, + max_tokens: int, + ): + self._model = model + self._history = history + self._event_bus = event_bus + self._max_tokens = max_tokens + + def respond(self, user_input: str) -> str: + self._history.add("user", user_input) + prompt = self._model.build_prompt(self._history.get()) + + response_text = "" + for token in self._model.stream(prompt, self._max_tokens): + self._event_bus.publish(self.EVENT_TOKEN, token) + response_text += token + + self._event_bus.publish(self.EVENT_END) + self._history.add("assistant", response_text) + return response_text diff --git a/services/chat/compact_service.py b/services/chat/compact_service.py new file mode 100644 index 0000000..3d6b87e --- /dev/null +++ b/services/chat/compact_service.py @@ -0,0 +1,18 @@ +from services.model.base import AbstractModelService + + +class CompactService: + """오래된 대화 턴을 LLM으로 요약하는 서비스.""" + + def __init__(self, model: AbstractModelService, max_tokens: int = 512): + self._model = model + self._max_tokens = max_tokens + + def summarize(self, turns: list[dict]) -> str: + text = "\n".join(f"{t['role']}: {t['content']}" for t in turns) + prompt_history = [ + {"role": "system", "content": "당신은 대화 요약 전문가입니다."}, + {"role": "user", "content": f"다음 대화의 핵심 내용을 한국어로 간결하게 요약해주세요:\n\n{text}"}, + ] + prompt = self._model.build_prompt(prompt_history) + return "".join(self._model.stream(prompt, self._max_tokens)) diff --git a/services/chat/history_service.py b/services/chat/history_service.py new file mode 100644 index 0000000..5489adc --- /dev/null +++ b/services/chat/history_service.py @@ -0,0 +1,81 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from services.db.conversation_repository import ConversationRepository + from services.chat.compact_service import CompactService + + +class HistoryService: + """대화 히스토리를 관리하는 서비스.""" + + def __init__( + self, + system_prompt: str, + max_turns: int, + compact_threshold: int, + repository: ConversationRepository | None = None, + compact_service: CompactService | None = None, + ): + self._system_prompt = system_prompt + self._max_turns = max_turns + self._compact_threshold = compact_threshold + self._repository = repository + self._compact_service = compact_service + self._summary: str | None = None + self._turns: list[dict] = [] + self._conversation_id: int | None = None + + if repository: + self._load_or_create() + + # ── DB 초기화 ──────────────────────────────────────────────── + + def _load_or_create(self) -> None: + conv_id = self._repository.get_latest_conversation_id() + if conv_id: + summary_id, summary = self._repository.get_latest_summary(conv_id) + turns = self._repository.load_turns_after( + conv_id, summary_id, self._compact_threshold * 2 + ) + self._summary = summary + self._turns = turns + self._conversation_id = conv_id + else: + self._conversation_id = self._repository.create_conversation() + + # ── 공개 인터페이스 ─────────────────────────────────────────── + + def add(self, role: str, content: str) -> None: + self._turns.append({"role": role, "content": content}) + if self._repository and self._conversation_id: + self._repository.save_message(self._conversation_id, role, content) + if role == "assistant": + self._maybe_compact() + + def get(self) -> list[dict]: + msgs = [{"role": "system", "content": self._system_prompt}] + if self._summary: + msgs.append({"role": "system", "content": f"[이전 대화 요약]\n{self._summary}"}) + msgs.extend(self._turns) + return msgs + + def reset(self, new_conversation_id: int) -> None: + self._summary = None + self._turns = [] + self._conversation_id = new_conversation_id + + # ── 내부 ───────────────────────────────────────────────────── + + def _maybe_compact(self) -> None: + if not self._compact_service or len(self._turns) <= self._compact_threshold: + return + + mid = len(self._turns) // 2 + old_turns, self._turns = self._turns[:mid], self._turns[mid:] + + print("\n[대화 내용을 압축하는 중...]\n", flush=True) + self._summary = self._compact_service.summarize(old_turns) + + if self._repository and self._conversation_id: + self._repository.save_summary(self._conversation_id, self._summary) diff --git a/services/db/__init__.py b/services/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/db/conversation_repository.py b/services/db/conversation_repository.py new file mode 100644 index 0000000..bc96dc7 --- /dev/null +++ b/services/db/conversation_repository.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from services.db.mysql_service import DatabaseService + + +class ConversationRepository: + """td_conversations / td_messages 테이블 접근을 담당하는 Repository.""" + + def __init__(self, db: DatabaseService): + self._db = db + + def create_conversation(self) -> int: + return self._db.execute_write( + "INSERT INTO td_conversations () VALUES ()" + ) + + def get_latest_conversation_id(self) -> int | None: + rows = self._db.execute( + "SELECT id FROM td_conversations ORDER BY created_at DESC LIMIT 1" + ) + return rows[0]["id"] if rows else None + + def save_message(self, conversation_id: int, role: str, content: str) -> None: + self._db.execute_write( + "INSERT INTO td_messages (conversation_id, role, content) VALUES (%s, %s, %s)", + (conversation_id, role, content), + ) + + def save_summary(self, conversation_id: int, summary: str) -> None: + self._db.execute_write( + "INSERT INTO td_messages (conversation_id, role, content) VALUES (%s, %s, %s)", + (conversation_id, "summary", summary), + ) + + def get_latest_summary(self, conversation_id: int) -> tuple[int | None, str | None]: + """가장 최근 요약 메시지의 (id, content)를 반환. 없으면 (None, None).""" + rows = self._db.execute( + """SELECT id, content FROM td_messages + WHERE conversation_id = %s AND role = 'summary' + ORDER BY created_at DESC LIMIT 1""", + (conversation_id,), + ) + if rows: + return rows[0]["id"], rows[0]["content"] + return None, None + + def load_turns_after( + self, conversation_id: int, after_id: int | None, limit: int + ) -> list[dict]: + """요약 이후의 user/assistant 턴을 최근 limit개 반환.""" + if after_id is not None: + rows = self._db.execute( + """SELECT role, content FROM td_messages + WHERE conversation_id = %s AND id > %s AND role IN ('user', 'assistant') + ORDER BY created_at DESC LIMIT %s""", + (conversation_id, after_id, limit), + ) + else: + rows = self._db.execute( + """SELECT role, content FROM td_messages + WHERE conversation_id = %s AND role IN ('user', 'assistant') + ORDER BY created_at DESC LIMIT %s""", + (conversation_id, limit), + ) + return list(reversed(rows)) diff --git a/services/db/mysql_service.py b/services/db/mysql_service.py new file mode 100644 index 0000000..410f05e --- /dev/null +++ b/services/db/mysql_service.py @@ -0,0 +1,63 @@ +from __future__ import annotations +from typing import Any + + +class DatabaseService: + """MySQL 연결을 캡슐화하는 서비스. 미설정 시 graceful skip.""" + + def __init__(self, host: str, port: int, db: str, user: str, password: str): + self._config = dict(host=host, port=port, db=db, user=user, passwd=password) + self._conn = None + + def connect(self) -> None: + if not self._config["user"]: + return + try: + import pymysql + self._conn = pymysql.connect(**self._config) + except Exception as e: + print(f"[DB] 연결 실패 (선택적 기능): {e}") + + def execute(self, sql: str, params: tuple = ()) -> list[dict[str, Any]]: + if self._conn is None: + return [] + cursor = self._conn.cursor() + cursor.execute(sql, params) + columns = [d[0] for d in cursor.description or []] + return [dict(zip(columns, row)) for row in cursor.fetchall()] + + def execute_write(self, sql: str, params: tuple = ()) -> int: + """INSERT/UPDATE/DELETE 실행 후 lastrowid 반환.""" + if self._conn is None: + return 0 + cursor = self._conn.cursor() + cursor.execute(sql, params) + self._conn.commit() + return cursor.lastrowid + + def init_schema(self) -> None: + if self._conn is None: + return + cursor = self._conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS td_conversations ( + id INT AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS td_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + conversation_id INT NOT NULL, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (conversation_id) REFERENCES td_conversations(id) + ) + """) + self._conn.commit() + + def close(self) -> None: + if self._conn: + self._conn.close() + self._conn = None diff --git a/services/events/__init__.py b/services/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/events/event_bus.py b/services/events/event_bus.py new file mode 100644 index 0000000..93276b2 --- /dev/null +++ b/services/events/event_bus.py @@ -0,0 +1,19 @@ +from collections import defaultdict +from typing import Callable + + +class EventBus: + """Observer 패턴 기반 이벤트 버스.""" + + def __init__(self): + self._handlers: dict[str, list[Callable]] = defaultdict(list) + + def subscribe(self, event: str, handler: Callable) -> None: + self._handlers[event].append(handler) + + def unsubscribe(self, event: str, handler: Callable) -> None: + self._handlers[event].remove(handler) + + def publish(self, event: str, *args, **kwargs) -> None: + for handler in self._handlers[event]: + handler(*args, **kwargs) diff --git a/services/events/handlers.py b/services/events/handlers.py new file mode 100644 index 0000000..f643230 --- /dev/null +++ b/services/events/handlers.py @@ -0,0 +1,15 @@ +import sys + + +class StreamTokenHandler: + """스트림 토큰을 stdout에 실시간 출력하는 핸들러.""" + + def __call__(self, token: str) -> None: + print(token, end="", flush=True) + + +class StreamEndHandler: + """스트림 종료 시 개행을 출력하는 핸들러.""" + + def __call__(self) -> None: + print("\n") diff --git a/services/model/__init__.py b/services/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/model/base.py b/services/model/base.py new file mode 100644 index 0000000..9a4bd07 --- /dev/null +++ b/services/model/base.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from typing import Iterator + + +class AbstractModelService(ABC): + """LLM 백엔드 Strategy 인터페이스.""" + + @abstractmethod + def load(self) -> None: + """모델을 메모리에 로드한다.""" + + @abstractmethod + def stream(self, prompt: str, max_tokens: int) -> Iterator[str]: + """프롬프트를 받아 토큰을 스트리밍한다.""" + + @abstractmethod + def build_prompt(self, history: list[dict]) -> str: + """대화 히스토리를 모델 입력 형식으로 변환한다.""" diff --git a/services/model/mlx_model.py b/services/model/mlx_model.py new file mode 100644 index 0000000..73314e2 --- /dev/null +++ b/services/model/mlx_model.py @@ -0,0 +1,29 @@ +from typing import Iterator + +from services.model.base import AbstractModelService + + +class MlxModelService(AbstractModelService): + """MLX 기반 로컬 LLM Strategy 구현체.""" + + def __init__(self, model_id: str): + self._model_id = model_id + self._model = None + self._tokenizer = None + + def load(self) -> None: + from mlx_lm import load + print(f"모델 로딩 중: {self._model_id}") + self._model, self._tokenizer = load(self._model_id) + + def build_prompt(self, history: list[dict]) -> str: + return self._tokenizer.apply_chat_template( + history, + tokenize=False, + add_generation_prompt=True, + ) + + def stream(self, prompt: str, max_tokens: int) -> Iterator[str]: + from mlx_lm import stream_generate + for chunk in stream_generate(self._model, self._tokenizer, prompt=prompt, max_tokens=max_tokens): + yield chunk.text diff --git a/services/ui/__init__.py b/services/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ui/cli_service.py b/services/ui/cli_service.py new file mode 100644 index 0000000..fbe2322 --- /dev/null +++ b/services/ui/cli_service.py @@ -0,0 +1,23 @@ +class CliUiService: + """CLI 입출력 서비스.""" + + def show_banner(self, model_id: str) -> None: + print(f"모델 로딩 중: {model_id}") + print("(첫 실행 시 HuggingFace에서 자동 다운로드됩니다. 약 4.5GB)\n") + print("=" * 50) + print("육아 & 금융 상담 챗봇 시작!") + print("종료: '종료' / 'quit' / 'exit' 입력") + print("초기화: 'reset' 또는 'clear' 입력") + print("=" * 50 + "\n") + + def prompt_user(self) -> str: + return input("나: ").strip() + + def show_assistant_prefix(self) -> None: + print("\n도우미: ", end="", flush=True) + + def is_exit_command(self, text: str) -> bool: + return text.lower() in ("종료", "quit", "exit") + + def is_reset_command(self, text: str) -> bool: + return text.lower() in ("reset", "clear", "초기화")