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:
@@ -8,14 +8,16 @@ class ConversationRepository:
|
||||
def __init__(self, db: DatabaseService):
|
||||
self._db = db
|
||||
|
||||
def create_conversation(self) -> int:
|
||||
def create_conversation(self, user_id: str = "default") -> int:
|
||||
return self._db.execute_write(
|
||||
"INSERT INTO td_conversations () VALUES ()"
|
||||
"INSERT INTO td_conversations (user_id) VALUES (%s)",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
def get_latest_conversation_id(self) -> int | None:
|
||||
def get_latest_conversation_id(self, user_id: str = "default") -> int | None:
|
||||
rows = self._db.execute(
|
||||
"SELECT id FROM td_conversations ORDER BY created_at DESC LIMIT 1"
|
||||
"SELECT id FROM td_conversations WHERE user_id = %s ORDER BY created_at DESC LIMIT 1",
|
||||
(user_id,),
|
||||
)
|
||||
return rows[0]["id"] if rows else None
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
from services.db.mysql_service import DatabaseService
|
||||
|
||||
|
||||
class UserProfileRepository:
|
||||
"""td_user_profile 테이블을 통한 사용자 장기 메모리 저장소."""
|
||||
|
||||
def __init__(self, db: DatabaseService):
|
||||
self._db = db
|
||||
|
||||
def remember(self, key: str, value: str, user_id: str = "default") -> None:
|
||||
self._db.execute_write(
|
||||
"""INSERT INTO td_user_profile (user_id, key_name, value)
|
||||
VALUES (%s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()""",
|
||||
(user_id, key, value),
|
||||
)
|
||||
|
||||
def recall(self, key: str, user_id: str = "default") -> str | None:
|
||||
rows = self._db.execute(
|
||||
"SELECT value FROM td_user_profile WHERE user_id = %s AND key_name = %s",
|
||||
(user_id, key),
|
||||
)
|
||||
return rows[0]["value"] if rows else None
|
||||
|
||||
def get_all(self, user_id: str = "default") -> dict[str, str]:
|
||||
rows = self._db.execute(
|
||||
"SELECT key_name, value FROM td_user_profile WHERE user_id = %s ORDER BY updated_at",
|
||||
(user_id,),
|
||||
)
|
||||
return {r["key_name"]: r["value"] for r in rows}
|
||||
Reference in New Issue
Block a user