Compare commits

..

3 Commits

Author SHA1 Message Date
shinalok 145b0cc96f Implement Phase 12 feedback, Phase 13 Semantic Chunker, Phase 13-B Reranker, Bug 5 thinking fix
- Phase 12: FeedbackRepository + td_feedback 테이블, Gradio 👍/👎 이벤트, run_id 추적, LangSmith create_feedback() 연동
- Phase 13: 커스텀 _SemanticSplitter 제거 → langchain_experimental.SemanticChunker 교체, buffer_size/threshold_type 환경변수 적용
- Phase 13-B: RerankService (Cross-Encoder), RetrieverService.search()에 reranker 통합, tools.py as_retriever() → search() 전환
- Bug 5: mlx_chat_model enable_thinking 런타임 오버라이드, agent_service stream_mode=["messages","custom"] 이중 스트림, thinking 토큰 custom 이벤트로 emit
- ROADMAP: LLM 모델명 8B 반영, RAG에 Reranker 추가, 추천 진행 순서 갱신

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:41:36 +09:00
shinalok e1d7e9cc21 Merge ROADMAP.md and ROADMAP2.md into single roadmap
- Combine Phase 4~7 history (ROADMAP.md) with Phase 9~14 and bug fixes (ROADMAP2.md)
- Add bug 4 (age calculation) and Phase 13 Semantic Chunker to completed items
- Remove ROADMAP2.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:11:27 +09:00
shinalok b4b628ab78 Fix age calculation: inject today's date, add Korean/international age
- Prepend today's date to system prompt on every call so LLM uses correct year
- Calculate both Korean age (현재연도-출생연도+1) and 만 나이 with exact birthday handling
- Support full date (생년월일) and year-only (생년) profile values
- Update remember_user_info to encourage storing full birth date
- Strengthen get_current_date tool description for age-related queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:10:30 +09:00
14 changed files with 662 additions and 331 deletions
+60 -16
View File
@@ -1,4 +1,4 @@
"""Gradio Web UI — 율봇 Phase 4 + Phase 9/10 + Phase 14(음성).""" """Gradio Web UI — 율봇 Phase 4 + Phase 9/10 + Phase 12(피드백) + Phase 14(음성)."""
import os import os
import subprocess import subprocess
import tempfile import tempfile
@@ -17,6 +17,7 @@ db.init_schema()
ingestion = container.ingestion_service() ingestion = container.ingestion_service()
retriever = container.retriever_service() retriever = container.retriever_service()
feedback_repo = container.feedback_repository()
_cfg = container.config() _cfg = container.config()
_agent_cache: dict[str, AgentService] = {} _agent_cache: dict[str, AgentService] = {}
@@ -44,7 +45,7 @@ def transcribe_audio(filepath: str) -> str:
def tts_speak(text: str, voice: str) -> str | None: def tts_speak(text: str, voice: str) -> str | None:
"""텍스트를 macOS say 명령어로 음성 변환, 재생용 wav 파일 경로 반환.""" """텍스트를 macOS say 명령어로 음성 변환, 재생용 aiff 파일 경로 반환."""
if not text: if not text:
return None return None
try: try:
@@ -77,36 +78,72 @@ def _get_agent(user_id: str) -> AgentService:
return _agent_cache[user_id] return _agent_cache[user_id]
async def respond(message, history, show_thinking, user_id, use_tts): async def respond(message, history, show_thinking, user_id, use_tts, run_ids):
if not message.strip(): if not message.strip():
yield history, "", None yield history, "", None, run_ids
return return
agent = _get_agent(user_id) agent = _get_agent(user_id)
history = list(history) history = list(history)
run_ids = list(run_ids)
history.append({"role": "user", "content": message}) history.append({"role": "user", "content": message})
history.append({"role": "assistant", "content": ""}) history.append({"role": "assistant", "content": ""})
yield history, "", None yield history, "", None, run_ids
async for token in agent.stream_response(message, show_thinking=show_thinking): async for token in agent.stream_response(message, show_thinking=show_thinking):
history[-1]["content"] += token history[-1]["content"] += token
yield history, "", None yield history, "", None, run_ids
run_ids.append(agent.last_run_id)
if use_tts: if use_tts:
response_text = history[-1]["content"] response_text = history[-1]["content"]
audio_path = tts_speak(response_text, _cfg.tts_voice) audio_path = tts_speak(response_text, _cfg.tts_voice)
yield history, "", audio_path yield history, "", audio_path, run_ids
else:
yield history, "", None, run_ids
def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
idx = like_data.index
if isinstance(idx, (list, tuple)):
idx = idx[0]
if not isinstance(idx, int) or idx >= len(history):
return
if history[idx].get("role") != "assistant":
return
asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant")
run_id = run_ids[asst_turn] if asst_turn < len(run_ids) else None
def _to_str(val) -> str:
return val if isinstance(val, str) else str(val)
user_msg = _to_str(history[idx - 1]["content"]) if idx > 0 else ""
asst_msg = _to_str(history[idx]["content"])
rating = 1 if like_data.liked else -1
try:
feedback_repo.save_feedback(user_id, user_msg, asst_msg, rating, run_id)
except Exception as e:
print(f"[Feedback] DB 저장 실패: {e}")
if run_id and os.getenv("LANGCHAIN_TRACING_V2") == "true":
try:
from langsmith import Client
Client().create_feedback(run_id=run_id, key="user_feedback", score=rating)
except Exception as e:
print(f"[Feedback] LangSmith 기록 실패: {e}")
def switch_user(user_id): def switch_user(user_id):
"""사용자 전환 시 채팅 화면 초기화 (대화 이력은 유지).""" """사용자 전환 시 채팅 화면과 run_ids 초기화 (대화 이력은 DB에 유지)."""
return [] return [], []
def reset_chat(user_id): def reset_chat(user_id):
agent = _get_agent(user_id) agent = _get_agent(user_id)
agent.reset() agent.reset()
return [] return [], []
def ingest_files(files): def ingest_files(files):
@@ -143,6 +180,7 @@ with gr.Blocks(title="율봇") as demo:
gr.Markdown("# 율봇\n육아·금융 전문 AI 상담 도우미") gr.Markdown("# 율봇\n육아·금융 전문 AI 상담 도우미")
user_state = gr.State(DEFAULT_USER) user_state = gr.State(DEFAULT_USER)
run_ids_state = gr.State([])
with gr.Tab("대화"): with gr.Tab("대화"):
with gr.Row(): with gr.Row():
@@ -185,7 +223,7 @@ with gr.Blocks(title="율봇") as demo:
user_selector.change( user_selector.change(
switch_user, switch_user,
inputs=[user_selector], inputs=[user_selector],
outputs=[chatbot], outputs=[chatbot, run_ids_state],
).then( ).then(
lambda u: u, inputs=[user_selector], outputs=[user_state] lambda u: u, inputs=[user_selector], outputs=[user_state]
) )
@@ -198,15 +236,21 @@ with gr.Blocks(title="율봇") as demo:
send_btn.click( send_btn.click(
respond, respond,
inputs=[msg_box, chatbot, show_thinking, user_state, use_tts], inputs=[msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state],
outputs=[chatbot, msg_box, tts_output], outputs=[chatbot, msg_box, tts_output, run_ids_state],
) )
msg_box.submit( msg_box.submit(
respond, respond,
inputs=[msg_box, chatbot, show_thinking, user_state, use_tts], inputs=[msg_box, chatbot, show_thinking, user_state, use_tts, run_ids_state],
outputs=[chatbot, msg_box, tts_output], outputs=[chatbot, msg_box, tts_output, run_ids_state],
)
reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state])
chatbot.like(
handle_feedback,
inputs=[chatbot, run_ids_state, user_state],
outputs=[],
) )
reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot])
with gr.Tab("문서 등록"): with gr.Tab("문서 등록"):
gr.Markdown("PDF 또는 TXT 파일을 업로드하면 율봇이 내용을 참고해 답변합니다.") gr.Markdown("PDF 또는 TXT 파일을 업로드하면 율봇이 내용을 참고해 답변합니다.")
+14 -1
View File
@@ -34,7 +34,13 @@ class Config(BaseSettings):
# RAG # RAG
rag_top_k: int = 3 rag_top_k: int = 3
semantic_breakpoint_threshold_type: str = "percentile" # percentile | standard_deviation | interquartile semantic_breakpoint_threshold_type: str = "percentile" # percentile | standard_deviation | interquartile | gradient
semantic_buffer_size: int = 1 # 인접 문장 몇 개를 묶어 임베딩할지 (1=단일 문장, 2=전후 1문장 포함)
# Reranker (RERANKER_ENABLED=true 시 활성화)
reranker_enabled: bool = False
reranker_model_id: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1" # 한국어 지원 다국어 모델
reranker_fetch_k: int = 10 # rerank 전 벡터 검색 후보 수 (rag_top_k보다 커야 함)
rag_verbose: bool = False rag_verbose: bool = False
rag_show_sources: bool = False rag_show_sources: bool = False
langgraph_verbose: bool = False langgraph_verbose: bool = False
@@ -54,6 +60,13 @@ class Config(BaseSettings):
항상 쉽고 친근한 말투로 설명하고, 전문 용어는 풀어서 설명합니다. 항상 쉽고 친근한 말투로 설명하고, 전문 용어는 풀어서 설명합니다.
의학적 진단이나 법적 판단이 필요한 경우에는 반드시 전문가 상담을 권유합니다. 의학적 진단이나 법적 판단이 필요한 경우에는 반드시 전문가 상담을 권유합니다.
## 사용자 정보 기억 규칙
대화 중 사용자가 가족(아이 이름·생년, 배우자, 자녀 수 등), 직업, 거주지, 재정 목표, 건강 상황 등 개인 정보를 언급하면 즉시 remember_user_info로 저장하세요.
- 아이 나이는 생년월일 전체를 저장합니다. (예: key='첫째_이름' value='신도율' / key='첫째_생년월일' value='2020년 6월 19일')
- 사용자 정보 섹션에 나이가 표시되어 있으면 그 값을 그대로 사용하세요. 직접 계산하지 마세요.
- 나이를 직접 계산해야 할 경우에는 반드시 get_current_date 도구를 먼저 호출하여 오늘 날짜를 확인하세요.
- 나이는 항상 한국 나이와 만 나이를 함께 알려주세요. (한국 나이 = 현재 연도 - 출생 연도 + 1 / 만 나이 = 생일이 지났으면 현재 연도 - 출생 연도, 생일이 안 지났으면 -1)
## 문서 검색 규칙 ## 문서 검색 규칙
육아·금융 관련 질문이라면 자신의 학습 지식으로 직접 답하지 말고, 반드시 search_documents 도구를 먼저 호출하세요. 육아·금융 관련 질문이라면 자신의 학습 지식으로 직접 답하지 말고, 반드시 search_documents 도구를 먼저 호출하세요.
검색 결과가 없거나 관련 문서가 등록되어 있지 않은 경우에만 학습 지식을 보조적으로 활용합니다.""" 검색 결과가 없거나 관련 문서가 등록되어 있지 않은 경우에만 학습 지식을 보조적으로 활용합니다."""
+15
View File
@@ -9,11 +9,13 @@ from services.chat.compact_service import CompactService
from services.db.mysql_service import DatabaseService from services.db.mysql_service import DatabaseService
from services.db.conversation_repository import ConversationRepository from services.db.conversation_repository import ConversationRepository
from services.db.user_profile_repository import UserProfileRepository from services.db.user_profile_repository import UserProfileRepository
from services.db.feedback_repository import FeedbackRepository
from services.ui.cli_service import CliUiService from services.ui.cli_service import CliUiService
from services.events.event_bus import EventBus from services.events.event_bus import EventBus
from services.events.handlers import StreamTokenHandler, StreamEndHandler from services.events.handlers import StreamTokenHandler, StreamEndHandler
from langchain_huggingface import HuggingFaceEmbeddings from langchain_huggingface import HuggingFaceEmbeddings
from services.rag.ingestion_service import IngestionService from services.rag.ingestion_service import IngestionService
from services.rag.rerank_service import RerankService
from services.rag.retriever_service import RetrieverService from services.rag.retriever_service import RetrieverService
from services.agent.agent_service import AgentService from services.agent.agent_service import AgentService
@@ -60,6 +62,11 @@ class Container(containers.DeclarativeContainer):
db=db_service, db=db_service,
) )
feedback_repository = providers.Singleton(
FeedbackRepository,
db=db_service,
)
history_service = providers.Factory( history_service = providers.Factory(
HistoryService, HistoryService,
system_prompt=providers.Callable(lambda c: c.system_prompt, config), system_prompt=providers.Callable(lambda c: c.system_prompt, config),
@@ -97,6 +104,12 @@ class Container(containers.DeclarativeContainer):
breakpoint_threshold_type=providers.Callable( breakpoint_threshold_type=providers.Callable(
lambda c: c.semantic_breakpoint_threshold_type, config lambda c: c.semantic_breakpoint_threshold_type, config
), ),
buffer_size=providers.Callable(lambda c: c.semantic_buffer_size, config),
)
reranker = providers.Callable(
lambda c: RerankService(c.reranker_model_id) if c.reranker_enabled else None,
config,
) )
retriever_service = providers.Singleton( retriever_service = providers.Singleton(
@@ -105,6 +118,8 @@ class Container(containers.DeclarativeContainer):
qdrant_url=providers.Callable(lambda c: c.qdrant_url, config), qdrant_url=providers.Callable(lambda c: c.qdrant_url, config),
collection_name=providers.Callable(lambda c: c.qdrant_collection, config), collection_name=providers.Callable(lambda c: c.qdrant_collection, config),
top_k=providers.Callable(lambda c: c.rag_top_k, config), top_k=providers.Callable(lambda c: c.rag_top_k, config),
reranker=reranker,
rerank_fetch_k=providers.Callable(lambda c: c.reranker_fetch_k, config),
) )
# Phase 3 — LangGraph Agent # Phase 3 — LangGraph Agent
+257 -10
View File
@@ -1,17 +1,43 @@
# 율봇 개발 로드맵 # 율봇 개발 로드맵
## 현재 구현 상태 (Phase 1~7 완료) ## 현재 구현 상태
| 영역 | 현황 | | 영역 | 현황 |
|------|------| |------|------|
| LLM | Qwen2.5-7B-Instruct-4bit (MLX, Apple Silicon) | | LLM | Qwen3-8B-4bit (MLX, Apple Silicon) |
| Agent | LangGraph ReAct + Tool Calling + Thinking 모드 | | Agent | LangGraph ReAct + Tool Calling + Thinking 모드 |
| RAG | Qdrant + BAAI/bge-m3 임베딩 | | RAG | Qdrant + BAAI/bge-m3 임베딩 + Semantic Chunking (`SemanticChunker`) + Reranker (BAAI/bge-reranker-v2-m3) |
| Tools | `search_documents`, `get_current_date`, `web_search`, `remember_user_info`, `recall_user_info` (5개) | | Tools | `search_documents`, `web_search`, `get_current_date`, `remember_user_info`, `recall_user_info` (5개) |
| UI | Gradio Web UI (`app.py`) + CLI (`main.py`) | | Feedback | Gradio 👍/👎 → `td_feedback` DB 저장 + LangSmith `create_feedback()` 연동 |
| Memory | LangGraph MemorySaver (세션 내) + MySQL (대화 영구 저장) + `td_user_profile` (장기 사용자 메모리) | | UI | CLI + Gradio Web UI + 음성 입력(STT)/출력(TTS) |
| Memory | LangGraph MemorySaver (세션 내) + MySQL 대화 저장 + 장기 사용자 프로필 |
| Tracing | LangSmith 트레이싱 |
| Streaming | 비동기 토큰 스트리밍 + `<think>` 블록 파싱 | | Streaming | 비동기 토큰 스트리밍 + `<think>` 블록 파싱 |
| Tracing | LangSmith 트레이싱 설정 완료 (`.env`에서 활성화 가능) | | History Compact | 대화 20턴 초과 시 오래된 절반을 LLM으로 자동 요약 (`CompactService`) |
| 나이 계산 | 시스템 프롬프트에 오늘 날짜 주입 + 한국 나이/만 나이 자동 계산 |
---
## 버그 수정 현황
### ✅ 버그 1 — RAG 중복 수집 (수정 완료)
`IngestionService._delete_by_source()`를 구현해 같은 파일 경로로 저장된 기존 청크를 `ingest()` 시작 시 삭제한다.
### ✅ 버그 2 — LangGraph MemorySaver와 MySQL 이력 미연동 (수정 완료)
`AgentService.__init__`에서 MySQL에 저장된 최근 10턴을 `_pending_history`로 불러온 뒤, 첫 `stream_response()` 호출 시 LangGraph 초기 메시지로 주입한다.
### ✅ 버그 3 — 단일 사용자 전제 (수정 완료)
DB 스키마(`td_conversations.user_id`, `td_user_profile.user_id`)는 `_migrate_schema`로 자동 마이그레이션. `AgentService``user_id` 파라미터 추가, 모든 Repository 호출에 전파. Gradio에 사용자 선택 드롭다운(아록/근혜/도율/하율) 추가 및 사용자별 에이전트 캐시 구현.
### ✅ 버그 4 — 나이 계산 오류 (수정 완료)
LLM이 훈련 데이터 기준 연도로 나이를 계산하는 문제. `AgentService.call_model()`에서 매 호출 시 시스템 프롬프트 앞에 `오늘 날짜: {date.today().isoformat()}`를 주입. 프로필에서 생년월일/생년 값을 파싱해 한국 나이(현재연도-출생연도+1)와 만 나이(생일 기준 정확 계산)를 자동 계산해 시스템 프롬프트에 포함.
### ✅ 버그 5 — 사고 과정(thinking) 체크박스 무효 (수정 완료)
ON/OFF와 무관하게 사고 과정이 표시되지 않던 버그.
- `call_model` 내부에서 `get_stream_writer()`로 thinking 토큰을 custom 이벤트로 emit → 답변 앞에 먼저 스트리밍
- 체크박스 값을 LangGraph configurable → `llm_with_tools.bind(enable_thinking=...)` 로 모델 레벨까지 전달 (`.env` `ENABLE_THINKING` 설정과 독립)
- `stream_response` 루프를 `stream_mode=["messages", "custom"]` 이중 스트림으로 전환
- `self._think_verbose` 인스턴스 변수 참조 버그 수정 (`_think_verbose` 로컬 변수 사용)
--- ---
@@ -47,10 +73,231 @@
--- ---
## Phase 8멀티모달 이미지 이해 ★☆☆ ## Phase 9문서 관리
**배경**: 이유식 사진 → "이 재료로 만들 수 있는 이유식은?", 금융 서류 사진 → 내용 분석 등 이미지 기반 질문 처리. - `IngestionService._delete_by_source()` — 파일 경로 기반 중복 청크 삭제
- `RetrieverService.list_documents()` — Qdrant scroll로 고유 source 목록 반환
- `RetrieverService.delete_document(source)` — source 기준 청크 전체 삭제
- Gradio "문서 관리" 탭 — 목록 테이블 + 경로 입력 삭제 버튼 + 앱 로드 시 자동 새로고침
**제약**: Qwen2.5-7B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 모델 교체 필요. ---
## ✅ Phase 10 — 멀티유저 지원
Bug 3 수정 및 Phase 9 작업과 함께 완전 구현됨.
- DB 마이그레이션: `mysql_service._migrate_schema()``td_conversations`, `td_user_profile` 양쪽에 `user_id` 컬럼 자동 추가
- `ConversationRepository`: `create_conversation(user_id)` / `get_latest_conversation_id(user_id)` — user_id 기반 격리
- `AgentService`: `user_id` 파라미터 추가, 모든 프로필·대화 조회에 전파
- `make_memory_tools(profile_repo, user_id)`: remember/recall 도구가 올바른 사용자 데이터만 접근
- Gradio: 사용자 선택 드롭다운(아록/근혜/도율/하율, 기본값 아록) + `_agent_cache` 사전으로 사용자별 에이전트 분리
---
## ✅ Phase 11 — 대화 이력 복원
버그 2와 함께 해결됨. `AgentService` 초기화 시 MySQL에서 최근 10턴을 `_pending_history`에 로드 → 첫 메시지와 함께 LangGraph에 주입.
```python
turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
# → HumanMessage / AIMessage 변환 후 _pending_history에 저장
```
---
## ✅ Phase 12 — 답변 피드백 & 품질 개선
**배경**: 에이전트가 잘못된 답변을 해도 피드백 루프가 없어 개선이 어려움.
**구현 내용**:
- Gradio Chatbot 메시지마다 👍 / 👎 버튼 (`chatbot.like()` 이벤트)
- `td_feedback` 테이블에 `user_id`, 질문, 답변, 평점 저장 (`FeedbackRepository`)
- `AgentService`에서 응답마다 `run_id`(UUID)를 LangChain config에 주입 → `last_run_id` property로 노출
- `run_ids_state`(gr.State)로 대화 턴별 `run_id` 추적
- LangSmith `Client().create_feedback()` 연동 (트레이싱 활성화 시 자동 기록)
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 향상)
---
## ✅ Phase 13 — RAG 품질 향상 ★★★ (완료)
**배경**: 고정 크기 청킹 + 벡터 유사도 검색만으로는 관련 없는 청크가 섞일 수 있음.
**✅ Semantic Chunker — 완료**
커스텀 `_SemanticSplitter`를 제거하고 `langchain_experimental.SemanticChunker`로 교체 (`services/rag/ingestion_service.py`).
기존에 무시되던 `semantic_breakpoint_threshold_type` 설정이 이제 실제로 적용된다.
| 기능 | 지원 여부 |
|------|----------|
| breakpoint_threshold_type | ✅ percentile / standard_deviation / interquartile / gradient |
| buffer_size | ✅ `SEMANTIC_BUFFER_SIZE` 환경변수로 설정 |
| min_chunk_size | ✅ (SemanticChunker 기본 지원) |
| HuggingFaceEmbeddings 재사용 | ✅ 기존 임베딩 모델 그대로 사용 |
> **langchain-experimental 패키지 상태**:
> `langchain-experimental` v0.4.2는 공식 유지보수 종료가 선언됐지만([#87](https://github.com/langchain-ai/langchain-experimental/issues/87)),
> `SemanticChunker` 자체는 현재 정상 동작하며 후속 패키지(`langchain-text-splitters`)로 이전 완료 시 migration 예정.
**✅ 미완 1 — Semantic Chunker 기능 완성 (완료)**
> 기존 Qdrant 저장 문서는 재등록해야 새 청킹 방식이 적용됨.
**난이도**: 중간 | **임팩트**: 중간 (답변 정확도 향상)
---
## ✅ Phase 14 — 음성 인터페이스
**배경**: 육아 중에는 손이 자유롭지 않아 타이핑이 어려움.
**구현 내용**:
- `openai-whisper` (small 모델) — 마이크 녹음 → 한국어 텍스트 변환, 지연 로딩
- macOS `say -v Yuna` — 에이전트 응답을 음성으로 읽어줌 (aiff 파일 경유)
- Gradio "대화" 탭 확장 — 마이크 녹음 + "음성→텍스트 변환" 버튼 + "음성으로 답변 읽기" 체크박스 + TTS 오디오 플레이어
- LLM/Agent 레이어 변경 없음 — 순수 I/O 어댑터로 구현
**config.py 추가**: `whisper_model_size = "small"`, `tts_voice = "Yuna"`
**난이도**: 중간 | **임팩트**: 높음 (핵심 사용 시나리오)
---
## ✅ Phase 13-B — Reranker ★★☆
**배경**: 벡터 유사도 검색은 의미적으로 비슷한 청크를 가져오지만, 질문과 실제로 관련 있는 청크를 정확히 가려내지 못하는 경우가 있다. Reranker는 검색 후 순위를 재조정해 LLM에 전달되는 컨텍스트 품질을 높인다.
**구현 내용**:
- `services/rag/rerank_service.py``RerankService` 클래스 (Cross-Encoder 래퍼)
- `RetrieverService.search()`: reranker 활성화 시 `rerank_fetch_k`(기본 10)개 후보 검색 → rerank → 상위 `rag_top_k`(기본 3)개 반환
- `tools.py` `make_retriever_tool`: `as_retriever()``search()` 직접 호출로 변경 (reranker 자동 적용)
- `.env` `RERANKER_ENABLED=true`로 활성화, 기본 비활성 (첫 실행 시 모델 다운로드)
| 설정 | 기본값 | 설명 |
|------|--------|------|
| `RERANKER_ENABLED` | `false` | `true`로 설정 시 활성화 |
| `RERANKER_MODEL_ID` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | 한국어 포함 다국어 모델 (117MB) |
| `RERANKER_FETCH_K` | `10` | rerank 전 벡터 검색 후보 수 |
**난이도**: 중간 | **임팩트**: 높음 (관련성 낮은 청크 필터링 → 답변 정확도 향상)
---
## Phase 18 — Hybrid Search (BM25 + Vector) ★★☆
**배경**: 한국어 질문에서 고유명사·전문용어가 포함된 경우 의미 검색(Dense)만으로는 recall이 떨어진다. BM25 키워드 검색과 결합(Hybrid)하면 보완이 가능하다.
**구현 방식**:
- Qdrant의 Sparse Vector 지원 활용 (`FastEmbedSparseEmbeddings` 또는 BM42)
- 인덱싱 시 dense + sparse 두 벡터 동시 저장
- 검색 시 `RRF(Reciprocal Rank Fusion)`로 결과 통합
- `IngestionService`, `RetrieverService` 양쪽 수정 필요
**난이도**: 중간 | **임팩트**: 높음 (키워드 포함 질문 recall 대폭 향상)
---
## Phase 19 — Query Rewriting ★☆☆
**배경**: 사용자 구어체 질문("아이가 밥을 안 먹어요")은 벡터 검색에 최적화되어 있지 않다. LLM이 검색 전에 질문을 재작성하면 관련 문서 검색 확률이 높아진다.
**구현 방식**:
- LangGraph에 `query_rewrite` 노드 추가 (agent → query_rewrite → tools 순서)
- 또는 `search_documents` 도구 내부에서 rewrite 후 검색
- 프롬프트: "다음 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요"
**난이도**: 하 | **임팩트**: 중간 (구어체 질문 검색 품질 향상)
---
## Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆
**배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다.
**구현 방식**:
- `ragas` 라이브러리로 Faithfulness·Answer Relevancy·Context Recall 자동 측정
- 테스트 질문-정답 셋을 `eval/` 디렉터리에 관리
- 설정 변경 후 `python eval/run_ragas.py`로 비교 가능
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 관리 기반)
---
## Phase 15 — 모델 선택 (Claude API / OpenAI 옵션) ★☆☆
**배경**: 로컬 MLX 모델은 Apple Silicon 전용. 원격 접속 시나리오나 더 높은 품질이 필요할 때 Claude API/OpenAI를 선택할 수 있으면 유연성 확보.
**구현 방식**: `config.py``model_provider` 추가, `container.py`에서 provider별 chat_model 분기.
```python
model_provider: str = "mlx" # "mlx" | "claude" | "openai"
```
**난이도**: 중간 | **임팩트**: 중간
---
## Phase 16 — Docker 컨테이너화 ★☆☆
**배경**: 현재 로컬 전용. 가족이나 지인도 쓸 수 있도록 서버 배포 가능한 형태로 패키징.
**구현 범위**:
```
docker-compose.yml
├── youlbot (Gradio app)
├── qdrant
└── mysql
```
> 주의: MLX는 Apple Silicon 전용이라 서버 배포 시 Phase 15(모델 선택)이 선행되어야 함.
**난이도**: 높음 | **임팩트**: 중간
---
## Phase 17 — 멀티모달 이미지 이해 ★☆☆
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
**제약**: Qwen3-14B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 교체 필요.
**난이도**: 높음 | **임팩트**: 높음 (장기 과제) **난이도**: 높음 | **임팩트**: 높음 (장기 과제)
---
## 추천 진행 순서
```
단기 (1~2주) 중기 (1개월) 장기
──────────────────────── ────────────────────── ──────────────────
Phase 18 Hybrid Search → Phase 15 (모델선택) → Phase 16 (Docker)
Phase 19 Query Rewriting → Phase 20 (RAGAS 평가) → Phase 17 (멀티모달)
```
### 우선순위 매트릭스
| Phase | 상태 | 난이도 | 임팩트 | 추천 순위 |
|-------|------|--------|--------|-----------|
| 버그 1 RAG 중복 | ✅ 완료 | — | — | — |
| 버그 2 이력 미연동 | ✅ 완료 | — | — | — |
| 버그 3 단일 사용자 | ✅ 완료 | — | — | — |
| 버그 4 나이 계산 오류 | ✅ 완료 | — | — | — |
| 버그 5 thinking 체크박스 무효 | ✅ 완료 | — | — | — |
| Phase 4 Web UI | ✅ 완료 | — | — | — |
| Phase 5 장기 사용자 메모리 | ✅ 완료 | — | — | — |
| Phase 6 웹 검색 | ✅ 완료 | — | — | — |
| Phase 7 LangSmith 트레이싱 | ✅ 완료 | — | — | — |
| Phase 9 문서 관리 | ✅ 완료 | — | — | — |
| Phase 10 멀티유저 | ✅ 완료 | — | — | — |
| Phase 11 이력 복원 | ✅ 완료 | — | — | — |
| Phase 12 피드백 | ✅ 완료 | — | — | — |
| Phase 13 Semantic Chunker | ✅ 완료 | — | — | — |
| Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — |
| Phase 13-B Reranker | ✅ 완료 | — | — | — |
| Phase 18 Hybrid Search | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 |
| Phase 19 Query Rewriting | 🔲 신규 | 하 | 중간 | 3순위 |
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 5순위 |
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 6순위 |
| Phase 17 멀티모달 | 🔲 미완 | 높음 | 높음 | 7순위 |
-224
View File
@@ -1,224 +0,0 @@
# 율봇 개발 로드맵 2
## 현재 구현 상태 (Phase 1~11 + Phase 14 완료, 버그 1~3 수정 완료, 모델 업그레이드)
| 영역 | 현황 |
|------|------|
| LLM | Qwen3-14B-4bit (MLX, Apple Silicon) |
| Agent | LangGraph ReAct + Tool Calling + Thinking 모드 |
| RAG | Qdrant + BAAI/bge-m3 임베딩 |
| Tools | `search_documents`, `web_search`, `get_current_date`, `remember_user_info`, `recall_user_info` (5개) |
| UI | CLI + Gradio Web UI |
| Memory | LangGraph MemorySaver (세션 내) + MySQL 대화 저장 + 장기 사용자 프로필 |
| Tracing | LangSmith 트레이싱 |
| Streaming | 비동기 토큰 스트리밍 + `<think>` 블록 파싱 |
| History Compact | 대화 20턴 초과 시 오래된 절반을 LLM으로 자동 요약 (`CompactService`) |
---
## 버그 수정 현황
### ✅ 버그 1 — RAG 중복 수집 (수정 완료)
`IngestionService._delete_by_source()`를 구현해 같은 파일 경로로 저장된 기존 청크를 `ingest()` 시작 시 삭제한다.
### ✅ 버그 2 — LangGraph MemorySaver와 MySQL 이력 미연동 (수정 완료)
`AgentService.__init__`에서 MySQL에 저장된 최근 10턴을 `_pending_history`로 불러온 뒤, 첫 `stream_response()` 호출 시 LangGraph 초기 메시지로 주입한다.
### ✅ 버그 3 — 단일 사용자 전제 (수정 완료)
DB 스키마(`td_conversations.user_id`, `td_user_profile.user_id`)는 `_migrate_schema`로 자동 마이그레이션. `AgentService``user_id` 파라미터 추가, 모든 Repository 호출에 전파. Gradio에 사용자 선택 드롭다운(아록/근혜/도율/하율) 추가 및 사용자별 에이전트 캐시 구현.
---
## ✅ Phase 9 — 문서 관리 (완료)
- `IngestionService._delete_by_source()` — 파일 경로 기반 중복 청크 삭제
- `RetrieverService.list_documents()` — Qdrant scroll로 고유 source 목록 반환
- `RetrieverService.delete_document(source)` — source 기준 청크 전체 삭제
- Gradio "문서 관리" 탭 — 목록 테이블 + 경로 입력 삭제 버튼 + 앱 로드 시 자동 새로고침
---
## ✅ Phase 10 — 멀티유저 지원 (완료)
Bug 3 수정 및 Phase 9 작업과 함께 완전 구현됨.
- DB 마이그레이션: `mysql_service._migrate_schema()``td_conversations`, `td_user_profile` 양쪽에 `user_id` 컬럼 자동 추가
- `ConversationRepository`: `create_conversation(user_id)` / `get_latest_conversation_id(user_id)` — user_id 기반 격리
- `AgentService`: `user_id` 파라미터 추가, 모든 프로필·대화 조회에 전파
- `make_memory_tools(profile_repo, user_id)`: remember/recall 도구가 올바른 사용자 데이터만 접근
- Gradio: 사용자 선택 드롭다운(아록/근혜/도율/하율, 기본값 아록) + `_agent_cache` 사전으로 사용자별 에이전트 분리
---
## ✅ Phase 11 — 대화 이력 복원 (수정 완료)
버그 2와 함께 해결됨.
`AgentService` 초기화 시 MySQL에서 최근 10턴을 `_pending_history`에 로드 → 첫 메시지와 함께 LangGraph에 주입.
```python
# agent_service.py 초기화 (구현됨)
turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
# → HumanMessage / AIMessage 변환 후 _pending_history에 저장
```
---
## Phase 12 — 답변 피드백 & 품질 개선 ★★☆
**배경**: 에이전트가 잘못된 답변을 해도 피드백 루프가 없어 개선이 어려움.
**구현 범위**:
- Gradio 채팅 메시지마다 👍 / 👎 버튼
- `td_feedback` 테이블에 메시지·평점 저장
- LangSmith의 `run_id`와 연결해 피드백을 트레이스에 기록 (`langsmith.Client().create_feedback()`)
```sql
CREATE TABLE td_feedback (
id INT AUTO_INCREMENT PRIMARY KEY,
message TEXT,
response TEXT,
rating TINYINT, -- 1: 좋음, -1: 나쁨
langsmith_run_id VARCHAR(100),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 향상)
---
## Phase 13 — RAG 품질 향상 (Reranker + 청킹 개선) ★★☆ (부분 완료)
**배경**: 현재 고정 크기 청킹 + 벡터 유사도 검색만으로는 관련 없는 청크가 섞일 수 있음.
**✅ Semantic Chunker — 완료**
- `_SemanticSplitter` 클래스 직접 구현 (`services/rag/ingestion_service.py`)
- `langchain-experimental` 사용 없이 numpy + 기존 BAAI/bge-m3 임베딩으로 구현
- 인접 문장 간 코사인 유사도 계산 → 유사도 하위 5% 지점에서 청크 분리
- `config.py`에서 `rag_chunk_size` / `rag_chunk_overlap` 제거 → `semantic_breakpoint_threshold_type` 추가
**🔲 미완 — Reranker**
1. **Reranker 추가**`cross-encoder/ms-marco-MiniLM-L-6-v2`로 검색 결과 재순위
2. **top_k 조정** — 검색 후 rerank → 상위 3개만 LLM에 전달
> 기존 Qdrant 저장 문서는 재등록해야 새 청킹 방식이 적용됨.
**난이도**: 중간 | **임팩트**: 중간 (답변 정확도 향상)
---
## ✅ Phase 14 — 음성 인터페이스 (완료)
**배경**: 육아 중에는 손이 자유롭지 않아 타이핑이 어려움. 음성으로 질문하고 답변을 들을 수 있으면 핵심 사용 시나리오 커버.
**구현 내용**:
- `openai-whisper` (small 모델) — 마이크 녹음 → 한국어 텍스트 변환, 지연 로딩
- macOS `say -v Yuna` — 에이전트 응답을 음성으로 읽어줌 (aiff 파일 경유)
- Gradio "대화" 탭 확장 — 마이크 녹음 + "음성→텍스트 변환" 버튼 + "음성으로 답변 읽기" 체크박스 + TTS 오디오 플레이어
- LLM/Agent 레이어 변경 없음 — 순수 I/O 어댑터로 구현
```python
# app.py — STT
def transcribe_audio(filepath: str) -> str:
result = whisper.load_model("small").transcribe(filepath, language="ko")
return result["text"].strip()
# app.py — TTS
def tts_speak(text: str, voice: str) -> str | None:
subprocess.run(["say", "-v", voice, "-o", tmp.name, text], ...)
```
**config.py 추가**: `whisper_model_size = "small"`, `tts_voice = "Yuna"`
**난이도**: 중간 | **임팩트**: 높음 (핵심 사용 시나리오)
---
## Phase 15 — 예방접종·건강검진 알림 스케줄러 ★★☆
**배경**: 아이 생년을 기억하고 있으므로, 예방접종 일정(BCG, DTaP 등)을 자동 계산해 알림을 줄 수 있음. 율봇의 차별화 포인트.
**구현 방식**:
- `td_user_profile`에서 아이 생년 조회 → 예방접종 스케줄 계산 Tool
- Gradio "건강 일정" 탭: 달력형 일정 표시
- APScheduler로 당일 알림 (또는 Gradio 시작 시 오늘 일정 배너)
```python
@tool
def get_vaccination_schedule(birth_year: int, birth_month: int) -> str:
"""아이 생년월을 기반으로 예방접종 일정을 계산합니다."""
```
**난이도**: 중간 | **임팩트**: 높음 (육아 특화 차별화)
---
## Phase 16 — 모델 선택 (Claude API / OpenAI 옵션) ★☆☆
**배경**: 로컬 MLX 모델은 Apple Silicon 전용. 원격 접속 시나리오나 더 높은 품질이 필요할 때 Claude API/OpenAI를 선택할 수 있으면 유연성 확보.
**구현 방식**: `config.py``model_provider` 추가, `container.py`에서 provider별 chat_model 분기.
```python
model_provider: str = "mlx" # "mlx" | "claude" | "openai"
```
**난이도**: 중간 | **임팩트**: 중간
---
## Phase 17 — Docker 컨테이너화 ★☆☆
**배경**: 현재 로컬 전용. 가족이나 지인도 쓸 수 있도록 서버 배포 가능한 형태로 패키징.
**구현 범위**:
```
docker-compose.yml
├── youlbot (Gradio app)
├── qdrant
└── mysql
```
> 주의: MLX는 Apple Silicon 전용이라 서버 배포 시 Phase 16(모델 선택)이 선행되어야 함.
**난이도**: 높음 | **임팩트**: 중간
---
## Phase 18 — 멀티모달 이미지 이해 ★☆☆
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
**제약**: Qwen3-8B는 이미지 미지원 → `mlx-community/Qwen2.5-VL-7B-Instruct-4bit` 교체 필요.
**난이도**: 높음 | **임팩트**: 높음 (장기 과제)
---
## 추천 진행 순서
```
단기 (1~2주) 중기 (1개월) 장기
──────────────── ────────────────── ──────────────
Phase 14 (음성) → Phase 13 (RAG품질) → Phase 17 (Docker)
Phase 15 (알림) Phase 16 (모델선택) Phase 18 (멀티모달)
Phase 12 (피드백)
```
### 우선순위 매트릭스
| Phase | 상태 | 난이도 | 임팩트 | 추천 순위 |
|-------|------|--------|--------|-----------|
| 버그 1 RAG 중복 | ✅ 완료 | — | — | — |
| 버그 2 이력 미연동 | ✅ 완료 | — | — | — |
| 버그 3 단일 사용자 | ✅ 완료 | — | — | — |
| Phase 9 문서 관리 | ✅ 완료 | — | — | — |
| Phase 10 멀티유저 | ✅ 완료 | — | — | — |
| Phase 11 이력 복원 | ✅ 완료 | — | — | — |
| Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — |
| Phase 15 예방접종 알림 | 🔲 미완 | 중간 | 높음 | ⭐ 2순위 |
| Phase 12 피드백 | 🔲 미완 | 중간 | 중간 | 3순위 |
| Phase 13 RAG 품질 (청킹 완료, Reranker 미완) | 🔲 진행 중 | 중간 | 중간 | 4순위 |
| Phase 16 모델 선택 | 🔲 미완 | 중간 | 중간 | 5순위 |
| Phase 17 Docker | 🔲 미완 | 높음 | 중간 | 6순위 |
| Phase 18 멀티모달 | 🔲 미완 | 높음 | 높음 | 7순위 |
+150
View File
@@ -0,0 +1,150 @@
# 사고 과정 표시 기능 분석 보고서
**테스트 일시**: 2026-05-28
**테스트 질문**: "논문 결과가 어떻게 돼?"
**앱 버전**: http://localhost:7860
---
## 테스트 결과 요약
| 항목 | 사고 과정 OFF | 사고 과정 ON |
|------|-------------|------------|
| 총 소요 시간 | 200.5s | 233.7s |
| 1단계 (질문 분석) | 59.8s | 77.4s |
| 사고 과정 블록 표시 | 없음 | **없음 (버그)** |
| 최종 답변 내용 | 6개 섹션, 동일 | 6개 섹션, 동일 |
| 답변 차이 | **없음** | **없음** |
**결론: ON/OFF 체크박스가 현재 아무런 시각적 차이를 만들지 않는다.**
---
## 실제 응답 (두 경우 모두 동일)
```
[LangGraph → agent: 질문 분석 중] (59.84s)
문서 검색 중... ("어머니의 반응성 상호작용이 아동의 중심축 행동과 지능 및 다중지능 발달에 미치는 영향")
[LangGraph → tools: 도구 실행 중] (71.18s)
[결과: 3개 문서 반환 → agent 복귀]
[문서 검색: "어머니의 반응성 상호작용이 아동의 중심축 행동과 지능 및 다중지능 발달에 미치는 영향"]
→ [문서 1] 1, 81-99 어머니의 반응성 상호작용이 아동의 중심축 행동...
→ [문서 2] 김정미․정은주/ 어머니의반응성상호작용이...
→ [문서 3] 김정미․정은주/ 어머니의반응성상호작용이...
[LangGraph → agent: 검색 결과 반영 중] (132.91s)
[LangGraph → agent: 최종 답변 생성]
본 연구의 결과는 다음과 같이 요약할 수 있습니다:
1. 어머니의 반응성 상호작용과 아동의 중심축 행동 간의 관계
...
```
사고 과정 ON을 선택했을 때 기대되는 `[사고 과정]...[/사고 과정]` 블록이 나타나지 않음.
---
## 원인 분석
### 구조적 문제
```
LLM 생성 흐름:
<think>사고 내용...</think> → 최종 답변 텍스트
↓ ↓
AIMessageChunk AIMessageChunk
content="" content="본 연구의..."
additional_kwargs= additional_kwargs={}
{"thinking": "..."}
```
#### 핵심 병목: `call_model` 내부 누적 방식
`agent_service.py:111``call_model` 함수는 LLM 청크를 내부에서 모두 누적한 뒤 **단일 `AIMessage`로 반환**한다:
```python
async for chunk in llm_with_tools.astream(msgs, config):
thinking_acc += chunk.additional_kwargs.get("thinking", "")
content_acc += chunk.content or ""
...
return {"messages": [AIMessage(content=content_acc, additional_kwargs={"thinking": thinking_acc})]}
```
LangGraph `stream_mode="messages"`는 내부 LLM 청크를 외부로 통과시키지만,
사고 청크(`content=""`, `additional_kwargs={"thinking":"..."}`)는
빈 content로 인해 **LangGraph 스트림에서 필터링**되거나 전달되지 않는 것으로 보인다.
결과적으로 `stream_response`가 수신하는 청크:
| 수신되는 것 | 수신 안 되는 것 |
|-----------|--------------|
| content가 있는 `AIMessageChunk` | **thinking이 있는 `AIMessageChunk`** |
| 최종 `AIMessage` (thinking 포함) | |
#### 왜 최종 `AIMessage`의 thinking도 표시 안 되는가
`stream_response:221`의 조건이 이를 차단한다:
```python
elif node == "agent" and isinstance(chunk, AIMessage):
if not content_started and not thinking_open: # ← content_started=True면 전체 스킵
thinking = chunk.additional_kwargs.get("thinking", "")
if thinking and _think_verbose:
yield "\n[사고 과정]\n"
...
```
content `AIMessageChunk`들이 먼저 처리되면서 `content_started = True`가 세팅됨.
최종 `AIMessage`가 도착할 때는 이미 `content_started=True`라 전체 블록이 실행되지 않는다.
---
## 적용된 버그 수정 (2026-05-28)
### 수정 1: `agent_service.py:223` — 인스턴스 변수 참조 오류
```diff
- if thinking and self._think_verbose: # 항상 False (config 기본값)
+ if thinking and _think_verbose: # 체크박스 값 사용
```
이 수정은 엣지케이스(content 스트리밍 없이 최종 AIMessage만 도달하는 경우)에서 체크박스를 올바르게 반영한다.
그러나 정상 스트리밍 경로에서는 `content_started=True` 조건이 여전히 블록을 막는다.
---
## 제안하는 추가 수정
`stream_response`에서 최종 `AIMessage`의 thinking을 저장해두고,
스트리밍 루프 종료 후 표시하는 방식이 가장 간단하다:
```python
# 루프 내 - AIMessage 처리 시 thinking 저장
elif node == "agent" and isinstance(chunk, AIMessage):
if not thinking_open:
deferred_thinking = chunk.additional_kwargs.get("thinking", "")
if chunk.content and not content_started:
...
# 루프 종료 후
if deferred_thinking and _think_verbose:
yield "\n\n---\n**[사고 과정]**\n\n"
yield deferred_thinking
yield "\n\n**[/사고 과정]**\n"
```
> 단, thinking이 답변 뒤에 표시되는 UX 트레이드오프가 있다.
> 답변 전에 표시하려면 `call_model`을 리팩토링해 thinking을 먼저 스트리밍해야 한다.
---
## 소요 시간 비교 참고
ON이 OFF보다 약 33초 더 걸린 점은 주목할 만하다.
`enable_thinking=True`(config 설정)로 모델이 항상 thinking을 생성하므로,
ON/OFF 간 소요 시간 차이는 모델 비결정성(temperature)에 의한 자연 편차로 보인다.
체크박스는 표시 여부만 제어하며 모델 동작 자체는 바꾸지 않는다.
+81 -16
View File
@@ -6,6 +6,7 @@ from typing import AsyncIterator
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.memory import MemorySaver
from langgraph.config import get_stream_writer
from langgraph.graph import START, MessagesState, StateGraph from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition from langgraph.prebuilt import ToolNode, tools_condition
@@ -43,6 +44,7 @@ class AgentService:
self._conv_id: int | None = None self._conv_id: int | None = None
self._pending_history: list = [] self._pending_history: list = []
self._user_id = user_id self._user_id = user_id
self._last_run_id: str | None = None
if conversation_repository: if conversation_repository:
try: try:
@@ -74,18 +76,52 @@ class AgentService:
llm_with_tools = chat_model.bind_tools(tools) llm_with_tools = chat_model.bind_tools(tools)
async def call_model(state: MessagesState, config: RunnableConfig) -> dict: async def call_model(state: MessagesState, config: RunnableConfig) -> dict:
system_content = self._system_prompt from datetime import date
system_content = f"오늘 날짜: {date.today().isoformat()}\n\n" + self._system_prompt
if self._profile_repo: if self._profile_repo:
profile = self._profile_repo.get_all(self._user_id) profile = self._profile_repo.get_all(self._user_id)
if profile: if profile:
lines = "\n".join(f"- {k}: {v}" for k, v in profile.items()) import re
system_content += f"\n\n## 사용자 정보 (이전 대화에서 기억된 내용)\n{lines}" from datetime import date
today = date.today()
current_year = today.year
_DATE_KEYS = ("생년월일", "생년", "생일")
lines = []
for k, v in profile.items():
if any(term in k for term in _DATE_KEYS):
full_date = re.search(r'(\d{4})[년\-/.]\s*(\d{1,2})[월\-/.]\s*(\d{1,2})', v)
year_only = re.search(r'\b(19|20)\d{2}\b', v)
age_key = re.sub(r'생년월일|생년|생일', '나이', k)
if full_date:
by, bm, bd = int(full_date.group(1)), int(full_date.group(2)), int(full_date.group(3))
korean_age = current_year - by + 1
intl_age = current_year - by - (1 if today < date(current_year, bm, bd) else 0)
lines.append(f"- {age_key}: 한국 나이 {korean_age}세, 만 {intl_age}")
elif year_only:
by = int(year_only.group())
korean_age = current_year - by + 1
intl_age = current_year - by
lines.append(f"- {age_key}: 한국 나이 {korean_age}세, 만 {intl_age}~{intl_age - 1}세 (생일에 따라 다름)")
else:
lines.append(f"- {k}: {v}")
else:
lines.append(f"- {k}: {v}")
system_content += f"\n\n## 사용자 정보 (이전 대화에서 기억된 내용)\n" + "\n".join(lines)
msgs = [SystemMessage(content=system_content)] + state["messages"] msgs = [SystemMessage(content=system_content)] + state["messages"]
thinking_acc, content_acc, tool_calls_acc = "", "", [] thinking_acc, content_acc, tool_calls_acc = "", "", []
async for chunk in llm_with_tools.astream(msgs, config): try:
writer = get_stream_writer()
except Exception:
writer = None
# 체크박스 값을 모델의 enable_thinking으로 전달 (런타임 오버라이드)
show_thinking = config.get("configurable", {}).get("show_thinking", False)
_llm = llm_with_tools.bind(enable_thinking=show_thinking) if show_thinking != chat_model.enable_thinking else llm_with_tools
async for chunk in _llm.astream(msgs, config):
t = chunk.additional_kwargs.get("thinking", "") t = chunk.additional_kwargs.get("thinking", "")
if t: if t:
thinking_acc += t thinking_acc += t
if writer:
writer({"__thinking": t})
if chunk.content and isinstance(chunk.content, str): if chunk.content and isinstance(chunk.content, str):
content_acc += chunk.content content_acc += chunk.content
if chunk.tool_calls: if chunk.tool_calls:
@@ -107,13 +143,18 @@ class AgentService:
self._agent = builder.compile(checkpointer=MemorySaver()) self._agent = builder.compile(checkpointer=MemorySaver())
@property @property
def _config(self) -> dict: def last_run_id(self) -> str | None:
return {"configurable": {"thread_id": self._thread_id}} return self._last_run_id
def _make_config(self, show_thinking: bool = False) -> dict:
return {"configurable": {"thread_id": self._thread_id, "show_thinking": show_thinking}}
async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str]: async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str]:
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다.""" """사용자 입력을 받아 응답 토큰을 순서대로 yield한다."""
_think_verbose = show_thinking if show_thinking is not None else self._think_verbose _think_verbose = show_thinking if show_thinking is not None else self._think_verbose
self._source_buffer.clear() self._source_buffer.clear()
run_id = uuid.uuid4()
run_config = {**self._make_config(_think_verbose), "run_id": str(run_id)}
# 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입 # 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입
if self._pending_history: if self._pending_history:
@@ -130,13 +171,42 @@ class AgentService:
content_started = False # 노드 당 레이블 1회 출력 제어 content_started = False # 노드 당 레이블 1회 출력 제어
start_time = time.perf_counter() start_time = time.perf_counter()
async for chunk, metadata in self._agent.astream( async for stream_event in self._agent.astream(
messages, self._config, stream_mode="messages" messages, run_config, stream_mode=["messages", "custom"]
): ):
mode, data = stream_event
# ── custom 이벤트 — call_model writer가 emit한 thinking 토큰 ──
if mode == "custom":
if isinstance(data, dict) and "__thinking" in data:
# thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신
if "agent" != prev_node:
if thinking_open:
yield "\n[/사고 과정]\n"
thinking_open = False
content_started = False
if lg:
elapsed = time.perf_counter() - start_time
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"
prev_node = "agent"
if _think_verbose:
if not thinking_open:
yield "\n[사고 과정]\n"
thinking_open = True
yield data["__thinking"]
continue
# ── messages 이벤트 ──────────────────────────────────────
chunk, metadata = data
node = metadata.get("langgraph_node", "") node = metadata.get("langgraph_node", "")
# ── 노드 전환 시 플래그 리셋 + 레이블 출력 ────────────── # ── 노드 전환 시 플래그 리셋 + 레이블 출력 ──────────────
# (agent 레이블은 custom 이벤트 핸들러에서 이미 처리될 수 있으므로 중복 방지)
if node != prev_node: if node != prev_node:
if thinking_open:
yield "\n[/사고 과정]\n"
thinking_open = False
content_started = False content_started = False
if lg: if lg:
if node == "agent": if node == "agent":
@@ -150,13 +220,6 @@ class AgentService:
# ── agent 노드 — AIMessageChunk만 처리 (중복 방지) ────── # ── agent 노드 — AIMessageChunk만 처리 (중복 방지) ──────
if node == "agent" and isinstance(chunk, 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 chunk.tool_calls:
if thinking_open: if thinking_open:
yield "\n[/사고 과정]\n" yield "\n[/사고 과정]\n"
@@ -188,7 +251,7 @@ class AgentService:
elif node == "agent" and isinstance(chunk, AIMessage): elif node == "agent" and isinstance(chunk, AIMessage):
if not content_started and not thinking_open: if not content_started and not thinking_open:
thinking = chunk.additional_kwargs.get("thinking", "") thinking = chunk.additional_kwargs.get("thinking", "")
if thinking and self._think_verbose: if thinking and _think_verbose:
yield "\n[사고 과정]\n" yield "\n[사고 과정]\n"
yield thinking yield thinking
yield "\n[/사고 과정]\n" yield "\n[/사고 과정]\n"
@@ -222,6 +285,8 @@ class AgentService:
if thinking_open: if thinking_open:
yield "\n[/사고 과정]\n" yield "\n[/사고 과정]\n"
self._last_run_id = str(run_id)
# 대화 내용을 MySQL에 저장 # 대화 내용을 MySQL에 저장
if self._conv_repo and self._conv_id and response_content: if self._conv_repo and self._conv_id and response_content:
try: try:
+6 -6
View File
@@ -5,7 +5,7 @@ from langchain_core.tools import tool
@tool @tool
def get_current_date() -> str: def get_current_date() -> str:
"""오늘 날짜를 반환합니다. 날짜·기간 관련 질문에 사용하세요.""" """오늘 날짜를 반환합니다. 나이 계산, 날짜 비교 등 현재 날짜가 필요할 때 반드시 먼저 호출하세요."""
return date.today().isoformat() return date.today().isoformat()
@@ -24,15 +24,14 @@ def web_search(query: str) -> str:
def make_retriever_tool(retriever_service): def make_retriever_tool(retriever_service):
"""as_retriever()를 사용하는 단순 검색 Tool (source_buffer 없음).""" """retriever_service.search()를 사용하는 검색 Tool (Reranker 자동 적용)."""
retriever = retriever_service.as_retriever()
@tool @tool
def search_documents(query: str) -> str: def search_documents(query: str) -> str:
"""등록된 문서(논문, 육아 가이드, 금융 자료 등)에서 관련 정보를 검색합니다. """등록된 문서(논문, 육아 가이드, 금융 자료 등)에서 관련 정보를 검색합니다.
육아·금융 관련 질문이 오면 자신의 지식으로 답하기 전에 반드시 이 도구를 먼저 호출하세요. 육아·금융 관련 질문이 오면 자신의 지식으로 답하기 전에 반드시 이 도구를 먼저 호출하세요.
등록된 문서가 없거나 검색 결과가 없을 때만 자신의 학습 지식을 보조적으로 활용합니다.""" 등록된 문서가 없거나 검색 결과가 없을 때만 자신의 학습 지식을 보조적으로 활용합니다."""
docs = retriever.invoke(query) docs = retriever_service.search(query)
if not docs: if not docs:
return "관련 문서를 찾을 수 없습니다." return "관련 문서를 찾을 수 없습니다."
return "\n\n".join( return "\n\n".join(
@@ -48,8 +47,9 @@ def make_memory_tools(profile_repo, user_id: str = "default"):
@tool @tool
def remember_user_info(key: str, value: str) -> str: def remember_user_info(key: str, value: str) -> str:
"""사용자 정보를 영구 저장합니다. 다음 대화에도 기억해야 할 정보를 저장하세요. """사용자 정보를 영구 저장합니다. 다음 대화에도 기억해야 할 정보를 저장하세요.
- 아이 나이는 반드시 '생년(출생연도)'으로 저장하세요. 나이는 매년 바뀌지만 생년은 영구적입니다. - 아이 생년월일은 전체 날짜로 저장하세요. 날짜를 모르면 연도만이라도 저장하세요.
예: key='첫째_이름' value='신도율', key='첫째_생년' value='2020' 예: key='첫째_이름' value='신도율', key='첫째_생년월일' value='2020년 6월 19일'
연도만 알 경우: key='첫째_생년' value='2020'
- 기타 key 예시: 재정_목표, 거주지, 직업, 자녀수""" - 기타 key 예시: 재정_목표, 거주지, 직업, 자녀수"""
profile_repo.remember(key, value, user_id=user_id) profile_repo.remember(key, value, user_id=user_id)
return f"'{key}' 정보를 기억했습니다: {value}" return f"'{key}' 정보를 기억했습니다: {value}"
+19
View File
@@ -0,0 +1,19 @@
class FeedbackRepository:
def __init__(self, db):
self._db = db
def save_feedback(
self,
user_id: str,
message: str,
response: str,
rating: int,
langsmith_run_id: str | None = None,
) -> None:
self._db.execute_write(
"""
INSERT INTO td_feedback (user_id, message, response, rating, langsmith_run_id)
VALUES (%s, %s, %s, %s, %s)
""",
(user_id, message, response, rating, langsmith_run_id),
)
+11
View File
@@ -99,6 +99,17 @@ class DatabaseService:
UNIQUE KEY uq_user_key (user_id, key_name) UNIQUE KEY uq_user_key (user_id, key_name)
) )
""") """)
cursor.execute("""
CREATE TABLE IF NOT EXISTS td_feedback (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(50) NOT NULL DEFAULT 'default',
message TEXT,
response TEXT,
rating TINYINT,
langsmith_run_id VARCHAR(100),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit() conn.commit()
self._migrate_schema(conn) self._migrate_schema(conn)
+14 -5
View File
@@ -82,7 +82,13 @@ class MlxChatModel(BaseChatModel):
}) })
return result return result
def _build_prompt(self, messages: List[BaseMessage], tools: Optional[list] = None) -> str: def _build_prompt(
self,
messages: List[BaseMessage],
tools: Optional[list] = None,
enable_thinking: Optional[bool] = None,
) -> str:
_enable_thinking = enable_thinking if enable_thinking is not None else self.enable_thinking
kwargs: dict = { kwargs: dict = {
"tokenize": False, "tokenize": False,
"add_generation_prompt": True, "add_generation_prompt": True,
@@ -91,7 +97,7 @@ class MlxChatModel(BaseChatModel):
kwargs["tools"] = tools kwargs["tools"] = tools
# Qwen3 thinking 모드 — 지원하지 않는 모델은 무시됨 # Qwen3 thinking 모드 — 지원하지 않는 모델은 무시됨
try: try:
kwargs["enable_thinking"] = self.enable_thinking kwargs["enable_thinking"] = _enable_thinking
return self._tokenizer.apply_chat_template(self._to_chat_dicts(messages), **kwargs) return self._tokenizer.apply_chat_template(self._to_chat_dicts(messages), **kwargs)
except TypeError: except TypeError:
kwargs.pop("enable_thinking") kwargs.pop("enable_thinking")
@@ -145,7 +151,8 @@ class MlxChatModel(BaseChatModel):
from mlx_lm import generate from mlx_lm import generate
tools = kwargs.get("tools") tools = kwargs.get("tools")
prompt = self._build_prompt(messages, tools) enable_thinking_override = kwargs.pop("enable_thinking", None)
prompt = self._build_prompt(messages, tools, enable_thinking=enable_thinking_override)
text = generate( text = generate(
self._model, self._model,
self._tokenizer, self._tokenizer,
@@ -169,7 +176,9 @@ class MlxChatModel(BaseChatModel):
from mlx_lm import stream_generate from mlx_lm import stream_generate
tools = kwargs.get("tools") tools = kwargs.get("tools")
prompt = self._build_prompt(messages, tools) enable_thinking_override = kwargs.pop("enable_thinking", None)
_enable_thinking = enable_thinking_override if enable_thinking_override is not None else self.enable_thinking
prompt = self._build_prompt(messages, tools, enable_thinking=_enable_thinking)
OPEN_THINK = "<think>" OPEN_THINK = "<think>"
CLOSE_THINK = "</think>" CLOSE_THINK = "</think>"
@@ -178,7 +187,7 @@ class MlxChatModel(BaseChatModel):
SAFE = max(len(OPEN_THINK), len(CLOSE_THINK), len(OPEN_TOOL), len(CLOSE_TOOL)) SAFE = max(len(OPEN_THINK), len(CLOSE_THINK), len(OPEN_TOOL), len(CLOSE_TOOL))
# enable_thinking=False 모델은 <think> 블록을 생성하지 않으므로 post_think에서 시작 # enable_thinking=False 모델은 <think> 블록을 생성하지 않으므로 post_think에서 시작
state = "pre_think" if self.enable_thinking else "post_think" state = "pre_think" if _enable_thinking else "post_think"
buf = "" buf = ""
out: list[ChatGenerationChunk] = [] out: list[ChatGenerationChunk] = []
+7 -52
View File
@@ -1,59 +1,10 @@
import re
import numpy as np
from langchain_community.document_loaders import PDFPlumberLoader, TextLoader from langchain_community.document_loaders import PDFPlumberLoader, TextLoader
from langchain_core.documents import Document from langchain_experimental.text_splitter import SemanticChunker
from langchain_qdrant import QdrantVectorStore from langchain_qdrant import QdrantVectorStore
from qdrant_client import QdrantClient from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue, FilterSelector from qdrant_client.models import Filter, FieldCondition, MatchValue, FilterSelector
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-10))
class _SemanticSplitter:
"""문장 임베딩 유사도 기반 청커.
인접 문장 간 코사인 유사도를 계산하고, 유사도가 낮은(= 의미 전환) 지점에서 청크를 분리한다.
breakpoint_percentile=95이면 유사도 하위 5% 지점이 분리 경계가 된다.
"""
_SENTENCE_RE = re.compile(r"(?<=[.!?。!?])\s+")
def __init__(self, embeddings, breakpoint_percentile: int = 95):
self._embeddings = embeddings
self._percentile = breakpoint_percentile
def split_documents(self, docs: list[Document]) -> list[Document]:
result = []
for doc in docs:
for chunk_text in self._split_text(doc.page_content):
result.append(Document(page_content=chunk_text, metadata=doc.metadata))
return result
def _split_text(self, text: str) -> list[str]:
sentences = [s for s in self._SENTENCE_RE.split(text.strip()) if s.strip()]
if len(sentences) <= 1:
return [text.strip()] if text.strip() else []
vecs = np.array(self._embeddings.embed_documents(sentences))
similarities = [_cosine_similarity(vecs[i], vecs[i + 1]) for i in range(len(vecs) - 1)]
threshold = float(np.percentile(similarities, 100 - self._percentile))
breakpoints = [i + 1 for i, s in enumerate(similarities) if s < threshold]
chunks, start = [], 0
for bp in breakpoints:
chunk = " ".join(sentences[start:bp]).strip()
if chunk:
chunks.append(chunk)
start = bp
tail = " ".join(sentences[start:]).strip()
if tail:
chunks.append(tail)
return chunks
class IngestionService: class IngestionService:
"""문서를 의미 단위 청크로 분할해 Qdrant에 저장하는 수집 파이프라인.""" """문서를 의미 단위 청크로 분할해 Qdrant에 저장하는 수집 파이프라인."""
@@ -63,12 +14,16 @@ class IngestionService:
qdrant_url: str, qdrant_url: str,
collection_name: str, collection_name: str,
breakpoint_threshold_type: str = "percentile", breakpoint_threshold_type: str = "percentile",
buffer_size: int = 1,
): ):
self._embeddings = embeddings self._embeddings = embeddings
self._qdrant_url = qdrant_url self._qdrant_url = qdrant_url
self._collection_name = collection_name self._collection_name = collection_name
# breakpoint_threshold_type은 향후 확장용으로 수용 (현재는 percentile 방식 고정) self._splitter = SemanticChunker(
self._splitter = _SemanticSplitter(embeddings, breakpoint_percentile=95) embeddings=embeddings,
breakpoint_threshold_type=breakpoint_threshold_type,
buffer_size=buffer_size,
)
self._client = QdrantClient(url=qdrant_url) self._client = QdrantClient(url=qdrant_url)
def _delete_by_source(self, source_path: str) -> None: def _delete_by_source(self, source_path: str) -> None:
+19
View File
@@ -0,0 +1,19 @@
from langchain_core.documents import Document
class RerankService:
"""Cross-Encoder 기반 재순위(Reranker) 서비스."""
def __init__(self, model_id: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"):
from sentence_transformers import CrossEncoder
print(f"Reranker 로딩 중: {model_id}")
self._model = CrossEncoder(model_id)
print("Reranker 로딩 완료")
def rerank(self, query: str, docs: list[Document], top_k: int) -> list[Document]:
if not docs:
return docs
pairs = [(query, doc.page_content) for doc in docs]
scores = self._model.predict(pairs)
ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
return [doc for _, doc in ranked[:top_k]]
+9 -1
View File
@@ -13,6 +13,8 @@ class RetrieverService:
qdrant_url: str, qdrant_url: str,
collection_name: str, collection_name: str,
top_k: int, top_k: int,
reranker=None,
rerank_fetch_k: int = 10,
): ):
self._client = QdrantClient(url=qdrant_url) self._client = QdrantClient(url=qdrant_url)
self._collection_name = collection_name self._collection_name = collection_name
@@ -22,12 +24,18 @@ class RetrieverService:
embedding=embeddings, embedding=embeddings,
) )
self._top_k = top_k self._top_k = top_k
self._reranker = reranker
self._rerank_fetch_k = rerank_fetch_k
def as_retriever(self): def as_retriever(self):
return self._store.as_retriever(search_kwargs={"k": self._top_k}) return self._store.as_retriever(search_kwargs={"k": self._top_k})
def search(self, query: str) -> list[Document]: def search(self, query: str) -> list[Document]:
return self._store.similarity_search(query, k=self._top_k) fetch_k = self._rerank_fetch_k if self._reranker else self._top_k
docs = self._store.similarity_search(query, k=fetch_k)
if self._reranker:
docs = self._reranker.rerank(query, docs, top_k=self._top_k)
return docs
def list_documents(self) -> list[str]: def list_documents(self) -> list[str]:
"""Qdrant에 저장된 고유 파일 경로 목록을 반환한다.""" """Qdrant에 저장된 고유 파일 경로 목록을 반환한다."""