Compare commits

..

21 Commits

Author SHA1 Message Date
shinalok 0db20ca829 docs: .env.example 전면 업데이트 — 누락된 설정값 모두 추가
기존에 빠져 있던 항목 추가:
- LLM: ENABLE_THINKING, THINK_VERBOSE
- Qdrant: QDRANT_URL, QDRANT_COLLECTION
- Embedding: EMBEDDING_MODEL_ID, EMBEDDING_DEVICE
- RAG: RAG_TOP_K, RAG_VERBOSE, RAG_SHOW_SOURCES, LANGGRAPH_VERBOSE
- Semantic Chunker: SEMANTIC_BREAKPOINT_THRESHOLD_TYPE, SEMANTIC_BUFFER_SIZE
- Reranker: RERANKER_ENABLED, RERANKER_MODEL_ID, RERANKER_FETCH_K
- Voice: WHISPER_MODEL_SIZE, TTS_VOICE
- Vision: VISION_ENABLED, VISION_MODEL_ID, VISION_MAX_TOKENS
- IDEA-1: CONV_RAG_ENABLED
- IDEA-2: TELEGRAM_BOT_TOKEN, TELEGRAM_USER_MAP
- IDEA-5: CRAG_ENABLED
- IDEA-8: GRAPH_ENABLED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:55:13 +09:00
shinalok a05d2f474e IDEA-8: GraphRAG — NetworkX 기반 지식 그래프
- td_knowledge_graph 테이블 (user_id, subject, relation, object 트리플)
- GraphService: MultiDiGraph 인메모리 캐시 + MySQL 영속화
- add_relation / query_entity LangChain 도구
- call_model에 그래프 요약 자동 주입 (시스템 프롬프트)
- GRAPH_ENABLED=true 환경변수로 활성화
- requirements.txt에 networkx>=3.0 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:08:39 +09:00
shinalok 0b50444e43 IDEA-2/1/5/7: 스마트 알림, 대화 기반 RAG, CRAG, 파라미터 자동 튜닝
- IDEA-2 스마트 알림: td_reminders 테이블, set_reminder/list_reminders 도구,
  SchedulerService(asyncio 60초 루프, D-7/D-1/D-0 Telegram push),
  FastAPI lifespan 연동, GET /reminders/{user_id} 엔드포인트

- IDEA-1 대화 기반 RAG: IngestionService.store_text() 추가,
  AgentService._maybe_index_conversation() — 응답 후 LLM 판단 → Qdrant 저장
  (CONV_RAG_ENABLED=true 활성화, background task로 응답 속도 무관)

- IDEA-5 CRAG: AgentState에 crag_fallback_used 플래그 추가,
  crag_check LangGraph 노드 — search_documents 결과 없으면 web_search 자동 주입,
  route_after_crag으로 fallback 1회 루프 제어 (CRAG_ENABLED=true 활성화)

- IDEA-7 RAG Auto-Eval: eval/auto_tune.py — API 서버 없이 파라미터 조합별
  context_precision/recall 비교, 최적 설정 추천

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:04:05 +09:00
shinalok c264573a67 Fix: ToolNode에 미정의 변수 tools 대신 self._base_tools 사용
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:08:11 +09:00
shinalok 68f741af72 Phase 17: Multimodal image understanding via analyze_image tool
Dual-model approach (C): Qwen3-8B handles conversation, Qwen2.5-VL-7B
analyzes images on demand via analyze_image LangChain tool.

- services/model/mlx_vision_model.py: MlxVisionModel (mlx-vlm wrapper, lazy load)
- services/agent/tools.py: make_vision_tool(vision_model, image_path)
- agent_service.py: stream_response(image_path=None), dynamic tool binding
  via config["image_path"] — thread-safe per-request rebinding
- container.py: vision_model Singleton provider
- config.py: vision_enabled, vision_model_id, vision_max_tokens
- api.py: image_base64 in ChatRequest, decode to temp file, cleanup after stream

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:52:10 +09:00
shinalok bdb6fd83c4 Fix RAGAS eval: increase timeout for local LLM, safe score extraction
- RunConfig(timeout=600, max_workers=1): local Qwen3 needs more than 60s/call
- Extract scores from df.mean() instead of result[key] to handle NaN safely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 19:41:32 +09:00
shinalok a2dff825ad Fix: use Container class (not container instance) in eval script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:43:51 +09:00
shinalok 3faf8b09ce Phase 20: RAGAS evaluation suite
- eval/run_ragas.py: collect contexts (RetrieverService) + answers (/chat API),
  evaluate with faithfulness / answer_relevancy / context_recall / context_precision
- eval/dataset.jsonl: 5 Korean Q&A pairs for initial evaluation
- eval/requirements.txt: ragas==0.2.9, datasets, langchain-google-vertexai
- Evaluator LLM priority: OpenAI > Anthropic > local Qwen3
- Runtime shim for ragas 0.2 / langchain-community 0.4+ vertexai incompatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:11:00 +09:00
shinalok 589946ab36 Phase 25: RAG sources in collapsible box + Korean thinking enforcement
- agent_service: yield {"__sources": [...]} token instead of __meta for sources
- agent_service: inject Korean-only rule at top of system message before date
- config.py: strengthen Korean thinking instruction in system prompt
- ROADMAP: add Phase 25 entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:14:59 +09:00
shinalok efc5fe6961 Update ROADMAP: Phase 24 thinking UI + bug 6 TTS fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:55:15 +09:00
shinalok c0992374af Emit __start signal at call_model entry for instant UI feedback
call_model now emits writer({"__start": True}) before LLM inference.
stream_response() converts it to {"__status": label} — distinct from
__meta so the UI shows it immediately without accumulating in the log.
Removes the 10-second silent wait before the first progress message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:08:30 +09:00
shinalok c061aef220 Separate thinking tokens from meta — use __thinking key for display
stream_response() now yields {"__thinking": str} for thinking content
instead of {"__meta": str}. Removed [사고 과정]/[/사고 과정] marker
yields; consumers handle thinking display independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:33:06 +09:00
shinalok 67821250fd Tag metadata tokens as {\"__meta\"} to separate TTS from progress messages
stream_response() now yields plain str for actual answer tokens and
{\"__meta\": str} dicts for progress/thinking/source metadata.
Consumers (WebUI, Telegram) can filter __meta tokens for TTS/display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:08:14 +09:00
shinalok e9a6d00059 Phase 23: WebUI separation — api.py + youlbot-webui project
- api.py: /chat SSE done event now includes run_id for feedback linking
  (format: data: {"__done": true, "run_id": "uuid"})
- api.py: Add POST /feedback endpoint with LangSmith integration
- ROADMAP.md: Add Phase 23 documentation

Note: youlbot-webui/ created at /Users/sal/workspace/youlbot-webui/
(separate project, tracked independently)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 22:00:22 +09:00
shinalok 2e9e8a33fe Implement Phase 22: REST API (FastAPI + SSE streaming)
- api.py: FastAPI 앱 신규 생성
  - GET /health, POST /chat (SSE), POST /reset, POST /ingest, GET/DELETE /documents
  - SSE 포맷: data: <JSON 토큰>\n\n / data: [DONE]\n\n
  - Bearer Token 인증 (API_TOKEN 미설정 시 개발 모드)
  - user_id 파라미터로 멀티유저 지원 (기존 AgentService·DB 구조 재사용)
- config.py: api_token 필드 추가
- app.py: _get_agent에 query_rewrite_enabled 누락 수정
- requirements.txt: fastapi, uvicorn[standard], python-multipart 추가
- ROADMAP: Phase 22 , Telegram Bot 클라이언트 예시 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 20:11:49 +09:00
shinalok 432cc9565c Add Phase 21 (Telegram Bot) and Phase 22 (REST API) to ROADMAP
- Phase 21: python-telegram-bot 직접 AgentService 연결 (동일 머신)
  - /start, /reset 커맨드, 스트리밍 edit_message_text, Telegram user_id → user_id 매핑
- Phase 22: FastAPI + SSE 스트리밍 REST API (원격 Python 클라이언트)
  - POST /chat, POST /ingest, GET/DELETE /documents, Bearer Token 인증
- 우선순위 재조정: Telegram(1순위) → REST API(2순위) → RAGAS(3순위) → 모델선택(4순위)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 20:01:21 +09:00
shinalok e4c56a9b6c Implement Phase 19: Query Rewriting via LangGraph node
- query_rewrite 노드 추가 (agent → query_rewrite → tools 순서)
- route_after_agent: search_documents 호출 시에만 query_rewrite 라우팅, 그 외 직접 tools
  - tools_condition(prebuilt) 제거 → 커스텀 라우팅 함수로 대체
- query_rewrite_node: 구어체 쿼리를 키워드 중심 문장으로 변환
  - 이전 대화 2턴 컨텍스트로 대명사·지시어 해소
  - enable_thinking=False 바인딩으로 불필요한 사고 과정 제거
  - __query_rewrite 커스텀 이벤트 emit → RAG_VERBOSE 시 변환 결과 출력
- QUERY_REWRITE_ENABLED=true 로 활성화 (기본값 false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:55:13 +09:00
shinalok 86370f6c1e Implement Phase 18: Hybrid Search (BM25 + Vector)
- FastEmbedSparse(Qdrant/bm25) 기반 sparse 임베딩 추가 (fastembed 패키지)
- IngestionService: HYBRID_SEARCH_ENABLED 시 dense + sparse 동시 저장 (RetrievalMode.HYBRID)
  - _ensure_collection_schema(): sparse vector 미설정 컬렉션 자동 삭제·재생성
- RetrieverService: hybrid 스토어 + dense 폴백 구조, Qdrant 내장 RRF로 결과 통합
- container.py: sparse_embeddings Singleton 프로바이더, ingestion/retriever 양쪽 주입
- .env.example: HYBRID_SEARCH_ENABLED, SPARSE_MODEL_ID 항목 추가

활성화: .env에 HYBRID_SEARCH_ENABLED=true 설정 후 기존 문서 재수집 필요

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:47:17 +09:00
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
29 changed files with 2790 additions and 388 deletions
+76 -5
View File
@@ -1,17 +1,88 @@
# LLM 모델 설정 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 율봇 환경 설정 예시 (.env.example)
# 실제 사용 시 .env로 복사 후 값을 채워주세요.
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ── LLM ──────────────────────────────────────────
MODEL_ID=mlx-community/Qwen3-8B-4bit MODEL_ID=mlx-community/Qwen3-8B-4bit
MAX_TOKENS=1024 MAX_TOKENS=1024
MAX_HISTORY_TURNS=30 MAX_HISTORY_TURNS=10 # 메모리에 유지할 최대 대화 턴 수
COMPACT_THRESHOLD=40 COMPACT_THRESHOLD=20 # 이 턴 초과 시 오래된 대화를 LLM으로 자동 요약
ENABLE_THINKING=true # Thinking 모드 활성화 (Qwen3 지원)
THINK_VERBOSE=false # true 시 UI에 thinking 토큰 스트리밍
# MySQL 설정 (미설정 시 DB 기능 비활성화) # ── MySQL ─────────────────────────────────────────
# 미설정(DB_USER 빈 값) 시 DB 기능 전체 비활성화 (인메모리 모드)
DB_HOST=localhost DB_HOST=localhost
DB_PORT=3306 DB_PORT=3306
DB_NAME=youlbot DB_NAME=youlbot
DB_USER= DB_USER=
DB_PASSWORD= DB_PASSWORD=
# LangSmith 트레이싱 (Phase 7) — https://smith.langchain.com 에서 API 키 발급 # ── Qdrant ────────────────────────────────────────
QDRANT_URL=http://localhost:6333
QDRANT_COLLECTION=youlbot_docs
# ── Embedding ────────────────────────────────────
EMBEDDING_MODEL_ID=BAAI/bge-m3
EMBEDDING_DEVICE=mps # mps (Apple Silicon) | cpu | cuda
# ── RAG 검색 ─────────────────────────────────────
RAG_TOP_K=3 # 최종 반환할 문서 청크 수
RAG_VERBOSE=false # true 시 검색 쿼리·청크 내용 출력
RAG_SHOW_SOURCES=false # true 시 답변 아래 출처(파일명·페이지) 표시
LANGGRAPH_VERBOSE=false # true 시 LangGraph 노드 전환 로그 출력
# ── Semantic Chunker (Phase 13) ───────────────────
SEMANTIC_BREAKPOINT_THRESHOLD_TYPE=percentile # percentile | standard_deviation | interquartile | gradient
SEMANTIC_BUFFER_SIZE=1 # 인접 문장 묶음 크기 (1=단일 문장)
# ── Reranker (Phase 13-B) ────────────────────────
RERANKER_ENABLED=false
RERANKER_MODEL_ID=cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 # 한국어 지원 다국어 모델
RERANKER_FETCH_K=10 # rerank 전 후보 수 (RAG_TOP_K보다 커야 함)
# ── Hybrid Search (Phase 18) — BM25 + Vector ─────
# 활성화 후 기존 문서는 재수집 필요
HYBRID_SEARCH_ENABLED=false
SPARSE_MODEL_ID=Qdrant/bm25
# ── Query Rewriting (Phase 19) ───────────────────
QUERY_REWRITE_ENABLED=false
# ── CRAG — 검색 결과 없을 때 web_search 자동 fallback (IDEA-5) ──
CRAG_ENABLED=false
# ── 대화 기반 자동 RAG 인덱싱 (IDEA-1) ───────────
# 응답 완료 후 LLM이 유용한 정보 판단 → Qdrant 자동 저장 (background task)
CONV_RAG_ENABLED=false
# ── 지식 그래프 / GraphRAG (IDEA-8) ──────────────
# add_relation / query_entity 도구 활성화 + 시스템 프롬프트 자동 주입
GRAPH_ENABLED=false
# ── REST API (Phase 22) ───────────────────────────
# 빈 값이면 인증 없음 (개발 모드)
API_TOKEN=
# ── LangSmith 트레이싱 (Phase 7) ─────────────────
# https://smith.langchain.com 에서 API 키 발급
LANGCHAIN_TRACING_V2=false LANGCHAIN_TRACING_V2=false
LANGCHAIN_API_KEY= LANGCHAIN_API_KEY=
LANGCHAIN_PROJECT=youlbot LANGCHAIN_PROJECT=youlbot
# ── 음성 인터페이스 (Phase 14) ────────────────────
WHISPER_MODEL_SIZE=small # tiny | base | small | medium | large
TTS_VOICE=Yuna # macOS say 명령어 한국어 음성 (Yuna | Siri 등)
# ── 멀티모달 이미지 이해 (Phase 17) ──────────────
VISION_ENABLED=false
VISION_MODEL_ID=mlx-community/Qwen2.5-VL-7B-Instruct-4bit
VISION_MAX_TOKENS=512
# ── 스마트 알림 / Telegram push (IDEA-2) ─────────
# BotFather에서 봇 생성 후 토큰 입력
TELEGRAM_BOT_TOKEN=
# user_id → Telegram numeric chat_id 매핑 (JSON 형식)
# 각 가족의 Telegram ID는 봇에 /start 전송 후 로그에서 확인
TELEGRAM_USER_MAP={"아록": "", "근혜": "", "도율": "", "하율": ""}
+209
View File
@@ -0,0 +1,209 @@
"""율봇 REST API — Phase 22.
실행:
uvicorn api:app --host 0.0.0.0 --port 8000
클라이언트 예시:
import httpx, json
headers = {"Authorization": "Bearer YOUR_TOKEN"}
with httpx.Client() as c:
with c.stream("POST", "http://localhost:8000/chat",
json={"message": "안녕", "user_id": "홍길동"},
headers=headers, timeout=120) as r:
for line in r.iter_lines():
if not line.startswith("data: "):
continue
payload = json.loads(line[6:])
if isinstance(payload, dict) and payload.get("__done"):
break # run_id = payload["run_id"]
print(payload, end="", flush=True)
"""
import json
import os
import tempfile
from contextlib import asynccontextmanager
from dotenv import load_dotenv
load_dotenv()
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from container import Container
from services.agent.agent_service import AgentService
_container = Container()
_container.db_service().connect()
_container.db_service().init_schema()
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = _container.scheduler_service()
scheduler.start()
yield
scheduler.shutdown()
app = FastAPI(title="율봇 API", version="1.0", lifespan=lifespan)
_cfg = _container.config()
_agent_cache: dict[str, AgentService] = {}
# Vision 모델 — VISION_ENABLED=true 시 lazy 초기화
_vision_model = _container.vision_model() if _cfg.vision_enabled else None
def _get_agent(user_id: str) -> AgentService:
if user_id not in _agent_cache:
_agent_cache[user_id] = AgentService(
chat_model=_container.chat_model(),
retriever_service=_container.retriever_service(),
system_prompt=_cfg.system_prompt,
rag_verbose=_cfg.rag_verbose,
rag_show_sources=_cfg.rag_show_sources,
langgraph_verbose=_cfg.langgraph_verbose,
think_verbose=_cfg.think_verbose,
query_rewrite_enabled=_cfg.query_rewrite_enabled,
user_profile_repository=_container.user_profile_repository(),
conversation_repository=_container.conversation_repository(),
reminder_repository=_container.reminder_repository(),
ingestion_service=_container.ingestion_service() if _cfg.conv_rag_enabled else None,
crag_enabled=_cfg.crag_enabled,
conv_rag_enabled=_cfg.conv_rag_enabled,
graph_service=_container.graph_service() if _cfg.graph_enabled else None,
user_id=user_id,
)
if _vision_model:
_agent_cache[user_id].set_vision_model(_vision_model)
return _agent_cache[user_id]
def _auth(authorization: str = Header(default="")):
"""API_TOKEN 설정 시 Bearer 토큰 검증. 미설정 시 인증 스킵(개발 모드)."""
token = _cfg.api_token
if token and authorization != f"Bearer {token}":
raise HTTPException(status_code=401, detail="Unauthorized")
# ── 요청/응답 모델 ────────────────────────────────────────────
class ChatRequest(BaseModel):
message: str
user_id: str = "default"
show_thinking: bool = False
image_base64: str | None = None # base64 인코딩된 이미지 (선택)
class FeedbackRequest(BaseModel):
user_id: str = "default"
user_msg: str
asst_msg: str
rating: int
run_id: str | None = None
# ── 엔드포인트 ────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/chat")
async def chat(req: ChatRequest, _=Depends(_auth)):
"""SSE 스트리밍 응답. 각 라인: `data: <JSON 토큰>\n\n`, 종료: `data: [DONE]\n\n`"""
agent = _get_agent(req.user_id)
# 이미지 base64 → 임시 파일 저장
image_path: str | None = None
tmp_path: str | None = None
if req.image_base64 and _vision_model:
import base64
img_bytes = base64.b64decode(req.image_base64)
suffix = ".jpg"
if img_bytes[:4] == b"\x89PNG":
suffix = ".png"
elif img_bytes[:4] == b"GIF8":
suffix = ".gif"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir="/tmp", prefix="youlbot_img_")
tmp.write(img_bytes)
tmp.close()
image_path = tmp.name
tmp_path = tmp.name
async def generate():
try:
async for token in agent.stream_response(
req.message, show_thinking=req.show_thinking, image_path=image_path
):
yield f"data: {json.dumps(token, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'__done': True, 'run_id': agent.last_run_id}, ensure_ascii=False)}\n\n"
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/feedback")
async def save_feedback(req: FeedbackRequest, _=Depends(_auth)):
"""👍/👎 피드백 저장. LangSmith 트레이싱 활성화 시 자동 연동."""
_container.feedback_repository().save_feedback(
req.user_id, req.user_msg, req.asst_msg, req.rating, req.run_id
)
if req.run_id and os.getenv("LANGCHAIN_TRACING_V2") == "true":
try:
from langsmith import Client
Client().create_feedback(run_id=req.run_id, key="user_feedback", score=req.rating)
except Exception:
pass
return {"saved": True}
@app.post("/reset")
async def reset(user_id: str = "default", _=Depends(_auth)):
"""대화 이력 초기화."""
if user_id in _agent_cache:
_agent_cache[user_id].reset()
return {"reset": True, "user_id": user_id}
@app.post("/ingest")
async def ingest(file: UploadFile = File(...), _=Depends(_auth)):
"""PDF 또는 TXT 파일을 업로드해 벡터DB에 수집."""
suffix = os.path.splitext(file.filename or "")[1] or ".bin"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
f.write(await file.read())
tmp_path = f.name
try:
count = _container.ingestion_service().ingest([tmp_path])
return {"chunks": count, "filename": file.filename}
finally:
os.unlink(tmp_path)
@app.get("/documents")
async def list_documents(_=Depends(_auth)):
"""등록된 문서 경로 목록 반환."""
return {"documents": _container.retriever_service().list_documents()}
@app.delete("/documents/{source:path}")
async def delete_document(source: str, _=Depends(_auth)):
"""source 경로에 해당하는 모든 청크 삭제."""
_container.retriever_service().delete_document(source)
return {"deleted": source}
@app.get("/reminders/{user_id}")
async def list_reminders(user_id: str, days_ahead: int = 30, _=Depends(_auth)):
"""user_id의 예정 알림 목록 반환 (기본 30일 이내)."""
items = _container.reminder_repository().get_upcoming(user_id, days_ahead=days_ahead)
return {"reminders": [
{"id": r["id"], "remind_date": str(r["remind_date"]), "message": r["message"]}
for r in items
]}
+61 -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:
@@ -70,6 +71,7 @@ def _get_agent(user_id: str) -> AgentService:
rag_show_sources=_cfg.rag_show_sources, rag_show_sources=_cfg.rag_show_sources,
langgraph_verbose=_cfg.langgraph_verbose, langgraph_verbose=_cfg.langgraph_verbose,
think_verbose=_cfg.think_verbose, think_verbose=_cfg.think_verbose,
query_rewrite_enabled=_cfg.query_rewrite_enabled,
user_profile_repository=container.user_profile_repository(), user_profile_repository=container.user_profile_repository(),
conversation_repository=container.conversation_repository(), conversation_repository=container.conversation_repository(),
user_id=user_id, user_id=user_id,
@@ -77,36 +79,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 +181,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 +224,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 +237,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 파일을 업로드하면 율봇이 내용을 참고해 답변합니다.")
+46 -2
View File
@@ -34,7 +34,23 @@ 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보다 커야 함)
# Hybrid Search (Phase 18) — BM25 + Vector
hybrid_search_enabled: bool = False
sparse_model_id: str = "Qdrant/bm25" # fastembed sparse 모델 (언어 무관 BM25)
# Query Rewriting (Phase 19) — 구어체 질문을 검색 최적화 쿼리로 변환
query_rewrite_enabled: bool = False
# REST API (Phase 22) — 빈 문자열이면 인증 스킵 (개발 모드)
api_token: str = ""
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
@@ -43,7 +59,28 @@ class Config(BaseSettings):
whisper_model_size: str = "small" whisper_model_size: str = "small"
tts_voice: str = "Yuna" # macOS say 명령어 한국어 음성 tts_voice: str = "Yuna" # macOS say 명령어 한국어 음성
system_prompt: str = """모든 응답과 내부 사고 과정을 반드시 한국어로 작성하세요. # Vision (Phase 17)
vision_enabled: bool = False
vision_model_id: str = "mlx-community/Qwen2.5-VL-7B-Instruct-4bit"
vision_max_tokens: int = 512
# 지식 그래프 (IDEA-8) — GraphRAG
graph_enabled: bool = False
# CRAG — 검색 결과 없을 때 web_search 자동 fallback (IDEA-5)
crag_enabled: bool = False
# 대화 기반 자동 RAG 인덱싱 (IDEA-1)
conv_rag_enabled: bool = False
# Scheduler / Telegram 알림 (IDEA-2)
# TELEGRAM_BOT_TOKEN: BotFather에서 발급받은 봇 토큰
# TELEGRAM_USER_MAP: JSON 형식으로 user_id → Telegram chat_id 매핑
# 예) TELEGRAM_USER_MAP={"아록": "123456789", "근혜": "987654321"}
telegram_bot_token: str = ""
telegram_user_map: str = "{}"
system_prompt: str = """모든 사고 과정(thinking)과 답변은 반드시 한국어로만 작성하세요. 영어 사용 절대 금지.
당신의 이름은 '율봇'입니다. 친절하고 따뜻한 한국어 상담 도우미입니다. 당신의 이름은 '율봇'입니다. 친절하고 따뜻한 한국어 상담 도우미입니다.
육아와 금융 두 분야를 전문으로 합니다. 육아와 금융 두 분야를 전문으로 합니다.
@@ -54,6 +91,13 @@ class Config(BaseSettings):
항상 쉽고 친근한 말투로 설명하고, 전문 용어는 풀어서 설명합니다. 항상 쉽고 친근한 말투로 설명하고, 전문 용어는 풀어서 설명합니다.
의학적 진단이나 법적 판단이 필요한 경우에는 반드시 전문가 상담을 권유합니다. 의학적 진단이나 법적 판단이 필요한 경우에는 반드시 전문가 상담을 권유합니다.
## 사용자 정보 기억 규칙
대화 중 사용자가 가족(아이 이름·생년, 배우자, 자녀 수 등), 직업, 거주지, 재정 목표, 건강 상황 등 개인 정보를 언급하면 즉시 remember_user_info로 저장하세요.
- 아이 나이는 생년월일 전체를 저장합니다. (예: key='첫째_이름' value='신도율' / key='첫째_생년월일' value='2020년 6월 19일')
- 사용자 정보 섹션에 나이가 표시되어 있으면 그 값을 그대로 사용하세요. 직접 계산하지 마세요.
- 나이를 직접 계산해야 할 경우에는 반드시 get_current_date 도구를 먼저 호출하여 오늘 날짜를 확인하세요.
- 나이는 항상 한국 나이와 만 나이를 함께 알려주세요. (한국 나이 = 현재 연도 - 출생 연도 + 1 / 만 나이 = 생일이 지났으면 현재 연도 - 출생 연도, 생일이 안 지났으면 -1)
## 문서 검색 규칙 ## 문서 검색 규칙
육아·금융 관련 질문이라면 자신의 학습 지식으로 직접 답하지 말고, 반드시 search_documents 도구를 먼저 호출하세요. 육아·금융 관련 질문이라면 자신의 학습 지식으로 직접 답하지 말고, 반드시 search_documents 도구를 먼저 호출하세요.
검색 결과가 없거나 관련 문서가 등록되어 있지 않은 경우에만 학습 지식을 보조적으로 활용합니다.""" 검색 결과가 없거나 관련 문서가 등록되어 있지 않은 경우에만 학습 지식을 보조적으로 활용합니다."""
+52
View File
@@ -9,13 +9,20 @@ 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.db.reminder_repository import ReminderRepository
from services.scheduler_service import SchedulerService
from services.knowledge.graph_service import GraphService
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 langchain_qdrant import FastEmbedSparse
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
from services.model.mlx_vision_model import MlxVisionModel
class Container(containers.DeclarativeContainer): class Container(containers.DeclarativeContainer):
@@ -60,6 +67,28 @@ class Container(containers.DeclarativeContainer):
db=db_service, db=db_service,
) )
feedback_repository = providers.Singleton(
FeedbackRepository,
db=db_service,
)
reminder_repository = providers.Singleton(
ReminderRepository,
db=db_service,
)
scheduler_service = providers.Singleton(
SchedulerService,
reminder_repo=reminder_repository,
bot_token=providers.Callable(lambda c: c.telegram_bot_token, config),
user_map_json=providers.Callable(lambda c: c.telegram_user_map, config),
)
graph_service = providers.Singleton(
GraphService,
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),
@@ -89,6 +118,16 @@ class Container(containers.DeclarativeContainer):
model_kwargs=providers.Callable(lambda c: {"device": c.embedding_device}, config), model_kwargs=providers.Callable(lambda c: {"device": c.embedding_device}, config),
) )
reranker = providers.Callable(
lambda c: RerankService(c.reranker_model_id) if c.reranker_enabled else None,
config,
)
sparse_embeddings = providers.Singleton(
lambda c: FastEmbedSparse(model_name=c.sparse_model_id) if c.hybrid_search_enabled else None,
config,
)
ingestion_service = providers.Singleton( ingestion_service = providers.Singleton(
IngestionService, IngestionService,
embeddings=embeddings, embeddings=embeddings,
@@ -97,6 +136,8 @@ 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),
sparse_embeddings=sparse_embeddings,
) )
retriever_service = providers.Singleton( retriever_service = providers.Singleton(
@@ -105,6 +146,16 @@ 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),
sparse_embeddings=sparse_embeddings,
)
# Phase 17 — Vision Model (lazy load)
vision_model = providers.Singleton(
MlxVisionModel,
model_id=providers.Callable(lambda c: c.vision_model_id, config),
max_tokens=providers.Callable(lambda c: c.vision_max_tokens, config),
) )
# Phase 3 — LangGraph Agent # Phase 3 — LangGraph Agent
@@ -117,6 +168,7 @@ class Container(containers.DeclarativeContainer):
rag_show_sources=providers.Callable(lambda c: c.rag_show_sources, config), rag_show_sources=providers.Callable(lambda c: c.rag_show_sources, config),
langgraph_verbose=providers.Callable(lambda c: c.langgraph_verbose, config), langgraph_verbose=providers.Callable(lambda c: c.langgraph_verbose, config),
think_verbose=providers.Callable(lambda c: c.think_verbose, config), think_verbose=providers.Callable(lambda c: c.think_verbose, config),
query_rewrite_enabled=providers.Callable(lambda c: c.query_rewrite_enabled, config),
user_profile_repository=user_profile_repository, user_profile_repository=user_profile_repository,
conversation_repository=conversation_repository, conversation_repository=conversation_repository,
) )
@@ -0,0 +1,202 @@
---
template: plan
version: 1.3
feature: phase17-multimodal
date: 2026-06-02
author: sal
project: youlbot
status: Draft
---
# phase17-multimodal Planning Document
> **Summary**: analyze_image 도구 방식으로 이미지 이해 기능을 추가한다.
> Qwen3-8B가 대화를 유지하고, 이미지 첨부 시 Qwen2.5-VL-7B를 도구로 호출해 설명을 얻은 뒤 답변한다.
>
> **Project**: youlbot
> **Author**: sal
> **Date**: 2026-06-02
> **Status**: Draft
---
## Executive Summary
| Perspective | Content |
|-------------|---------|
| **Problem** | 이유식 사진·금융 서류 등 이미지를 텍스트로만 처리하는 현재 한계 |
| **Solution** | Qwen2.5-VL-7B를 `analyze_image` LangChain 도구로 래핑, Qwen3-8B가 필요 시 자동 호출 |
| **Function/UX Effect** | 채팅창에 이미지 첨부 → 자동 분석 → 육아·금융 상담으로 자연스럽게 연결 |
| **Core Value** | 텍스트 추론 품질(Qwen3-8B)을 유지하면서 이미지 이해 기능 추가 |
---
## Context Anchor
| Key | Value |
|-----|-------|
| **WHY** | 손이 자유롭지 않은 육아 상황에서 사진 한 장으로 재료 분석·서류 해석이 가능해야 함 |
| **WHO** | 아록(주 사용자) — 이유식 사진, 건강보험 서류, 접종 기록지 등 촬영 후 질문 |
| **RISK** | 16GB 메모리에서 두 모델 동시 로드 시 OOM 가능 → Vision 모델 lazy load로 완화 |
| **SUCCESS** | 이미지 첨부 → analyze_image 도구 자동 호출 → 설명이 대화 히스토리에 남아 후속 질문 가능 |
| **SCOPE** | 이미지 분석 + 채팅 연동. 동영상·실시간 캡처는 제외 |
---
## 1. Overview
### 1.1 Purpose
사진을 첨부하면 `analyze_image` 도구가 Qwen2.5-VL-7B를 호출해 이미지 설명을 생성하고,
Qwen3-8B가 그 설명을 컨텍스트로 삼아 육아·금융 상담 답변을 제공한다.
### 1.2 모델 분담
| 모델 | 역할 | 메모리 |
|------|------|--------|
| Qwen3-8B-4bit | 대화·추론·도구 결정 (항상 로드) | ~5GB |
| Qwen2.5-VL-7B-Instruct-4bit | 이미지 분석 (lazy load) | ~5GB |
| 합계 | — | ~10GB / 16GB 사용 가능 |
---
## 2. Scope
### 2.1 In Scope
- `mlx-vlm` 패키지로 Vision 모델 로드 및 추론
- `analyze_image(image_path, prompt)` LangChain 도구 구현
- AgentService: 요청에 이미지 있을 때 도구 동적 주입
- API(`/chat`): 이미지 파일 업로드 지원 (multipart form)
- WebUI: 채팅 입력창에 이미지 첨부 버튼 추가
- Telegram: 사진 메시지 수신 → 이미지 다운로드 → API 전달
### 2.2 Out of Scope
- 동영상 분석
- 이미지 생성(text-to-image)
- 실시간 카메라 입력
---
## 3. Architecture — C방식 (analyze_image 도구)
```
사용자
│ 텍스트 + 이미지(선택)
API /chat (multipart form)
│ image → /tmp/youlbot_img_xxx.jpg 저장
│ image_path → AgentService.stream_response(message, image_path=...)
AgentService
│ image_path 있을 때: analyze_image 도구를 tools 목록에 동적 추가
│ image_path를 도구 클로저로 바인딩
LangGraph ReAct
│ Qwen3-8B가 이미지 관련 질문 감지 → analyze_image() 자동 호출
analyze_image 도구
│ mlx_vision_model.analyze(image_path, prompt)
MlxVisionModel (Qwen2.5-VL-7B, lazy load)
│ 이미지 설명 텍스트 반환
LangGraph
│ 설명이 ToolMessage로 대화 히스토리에 저장
Qwen3-8B → 최종 답변 생성
```
**핵심 특성:**
- Vision 모델은 처음 analyze_image 호출 시 로드 (이후 캐시)
- 이미지 설명이 대화 히스토리에 남아 후속 질문("그 재료로 이유식 만들어줘") 가능
- 이미지 없는 메시지는 기존과 완전히 동일하게 동작
---
## 4. 변경 파일 목록
### 신규 생성
| 파일 | 설명 |
|------|------|
| `services/model/mlx_vision_model.py` | MlxVisionModel 클래스 (mlx-vlm 래퍼, lazy load) |
### 수정
| 파일 | 변경 내용 |
|------|----------|
| `config.py` | `vision_enabled: bool`, `vision_model_id: str` 추가 |
| `container.py` | `vision_model` Singleton 프로바이더 추가 |
| `services/agent/tools.py` | `make_vision_tool(vision_model, image_path)` 추가 |
| `services/agent/agent_service.py` | `stream_response(image_path=None)` 파라미터 추가, 도구 동적 주입 |
| `api.py` | `/chat` → multipart form으로 변경, 이미지 temp 저장 |
| `youlbot-webui/api_client.py` | `chat(image_path=None)` 파라미터 추가, multipart 전송 |
| `youlbot-webui/app.py` | 채팅 입력 영역에 이미지 업로드 컴포넌트 추가 |
---
## 5. 주요 구현 세부사항
### 5.1 MlxVisionModel
```python
class MlxVisionModel:
def __init__(self, model_id: str): ...
def analyze(self, image_path: str, prompt: str = "이 이미지를 한국어로 자세히 설명해줘.") -> str:
# 첫 호출 시 lazy load
# mlx_vlm.generate() 호출
# 한국어 설명 반환
```
### 5.2 make_vision_tool
```python
def make_vision_tool(vision_model, image_path: str):
@tool
def analyze_image(prompt: str = "이 이미지를 설명해줘") -> str:
"""현재 첨부된 이미지를 분석한다."""
return vision_model.analyze(image_path, prompt)
return analyze_image
```
### 5.3 API /chat 변경
- JSON Body → `multipart/form-data`
- 필드: `message`, `user_id`, `show_thinking`, `image` (optional file)
- 이미지를 `/tmp/youlbot_img_{uuid}.{ext}`에 저장 후 agent에 전달
- 응답 완료 후 temp 파일 삭제
### 5.4 WebUI 변경
- `gr.Image(type="filepath", ...)` 컴포넌트 채팅 입력 영역에 추가
- 이미지 첨부 시 api_client.chat()에 image_path 전달
- 전송 후 이미지 초기화
---
## 6. 환경 설정
```env
# .env 추가
VISION_ENABLED=true
VISION_MODEL_ID=mlx-community/Qwen2.5-VL-7B-Instruct-4bit
```
```bash
# 패키지 설치
pip install mlx-vlm
```
---
## 7. 위험 요소 및 대응
| 위험 | 대응 |
|------|------|
| 16GB에서 두 모델 동시 OOM | Vision 모델 lazy load + 미사용 시 unload 옵션 제공 |
| mlx-vlm API 변경 가능성 | MlxVisionModel로 캡슐화해 교체 용이하게 |
| Telegram 이미지 전달 복잡성 | Phase 17-B로 분리, 우선 WebUI만 구현 |
| 이미지 temp 파일 누적 | 응답 완료 후 즉시 삭제 |
---
## 8. 성공 기준
- [ ] 이미지 첨부 시 `analyze_image` 도구가 자동 호출되어 설명 생성
- [ ] "이 사진에서 뭐가 보여?" 후속 질문이 히스토리 기반으로 동작
- [ ] 이미지 없는 일반 질문은 기존과 동일하게 Qwen3-8B로 처리
- [ ] 16GB 환경에서 OOM 없이 동작
+667 -12
View File
@@ -1,17 +1,56 @@
# 율봇 개발 로드맵 # 율봇 개발 로드맵
## 현재 구현 상태 (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 임베딩 | | Scheduler | asyncio task 기반 알림 스케줄러 — D-7/D-1/D-0 Telegram push (`SchedulerService`) |
| Tools | `search_documents`, `get_current_date`, `web_search`, `remember_user_info`, `recall_user_info` (5개) | | RAG | Qdrant + BAAI/bge-m3 임베딩 + Semantic Chunking (`SemanticChunker`) + Reranker (BAAI/bge-reranker-v2-m3) |
| UI | Gradio Web UI (`app.py`) + CLI (`main.py`) | | Tools | `search_documents`, `web_search`, `get_current_date`, `remember_user_info`, `recall_user_info`, `set_reminder`, `list_reminders` (7개) |
| Memory | LangGraph MemorySaver (세션 내) + MySQL (대화 영구 저장) + `td_user_profile` (장기 사용자 메모리) | | Feedback | Gradio 👍/👎 → `td_feedback` DB 저장 + LangSmith `create_feedback()` 연동 |
| Streaming | 비동기 토큰 스트리밍 + `<think>` 블록 파싱 | | UI | CLI + Gradio Web UI + 음성 입력(STT)/출력(TTS) |
| Tracing | LangSmith 트레이싱 설정 완료 (`.env`에서 활성화 가능) | | Memory | LangGraph MemorySaver (세션 내) + MySQL 대화 저장 + 장기 사용자 프로필 |
| Tracing | LangSmith 트레이싱 |
| Streaming | 비동기 토큰 스트리밍 + 타입별 이벤트 분리 (`__meta` / `__thinking` / `__status`) |
| 사고 과정 UI | 스트리밍 중 현재 줄 실시간 표시 → 완료 후 접기/펼치기 (`<details>`) |
| 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)와 만 나이(생일 기준 정확 계산)를 자동 계산해 시스템 프롬프트에 포함.
### ✅ 버그 6 — TTS가 진행 메시지까지 읽는 문제 (수정 완료)
`stream_response()``[LangGraph → agent: ...]`, `문서 검색 중...` 등 진행 메시지와 실제 답변을 동일한 plain string으로 yield해 TTS가 전부 읽던 문제.
- `stream_response()` yield 타입 분리: 답변 → `plain str`, 진행/thinking/출처 → `{"__meta": str}` dict
- thinking 토큰은 별도 `{"__thinking": str}` key 사용
- `call_model` 시작 직후 `writer({"__start": True})` emit → `{"__status": label}` 변환으로 LLM 추론 전 즉각 피드백
- `api.py`: `json.dumps(token)` 이 dict/str 모두 처리하므로 변경 없음
- WebUI `respond()`: `tts_text` 누적 변수 분리, `__meta`·`__thinking` 토큰 제외 후 TTS 전달
- Telegram `bot.py`: `__meta`·`__thinking` 토큰 skip
### ✅ 버그 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 +86,626 @@
--- ---
## 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)하면 보완이 가능하다.
**구현 내용**:
- `FastEmbedSparse(model_name="Qdrant/bm25")` — 언어 무관 BM25 sparse 임베딩 (`fastembed` 패키지)
- `IngestionService`: `HYBRID_SEARCH_ENABLED=true` 시 dense + sparse 동시 저장 (`RetrievalMode.HYBRID`)
- `RetrieverService`: hybrid 스토어로 검색 → Qdrant 내장 RRF로 결과 통합; sparse vector 미설정 컬렉션은 dense로 자동 폴백
- `_ensure_collection_schema()`: hybrid 전환 시 스키마 불일치 컬렉션 자동 재생성 (기존 문서 재수집 필요)
- `.env` `HYBRID_SEARCH_ENABLED=true`로 활성화, 활성화 후 기존 문서 재수집 필요
| 설정 | 기본값 | 설명 |
|------|--------|------|
| `HYBRID_SEARCH_ENABLED` | `false` | `true`로 설정 시 활성화 |
| `SPARSE_MODEL_ID` | `Qdrant/bm25` | fastembed sparse 모델 (첫 실행 시 자동 다운로드) |
**난이도**: 중간 | **임팩트**: 높음 (키워드 포함 질문 recall 대폭 향상)
---
## ✅ Phase 19 — Query Rewriting ★☆☆
**배경**: 사용자 구어체 질문("아이가 밥을 안 먹어요")은 벡터 검색에 최적화되어 있지 않다. LLM이 검색 전에 질문을 재작성하면 관련 문서 검색 확률이 높아진다.
**구현 내용**:
- LangGraph 그래프에 `query_rewrite` 노드 추가 — `agent → query_rewrite → tools` 순서
- `search_documents` 호출 시에만 작동하는 조건부 라우팅 (`route_after_agent`): 다른 도구 호출이나 tool 없음 케이스는 그대로 통과
- 구어체 → 키워드 중심 쿼리로 변환 + 대명사·지시어를 구체적 명칭으로 해소 (이전 대화 2턴 컨텍스트 활용)
- `tools_condition` 제거 → 커스텀 `route_after_agent` 함수로 대체
- 변환 결과를 custom stream 이벤트로 emit → `RAG_VERBOSE=true``쿼리 최적화: "원본" → "최적화"` 출력
- `.env` `QUERY_REWRITE_ENABLED=true`로 활성화
**난이도**: 하 | **임팩트**: 중간 (구어체 질문 검색 품질 향상)
---
## ✅ Phase 21 — Telegram Bot ★★☆
**배경**: Gradio Web UI는 브라우저에서만 사용 가능. 텔레그램으로 이동 중에도 율봇과 대화하고 싶음.
**구현 방식**: youlbot REST API(Phase 22) 호출 — `youlbot-telegram/` 별도 프로젝트로 분리.
```
youlbot-telegram/
├── bot.py ← Application (python-telegram-bot >= 20.0, async)
│ ├── /start, /reset CommandHandler
│ └── MessageHandler → api_client.chat() → edit_message_text() (타이핑 효과)
├── api_client.py ← httpx 기반 REST API 클라이언트 (chat/reset)
├── .env ← TELEGRAM_BOT_TOKEN, YOULBOT_API_URL, 유저 ID 매핑
└── requirements.txt
```
**구현 내용**:
- `python-telegram-bot>=20.0` (asyncio 기반)
- `youlbot-telegram/bot.py` — 새 진입점 (`python bot.py`로 실행)
- `/start` — 환영 메시지 + 매핑된 youlbot 사용자 이름 표시
- `/reset``api_client.reset(user_id)` 호출로 대화 이력 초기화
- 일반 메시지 → `api_client.chat()` SSE 스트리밍 → 0.6초 간격 실시간 편집
- Telegram numeric ID → youlbot user_id `.env` 매핑 (`USER_아록_TELEGRAM_ID` 등)
- 미등록 사용자에게 Telegram ID 안내 메시지 표시
**실행 방법**:
```bash
cd youlbot-telegram
python bot.py
```
**난이도**: 중간 | **임팩트**: 높음 (모바일·이동 중 접근)
---
## ✅ Phase 22 — REST API (FastAPI) ★★☆
**배경**: 다른 Python 스크립트나 원격 서버에서 율봇을 호출하려면 HTTP API가 필요하다.
Telegram Bot을 별도 프로젝트로 분리해 이 API를 호출하는 구조로 사용 가능.
**구현 내용**:
- `api.py` — FastAPI 앱, `uvicorn api:app --host 0.0.0.0 --port 8000`으로 실행
- SSE(`text/event-stream`) 스트리밍: 각 라인 `data: <JSON 토큰>\n\n`, 종료 `data: [DONE]\n\n`
- Bearer Token 인증 (`.env` `API_TOKEN` 설정; 빈 값이면 개발 모드 무인증)
- `user_id` 파라미터로 멀티유저 지원 (기존 DB·메모리 구조 그대로 재사용)
| 엔드포인트 | 설명 |
|-----------|------|
| `GET /health` | 헬스체크 |
| `POST /chat` | SSE 스트리밍 대화 (`message`, `user_id`, `show_thinking`) |
| `POST /reset` | 대화 이력 초기화 (`user_id`) |
| `POST /ingest` | PDF/TXT 파일 업로드 → 벡터DB 수집 |
| `GET /documents` | 등록 문서 목록 |
| `DELETE /documents/{source}` | 문서 삭제 |
**클라이언트 예시 (별도 Telegram 봇 프로젝트)**:
```python
import httpx, json
API_URL = "http://192.168.10.x:8000"
HEADERS = {"Authorization": "Bearer YOUR_TOKEN"}
async def ask_youlbot(message: str, user_id: str) -> str:
full = ""
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream("POST", f"{API_URL}/chat",
json={"message": message, "user_id": user_id},
headers=HEADERS) as r:
async for line in r.aiter_lines():
if line.startswith("data: ") and line != "data: [DONE]":
full += json.loads(line[6:])
return full
```
**난이도**: 중간 | **임팩트**: 높음 (확장성·외부 연동)
---
## ✅ Phase 23 — WebUI 분리 (youlbot-webui 별도 프로젝트) ★★☆
**배경**: 현재 `app.py`(Gradio)는 `container.py`를 직접 import해 서비스를 사용한다.
REST API(Phase 22)를 완성했으므로, WebUI를 독립 프로젝트로 분리해 API만 호출하도록 변경한다.
분리 후 youlbot은 순수 백엔드(API 서버)로만 동작하며, Telegram Bot과 WebUI가 모두 같은 API를 공유한다.
**구현 내용**:
**① youlbot/api.py 보완**
- `POST /feedback` 엔드포인트 추가 (FeedbackRepository 노출 + LangSmith 연동)
- `/chat` SSE 마지막 이벤트에 `run_id` 포함 → 피드백 연결 가능
```
data: {"__done": true, "run_id": "uuid"}
```
**② 신규 프로젝트 youlbot-webui/**
```
youlbot-webui/
├── app.py ← Gradio UI (REST API 호출 방식으로 재작성)
├── api_client.py ← httpx 기반 API 클라이언트 (chat/reset/ingest/documents/feedback)
├── .env ← YOULBOT_API_URL, YOULBOT_API_TOKEN
├── .env.example
└── requirements.txt ← gradio, httpx, python-dotenv, openai-whisper
```
| 기존 app.py (container 직접 사용) | 변경 후 (API 클라이언트) |
|---|---|
| `container.ingestion_service()` | `api_client.ingest(path)` |
| `agent.stream_response()` | `api_client.chat(msg, user_id)` |
| `retriever.list_documents()` | `api_client.list_documents()` |
| `feedback_repo.save_feedback()` | `api_client.save_feedback(...)` |
| STT (Whisper) | 변경 없음 — WebUI 로컬 실행 유지 |
| TTS (macOS say) | 변경 없음 — WebUI 로컬 실행 유지 |
**실행 방법**:
```bash
# 백엔드
cd youlbot && uvicorn api:app --host 0.0.0.0 --port 8000
# WebUI (별도 터미널, 별도 프로젝트)
cd youlbot-webui && python app.py
```
기존 `youlbot/app.py`는 레거시 직접 실행 옵션으로 보존.
**난이도**: 중간 | **임팩트**: 높음 (백엔드/프론트엔드 완전 분리, 다중 클라이언트 지원)
---
## ✅ Phase 24 — 사고 과정 UI 분리 & 실시간 피드백 ★★☆
**배경**: 사고 과정(thinking)·진행 로그가 답변과 섞여 출력되고, 10초 동안 아무 피드백 없이 대기하는 UX 문제.
**구현 내용**:
**① 스트리밍 토큰 타입 분리 (`youlbot/services/agent/agent_service.py`)**
- 답변: `yield str` (기존 그대로)
- 진행 메시지(`[LangGraph → ...]`, `문서 검색 중...` 등): `yield {"__meta": str}`
- 사고 과정 내용: `yield {"__thinking": str}`
- LLM 추론 시작 즉시: `writer({"__start": True})` → `yield {"__status": label}` 으로 변환
**② 사고 과정 전용 박스 (`youlbot-webui/app.py`)**
| 단계 | 표시 방식 | 비고 |
|------|-----------|------|
| 전송 즉시 | `🤔 질문을 분석하고 있습니다...` | 단순 div, LLM 추론 전 즉각 표시 |
| 스트리밍 중 | `🤔 분석 중...` + 현재 줄 | plain `<div>`, 새 줄 도착 시 이전 줄 교체 |
| 진행 로그 | `🤔 분석 중...` + 로그 메시지 | `__meta` 토큰 전체를 표시 |
| 완료 | `💭 분석 완료 ▶` | `<details>` 로 전환, 클릭 시 전체 내용 펼침 |
- 스트리밍 중 `<details>` 미사용 → 내용 업데이트 시 닫힘 현상 없음
- TTS는 순수 답변 토큰만 읽음 (`__meta`·`__thinking` 제외)
- 챗봇에는 답변만 표시 (진행 메시지 숨김)
- `show_thinking` 체크박스 기본값 ON으로 변경
**③ 멀티클라이언트 대응**
- `youlbot-telegram/bot.py`: `__meta`·`__thinking` 토큰 skip → 순수 답변만 스트리밍
- `asyncio.get_event_loop().run_until_complete()` → `asyncio.run()` 전체 교체 (AnyIO 워커 스레드 호환)
**난이도**: 중간 | **임팩트**: 높음 (UX 대폭 개선)
---
## ✅ Phase 25 — RAG 출처 전용 접기/펼치기 박스
**배경**: RAG 검색 출처가 사고 과정(thinking)과 같은 `__meta` 토큰으로 섞여 "💭 분석 완료" 박스 안에 표시되던 문제.
**구현 내용**:
- `agent_service.py`: 출처를 `{"__meta": "..."}` 개별 토큰 대신 `{"__sources": [{filename, page}, ...]}` 단일 토큰으로 yield
- `youlbot-webui/app.py`:
- `_sources_html()` 헬퍼 추가 — `<details>` 기반 접기/펼치기
- chatbot 바로 아래 `source_box = gr.HTML()` 컴포넌트 추가
- `respond()`에서 `__sources` 토큰 처리 → 답변 완료 후 "📄 출처 (N개)" 박스 표시
- `youlbot-telegram/bot.py`: `__sources` 토큰 skip 처리 추가
**난이도**: 하 | **임팩트**: 중간 (UX 개선 — 출처와 사고 과정 분리)
---
## ✅ Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆
**배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다.
**구현 내용**:
```
eval/
├── dataset.jsonl ← 평가용 Q&A 쌍 (질문·정답 — 필요 시 수정)
├── run_ragas.py ← 평가 실행 스크립트
├── requirements.txt ← ragas==0.2.9, datasets, langchain-google-vertexai
└── results/ ← report_YYYYMMDD_HHMMSS.{csv,json} 저장
```
**평가 지표**:
| 지표 | 설명 |
|------|------|
| `faithfulness` | 답변이 검색 컨텍스트에 충실한가 (환각 탐지) |
| `answer_relevancy` | 답변이 질문에 얼마나 관련 있는가 |
| `context_recall` | 컨텍스트가 정답에 필요한 정보를 포함하는가 |
| `context_precision` | 검색된 컨텍스트 중 실제 유용한 비율 |
**평가 LLM 우선순위**: OpenAI GPT-4o-mini > Anthropic Claude Haiku > 로컬 Qwen3
**실행 방법**:
```bash
# API 서버 실행 후
python eval/run_ragas.py
python eval/run_ragas.py --dataset eval/dataset.jsonl --api http://localhost:8000
```
**호환성 처리**: ragas 0.2가 langchain-community 0.4+에서 `ChatVertexAI` 임포트 실패하는 문제를 런타임 shim으로 우회.
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 관리 기반)
---
## 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 — 멀티모달 이미지 이해 ★☆☆
**배경**: 이유식 사진 → 재료 분석, 금융 서류 사진 → 내용 해석 등.
**구현 방식**: Dual-model C방식 — analyze_image 도구
| 모델 | 역할 |
|------|------|
| Qwen3-8B-4bit | 대화·추론 (항상 로드) |
| Qwen2.5-VL-7B-Instruct-4bit | 이미지 분석 (lazy load) |
- `services/model/mlx_vision_model.py` — MlxVisionModel (mlx-vlm 래퍼, lazy load)
- `services/agent/tools.py` — `make_vision_tool(vision_model, image_path)` 추가
- `agent_service.py` — `stream_response(image_path=None)`, config 경유 vision tool 동적 주입
- `api.py` — `image_base64` 필드 추가, temp 파일 저장 후 응답 완료 시 삭제
- `youlbot-webui` — `image_input` 컴포넌트 추가, ChatService.chat(image_path=) 연결
- `.env` — `VISION_ENABLED=true`, `VISION_MODEL_ID` 설정
**실행 방법**: API 서버 재시작 후 WebUI 이미지 첨부 버튼으로 사진 전송
**난이도**: 높음 | **임팩트**: 높음
---
## 추천 진행 순서
```
단기 (1~2주) 중기 (1개월) 장기
──────────────────────── ────────────────────── ──────────────────
Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 16 (Docker)
→ Phase 17 (멀티모달)
```
### 우선순위 매트릭스
| Phase | 상태 | 난이도 | 임팩트 | 추천 순위 |
|-------|------|--------|--------|-----------|
| 버그 1 RAG 중복 | ✅ 완료 | — | — | — |
| 버그 2 이력 미연동 | ✅ 완료 | — | — | — |
| 버그 3 단일 사용자 | ✅ 완료 | — | — | — |
| 버그 4 나이 계산 오류 | ✅ 완료 | — | — | — |
| 버그 5 thinking 체크박스 무효 | ✅ 완료 | — | — | — |
| 버그 6 TTS 메타 토큰 혼재 | ✅ 완료 | — | — | — |
| 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 | ✅ 완료 | — | — | — |
| Phase 19 Query Rewriting | ✅ 완료 | — | — | — |
| Phase 21 Telegram Bot | ✅ 완료 | — | — | — |
| Phase 22 REST API | ✅ 완료 | — | — | — |
| Phase 23 WebUI 분리 | ✅ 완료 | — | — | — |
| Phase 24 사고 과정 UI 분리 | ✅ 완료 | — | — | — |
| Phase 25 RAG 출처 전용 박스 | ✅ 완료 | — | — | — |
| Phase 20 RAGAS 평가 | ✅ 완료 | — | — | — |
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
| Phase 17 멀티모달 | ✅ 완료 | — | — | — |
---
## 💡 IDEA — 신규 개선 아이디어
### 단기 — 빠르게 임팩트 큰 것
#### ✅ IDEA-1. 대화 기반 자동 RAG 업데이트
**배경**: 현재 문서 업로드만 RAG에 들어간다. 중요 대화 내용 자체를 자동으로 벡터DB에 추가하면 사용할수록 지식이 쌓이는 시스템이 된다.
**구현 내용**:
- `IngestionService.store_text(text, metadata)` — 단일 텍스트 직접 저장 (semantic chunking 없이)
- `AgentService._maybe_index_conversation()` — 응답 완료 후 LLM이 유용한 정보 판단 → 요약 → Qdrant 저장 (asyncio background task)
- `source="conversation"`, `user_id`, `timestamp` 메타데이터로 문서 RAG와 구분
- `.env` `CONV_RAG_ENABLED=true`로 활성화 (기본 비활성)
**난이도**: 하 | **임팩트**: 높음 (지식 자동 축적)
---
#### ✅ IDEA-2. 스마트 알림 & 일정 연동
**배경**: 예방접종, 약 먹을 시간, 병원 예약 등 날짜 기반 알림이 없다.
**구현 내용**:
- `td_reminders` 테이블 (user_id, remind_date, message, sent_d0/d1/d7)
- `set_reminder(remind_date, message)` + `list_reminders()` 도구 — LangGraph 에이전트 자동 호출
- `SchedulerService` — asyncio task 기반 60초 간격 체크 → D-7/D-1/D-0 Telegram push
- FastAPI `lifespan`으로 앱 시작/종료 시 스케줄러 자동 관리
- `GET /reminders/{user_id}` API 엔드포인트 추가
- `.env` 설정: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_USER_MAP={"아록":"123456"}`
**난이도**: 중간 | **임팩트**: 매우 높음 (육아 핵심 시나리오)
---
#### IDEA-3. 대화 요약 일간 리포트
**배경**: 하루에 어떤 질문을 했고 어떤 결정을 내렸는지 돌아볼 방법이 없다.
**구현 방향**:
- 매일 자정 cron → 당일 대화 요약 생성 (`CompactService` 재활용)
- Telegram으로 사용자별 요약 발송
- `/chat` API + APScheduler로 구현 가능 (새 인프라 불필요)
**난이도**: 하 | **임팩트**: 중간
---
#### IDEA-4. 텔레그램 그룹 채팅 지원
**배경**: 현재 1:1 채팅만 지원한다. 가족 그룹에서 `@율봇` 멘션으로 함께 사용하고 싶다.
**구현 방향**:
- 그룹 메시지에서 `@율봇` 멘션 감지 → 발신자 Telegram ID로 `user_id` 매핑
- 그룹 공용 컨텍스트(`user_id="family"`) 옵션
- `python-telegram-bot` 기존 코드에 그룹 핸들러 추가
**난이도**: 하 | **임팩트**: 높음 (가족 공동 사용)
---
### 중기 — RAG/에이전트 품질 향상
#### ✅ IDEA-5. Agentic RAG — 자기 교정 검색 (CRAG)
**배경**: 현재 `query_rewrite → search_documents` 1회로 끝난다. 검색 결과가 부족하면 재시도나 웹 검색 fallback이 없다.
**구현 내용**:
- `AgentState(TypedDict)` — `messages` + `crag_fallback_used` 커스텀 상태
- `crag_check` LangGraph 노드 — `search_documents` 결과가 비었으면 동일 쿼리로 `web_search` AIMessage 자동 주입
- `route_after_crag` — fallback AIMessage 있으면 tools 재실행, 없으면 agent로 복귀
- 그래프: `tools → crag_check → route_after_crag → {tools, agent}`
- 무한 루프 방지: `crag_fallback_used` 플래그로 1회만 fallback
- `.env` `CRAG_ENABLED=true`로 활성화 (기본 비활성)
**난이도**: 중간 | **임팩트**: 높음 (검색 실패 케이스 대폭 감소)
---
#### IDEA-6. 영수증/가계부 OCR
**배경**: `analyze_image` 도구가 이미 있다. 영수증 사진에서 지출을 자동 기록하면 가계 관리가 가능해진다.
**구현 방향**:
- `analyze_image` → 금액·항목·날짜 추출 → MySQL `td_expenses` 저장
- `get_monthly_expenses(month)` 도구 추가 → "이번 달 식비 얼마야?" 대응
- 카테고리 자동 분류 (식비/의료비/교육비 등)
**난이도**: 중간 | **임팩트**: 높음 (가계 관리 시나리오)
---
#### ✅ IDEA-7. RAG 파라미터 자동 튜닝 (Auto-Eval Loop)
**배경**: RAGAS 평가 인프라는 있는데, 파라미터 변경 효과를 수동으로 비교해야 한다.
**구현 내용**:
- `eval/auto_tune.py` — API 서버 없이 `RetrieverService` 직접 사용, 파라미터 조합별 `context_precision` + `context_recall` 비교
- 기본 조합 4개: `baseline(3/10)`, `top_k_5(5/15)`, `top_k_2(2/6)`, `fetch_k_20(3/20)`
- 평균 점수 기준 최적 조합 추천 + `.env` 설정값 안내
- `eval/results/tune_YYYYMMDD.json` 저장
- 실행: `python eval/auto_tune.py [--dataset eval/dataset.jsonl]`
**난이도**: 중간 | **임팩트**: 중간 (장기 품질 자동 관리)
---
### 장기 — 구조적 확장
#### ✅ IDEA-8. GraphRAG / 지식 그래프
**배경**: `td_user_profile`이 flat key-value라 엔티티 간 관계 추론이 불가능하다.
**구현 내용**:
- `td_knowledge_graph` 테이블 — (user_id, subject, relation, object) 트리플 영구 저장
- `GraphService` — NetworkX `MultiDiGraph` 인메모리 캐시 + MySQL 영속화
- `add_relation(subject, relation, obj)` 도구 — 관계 저장
예: `도율 -[알레르기]→ 복숭아`, `아록 -[자녀]→ 도율`
- `query_entity(entity)` 도구 — 출발/도착 방향 모든 관계 조회
- `call_model`에 저장된 그래프 요약을 시스템 프롬프트에 자동 주입
- `.env` `GRAPH_ENABLED=true`로 활성화 (기본 비활성)
**사용 예시**:
```
사용자: "도율이 복숭아 알레르기가 있어"
→ add_relation("도율", "알레르기", "복숭아")
사용자: "도율이 먹으면 안 되는 음식은?"
→ query_entity("도율") → "도율 -[알레르기]→ 복숭아"
```
**난이도**: 높음 | **임팩트**: 높음 (메모리 추론 능력 대폭 향상)
---
#### IDEA-9. PWA / 모바일 Web UI
**배경**: Gradio는 모바일 UX가 좋지 않다. 네이티브 앱처럼 설치하고 카메라 접근도 원활해야 한다.
**구현 방향**:
- `youlbot-webui`를 Next.js + shadcn/ui PWA로 재작성
- 홈 화면 설치, 오프라인 캐시, 네이티브 카메라 접근
- 기존 REST API 그대로 재사용 (백엔드 변경 없음)
- STT는 Web Speech API로 대체 (브라우저 내장)
**난이도**: 높음 | **임팩트**: 높음 (모바일 UX 대폭 개선)
---
### IDEA 우선순위 매트릭스
| IDEA | 설명 | 난이도 | 임팩트 | 추천 순위 |
|------|------|--------|--------|-----------|
| IDEA-2 스마트 알림 | ✅ asyncio 스케줄러 + Telegram push | 중간 | 매우 높음 | — |
| IDEA-4 텔레그램 그룹 채팅 | 기존 Bot 코드 확장 | 하 | 높음 | 1순위 |
| IDEA-3 일간 리포트 | CompactService 재활용 + SchedulerService | 하 | 중간 | 2순위 |
| IDEA-1 대화 기반 RAG | ✅ asyncio background + Qdrant 저장 | 하 | 높음 | — |
| IDEA-5 CRAG | ✅ crag_check LangGraph 노드 | 중간 | 높음 | — |
| IDEA-7 Auto-Eval | ✅ eval/auto_tune.py | 중간 | 중간 | — |
| IDEA-6 영수증 OCR | analyze_image 재활용 | 중간 | 높음 | 1순위 |
| IDEA-8 GraphRAG | ✅ NetworkX + MySQL + 2개 도구 | 높음 | 높음 | — |
| IDEA-9 PWA WebUI | 프론트엔드 재작성 | 높음 | 높음 | 8순위 |
-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)에 의한 자연 편차로 보인다.
체크박스는 표시 여부만 제어하며 모델 동작 자체는 바꾸지 않는다.
+191
View File
@@ -0,0 +1,191 @@
"""RAG 파라미터 자동 튜닝 스크립트 (IDEA-7)
API 서버 없이 RetrieverService를 직접 사용해 파라미터 조합별 context 품질을 비교한다.
평가 지표: context_precision, context_recall (RAGAS)
실행:
python eval/auto_tune.py [--dataset eval/dataset.jsonl]
출력:
eval/results/tune_YYYYMMDD_HHMMSS.json — 조합별 점수 및 추천 파라미터
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
from dotenv import load_dotenv
load_dotenv(ROOT / ".env")
# ── Compatibility shim (run_ragas.py 동일) ─────────────────────────────────────
try:
import langchain_community.chat_models.vertexai # noqa: F401
except ModuleNotFoundError:
try:
from langchain_google_vertexai import ChatVertexAI as _CV
_stub = type(sys)("langchain_community.chat_models.vertexai")
_stub.ChatVertexAI = _CV
sys.modules["langchain_community.chat_models.vertexai"] = _stub
except ImportError:
_stub = type(sys)("langchain_community.chat_models.vertexai")
_stub.ChatVertexAI = object
sys.modules["langchain_community.chat_models.vertexai"] = _stub
from ragas import evaluate
from ragas.metrics import context_precision, context_recall
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from datasets import Dataset
from ragas.run_config import RunConfig
from container import Container
from services.rag.retriever_service import RetrieverService
_container = Container()
_container.db_service().connect()
_container.db_service().init_schema()
_cfg = _container.config()
# ── 튜닝 대상 파라미터 조합 ────────────────────────────────────────────────────
VARIANTS = [
{"name": "baseline", "top_k": 3, "rerank_fetch_k": 10},
{"name": "top_k_5", "top_k": 5, "rerank_fetch_k": 15},
{"name": "top_k_2", "top_k": 2, "rerank_fetch_k": 6},
{"name": "fetch_k_20", "top_k": 3, "rerank_fetch_k": 20},
]
def _build_retriever(top_k: int, rerank_fetch_k: int) -> RetrieverService:
return RetrieverService(
embeddings=_container.embeddings(),
qdrant_url=_cfg.qdrant_url,
collection_name=_cfg.qdrant_collection,
top_k=top_k,
reranker=_container.reranker() if _cfg.reranker_enabled else None,
rerank_fetch_k=rerank_fetch_k,
sparse_embeddings=_container.sparse_embeddings() if _cfg.hybrid_search_enabled else None,
)
def _build_evaluator():
if os.getenv("OPENAI_API_KEY"):
from langchain_openai import ChatOpenAI
print("[AutoTune] 평가 LLM: OpenAI GPT-4o-mini")
return LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini", temperature=0))
if os.getenv("ANTHROPIC_API_KEY"):
from langchain_anthropic import ChatAnthropic
print("[AutoTune] 평가 LLM: Claude Haiku")
return LangchainLLMWrapper(ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0))
print("[AutoTune] 평가 LLM: 로컬 Qwen3")
return LangchainLLMWrapper(_container.chat_model())
def run(dataset_path: str) -> None:
samples = []
with open(dataset_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
samples.append(json.loads(line))
if not samples:
print(f"[오류] 데이터셋이 비어 있습니다: {dataset_path}")
sys.exit(1)
print(f"[AutoTune] 파라미터 튜닝 시작 — {len(samples)}개 질문, {len(VARIANTS)}개 조합\n")
llm = _build_evaluator()
emb = LangchainEmbeddingsWrapper(_container.embeddings())
run_cfg = RunConfig(timeout=300, max_retries=1, max_workers=1)
results = []
for variant in VARIANTS:
name = variant["name"]
print(f"── {name} (top_k={variant['top_k']}, fetch_k={variant['rerank_fetch_k']}) ──")
retriever = _build_retriever(variant["top_k"], variant["rerank_fetch_k"])
questions, ground_truths, contexts = [], [], []
for s in samples:
q = s["question"]
docs = retriever.search(q)
contexts.append([d.page_content for d in docs])
questions.append(q)
ground_truths.append(s["ground_truth"])
print(f" [{q[:40]}] → {len(docs)}개 청크")
ds = Dataset.from_dict({
"question": questions,
"contexts": contexts,
"ground_truth": ground_truths,
})
result = evaluate(
ds,
metrics=[context_precision, context_recall],
llm=llm,
embeddings=emb,
run_config=run_cfg,
raise_exceptions=False,
)
df = result.to_pandas()
def _score(col: str) -> float | None:
if col not in df.columns:
return None
val = df[col].dropna().mean()
return float(val) if val == val else None
scores = {
"context_precision": _score("context_precision"),
"context_recall": _score("context_recall"),
}
avg = sum(v for v in scores.values() if v is not None) / max(
sum(1 for v in scores.values() if v is not None), 1
)
results.append({**variant, "scores": scores, "avg": avg})
print(f" precision={scores['context_precision']}, recall={scores['context_recall']}, avg={avg:.3f}\n")
# ── 결과 출력 ──────────────────────────────────────────────────────────────
best = max(results, key=lambda r: r["avg"])
print("=" * 60)
print("AutoTune 결과")
print("=" * 60)
header = f"{'조합':<14} {'precision':>10} {'recall':>10} {'avg':>8}"
print(header)
print("-" * 60)
for r in sorted(results, key=lambda x: x["avg"], reverse=True):
marker = "" if r["name"] == best["name"] else ""
prec = f"{r['scores']['context_precision']:.3f}" if r['scores']['context_precision'] else "N/A"
rec = f"{r['scores']['context_recall']:.3f}" if r['scores']['context_recall'] else "N/A"
print(f" {r['name']:<12} {prec:>10} {rec:>10} {r['avg']:>8.3f}{marker}")
print("=" * 60)
print(f"\n추천: top_k={best['top_k']}, rerank_fetch_k={best['rerank_fetch_k']} ({best['name']})")
print(f" .env에 RAG_TOP_K={best['top_k']}, RERANKER_FETCH_K={best['rerank_fetch_k']} 설정\n")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
results_dir = ROOT / "eval" / "results"
results_dir.mkdir(exist_ok=True)
out = results_dir / f"tune_{ts}.json"
out.write_text(
json.dumps({"timestamp": ts, "best": best, "all": results}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"JSON 저장: {out}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="RAG 파라미터 자동 튜닝")
parser.add_argument("--dataset", default=str(ROOT / "eval" / "dataset.jsonl"))
args = parser.parse_args()
run(args.dataset)
+5
View File
@@ -0,0 +1,5 @@
{"question": "부모의 반응성이 아동의 인지 발달에 어떤 영향을 미치나요?", "ground_truth": "부모의 민감한 반응성은 아동의 인지 발달에 긍정적인 영향을 미친다. 특히 영유아기에 부모가 아동의 신호에 적절히 반응할 때 아동의 탐색 행동과 학습 능력이 향상된다."}
{"question": "아동의 사회성 발달을 돕기 위해 부모가 할 수 있는 것은?", "ground_truth": "부모는 일관된 애착 관계를 형성하고 긍정적인 상호작용 모델을 보여줌으로써 아동의 사회성 발달을 지원할 수 있다."}
{"question": "영유아기 발달 평가 방법에는 어떤 것들이 있나요?", "ground_truth": "영유아기 발달 평가는 표준화된 발달 검사, 관찰 기반 평가, 부모 보고 척도 등 다양한 방법을 통해 이루어진다."}
{"question": "논리수학 지능 발달에 영향을 미치는 요인은?", "ground_truth": "논리수학 지능 발달에는 부모의 상호작용 방식, 탐색 기회 제공, 문제 해결 경험 등이 영향을 미친다."}
{"question": "어머니의 반응성과 아동 언어 발달의 관계는?", "ground_truth": "어머니의 반응성은 아동의 언어 발달에 긍정적 영향을 미치며, 어머니가 아동의 발화에 민감하게 반응할수록 어휘 습득과 언어 이해 능력이 향상된다."}
+3
View File
@@ -0,0 +1,3 @@
ragas==0.2.9
datasets>=2.14.0
langchain-google-vertexai>=2.0.0
+257
View File
@@ -0,0 +1,257 @@
"""youlbot RAGAS 평가 스크립트 (Phase 20)
실행:
cd /path/to/youlbot
python eval/run_ragas.py [--dataset eval/dataset.jsonl] [--api http://localhost:8000]
결과:
eval/results/report_YYYYMMDD_HHMMSS.csv
사전 조건:
- youlbot API 서버 실행 중 (uvicorn api:app --port 8000)
- Qdrant + MySQL 접근 가능
- .env에 API_TOKEN, RAG_SHOW_SOURCES=true 설정
평가 지표:
- faithfulness : 답변이 검색 컨텍스트에 충실한가 (환각 탐지)
- answer_relevancy : 답변이 질문에 얼마나 관련 있는가
- context_recall : 컨텍스트가 정답에 필요한 정보를 포함하는가
- context_precision : 검색된 컨텍스트 중 실제 유용한 비율
참고:
평가에 로컬 LLM(Qwen3)을 사용하므로 결과 신뢰도는 모델 크기에 의존합니다.
더 정확한 평가를 원하면 OPENAI_API_KEY 또는 ANTHROPIC_API_KEY를 설정하세요.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
# ── Compatibility shim ───────────────────────────────────────────────────────
# ragas 0.2.x imports langchain_community.chat_models.vertexai which was
# removed in langchain-community 0.4+. Re-export from langchain-google-vertexai.
try:
import langchain_community.chat_models.vertexai # noqa: F401
except ModuleNotFoundError:
try:
from langchain_google_vertexai import ChatVertexAI as _CV
_stub = type(sys)("langchain_community.chat_models.vertexai")
_stub.ChatVertexAI = _CV
sys.modules["langchain_community.chat_models.vertexai"] = _stub
except ImportError:
# vertexai not available — inject an empty stub (unused by our eval)
_stub = type(sys)("langchain_community.chat_models.vertexai")
_stub.ChatVertexAI = object
sys.modules["langchain_community.chat_models.vertexai"] = _stub
from ragas import evaluate
from ragas.metrics import answer_relevancy, context_precision, context_recall, faithfulness
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from datasets import Dataset
# ── Project path ─────────────────────────────────────────────────────────────
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.chdir(ROOT) # .env 읽기 위해 프로젝트 루트로 이동
from container import Container # noqa: E402 (after sys.path setup)
_container = Container()
_container.db_service().connect()
_container.db_service().init_schema()
# ── Answer collection via API ────────────────────────────────────────────────
async def _collect_answer(api_url: str, token: str, message: str) -> str:
"""youlbot /chat SSE 스트림에서 순수 답변 텍스트만 수집."""
import httpx
headers = {"Authorization": f"Bearer {token}"} if token else {}
parts: list[str] = []
async with httpx.AsyncClient(timeout=180) as client:
async with client.stream(
"POST",
f"{api_url}/chat",
json={"message": message, "user_id": "eval", "show_thinking": False},
headers=headers,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
payload = json.loads(line[6:])
if isinstance(payload, str):
parts.append(payload)
elif isinstance(payload, dict) and payload.get("__done"):
await resp.aclose()
break
return "".join(parts)
def collect_answer(api_url: str, token: str, message: str) -> str:
return asyncio.run(_collect_answer(api_url, token, message))
# ── Evaluator LLM 선택 ────────────────────────────────────────────────────────
def _build_evaluator_llm():
"""평가용 LLM: OpenAI > Anthropic > 로컬 MLX 순으로 시도."""
if os.getenv("OPENAI_API_KEY"):
from langchain_openai import ChatOpenAI
print("[RAGAS] 평가 LLM: OpenAI GPT-4o-mini")
return LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini", temperature=0))
if os.getenv("ANTHROPIC_API_KEY"):
from langchain_anthropic import ChatAnthropic
print("[RAGAS] 평가 LLM: Anthropic Claude Haiku")
return LangchainLLMWrapper(
ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0)
)
print("[RAGAS] 평가 LLM: 로컬 Qwen3 (신뢰도 제한적)")
return LangchainLLMWrapper(_container.chat_model())
def _build_evaluator_embeddings():
return LangchainEmbeddingsWrapper(_container.embeddings())
# ── Main ──────────────────────────────────────────────────────────────────────
def run(dataset_path: str, api_url: str, api_token: str) -> None:
# 1. 데이터셋 로드
samples = []
with open(dataset_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
samples.append(json.loads(line))
if not samples:
print(f"[오류] 데이터셋이 비어 있습니다: {dataset_path}")
sys.exit(1)
print(f"[RAGAS] 평가 시작 — {len(samples)}개 질문, API: {api_url}")
# 2. RetrieverService 초기화
retriever = _container.retriever_service()
# 3. 질문별 context + answer 수집
questions: list[str] = []
answers: list[str] = []
contexts: list[list[str]] = []
ground_truths: list[str] = []
for i, sample in enumerate(samples, 1):
q = sample["question"]
gt = sample["ground_truth"]
print(f"\n[{i}/{len(samples)}] {q[:50]}...")
docs = retriever.search(q)
ctxs = [doc.page_content for doc in docs]
print(f" 컨텍스트: {len(ctxs)}개 청크")
answer = collect_answer(api_url, api_token, q)
print(f" 답변: {len(answer)}")
questions.append(q)
answers.append(answer)
contexts.append(ctxs)
ground_truths.append(gt)
# 4. RAGAS Dataset
ds = Dataset.from_dict(
{
"question": questions,
"answer": answers,
"contexts": contexts,
"ground_truth": ground_truths,
}
)
# 5. 평가 실행
llm = _build_evaluator_llm()
emb = _build_evaluator_embeddings()
# 로컬 LLM은 응답이 느리므로 타임아웃을 충분히 크게, 병렬 작업 수를 줄임
from ragas.run_config import RunConfig
run_cfg = RunConfig(timeout=600, max_retries=1, max_workers=1)
print("\n[RAGAS] 지표 계산 중... (로컬 LLM 사용 시 수 분 소요)")
result = evaluate(
ds,
metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
llm=llm,
embeddings=emb,
run_config=run_cfg,
raise_exceptions=False,
)
# 6. 결과 출력 및 저장
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
results_dir = ROOT / "eval" / "results"
results_dir.mkdir(exist_ok=True)
out_csv = results_dir / f"report_{ts}.csv"
out_json = results_dir / f"report_{ts}.json"
df = result.to_pandas()
df.to_csv(out_csv, index=False, encoding="utf-8-sig")
# 점수 추출: to_pandas() 컬럼 평균으로 안전하게 계산 (타임아웃 시 NaN 처리)
def _score(col: str) -> float | None:
if col not in df.columns:
return None
val = df[col].dropna().mean()
return float(val) if not (val != val) else None # NaN 체크
summary = {
"timestamp": ts,
"dataset": dataset_path,
"n_samples": len(samples),
"scores": {
"faithfulness": _score("faithfulness"),
"answer_relevancy": _score("answer_relevancy"),
"context_recall": _score("context_recall"),
"context_precision": _score("context_precision"),
},
}
out_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n{'='*55}")
print("RAGAS 평가 결과")
print("="*55)
for k, v in summary["scores"].items():
bar = "" * int((v or 0) * 20) if v is not None else ""
score_str = f"{v:.3f}" if v is not None else "N/A"
print(f" {k:<22} {score_str} {bar}")
print("="*55)
print(f"CSV : {out_csv}")
print(f"JSON: {out_json}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="youlbot RAGAS 평가")
parser.add_argument(
"--dataset",
default=str(ROOT / "eval" / "dataset.jsonl"),
help="평가 데이터셋 경로 (기본: eval/dataset.jsonl)",
)
parser.add_argument(
"--api",
default=os.getenv("YOULBOT_API_URL", "http://localhost:8000"),
help="youlbot API URL (기본: http://localhost:8000)",
)
args = parser.parse_args()
from dotenv import load_dotenv
load_dotenv(ROOT / ".env")
api_token = os.getenv("API_TOKEN", "")
run(args.dataset, args.api, api_token)
+8
View File
@@ -12,6 +12,12 @@ langchain-qdrant>=0.2.0
sentence-transformers>=3.0.0 sentence-transformers>=3.0.0
qdrant-client>=1.9.0 qdrant-client>=1.9.0
pdfplumber>=0.11.0 pdfplumber>=0.11.0
# Phase 18 — Hybrid Search (BM25 sparse vectors)
fastembed>=0.3.0
# Phase 22 — REST API
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
python-multipart>=0.0.7
# Phase 3 — Agent orchestration # Phase 3 — Agent orchestration
langgraph>=1.0.0 langgraph>=1.0.0
# Phase 4 — Web UI # Phase 4 — Web UI
@@ -20,3 +26,5 @@ gradio>=4.0.0
duckduckgo-search>=6.0.0 duckduckgo-search>=6.0.0
# Phase 14 — 음성 인터페이스 (STT) # Phase 14 — 음성 인터페이스 (STT)
openai-whisper>=20231117 openai-whisper>=20231117
# IDEA-8 — 지식 그래프 (GraphRAG)
networkx>=3.0
+358 -59
View File
@@ -1,15 +1,23 @@
import asyncio
import os import os
import time import time
import uuid import uuid
from typing import AsyncIterator from typing import Annotated, AsyncIterator, TypedDict
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.graph import START, MessagesState, StateGraph from langgraph.config import get_stream_writer
from langgraph.prebuilt import ToolNode, tools_condition from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
from langgraph.prebuilt import ToolNode
from services.agent.tools import get_current_date, make_memory_tools, make_retriever_tool, make_search_tool, web_search
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
crag_fallback_used: bool
from services.agent.tools import get_current_date, make_memory_tools, make_reminder_tools, make_retriever_tool, make_search_tool, make_vision_tool, web_search
from services.agent.graph_tools import make_graph_tools
class AgentService: class AgentService:
@@ -27,8 +35,14 @@ class AgentService:
rag_show_sources: bool = False, rag_show_sources: bool = False,
langgraph_verbose: bool = False, langgraph_verbose: bool = False,
think_verbose: bool = False, think_verbose: bool = False,
query_rewrite_enabled: bool = False,
user_profile_repository=None, user_profile_repository=None,
conversation_repository=None, conversation_repository=None,
reminder_repository=None,
ingestion_service=None,
crag_enabled: bool = False,
conv_rag_enabled: bool = False,
graph_service=None,
user_id: str = "default", user_id: str = "default",
): ):
self._system_prompt = system_prompt self._system_prompt = system_prompt
@@ -36,6 +50,7 @@ class AgentService:
self._rag_show_sources = rag_show_sources self._rag_show_sources = rag_show_sources
self._langgraph_verbose = langgraph_verbose self._langgraph_verbose = langgraph_verbose
self._think_verbose = think_verbose self._think_verbose = think_verbose
self._query_rewrite_enabled = query_rewrite_enabled
self._source_buffer: list[dict] = [] self._source_buffer: list[dict] = []
self._thread_id = "default" self._thread_id = "default"
self._profile_repo = user_profile_repository self._profile_repo = user_profile_repository
@@ -43,6 +58,11 @@ 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
self._ingestion_service = ingestion_service
self._crag_enabled = crag_enabled
self._conv_rag_enabled = conv_rag_enabled
self._graph_service = graph_service
if conversation_repository: if conversation_repository:
try: try:
@@ -67,25 +87,98 @@ class AgentService:
search_tool = make_search_tool(retriever_service, self._source_buffer) search_tool = make_search_tool(retriever_service, self._source_buffer)
else: else:
search_tool = make_retriever_tool(retriever_service) search_tool = make_retriever_tool(retriever_service)
tools = [search_tool, web_search, get_current_date] self._base_tools = [search_tool, web_search, get_current_date]
if user_profile_repository is not None: if user_profile_repository is not None:
remember_tool, recall_tool = make_memory_tools(user_profile_repository, user_id) remember_tool, recall_tool = make_memory_tools(user_profile_repository, user_id)
tools += [remember_tool, recall_tool] self._base_tools += [remember_tool, recall_tool]
llm_with_tools = chat_model.bind_tools(tools) if reminder_repository is not None:
set_reminder_tool, list_reminders_tool = make_reminder_tools(reminder_repository, user_id)
self._base_tools += [set_reminder_tool, list_reminders_tool]
if graph_service is not None:
add_relation_tool, query_entity_tool = make_graph_tools(graph_service, user_id)
self._base_tools += [add_relation_tool, query_entity_tool]
self._vision_model = None # set via set_vision_model()
self._llm_with_tools = chat_model.bind_tools(self._base_tools)
self._chat_model = chat_model
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 = (
"【언어 규칙】모든 사고 과정(thinking)과 답변을 반드시 한국어로 작성하세요. "
"영어 사용 금지. Think in Korean only.\n\n"
f"오늘 날짜: {date.today().isoformat()}\n\n"
+ self._system_prompt
)
if self._graph_service:
graph_summary = self._graph_service.get_summary(self._user_id)
if graph_summary:
system_content += (
"\n\n## 지식 그래프 (저장된 관계 정보)\n"
+ graph_summary
+ "\n\n**지식 그래프 사용 규칙**: 가족·사물 간 관계 정보(알레르기, "
"가족 관계, 선호도, 질환 등)는 add_relation으로 저장하고, "
"특정 인물 정보 조회 시 query_entity를 먼저 호출하세요."
)
else:
system_content += (
"\n\n**지식 그래프 사용 규칙**: 가족·사물 간 관계 정보(알레르기, "
"가족 관계, 선호도, 질환 등)를 언급하면 add_relation으로 저장하세요."
)
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
# LLM 추론 시작 직전에 즉시 신호 emit — UI에 "분석 중" 표시
if writer:
writer({"__start": True})
# 이미지 첨부 시 vision tool 동적 추가 (요청별로 독립적으로 바인딩)
cfg = config.get("configurable", {})
show_thinking = cfg.get("show_thinking", False)
image_path = cfg.get("image_path")
if image_path and self._vision_model:
tools_for_req = self._base_tools + [make_vision_tool(self._vision_model, image_path)]
_llm_base = self._chat_model.bind_tools(tools_for_req)
else:
_llm_base = self._llm_with_tools
_llm = _llm_base.bind(enable_thinking=show_thinking) if show_thinking != chat_model.enable_thinking else _llm_base
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:
@@ -97,23 +190,167 @@ class AgentService:
additional_kwargs=extra, additional_kwargs=extra,
)]} )]}
builder = StateGraph(MessagesState) async def query_rewrite_node(state: MessagesState, config: RunnableConfig) -> dict:
last_msg = state["messages"][-1]
if not (hasattr(last_msg, "tool_calls") and last_msg.tool_calls):
return {}
# 최근 사용자 메시지 2개를 컨텍스트로 활용 (대명사·지시어 해소)
recent_human = [m.content for m in state["messages"][:-1]
if isinstance(m, HumanMessage)][-2:]
ctx = ("\n\n이전 대화 컨텍스트:\n" + "\n".join(f"- {m}" for m in recent_human)
if recent_human else "")
try:
writer = get_stream_writer()
except Exception:
writer = None
_rewrite_llm = chat_model.bind(enable_thinking=False)
new_tool_calls = []
for tc in last_msg.tool_calls:
if tc["name"] == "search_documents":
original = tc["args"].get("query", "")
prompt = (
f"다음 구어체 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요.{ctx}\n\n"
f"규칙:\n"
f"- 핵심 개념과 전문용어를 포함하세요\n"
f"- 대명사(이것, 그것, 그 논문 등)는 구체적인 명칭으로 교체하세요\n"
f"- 변환된 질문만 한 문장으로 출력하세요. 부가 설명 없이 질문만 출력하세요\n\n"
f"원본 질문: {original}\n최적화된 질문:"
)
try:
result = await _rewrite_llm.ainvoke([HumanMessage(content=prompt)])
rewritten = result.content.strip()
except Exception as e:
print(f"[QueryRewrite] 실패: {e}")
rewritten = original
if rewritten and rewritten != original:
new_tool_calls.append({**tc, "args": {**tc["args"], "query": rewritten}})
if writer:
writer({"__query_rewrite": {"original": original, "rewritten": rewritten}})
else:
new_tool_calls.append(tc)
else:
new_tool_calls.append(tc)
if not last_msg.id:
return {}
new_msg = AIMessage(
id=last_msg.id,
content=last_msg.content,
tool_calls=new_tool_calls,
additional_kwargs=last_msg.additional_kwargs,
)
return {"messages": [new_msg]}
async def crag_check_node(state: AgentState) -> dict:
"""검색 결과 없을 때 web_search 자동 fallback 주입 (CRAG)."""
if state.get("crag_fallback_used", False):
return {}
messages = state["messages"]
# 마지막 search_documents 결과 탐색
last_search_msg = None
for msg in reversed(messages):
if hasattr(msg, "name") and msg.name == "search_documents":
last_search_msg = msg
break
if not last_search_msg or "관련 문서를 찾을 수 없습니다" not in last_search_msg.content:
return {}
# 해당 ToolMessage의 tool_call_id로 원본 AIMessage에서 검색 쿼리 추출
tool_call_id = getattr(last_search_msg, "tool_call_id", None)
query = ""
for msg in reversed(messages):
if isinstance(msg, AIMessage) and msg.tool_calls:
for tc in msg.tool_calls:
if tc.get("id") == tool_call_id:
query = tc.get("args", {}).get("query", "")
break
if query:
break
if not query:
return {}
fallback_msg = AIMessage(
content="",
tool_calls=[{
"id": str(uuid.uuid4()),
"name": "web_search",
"args": {"query": query},
"type": "tool_call",
}],
)
try:
writer = get_stream_writer()
writer({"__meta": f'\n[CRAG] 문서 없음 → 웹 검색으로 전환... ("{query}")\n'})
except Exception:
pass
return {"messages": [fallback_msg], "crag_fallback_used": True}
def route_after_crag(state: AgentState) -> str:
last_msg = state["messages"][-1] if state["messages"] else None
if (isinstance(last_msg, AIMessage) and last_msg.tool_calls
and state.get("crag_fallback_used", False)):
return "tools"
return "agent"
def route_after_agent(state: AgentState) -> str:
last_msg = state["messages"][-1]
if not (hasattr(last_msg, "tool_calls") and last_msg.tool_calls):
return END
if self._query_rewrite_enabled:
if any(tc["name"] == "search_documents" for tc in last_msg.tool_calls):
return "query_rewrite"
return "tools"
builder = StateGraph(AgentState)
builder.add_node("agent", call_model) builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools)) builder.add_node("query_rewrite", query_rewrite_node)
builder.add_node("tools", ToolNode(self._base_tools))
builder.add_edge(START, "agent") builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition) builder.add_conditional_edges("agent", route_after_agent)
builder.add_edge("tools", "agent") builder.add_edge("query_rewrite", "tools")
if crag_enabled:
builder.add_node("crag_check", crag_check_node)
builder.add_edge("tools", "crag_check")
builder.add_conditional_edges("crag_check", route_after_crag)
else:
builder.add_edge("tools", "agent")
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
async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str]: def set_vision_model(self, vision_model) -> None:
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다.""" self._vision_model = vision_model
def _make_config(self, show_thinking: bool = False, image_path: str | None = None) -> dict:
cfg: dict = {"thread_id": self._thread_id, "show_thinking": show_thinking}
if image_path:
cfg["image_path"] = image_path
return {"configurable": cfg}
async def stream_response(
self,
user_input: str,
show_thinking: bool | None = None,
image_path: str | None = None,
) -> AsyncIterator[str | dict]:
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다.
실제 답변: plain str
진행/thinking/출처 메타데이터: {"__meta": str} ← 소비자가 TTS 등에서 필터링 가능
"""
_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, image_path=image_path), "run_id": str(run_id)}
# 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입 # 재시작 후 첫 호출 시 MySQL 이력을 초기 상태에 주입
if self._pending_history: if self._pending_history:
@@ -121,7 +358,7 @@ class AgentService:
self._pending_history = [] self._pending_history = []
else: else:
all_messages = [HumanMessage(content=user_input)] all_messages = [HumanMessage(content=user_input)]
messages = {"messages": all_messages} messages = {"messages": all_messages, "crag_fallback_used": False}
response_content = "" # 실제 답변 내용만 누적 (MySQL 저장용) response_content = "" # 실제 답변 내용만 누적 (MySQL 저장용)
pending_tool_calls: dict = {} # tool_call_id → {name, args} pending_tool_calls: dict = {} # tool_call_id → {name, args}
prev_node: str = "" prev_node: str = ""
@@ -130,55 +367,78 @@ 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 이벤트 ────────────────────────────────────────────
if mode == "custom":
if isinstance(data, dict) and "__start" in data:
# call_model 시작 즉시 emit — LLM 추론 전에 상태 표시
label = "검색 결과를 분석하고 있습니다..." if prev_node == "tools" else "질문을 분석하고 있습니다..."
yield {"__status": label}
continue
if isinstance(data, dict) and "__query_rewrite" in data:
info = data["__query_rewrite"]
if lg or self._rag_verbose:
yield {"__meta": f'\n쿼리 최적화: "{info["original"]}""{info["rewritten"]}"\n'}
continue
if isinstance(data, dict) and "__thinking" in data:
# thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신
if "agent" != prev_node:
thinking_open = False
content_started = False
if lg:
elapsed = time.perf_counter() - start_time
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
yield {"__meta": f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"}
prev_node = "agent"
if _think_verbose:
thinking_open = True
yield {"__thinking": 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:
thinking_open = False
content_started = False content_started = False
if lg: if lg:
elapsed = time.perf_counter() - start_time
if node == "agent": if node == "agent":
elapsed = time.perf_counter() - start_time
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중" label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n" yield {"__meta": f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"}
elif node == "query_rewrite":
yield {"__meta": f"\n[LangGraph → query_rewrite: 쿼리 최적화 중] ({elapsed:.2f}s)\n"}
elif node == "tools": elif node == "tools":
elapsed = time.perf_counter() - start_time yield {"__meta": f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n"}
yield f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n"
prev_node = node prev_node = node
# ── 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: thinking_open = False
yield "\n[/사고 과정]\n"
thinking_open = False
for tc in chunk.tool_calls: for tc in chunk.tool_calls:
pending_tool_calls[tc["id"]] = tc pending_tool_calls[tc["id"]] = tc
if tc.get("name") == "search_documents": if tc.get("name") == "search_documents":
query = tc.get("args", {}).get("query", "") query = tc.get("args", {}).get("query", "")
yield f'\n문서 검색 중... ("{query}")\n' if query else "\n문서 검색 중...\n" yield {"__meta": f'\n문서 검색 중... ("{query}")\n'} if query else {"__meta": "\n문서 검색 중...\n"}
elif tc.get("name") == "web_search": elif tc.get("name") == "web_search":
query = tc.get("args", {}).get("query", "") query = tc.get("args", {}).get("query", "")
yield f'\n웹 검색 중... ("{query}")\n' if query else "\n웹 검색 중...\n" yield {"__meta": f'\n웹 검색 중... ("{query}")\n'} if query else {"__meta": "\n웹 검색 중...\n"}
elif lg: elif lg:
args_str = ", ".join(f'{k}="{v}"' for k, v in tc["args"].items()) args_str = ", ".join(f'{k}="{v}"' for k, v in tc["args"].items())
yield f" [tool_call: {tc['name']}({args_str})]\n" yield {"__meta": f" [tool_call: {tc['name']}({args_str})]\n"}
elif chunk.content: elif chunk.content:
if thinking_open: thinking_open = False
yield "\n[/사고 과정]\n"
thinking_open = False
if lg and not content_started: if lg and not content_started:
yield "\n[LangGraph → agent: 최종 답변 생성]\n\n" yield {"__meta": "\n[LangGraph → agent: 최종 답변 생성]\n\n"}
content_started = True content_started = True
response_content += chunk.content response_content += chunk.content
yield chunk.content yield chunk.content
@@ -188,13 +448,11 @@ 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 {"__thinking": thinking}
yield thinking
yield "\n[/사고 과정]\n"
if chunk.content: if chunk.content:
if lg: if lg:
yield "\n[LangGraph → agent: 최종 답변 생성]\n\n" yield {"__meta": "\n[LangGraph → agent: 최종 답변 생성]\n\n"}
response_content += chunk.content response_content += chunk.content
yield chunk.content yield chunk.content
@@ -202,25 +460,25 @@ class AgentService:
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "search_documents": elif node == "tools" and hasattr(chunk, "name") and chunk.name == "search_documents":
if lg: if lg:
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()] result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
yield f" [결과: {len(result_lines)}개 문서 반환 → agent 복귀]\n" yield {"__meta": f" [결과: {len(result_lines)}개 문서 반환 → agent 복귀]\n"}
if self._rag_verbose: if self._rag_verbose:
tc = pending_tool_calls.get(chunk.tool_call_id, {}) tc = pending_tool_calls.get(chunk.tool_call_id, {})
query = tc.get("args", {}).get("query", "") query = tc.get("args", {}).get("query", "")
yield f'\n[문서 검색: "{query}"]\n' yield {"__meta": f'\n[문서 검색: "{query}"]\n'}
for block in chunk.content.split("\n\n"): for block in chunk.content.split("\n\n"):
if block.strip(): if block.strip():
preview = block.strip().replace("\n", " ")[:80] preview = block.strip().replace("\n", " ")[:80]
yield f"{preview}\n" yield {"__meta": f"{preview}\n"}
yield "\n" yield {"__meta": "\n"}
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "web_search": elif node == "tools" and hasattr(chunk, "name") and chunk.name == "web_search":
if lg: if lg:
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()] result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
yield f" [웹 검색 결과: {len(result_lines)}건 → agent 복귀]\n" yield {"__meta": f" [웹 검색 결과: {len(result_lines)}건 → agent 복귀]\n"}
if thinking_open: thinking_open = False
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:
@@ -230,12 +488,53 @@ class AgentService:
except Exception as e: except Exception as e:
print(f"[Agent] 대화 저장 실패: {e}") print(f"[Agent] 대화 저장 실패: {e}")
# 대화 내용을 RAG에 비동기 인덱싱 (IDEA-1)
if self._conv_rag_enabled and self._ingestion_service and response_content:
asyncio.create_task(self._maybe_index_conversation(user_input, response_content))
if self._rag_show_sources and self._source_buffer: if self._rag_show_sources and self._source_buffer:
yield "\n\n[참고 문서]\n" sources = []
for src in self._source_buffer: for src in self._source_buffer:
filename = os.path.basename(src["source"]) entry = {"filename": os.path.basename(src["source"])}
page = f" {src['page']}페이지" if "page" in src else "" if "page" in src:
yield f"- {filename}{page}\n" entry["page"] = src["page"]
sources.append(entry)
yield {"__sources": sources}
async def _maybe_index_conversation(self, user_input: str, response: str) -> None:
"""대화 내용이 RAG에 저장할 만한 정보를 포함하면 Qdrant에 비동기 인덱싱."""
if len(response) < 80:
return
prompt = (
"다음 대화에서 육아·금융·건강 등 나중에 검색할 만한 유용한 정보가 있으면 "
"핵심만 2~4문장으로 간결하게 요약하세요. "
"단순 인사, 날짜 확인, 수치 계산은 '없음'이라고만 답하세요.\n\n"
f"질문: {user_input}\n"
f"답변: {response[:600]}\n\n"
"요약 (또는 '없음'):"
)
try:
result = await self._chat_model.bind(enable_thinking=False).ainvoke(
[HumanMessage(content=prompt)]
)
summary = result.content.strip()
if not summary or summary == "없음" or len(summary) < 20:
return
from datetime import datetime
metadata = {
"source": "conversation",
"user_id": self._user_id,
"question": user_input[:100],
"timestamp": datetime.now().isoformat(),
}
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, self._ingestion_service.store_text, summary, metadata
)
print(f"[ConvRAG] 인덱싱 완료: {summary[:60]}...")
except Exception as e:
print(f"[ConvRAG] 인덱싱 실패: {e}")
def reset(self) -> None: def reset(self) -> None:
"""새 thread_id로 대화 히스토리를 초기화한다.""" """새 thread_id로 대화 히스토리를 초기화한다."""
+24
View File
@@ -0,0 +1,24 @@
from langchain_core.tools import tool
def make_graph_tools(graph_service, user_id: str = "default"):
"""지식 그래프 저장/조회 Tool 쌍을 반환한다."""
@tool
def add_relation(subject: str, relation: str, obj: str) -> str:
"""가족 구성원이나 사물 사이의 관계를 지식 그래프에 저장합니다.
알레르기·가족 관계·선호도·질환·특기 등 관계형 정보를 저장할 때 사용하세요.
예:
subject='도율', relation='알레르기', obj='복숭아'
subject='아록', relation='자녀', obj='도율'
subject='근혜', relation='직업', obj='간호사'
subject='하율', relation='좋아하는음식', obj='바나나'"""
return graph_service.add_relation(subject, relation, obj, user_id)
@tool
def query_entity(entity: str) -> str:
"""특정 인물이나 사물에 대해 저장된 모든 관계 정보를 조회합니다.
예: entity='도율' → 도율의 알레르기, 나이, 부모, 좋아하는 것 등 모든 알려진 관계"""
return graph_service.query_entity(entity, user_id)
return add_relation, query_entity
+47 -7
View File
@@ -1,11 +1,22 @@
from datetime import date from datetime import date, datetime
from langchain_core.tools import tool from langchain_core.tools import tool
def make_vision_tool(vision_model, image_path: str):
"""현재 요청에 첨부된 이미지를 분석하는 도구."""
@tool
def analyze_image(prompt: str = "이 이미지를 한국어로 자세히 설명해줘.") -> str:
"""첨부된 이미지를 분석한다. 이미지 속 음식, 문서, 사람, 사물 등을 파악할 때 사용하세요."""
return vision_model.analyze(image_path, prompt)
return analyze_image
@tool @tool
def get_current_date() -> str: def get_current_date() -> str:
"""오늘 날짜를 반환합니다. 날짜·기간 관련 질문에 사용하세요.""" """오늘 날짜를 반환합니다. 나이 계산, 날짜 비교 등 현재 날짜가 필요할 때 반드시 먼저 호출하세요."""
return date.today().isoformat() return date.today().isoformat()
@@ -24,15 +35,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 +58,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}"
@@ -63,6 +74,35 @@ def make_memory_tools(profile_repo, user_id: str = "default"):
return remember_user_info, recall_user_info return remember_user_info, recall_user_info
def make_reminder_tools(reminder_repo, user_id: str = "default"):
"""알림 등록/조회 Tool 쌍을 반환한다."""
@tool
def set_reminder(remind_date: str, message: str) -> str:
"""특정 날짜에 텔레그램으로 알림을 보냅니다.
예방접종, 병원 예약, 기념일 등 기억해야 할 날짜를 등록하세요.
- remind_date: 알림 날짜 (YYYY-MM-DD 형식). 날짜를 모르면 get_current_date를 먼저 호출하세요.
- message: 알림 내용 (구체적으로 작성)
등록 시 D-7(7일 전), D-1(하루 전), D-0(당일) 세 번 알림이 발송됩니다."""
try:
parsed = datetime.strptime(remind_date, "%Y-%m-%d").date()
except ValueError:
return f"날짜 형식이 잘못되었습니다. YYYY-MM-DD 형식으로 입력해 주세요. (예: 2026-07-01)"
reminder_repo.add(user_id, parsed, message)
return f"알림이 등록되었습니다. {remind_date}'{message}' 알림을 보내드릴게요."
@tool
def list_reminders() -> str:
"""등록된 예정 알림 목록을 조회합니다. (향후 30일 이내)"""
items = reminder_repo.get_upcoming(user_id, days_ahead=30)
if not items:
return "등록된 예정 알림이 없습니다."
lines = [f"- {r['remind_date']}: {r['message']}" for r in items]
return "등록된 알림 목록:\n" + "\n".join(lines)
return set_reminder, list_reminders
def make_search_tool(retriever_service, source_buffer: list | None = None): def make_search_tool(retriever_service, source_buffer: list | None = None):
"""RetrieverService를 클로저로 감싼 문서 검색 Tool을 반환합니다. """RetrieverService를 클로저로 감싼 문서 검색 Tool을 반환합니다.
+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),
)
+34
View File
@@ -99,6 +99,40 @@ 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
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS td_knowledge_graph (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
subject VARCHAR(200) NOT NULL,
relation VARCHAR(100) NOT NULL,
object VARCHAR(200) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_subject (user_id, subject(80))
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS td_reminders (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
remind_date DATE NOT NULL,
message TEXT NOT NULL,
sent_d0 TINYINT(1) NOT NULL DEFAULT 0,
sent_d1 TINYINT(1) NOT NULL DEFAULT 0,
sent_d7 TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit() conn.commit()
self._migrate_schema(conn) self._migrate_schema(conn)
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import date, timedelta
from services.db.mysql_service import DatabaseService
class ReminderRepository:
"""td_reminders 테이블을 통한 알림 저장소."""
def __init__(self, db: DatabaseService):
self._db = db
def add(self, user_id: str, remind_date: date, message: str) -> int:
return self._db.execute_write(
"INSERT INTO td_reminders (user_id, remind_date, message) VALUES (%s, %s, %s)",
(user_id, remind_date.isoformat(), message),
)
def get_upcoming(self, user_id: str, days_ahead: int = 30) -> list[dict]:
today = date.today()
limit = today + timedelta(days=days_ahead)
return self._db.execute(
"""SELECT id, remind_date, message
FROM td_reminders
WHERE user_id = %s AND remind_date >= %s AND remind_date <= %s
ORDER BY remind_date""",
(user_id, today.isoformat(), limit.isoformat()),
)
def get_due(self, today: date) -> list[dict]:
"""D-0(당일), D-1(내일), D-7(7일 후) 미발송 알림 반환."""
d1 = today + timedelta(days=1)
d7 = today + timedelta(days=7)
return self._db.execute(
"""SELECT *,
CASE
WHEN remind_date = %s THEN 'd0'
WHEN remind_date = %s THEN 'd1'
WHEN remind_date = %s THEN 'd7'
END AS notify_type
FROM td_reminders
WHERE (remind_date = %s AND sent_d0 = 0)
OR (remind_date = %s AND sent_d1 = 0)
OR (remind_date = %s AND sent_d7 = 0)""",
(
today.isoformat(), d1.isoformat(), d7.isoformat(),
today.isoformat(), d1.isoformat(), d7.isoformat(),
),
)
def mark_sent(self, reminder_id: int, notify_type: str) -> None:
col = {"d0": "sent_d0", "d1": "sent_d1", "d7": "sent_d7"}.get(notify_type)
if col:
self._db.execute_write(
f"UPDATE td_reminders SET {col} = 1 WHERE id = %s",
(reminder_id,),
)
View File
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import networkx as nx
from services.db.mysql_service import DatabaseService
class GraphService:
"""NetworkX 기반 지식 그래프.
관계 트리플(subject, relation, object)을 MySQL에 영구 저장하고
메모리에 로드해 빠른 그래프 쿼리를 제공한다.
"""
def __init__(self, db: DatabaseService):
self._db = db
self._graphs: dict[str, nx.MultiDiGraph] = {}
def _load(self, user_id: str) -> nx.MultiDiGraph:
g = nx.MultiDiGraph()
rows = self._db.execute(
"SELECT subject, relation, object FROM td_knowledge_graph WHERE user_id = %s",
(user_id,),
)
for row in rows:
g.add_edge(row["subject"], row["object"], relation=row["relation"])
return g
def _graph(self, user_id: str) -> nx.MultiDiGraph:
if user_id not in self._graphs:
self._graphs[user_id] = self._load(user_id)
return self._graphs[user_id]
def _edge_exists(self, g: nx.MultiDiGraph, subject: str, relation: str, obj: str) -> bool:
return any(
d.get("relation") == relation and target == obj
for _, target, d in g.out_edges(subject, data=True)
)
def add_relation(self, subject: str, relation: str, obj: str, user_id: str) -> str:
"""관계 트리플을 저장한다. 동일 트리플이 존재하면 스킵."""
g = self._graph(user_id)
if self._edge_exists(g, subject, relation, obj):
return f"이미 저장된 관계입니다: {subject} -[{relation}]→ {obj}"
rows = self._db.execute(
"SELECT id FROM td_knowledge_graph "
"WHERE user_id=%s AND subject=%s AND relation=%s AND object=%s",
(user_id, subject, relation, obj),
)
if not rows:
self._db.execute_write(
"INSERT INTO td_knowledge_graph (user_id, subject, relation, object) "
"VALUES (%s, %s, %s, %s)",
(user_id, subject, relation, obj),
)
g.add_edge(subject, obj, relation=relation)
return f"'{subject} -[{relation}]→ {obj}' 관계를 저장했습니다."
def query_entity(self, entity: str, user_id: str) -> str:
"""엔티티에 연결된 모든 관계를 반환한다 (출발/도착 방향 모두)."""
g = self._graph(user_id)
if entity not in g:
return f"'{entity}'에 대해 저장된 정보가 없습니다."
lines = []
for _, target, data in g.out_edges(entity, data=True):
lines.append(f" {entity} -[{data['relation']}]→ {target}")
for source, _, data in g.in_edges(entity, data=True):
lines.append(f" {source} -[{data['relation']}]→ {entity}")
if not lines:
return f"'{entity}'에 대해 저장된 정보가 없습니다."
return f"'{entity}' 관련 정보:\n" + "\n".join(lines)
def get_summary(self, user_id: str) -> str:
"""시스템 프롬프트 주입용 전체 관계 요약. 없으면 빈 문자열."""
g = self._graph(user_id)
if not g.edges:
return ""
return "\n".join(
f" {s} -[{d['relation']}]→ {t}"
for s, t, d in g.edges(data=True)
)
+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] = []
+48
View File
@@ -0,0 +1,48 @@
"""Qwen2.5-VL (mlx-vlm) 기반 이미지 분석 서비스.
첫 analyze() 호출 시 모델을 lazy load해 메모리를 아낀다.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
_DEFAULT_PROMPT = "이 이미지를 한국어로 자세히 설명해줘. 사람, 음식, 문서 등 보이는 것을 빠짐없이 설명해."
class MlxVisionModel:
def __init__(self, model_id: str, max_tokens: int = 512) -> None:
self._model_id = model_id
self._max_tokens = max_tokens
self._model = None
self._processor = None
def _load(self) -> None:
if self._model is not None:
return
logger.info("Vision 모델 로딩 중: %s", self._model_id)
from mlx_vlm import load
self._model, self._processor = load(self._model_id)
logger.info("Vision 모델 로딩 완료")
def analyze(self, image_path: str, prompt: str = _DEFAULT_PROMPT) -> str:
"""이미지를 분석해 한국어 설명을 반환한다."""
self._load()
from mlx_vlm import generate
from mlx_vlm.prompt_utils import apply_chat_template
from mlx_vlm.utils import load_config
config = load_config(self._model_id)
formatted_prompt = apply_chat_template(
self._processor, config, prompt, num_images=1
)
result = generate(
self._model,
self._processor,
image=image_path,
prompt=formatted_prompt,
max_tokens=self._max_tokens,
verbose=False,
)
return result if isinstance(result, str) else str(result)
+43 -54
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, RetrievalMode
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,14 +14,32 @@ 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,
sparse_embeddings=None,
): ):
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._sparse_embeddings = sparse_embeddings
self._splitter = _SemanticSplitter(embeddings, breakpoint_percentile=95) self._splitter = SemanticChunker(
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 _ensure_collection_schema(self) -> None:
"""Hybrid 모드 전환 시 컬렉션에 sparse vector 설정이 없으면 삭제해 재생성을 유도한다."""
if not self._sparse_embeddings:
return
try:
info = self._client.get_collection(self._collection_name)
if not info.config.params.sparse_vectors:
print(f"[Hybrid] '{self._collection_name}' 컬렉션에 sparse vector 설정이 없어 재생성합니다.")
self._client.delete_collection(self._collection_name)
except Exception:
pass # 컬렉션 미존재 시 무시
def _delete_by_source(self, source_path: str) -> None: def _delete_by_source(self, source_path: str) -> None:
"""같은 파일 경로로 저장된 기존 청크를 모두 삭제한다.""" """같은 파일 경로로 저장된 기존 청크를 모두 삭제한다."""
try: try:
@@ -90,7 +59,23 @@ class IngestionService:
except Exception: except Exception:
pass # 컬렉션이 없을 때(최초 수집) 무시 pass # 컬렉션이 없을 때(최초 수집) 무시
def store_text(self, text: str, metadata: dict) -> None:
"""단일 텍스트를 Qdrant에 직접 저장 (semantic chunking 없이)."""
from langchain_core.documents import Document
doc = Document(page_content=text, metadata=metadata)
kwargs = dict(
documents=[doc],
embedding=self._embeddings,
url=self._qdrant_url,
collection_name=self._collection_name,
)
if self._sparse_embeddings:
kwargs["sparse_embedding"] = self._sparse_embeddings
kwargs["retrieval_mode"] = RetrievalMode.HYBRID
QdrantVectorStore.from_documents(**kwargs)
def ingest(self, file_paths: list[str]) -> int: def ingest(self, file_paths: list[str]) -> int:
self._ensure_collection_schema()
docs = [] docs = []
for path in file_paths: for path in file_paths:
self._delete_by_source(path) self._delete_by_source(path)
@@ -98,10 +83,14 @@ class IngestionService:
docs.extend(loader.load()) docs.extend(loader.load())
chunks = self._splitter.split_documents(docs) chunks = self._splitter.split_documents(docs)
QdrantVectorStore.from_documents( kwargs = dict(
documents=chunks, documents=chunks,
embedding=self._embeddings, embedding=self._embeddings,
url=self._qdrant_url, url=self._qdrant_url,
collection_name=self._collection_name, collection_name=self._collection_name,
) )
if self._sparse_embeddings:
kwargs["sparse_embedding"] = self._sparse_embeddings
kwargs["retrieval_mode"] = RetrievalMode.HYBRID
QdrantVectorStore.from_documents(**kwargs)
return len(chunks) return len(chunks)
+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]]
+35 -4
View File
@@ -1,5 +1,5 @@
from langchain_core.documents import Document from langchain_core.documents import Document
from langchain_qdrant import QdrantVectorStore from langchain_qdrant import QdrantVectorStore, RetrievalMode
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
@@ -13,21 +13,52 @@ 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,
sparse_embeddings=None,
): ):
self._client = QdrantClient(url=qdrant_url) self._client = QdrantClient(url=qdrant_url)
self._collection_name = collection_name self._collection_name = collection_name
self._store = QdrantVectorStore( self._top_k = top_k
self._reranker = reranker
self._rerank_fetch_k = rerank_fetch_k
self._sparse_embeddings = sparse_embeddings
# Dense-only store — hybrid 실패 시 폴백으로도 사용
self._dense_store = QdrantVectorStore(
client=self._client, client=self._client,
collection_name=collection_name, collection_name=collection_name,
embedding=embeddings, embedding=embeddings,
) )
self._top_k = top_k
if sparse_embeddings:
self._store = QdrantVectorStore(
client=self._client,
collection_name=collection_name,
embedding=embeddings,
sparse_embedding=sparse_embeddings,
retrieval_mode=RetrievalMode.HYBRID,
)
else:
self._store = self._dense_store
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
try:
docs = self._store.similarity_search(query, k=fetch_k)
except Exception as e:
if self._sparse_embeddings:
# 컬렉션에 sparse vector 없음 → dense 폴백 (재수집 필요)
print(f"[Hybrid] 검색 실패, dense 폴백 (문서 재수집 필요): {e}")
docs = self._dense_store.similarity_search(query, k=fetch_k)
else:
raise
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에 저장된 고유 파일 경로 목록을 반환한다."""
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import asyncio
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from datetime import date
logger = logging.getLogger(__name__)
_NOTIFY_PREFIX = {
"d0": "🔔 오늘 일정",
"d1": "📅 내일 일정",
"d7": "📆 7일 후 일정",
}
class SchedulerService:
"""asyncio 태스크 기반 알림 스케줄러.
매 60초마다 td_reminders를 확인해 D-7/D-1/D-0 Telegram 알림을 발송한다.
TELEGRAM_BOT_TOKEN이 비어 있으면 발송 없이 로그만 출력한다.
"""
def __init__(self, reminder_repo, bot_token: str, user_map_json: str):
self._repo = reminder_repo
self._token = bot_token
try:
self._user_map: dict[str, str] = json.loads(user_map_json) if user_map_json else {}
except Exception:
self._user_map = {}
self._task: asyncio.Task | None = None
def start(self) -> None:
self._task = asyncio.create_task(self._loop())
logger.info("[Scheduler] 알림 스케줄러 시작 (60초 간격)")
def shutdown(self) -> None:
if self._task:
self._task.cancel()
logger.info("[Scheduler] 알림 스케줄러 종료")
async def _loop(self) -> None:
while True:
try:
await self._check_reminders()
except Exception as e:
logger.error(f"[Scheduler] 알림 확인 중 오류: {e}")
await asyncio.sleep(60)
async def _check_reminders(self) -> None:
today = date.today()
reminders = self._repo.get_due(today)
for r in reminders:
notify_type = r.get("notify_type")
if not notify_type:
continue
text = self._format_message(r["message"], notify_type, r["remind_date"])
chat_id = self._user_map.get(r["user_id"])
if self._token and chat_id:
try:
self._send_telegram(chat_id, text)
except Exception as e:
logger.error(f"[Scheduler] Telegram 발송 실패 (user={r['user_id']}): {e}")
continue
else:
logger.info(f"[Scheduler] 알림(Telegram 미설정): {text}")
try:
self._repo.mark_sent(r["id"], notify_type)
except Exception as e:
logger.error(f"[Scheduler] mark_sent 실패: {e}")
def _format_message(self, message: str, notify_type: str, remind_date) -> str:
prefix = _NOTIFY_PREFIX.get(notify_type, "알림")
return f"[율봇 알림] {prefix}\n날짜: {remind_date}\n{message}"
def _send_telegram(self, chat_id: str, text: str) -> None:
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=10):
pass
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB