Implement Phase 4~14: LangGraph Agent, RAG pipeline, Gradio Web UI, voice interface

- Upgrade LLM to Qwen3-14B-4bit with Thinking mode (MlxChatModel as LangChain BaseChatModel)
- Add LangGraph ReAct agent with tool calling loop (search_documents, web_search, get_current_date, remember/recall_user_info)
- Add RAG pipeline: BAAI/bge-m3 embeddings + Qdrant vector store + semantic chunking (SemanticSplitter via cosine similarity)
- Replace fixed-size RecursiveCharacterTextSplitter with meaning-based SemanticSplitter (numpy only, no extra deps)
- Add Gradio Web UI (app.py): chat, document ingestion, document management tabs
- Add multi-user support (user_id isolation in DB + per-user agent cache + dropdown selector)
- Add conversation history restore from MySQL on agent init (Phase 11)
- Add UserProfileRepository for persistent user profile (remember/recall tools)
- Add thread-local DB connections to fix pymysql thread-safety with LangGraph ToolNode
- Add Phase 14 voice interface: Whisper STT (microphone → text) + macOS TTS (say -v Yuna)
- Enforce search_documents-first policy in system prompt and tool descriptions
- Update ROADMAP2.md: Phase 14 완료, Phase 13 청킹 부분 완료

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sal
2026-05-27 14:06:22 +09:00
parent cd41e9e33e
commit 06bcdb03ac
20 changed files with 1934 additions and 47 deletions
+82 -22
View File
@@ -1,47 +1,81 @@
from __future__ import annotations
import threading
from typing import Any
class DatabaseService:
"""MySQL 연결을 캡슐화하는 서비스. 미설정 시 graceful skip."""
"""MySQL 연결을 캡슐화하는 서비스. 미설정 시 graceful skip.
def __init__(self, host: str, port: int, db: str, user: str, password: str):
스레드별 독립 연결(thread-local)을 사용해 LangGraph ToolNode의
스레드 풀 실행과 pymysql 비안전성 문제를 해결한다.
"""
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
self._local = threading.local()
# ── DB 연결 ────────────────────────────────────────────────────────
def _get_conn(self):
if not self._config["user"]:
return None
import pymysql
conn = getattr(self._local, "conn", None)
if conn is None:
try:
self._local.conn = pymysql.connect(**self._config)
except Exception as e:
print(f"[DB] 연결 실패: {e}")
return None
else:
try:
conn.ping(reconnect=True)
except Exception:
try:
self._local.conn = pymysql.connect(**self._config)
except Exception as e:
print(f"[DB] 재연결 실패: {e}")
return None
return self._local.conn
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}")
self._get_conn()
def execute(self, sql: str, params: tuple = ()) -> list[dict[str, Any]]:
if self._conn is None:
conn = self._get_conn()
if conn is None:
return []
cursor = self._conn.cursor()
cursor = 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:
conn = self._get_conn()
if conn is None:
return 0
cursor = self._conn.cursor()
cursor = conn.cursor()
cursor.execute(sql, params)
self._conn.commit()
conn.commit()
return cursor.lastrowid
def init_schema(self) -> None:
if self._conn is None:
conn = self._get_conn()
if conn is None:
return
cursor = self._conn.cursor()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS td_conversations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(50) NOT NULL DEFAULT 'default',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
@@ -55,9 +89,35 @@ class DatabaseService:
FOREIGN KEY (conversation_id) REFERENCES td_conversations(id)
)
""")
self._conn.commit()
cursor.execute("""
CREATE TABLE IF NOT EXISTS td_user_profile (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(50) NOT NULL DEFAULT 'default',
key_name VARCHAR(100) NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_key (user_id, key_name)
)
""")
conn.commit()
self._migrate_schema(conn)
def _migrate_schema(self, conn) -> None:
cursor = conn.cursor()
for sql in [
"ALTER TABLE td_conversations ADD COLUMN user_id VARCHAR(50) NOT NULL DEFAULT 'default'",
"ALTER TABLE td_user_profile ADD COLUMN user_id VARCHAR(50) NOT NULL DEFAULT 'default'",
"ALTER TABLE td_user_profile DROP INDEX key_name",
"ALTER TABLE td_user_profile ADD UNIQUE KEY uq_user_key (user_id, key_name)",
]:
try:
cursor.execute(sql)
conn.commit()
except Exception:
pass
def close(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
conn = getattr(self._local, "conn", None)
if conn:
conn.close()
self._local.conn = None