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
View File
+248
View File
@@ -0,0 +1,248 @@
import os
import time
import uuid
from typing import AsyncIterator
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from services.agent.tools import get_current_date, make_memory_tools, make_retriever_tool, make_search_tool, web_search
class AgentService:
"""LangGraph ReAct 에이전트 서비스.
Tool Calling 루프, 대화 히스토리, 조건부 라우팅을 LangGraph가 담당한다.
"""
def __init__(
self,
chat_model,
retriever_service,
system_prompt: str,
rag_verbose: bool = False,
rag_show_sources: bool = False,
langgraph_verbose: bool = False,
think_verbose: bool = False,
user_profile_repository=None,
conversation_repository=None,
user_id: str = "default",
):
self._system_prompt = system_prompt
self._rag_verbose = rag_verbose
self._rag_show_sources = rag_show_sources
self._langgraph_verbose = langgraph_verbose
self._think_verbose = think_verbose
self._source_buffer: list[dict] = []
self._thread_id = "default"
self._profile_repo = user_profile_repository
self._conv_repo = conversation_repository
self._conv_id: int | None = None
self._pending_history: list = []
self._user_id = user_id
if conversation_repository:
try:
self._conv_id = conversation_repository.get_latest_conversation_id(user_id)
if self._conv_id is None:
self._conv_id = conversation_repository.create_conversation(user_id)
else:
turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
for turn in turns:
if turn["role"] == "user":
self._pending_history.append(HumanMessage(content=turn["content"]))
elif turn["role"] == "assistant":
self._pending_history.append(AIMessage(content=turn["content"]))
if self._pending_history:
print(f"[Agent] 이전 대화 {len(self._pending_history) // 2}턴 복원")
except Exception as e:
print(f"[Agent] 이력 복원 실패: {e}")
self._conv_id = None
self._pending_history = []
if rag_show_sources:
search_tool = make_search_tool(retriever_service, self._source_buffer)
else:
search_tool = make_retriever_tool(retriever_service)
tools = [search_tool, web_search, get_current_date]
if user_profile_repository is not None:
remember_tool, recall_tool = make_memory_tools(user_profile_repository, user_id)
tools += [remember_tool, recall_tool]
llm_with_tools = chat_model.bind_tools(tools)
async def call_model(state: MessagesState, config: RunnableConfig) -> dict:
system_content = self._system_prompt
if self._profile_repo:
profile = self._profile_repo.get_all(self._user_id)
if profile:
lines = "\n".join(f"- {k}: {v}" for k, v in profile.items())
system_content += f"\n\n## 사용자 정보 (이전 대화에서 기억된 내용)\n{lines}"
msgs = [SystemMessage(content=system_content)] + state["messages"]
thinking_acc, content_acc, tool_calls_acc = "", "", []
async for chunk in llm_with_tools.astream(msgs, config):
t = chunk.additional_kwargs.get("thinking", "")
if t:
thinking_acc += t
if chunk.content and isinstance(chunk.content, str):
content_acc += chunk.content
if chunk.tool_calls:
tool_calls_acc.extend(chunk.tool_calls)
extra = {"thinking": thinking_acc} if thinking_acc else {}
return {"messages": [AIMessage(
content=content_acc,
tool_calls=tool_calls_acc,
additional_kwargs=extra,
)]}
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
self._agent = builder.compile(checkpointer=MemorySaver())
@property
def _config(self) -> dict:
return {"configurable": {"thread_id": self._thread_id}}
async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str]:
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다."""
_think_verbose = show_thinking if show_thinking is not None else self._think_verbose
self._source_buffer.clear()
# 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입
if self._pending_history:
all_messages = self._pending_history + [HumanMessage(content=user_input)]
self._pending_history = []
else:
all_messages = [HumanMessage(content=user_input)]
messages = {"messages": all_messages}
response_content = "" # 실제 답변 내용만 누적 (MySQL 저장용)
pending_tool_calls: dict = {} # tool_call_id → {name, args}
prev_node: str = ""
lg = self._langgraph_verbose
thinking_open = False # [사고 과정] 헤더 출력 여부
content_started = False # 노드 당 레이블 1회 출력 제어
start_time = time.perf_counter()
async for chunk, metadata in self._agent.astream(
messages, self._config, stream_mode="messages"
):
node = metadata.get("langgraph_node", "")
# ── 노드 전환 시 플래그 리셋 + 레이블 출력 ──────────────
if node != prev_node:
content_started = False
if lg:
if node == "agent":
elapsed = time.perf_counter() - start_time
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"
elif node == "tools":
elapsed = time.perf_counter() - start_time
yield f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n"
prev_node = node
# ── agent 노드 — AIMessageChunk만 처리 (중복 방지) ──────
if node == "agent" and isinstance(chunk, AIMessageChunk):
thinking = chunk.additional_kwargs.get("thinking", "")
if thinking and _think_verbose:
if not thinking_open:
yield "\n[사고 과정]\n"
thinking_open = True
yield thinking
if chunk.tool_calls:
if thinking_open:
yield "\n[/사고 과정]\n"
thinking_open = False
for tc in chunk.tool_calls:
pending_tool_calls[tc["id"]] = tc
if tc.get("name") == "search_documents":
query = tc.get("args", {}).get("query", "")
yield f'\n문서 검색 중... ("{query}")\n' if query else "\n문서 검색 중...\n"
elif tc.get("name") == "web_search":
query = tc.get("args", {}).get("query", "")
yield f'\n웹 검색 중... ("{query}")\n' if query else "\n웹 검색 중...\n"
elif lg:
args_str = ", ".join(f'{k}="{v}"' for k, v in tc["args"].items())
yield f" [tool_call: {tc['name']}({args_str})]\n"
elif chunk.content:
if thinking_open:
yield "\n[/사고 과정]\n"
thinking_open = False
if lg and not content_started:
yield "\n[LangGraph → agent: 최종 답변 생성]\n\n"
content_started = True
response_content += chunk.content
yield chunk.content
# ── agent 노드 — AIMessage(최종 state) ──────────────────
# 청크 스트리밍이 없었던 경우(edge case)에만 처리
elif node == "agent" and isinstance(chunk, AIMessage):
if not content_started and not thinking_open:
thinking = chunk.additional_kwargs.get("thinking", "")
if thinking and self._think_verbose:
yield "\n[사고 과정]\n"
yield thinking
yield "\n[/사고 과정]\n"
if chunk.content:
if lg:
yield "\n[LangGraph → agent: 최종 답변 생성]\n\n"
response_content += chunk.content
yield chunk.content
# ── tools 노드 ───────────────────────────────────────────
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "search_documents":
if lg:
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
yield f" [결과: {len(result_lines)}개 문서 반환 → agent 복귀]\n"
if self._rag_verbose:
tc = pending_tool_calls.get(chunk.tool_call_id, {})
query = tc.get("args", {}).get("query", "")
yield f'\n[문서 검색: "{query}"]\n'
for block in chunk.content.split("\n\n"):
if block.strip():
preview = block.strip().replace("\n", " ")[:80]
yield f"{preview}\n"
yield "\n"
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "web_search":
if lg:
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
yield f" [웹 검색 결과: {len(result_lines)}건 → agent 복귀]\n"
if thinking_open:
yield "\n[/사고 과정]\n"
# 대화 내용을 MySQL에 저장
if self._conv_repo and self._conv_id and response_content:
try:
self._conv_repo.save_message(self._conv_id, "user", user_input)
self._conv_repo.save_message(self._conv_id, "assistant", response_content)
except Exception as e:
print(f"[Agent] 대화 저장 실패: {e}")
if self._rag_show_sources and self._source_buffer:
yield "\n\n[참고 문서]\n"
for src in self._source_buffer:
filename = os.path.basename(src["source"])
page = f" {src['page']}페이지" if "page" in src else ""
yield f"- {filename}{page}\n"
def reset(self) -> None:
"""새 thread_id로 대화 히스토리를 초기화한다."""
self._thread_id = str(uuid.uuid4())
self._pending_history = []
if self._conv_repo:
try:
self._conv_id = self._conv_repo.create_conversation(self._user_id)
except Exception:
self._conv_id = None
+96
View File
@@ -0,0 +1,96 @@
from datetime import date
from langchain_core.tools import tool
@tool
def get_current_date() -> str:
"""오늘 날짜를 반환합니다. 날짜·기간 관련 질문에 사용하세요."""
return date.today().isoformat()
@tool
def web_search(query: str) -> str:
"""최신 뉴스, 금리, 육아 정책 등 실시간 정보가 필요할 때 사용하세요. 저장된 문서에 없는 최신 정보를 검색합니다."""
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=5))
if not results:
return "검색 결과가 없습니다."
return "\n\n".join(
f"[{r['title']}]\n{r['body']}\n출처: {r['href']}"
for r in results
)
def make_retriever_tool(retriever_service):
"""as_retriever()를 사용하는 단순 검색 Tool (source_buffer 없음)."""
retriever = retriever_service.as_retriever()
@tool
def search_documents(query: str) -> str:
"""등록된 문서(논문, 육아 가이드, 금융 자료 등)에서 관련 정보를 검색합니다.
육아·금융 관련 질문이 오면 자신의 지식으로 답하기 전에 반드시 이 도구를 먼저 호출하세요.
등록된 문서가 없거나 검색 결과가 없을 때만 자신의 학습 지식을 보조적으로 활용합니다."""
docs = retriever.invoke(query)
if not docs:
return "관련 문서를 찾을 수 없습니다."
return "\n\n".join(
f"[문서 {i + 1}]\n{doc.page_content}" for i, doc in enumerate(docs)
)
return search_documents
def make_memory_tools(profile_repo, user_id: str = "default"):
"""사용자 정보 저장/조회 Tool 쌍을 반환한다."""
@tool
def remember_user_info(key: str, value: str) -> str:
"""사용자 정보를 영구 저장합니다. 다음 대화에도 기억해야 할 정보를 저장하세요.
- 아이 나이는 반드시 '생년(출생연도)'으로 저장하세요. 나이는 매년 바뀌지만 생년은 영구적입니다.
예: key='첫째_이름' value='신도율', key='첫째_생년' value='2020'
- 기타 key 예시: 재정_목표, 거주지, 직업, 자녀수"""
profile_repo.remember(key, value, user_id=user_id)
return f"'{key}' 정보를 기억했습니다: {value}"
@tool
def recall_user_info(key: str) -> str:
"""이전 대화에서 저장한 사용자 정보를 조회합니다."""
value = profile_repo.recall(key, user_id=user_id)
return value if value is not None else f"'{key}'에 대한 저장된 정보가 없습니다."
return remember_user_info, recall_user_info
def make_search_tool(retriever_service, source_buffer: list | None = None):
"""RetrieverService를 클로저로 감싼 문서 검색 Tool을 반환합니다.
source_buffer가 주어지면 검색된 문서의 메타데이터(source, page)를 누적 저장합니다.
"""
@tool
def search_documents(query: str) -> str:
"""등록된 문서(논문, 육아 가이드, 금융 자료 등)에서 관련 정보를 검색합니다.
육아·금융 관련 질문이 오면 자신의 지식으로 답하기 전에 반드시 이 도구를 먼저 호출하세요.
등록된 문서가 없거나 검색 결과가 없을 때만 자신의 학습 지식을 보조적으로 활용합니다."""
docs = retriever_service.search(query)
if source_buffer is not None:
for doc in docs:
src = doc.metadata.get("source", "")
page = doc.metadata.get("page", None)
if src:
entry = {"source": src}
if page is not None:
entry["page"] = page + 1 # 0-indexed → 1-indexed
if entry not in source_buffer:
source_buffer.append(entry)
if not docs:
return "관련 문서를 찾을 수 없습니다."
return "\n\n".join(
f"[문서 {i + 1}]\n{doc.page_content}" for i, doc in enumerate(docs)
)
return search_documents