γ……γ…Žγ…‡
Add CodeWeaver Gradio app
515f392
Raw History Blame Contribute Delete
51.7 kB
"""
CodeWeaver LangGraph λ…Έλ“œ κ΅¬ν˜„.
각 λ…Έλ“œλŠ” AgentStateλ₯Ό λ°›μ•„ μ²˜λ¦¬ν•˜κ³  μ—…λ°μ΄νŠΈλœ μƒνƒœλ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.
λͺ¨λ“  λ…Έλ“œλŠ” LangSmithλ₯Ό 톡해 μžλ™μœΌλ‘œ μΆ”μ λ©λ‹ˆλ‹€.
"""
import asyncio
import logging
import os
from typing import List, Literal, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from src.agent.state import AgentState, SearchResult
from src.agent.state import _MULTI_ANS_RESET_TOKEN # reset token for multi_answers reducer
from src.tools.search_tools import (
search_github,
search_official_docs,
search_stackoverflow,
)
from src.utils.tracing import trace_node
from src.vector_db.qdrant_client import QdrantManager
logger = logging.getLogger(__name__)
# LLM μ΄ˆκΈ°ν™” (Gemini 2.5 Flash)
llm = ChatGoogleGenerativeAI(
model="gemini-2.5-flash-lite",
temperature=0.7,
)
# Qdrant λ§€λ‹ˆμ € μ΄ˆκΈ°ν™”
qdrant_manager = QdrantManager()
@trace_node("analyze_question")
async def analyze_question_node(state: AgentState) -> dict:
"""
μ§ˆλ¬Έμ„ λΆ„μ„ν•˜μ—¬ μœ ν˜•μ„ λΆ„λ₯˜ν•˜κ³  μΊμ‹œ 적격성을 νŒλ‹¨ν•©λ‹ˆλ‹€.
Phase 2: Question Analysis & Cache Eligibility Decision
λΆ„λ₯˜:
- followup: 이전 λŒ€ν™”μ— μ˜μ‘΄ν•˜λŠ” 후속 질문
- cache_candidate: 독립적이고 μž¬μ‚¬μš© κ°€λŠ₯ν•œ 질문
- new_search: λ…λ¦½μ μ΄μ§€λ§Œ μΊμ‹œν•˜μ§€ μ•Šμ„ 질문 (μ‹œκ°„ 민감 λ“±)
"""
user_question = state.user_question
messages = state.messages
# λŒ€ν™” λ§₯락 ꡬ성
has_history = messages and len(messages) > 1
context_info = ""
if has_history:
context_info = "\n이전 λŒ€ν™” λ§₯락:\n"
for msg in messages[-4:-1]: # ν˜„μž¬ 질문 μ œμ™Έ 졜근 3개
if hasattr(msg, 'type') and hasattr(msg, 'content'):
role = "μ‚¬μš©μž" if msg.type == "human" else "AI"
context_info += f"{role}: {msg.content[:100]}\n"
analysis_prompt = f"""μ§ˆλ¬Έμ„ λΆ„μ„ν•˜μ—¬ μœ ν˜•μ„ λΆ„λ₯˜ν•˜κ³ , μΊμ‹œ 적격성을 νŒλ‹¨ν•˜μ„Έμš”.
{context_info}
ν˜„μž¬ 질문: {user_question}
λΆ„λ₯˜ κΈ°μ€€:
1. **clarification** (보좩/ν˜•μ‹ λ³€κ²½ μš”μ²­)
- 이전 λ‹΅λ³€/λŒ€ν™” λ‚΄μš©μ„ λ°”νƒ•μœΌλ‘œ "μ„€λͺ… 방식"을 λ°”κΎΈκ±°λ‚˜ 보좩을 μš”μ²­
- 예: "μ’€ 더 μ‰½κ²Œ μ„€λͺ…ν•΄μ€˜", "예제 μ½”λ“œλ‘œ λ³΄μ—¬μ€˜", "ν•œ μ€„λ‘œ μš”μ•½ν•΄μ€˜", "λ‹€μ‹œ μ„€λͺ…ν•΄μ€˜"
- 원칙: 검색/μΊμ‹œκ°€ μ•„λ‹ˆλΌ λŒ€ν™” νžˆμŠ€ν† λ¦¬ 기반 λ‹΅λ³€
- should_cache = false, canonical_question = null
2. **new_topic** (λŒ€ν™” 쀑 μƒˆ κ°œλ… 질문)
- λŒ€ν™”κ°€ μ΄μ–΄μ§€λŠ” μ€‘μ΄μ§€λ§Œ, 질문 μžμ²΄κ°€ λ…λ¦½μ μœΌλ‘œ μ„±λ¦½ν•˜λŠ” 'μƒˆ κ°œλ…/μ •μ˜/비ꡐ/μ‚¬μš©λ²•' 질문
- 예: (React 이야기 쀑) "Event ListenerλŠ” 뭐야?", "CORSκ°€ 뭐야?"
- 원칙: 검색 + μΊμ‹œ μ €μž₯ κ°€μΉ˜κ°€ 큼
- should_cache = true (κΈ°λ³Έ), canonical_question 생성
3. **independent** (μ™„μ „ 독립 질문)
- 이전 λŒ€ν™” 없이도 이해 κ°€λŠ₯ν•œ 일반 질문
- 예: "Spring Securityκ°€ 뭐야?", "Docker Compose μ‚¬μš©λ²•μ€?"
- 원칙: 검색 + μΊμ‹œ μ €μž₯ κ°€μΉ˜κ°€ 큼
- should_cache = true (κΈ°λ³Έ), canonical_question 생성
λ‹€μŒ JSON ν˜•μ‹μœΌλ‘œλ§Œ λ‹΅λ³€ν•˜μ„Έμš”:
{{
"question_type": "clarification|new_topic|independent",
"should_cache": true|false,
"reasoning": "λΆ„λ₯˜ 이유 1-2λ¬Έμž₯",
"canonical_question": "μΊμ‹œν•  μ •κ·œν™”λœ 질문 (should_cacheκ°€ true인 κ²½μš°μ—λ§Œ, μ•„λ‹ˆλ©΄ null)"
}}
JSON 외에 λ‹€λ₯Έ ν…μŠ€νŠΈλŠ” ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”."""
try:
messages_to_llm = [HumanMessage(content=analysis_prompt)]
response = llm.invoke(messages_to_llm)
# JSON νŒŒμ‹±
import json
response_text = response.content.strip()
# JSON 블둝 μΆ”μΆœ (λ§ˆν¬λ‹€μš΄ μ½”λ“œ 블둝 제거)
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0].strip()
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0].strip()
analysis = json.loads(response_text)
question_type = analysis.get("question_type", "independent")
should_cache = analysis.get("should_cache", False)
reasoning = analysis.get("reasoning", "")
canonical_question = analysis.get("canonical_question", user_question)
# μœ νš¨μ„± 검증
if question_type not in ["clarification", "new_topic", "independent"]:
question_type = "independent"
# 1μ°¨ μ •μ±… 보정: clarification은 μΊμ‹œ κΈˆμ§€
if question_type == "clarification":
should_cache = False
canonical_question = None
else:
# new_topic/independentλŠ” 기본적으둜 μΊμ‹œ κ°€λŠ₯
if canonical_question is None or (isinstance(canonical_question, str) and not canonical_question.strip()):
canonical_question = user_question
# μ‹€ν–‰(run) μ‹œμž‘λ§ˆλ‹€ step 둜그λ₯Ό λ¦¬μ…‹ν•˜κ³ , 이번 μ‹€ν–‰μ˜ step만 λˆ„μ λ˜κ²Œ 함
steps_delta = [
"__RESET_STEPS__",
f"πŸ” 질문 뢄석: {question_type} (μΊμ‹œ μ—¬λΆ€: {should_cache})",
]
return {
"question_type": question_type,
"should_cache": should_cache,
"analysis_reasoning": reasoning,
"canonical_question": canonical_question if should_cache else None,
"intermediate_steps": steps_delta
}
except Exception as e:
logger.error("질문 뢄석 μ‹€νŒ¨: %s", e, exc_info=True)
# κΈ°λ³Έκ°’: 독립 질문으둜 κ°„μ£Ό
steps_delta = [
"__RESET_STEPS__",
"⚠️ 질문 뢄석 μ‹€νŒ¨, κΈ°λ³Έκ°’ μ‚¬μš©: independent",
]
return {
"question_type": "independent",
"should_cache": True,
"analysis_reasoning": "뢄석 μ‹€νŒ¨, κΈ°λ³Έκ°’ μ‚¬μš©",
"canonical_question": user_question,
"intermediate_steps": steps_delta
}
@trace_node("check_cache")
async def check_cache_node(state: AgentState) -> dict:
"""
벑터 DB μΊμ‹œμ—μ„œ μœ μ‚¬ν•œ μ§ˆλ¬Έμ„ κ²€μƒ‰ν•©λ‹ˆλ‹€.
threshold 0.85 이상인 경우 μΊμ‹œ 히트둜 νŒλ‹¨ν•©λ‹ˆλ‹€.
"""
question_for_lookup = state.canonical_question or state.user_question
logger.info("μΊμ‹œ 확인 쀑: %s", question_for_lookup[:50])
try:
cached_result = await qdrant_manager.search_cache(
question=question_for_lookup,
threshold=0.85
)
updates = {}
steps_delta: List[str] = []
if cached_result:
updates["cached_result"] = cached_result
steps_delta.append(f"βœ… μΊμ‹œ 히트 (λ‹΅λ³€ 길이: {len(cached_result)}자)")
logger.info("μΊμ‹œ 히트")
else:
updates["cached_result"] = None
steps_delta.append("❌ μΊμ‹œ 미슀: μƒˆλ‘œμš΄ 검색 ν•„μš”")
logger.info("μΊμ‹œ 미슀")
except Exception as e:
logger.error("μΊμ‹œ 확인 μ‹€νŒ¨: %s", e, exc_info=True)
updates["cached_result"] = None
steps_delta.append(f"⚠️ μΊμ‹œ 확인 였λ₯˜: {str(e)}")
updates["intermediate_steps"] = steps_delta
return updates
@trace_node("create_plan")
def create_plan_node(state: AgentState) -> dict:
"""
μ§ˆλ¬Έμ„ λΆ„μ„ν•˜μ—¬ μœ ν˜•κ³Ό 개수λ₯Ό νŒλ‹¨ν•©λ‹ˆλ‹€.
Phase 4: Dynamic Parallel Search
- single_topic: ν•˜λ‚˜μ˜ 주제 (κΈ°μ‘΄ κ·Έλž˜ν”„ μ‹€ν–‰)
- multiple_questions: 독립 질문 2개 (Send API둜 κ·Έλž˜ν”„ 2회 μ‹€ν–‰)
- too_many: 독립 질문 3개 이상 (μ—λŸ¬ λ©”μ‹œμ§€)
LangGraph 곡식 κ°€μ΄λ“œλΌμΈ: λ…Έλ“œλŠ” ν•œ κ°€μ§€ 일만 μˆ˜ν–‰ (κ³„νš 수립)
"""
user_question = state.user_question
logger.info("질문 뢄석 및 κ³„νš 수립 쀑: %s", user_question[:50])
def _extract_question_candidates(text: str) -> List[str]:
"""μž…λ ₯ λ¬Έμžμ—΄μ—μ„œ '질문 후보'λ₯Ό μ΅œλŒ€ν•œ 보수적으둜 μΆ”μΆœν•©λ‹ˆλ‹€(3개 이상 κ°μ§€μš©)."""
import re
if not text:
return []
t = text.strip()
# 1) λ¬ΌμŒν‘œ 기반 뢄리 (κ°€μž₯ 신뒰도 λ†’μŒ)
parts = re.split(r"[??]+", t)
candidates = [p.strip() for p in parts if p.strip()]
if len(candidates) >= 2 and re.search(r"[??]", t):
# λ¬ΌμŒν‘œκ°€ μ‘΄μž¬ν•  λ•Œλ§Œ 이 κ·œμΉ™μ„ μ‹ λ’°
return candidates
# 2) μ€„λ°”κΏˆ/번호 λ§€κΈ°κΈ° 기반 (닀쀑 질문 μž…λ ₯ νŒ¨ν„΄)
lines = [ln.strip() for ln in re.split(r"[\r\n]+", t) if ln.strip()]
numbered = []
for ln in lines:
if re.match(r"^\s*(\d+[\.\)]|[-*])\s+", ln):
numbered.append(re.sub(r"^\s*(\d+[\.\)]|[-*])\s+", "", ln).strip())
if len(numbered) >= 2:
return numbered
# 3) κ΅¬λΆ„μž 기반(μ„Έλ―Έμ½œλ‘ ) β€” 보쑰
semi = [p.strip() for p in t.split(";") if p.strip()]
if len(semi) >= 2:
return semi
return [t]
def _hard_guard_too_many(text: str) -> Optional[dict]:
"""
ν•˜λ“œ κ°€λ“œ: μ‚¬μš©μžκ°€ '질문 3개 이상'을 ν•œ λ²ˆμ— λ˜μ§„ κ²ƒμœΌλ‘œ ν™•μ‹€ν•œ 경우,
LLM λΆ„λ₯˜μ™€ λ¬΄κ΄€ν•˜κ²Œ too_many둜 κ°•μ œν•©λ‹ˆλ‹€.
"""
import re
if not text:
return None
# κ°€μž₯ ν™•μ‹€ν•œ κΈ°μ€€: λ¬ΌμŒν‘œκ°€ 3개 이상
qmarks = len(re.findall(r"[??]", text))
if qmarks >= 3:
candidates = _extract_question_candidates(text)
msg = "μ£„μ†‘ν•©λ‹ˆλ‹€. μ§ˆλ¬Έμ€ ν•œ λ²ˆμ— μ΅œλŒ€ 2κ°œκΉŒμ§€ κ°€λŠ₯ν•©λ‹ˆλ‹€. κ°€μž₯ μ€‘μš”ν•œ 2개만 κ³¨λΌμ„œ λ‹€μ‹œ μ§ˆλ¬Έν•΄ μ£Όμ„Έμš”."
return {
"case": "too_many",
"sub_questions": candidates,
"reasoning": f"λ¬ΌμŒν‘œκ°€ {qmarks}개둜, 3개 μ΄μƒμ˜ 독립 질문으둜 νŒλ‹¨ν–ˆμŠ΅λ‹ˆλ‹€.",
"error_message": msg,
"steps_note": f"⚠️ 질문 수 초과 감지(λ¬ΌμŒν‘œ {qmarks}개) β†’ too_many둜 κ°•μ œ",
}
# 번호 λ§€κΈ°κΈ°/리슀트둜 3개 이상
candidates = _extract_question_candidates(text)
if len(candidates) >= 3:
msg = "μ£„μ†‘ν•©λ‹ˆλ‹€. μ§ˆλ¬Έμ€ ν•œ λ²ˆμ— μ΅œλŒ€ 2κ°œκΉŒμ§€ κ°€λŠ₯ν•©λ‹ˆλ‹€. κ°€μž₯ μ€‘μš”ν•œ 2개만 κ³¨λΌμ„œ λ‹€μ‹œ μ§ˆλ¬Έν•΄ μ£Όμ„Έμš”."
return {
"case": "too_many",
"sub_questions": candidates,
"reasoning": f"질문 후보가 {len(candidates)}개둜 κ°μ§€λ˜μ–΄ 3개 이상 질문으둜 νŒλ‹¨ν–ˆμŠ΅λ‹ˆλ‹€.",
"error_message": msg,
"steps_note": f"⚠️ 질문 수 초과 감지(후보 {len(candidates)}개) β†’ too_many둜 κ°•μ œ",
}
return None
# ν•˜λ“œ κ°€λ“œ(결정둠적) β€” LLM이 잘λͺ» λΆ„λ₯˜ν•˜λ”라도 3개 이상이면 무쑰건 차단
hard = _hard_guard_too_many(user_question)
if hard:
steps_delta = [
f"πŸ“‹ κ³„νš νƒ€μž…: {hard['case']}",
f" μ„œλΈŒμ§ˆλ¬Έ: {len(hard['sub_questions'])}개",
f" 이유: {hard['reasoning']}",
hard["steps_note"],
]
logger.info("κ³„νš 수립 μ™„λ£Œ(ν•˜λ“œ κ°€λ“œ): too_many, %d개 μ„œλΈŒμ§ˆλ¬Έ", len(hard["sub_questions"]))
return {
"plan": {
"case": hard["case"],
"sub_questions": hard["sub_questions"],
"reasoning": hard["reasoning"],
"error_message": hard["error_message"],
},
"is_multi_question": False,
"sub_question_index": 0,
"sub_question_text": None,
"original_multi_question": None,
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
"intermediate_steps": steps_delta,
}
plan_prompt = f"""μ§ˆλ¬Έμ„ λΆ„μ„ν•˜μ—¬ μœ ν˜•κ³Ό 개수λ₯Ό νŒλ‹¨ν•˜μ„Έμš”.
질문: {user_question}
**μ€‘μš”**: sub_questions의 μš©λ„λŠ” case에 따라 λ‹€λ¦…λ‹ˆλ‹€!
**Case 1: single_topic** (ν•˜λ‚˜μ˜ 주제)
- 예: "Spring Security JWT 인증 κ΅¬ν˜„"
β†’ sub_questions: ["κ°œλ…", "κ΅¬ν˜„", "예제"]
β†’ μš©λ„: λ‹΅λ³€ μ„Ήμ…˜ ꡬ쑰 (검색은 원본 질문으둜 1회만)
β†’ 검색: "Spring Security JWT 인증 κ΅¬ν˜„"
- 예: "React hooks μ™„λ²½ κ°€μ΄λ“œ"
β†’ sub_questions: ["hooksλž€", "μ£Όμš” hooks", "싀무 νŒ¨ν„΄"]
β†’ μš©λ„: λ‹΅λ³€ μ„Ήμ…˜ ꡬ쑰
β†’ 검색: "React hooks μ™„λ²½ κ°€μ΄λ“œ"
**Case 2: multiple_questions** (μ—¬λŸ¬ 독립 질문, μ΅œλŒ€ 2개)
- 예: "JWTκ°€ 뭐야? CORSλŠ”?"
β†’ sub_questions: ["JWTκ°€ 뭐야?", "CORSλŠ”?"]
β†’ μš©λ„: 각 μ§ˆλ¬Έλ§ˆλ‹€ 별도 검색
β†’ 검색: "JWTκ°€ 뭐야?" (1회), "CORSλŠ”?" (1회)
- 예: "Docker μ‚¬μš©λ²•μ€? Redis μ„€μΉ˜λŠ”?"
β†’ sub_questions: ["Docker μ‚¬μš©λ²•μ€?", "Redis μ„€μΉ˜λŠ”?"]
β†’ μš©λ„: 각 μ§ˆλ¬Έλ§ˆλ‹€ 별도 검색
**Case 3: too_many** (3개 이상 질문)
- 예: "JWT? CORS? Docker?"
β†’ λ„ˆλ¬΄ λ§Žμ•„μ„œ 처리 λΆˆκ°€
β†’ error_message 제곡
κ·œμΉ™:
- single_topic: sub_questionsλŠ” 짧은 ν‚€μ›Œλ“œ/ꡬ절 (1-5개)
- multiple_questions: sub_questionsλŠ” μ™„μ „ν•œ λ¬Έμž₯ (μ •ν™•νžˆ 2개만)
- too_many: 3개 이상이면 이 μΌ€μ΄μŠ€λ‘œ λΆ„λ₯˜
λ‹€μŒ JSON ν˜•μ‹μœΌλ‘œλ§Œ λ‹΅λ³€ν•˜μ„Έμš”:
{{
"case": "single_topic|multiple_questions|too_many",
"sub_questions": [...],
"reasoning": "이 μΌ€μ΄μŠ€λ‘œ νŒλ‹¨ν•œ 이유",
"error_message": "..." (too_many인 경우만, κ·Έ μ™ΈλŠ” 빈 λ¬Έμžμ—΄)
}}
JSON 외에 λ‹€λ₯Έ ν…μŠ€νŠΈλŠ” ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”."""
try:
import json
messages_to_llm = [HumanMessage(content=plan_prompt)]
response = llm.invoke(messages_to_llm)
# JSON νŒŒμ‹±
response_text = response.content.strip()
# JSON 블둝 μΆ”μΆœ (λ§ˆν¬λ‹€μš΄ μ½”λ“œ 블둝 제거)
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0].strip()
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0].strip()
plan_data = json.loads(response_text)
case = plan_data.get("case", "single_topic")
sub_questions = plan_data.get("sub_questions", [user_question])
reasoning = plan_data.get("reasoning", "")
error_message = plan_data.get("error_message", "")
# LLM κ²°κ³Όλ₯Ό 받은 뒀에도 ν•œ 번 더 ν•˜λ“œ κ°€λ“œ 적용 (μ•ˆμ „μž₯치)
hard2 = _hard_guard_too_many(user_question)
if hard2:
case = hard2["case"]
sub_questions = hard2["sub_questions"]
reasoning = hard2["reasoning"]
error_message = hard2["error_message"]
# μœ νš¨μ„± 검증
if not sub_questions or len(sub_questions) == 0:
sub_questions = [user_question]
case = "single_topic"
# multiple_questions일 λ•Œ 2개 μ œν•œ κ°•μ œ (단, 3개 이상은 μœ„ ν•˜λ“œ κ°€λ“œμ—μ„œ too_many둜 처리됨)
if case == "multiple_questions" and len(sub_questions) > 2:
sub_questions = sub_questions[:2]
reasoning += " (질문 수 μ œν•œ: μ΅œλŒ€ 2개)"
steps_delta = [
f"πŸ“‹ κ³„νš νƒ€μž…: {case}",
f" μ„œλΈŒμ§ˆλ¬Έ: {len(sub_questions)}개",
f" 이유: {reasoning}"
]
logger.info("κ³„νš 수립 μ™„λ£Œ: %s, %d개 μ„œλΈŒμ§ˆλ¬Έ", case, len(sub_questions))
# NOTE: 이 κ·Έλž˜ν”„λŠ” μ²΄ν¬ν¬μΈνŒ…/μŠ€λ ˆλ“œ μœ μ§€κ°€ κ°€λŠ₯ν•˜λ―€λ‘œ,
# multi_answersλŠ” λ§€ μ‹€ν–‰(run) μ‹œμž‘ μ‹œ 리셋해야 이전 ν„΄ λˆ„μ μ΄ λ°œμƒν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
return {
"plan": {
"case": case,
"sub_questions": sub_questions,
"reasoning": reasoning,
"error_message": error_message
},
"is_multi_question": False,
"sub_question_index": 0,
"sub_question_text": None,
"original_multi_question": None,
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
"intermediate_steps": steps_delta
}
except Exception as e:
logger.error("κ³„νš 수립 μ‹€νŒ¨: %s", e, exc_info=True)
# κΈ°λ³Έκ°’: 원본 질문 κ·ΈλŒ€λ‘œ μ‚¬μš©
steps_delta = [
"⚠️ κ³„νš 수립 μ‹€νŒ¨, κΈ°λ³Έκ°’ μ‚¬μš©: single_topic"
]
return {
"plan": {
"case": "single_topic",
"sub_questions": [user_question],
"reasoning": "κ³„νš 수립 μ‹€νŒ¨, κΈ°λ³Έκ°’ μ‚¬μš©",
"error_message": ""
},
"is_multi_question": False,
"sub_question_index": 0,
"sub_question_text": None,
"original_multi_question": None,
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
"intermediate_steps": steps_delta
}
@trace_node("classify_intent")
def classify_intent_node(state: AgentState) -> dict:
"""
LLM을 μ‚¬μš©ν•˜μ—¬ μ‚¬μš©μž 질문의 μ˜λ„λ₯Ό λΆ„λ₯˜ν•©λ‹ˆλ‹€.
λΆ„λ₯˜ μΉ΄ν…Œκ³ λ¦¬:
- debugging: μ—λŸ¬ ν•΄κ²°, 버그 μˆ˜μ •
- learning: κ°œλ… ν•™μŠ΅, 원리 이해
- code_review: μ½”λ“œ κ°œμ„ , λ¦¬νŒ©ν† λ§
"""
logger.info("μ˜λ„ λΆ„λ₯˜ 쀑: %s", state.user_question[:50])
classification_prompt = f"""μ§ˆλ¬Έμ„ λ‹€μŒ μ„Έ κ°€μ§€ μ˜λ„ 쀑 ν•˜λ‚˜λ‘œ λΆ„λ₯˜ν•˜μ„Έμš”:
1. debugging: μ—λŸ¬ ν•΄κ²°, 버그 μˆ˜μ •, 문제 ν•΄κ²°
예: "ImportErrorκ°€ λ°œμƒν•΄μš”", "이 μ½”λ“œκ°€ μž‘λ™ν•˜μ§€ μ•Šμ•„μš”"
2. learning: κ°œλ… ν•™μŠ΅, 원리 이해, νŠœν† λ¦¬μ–Ό
예: "async/awaitκ°€ λ­”κ°€μš”?", "JPA λ™μž‘ μ›λ¦¬λŠ”?"
3. code_review: μ½”λ“œ κ°œμ„ , λ¦¬νŒ©ν† λ§, 베슀트 ν”„λž™ν‹°μŠ€
예: "이 μ½”λ“œλ₯Ό κ°œμ„ ν•  방법은?", "더 λ‚˜μ€ μ„€κ³„λŠ”?"
질문: {state.user_question}
λ°˜λ“œμ‹œ debugging, learning, code_review 쀑 ν•˜λ‚˜λ§Œ λ‹΅ν•˜μ„Έμš”."""
updates = {}
steps_delta: List[str] = []
try:
messages = [
SystemMessage(content="당신은 개발자 μ§ˆλ¬Έμ„ λΆ„λ₯˜ν•˜λŠ” μ „λ¬Έκ°€μž…λ‹ˆλ‹€."),
HumanMessage(content=classification_prompt)
]
response = llm.invoke(messages)
intent_raw = response.content.strip().lower()
# μœ νš¨ν•œ μ˜λ„λ‘œ μ •κ·œν™”
valid_intents = ["debugging", "learning", "code_review"]
intent = next((i for i in valid_intents if i in intent_raw), "learning")
updates["detected_intent"] = intent
steps_delta.append(f"🎯 μ˜λ„ λΆ„λ₯˜: {intent}")
logger.info("μ˜λ„ λΆ„λ₯˜ μ™„λ£Œ: %s", intent)
except Exception as e:
logger.error("μ˜λ„ λΆ„λ₯˜ μ‹€νŒ¨: %s", e, exc_info=True)
updates["detected_intent"] = "learning"
steps_delta.append("⚠️ μ˜λ„ λΆ„λ₯˜ μ‹€νŒ¨, κΈ°λ³Έκ°’ μ‚¬μš©: learning")
updates["intermediate_steps"] = steps_delta
return updates
@trace_node("search_stackoverflow")
def search_stackoverflow_node(state: AgentState) -> dict:
"""
Stack Overflowμ—μ„œ 검색을 μˆ˜ν–‰ν•©λ‹ˆλ‹€.
Send APIλ₯Ό ν†΅ν•œ 병렬 κ²€μƒ‰μ˜ μΌλΆ€λ‘œ μ‹€ν–‰λ©λ‹ˆλ‹€.
search_results와 intermediate_stepsλŠ” Annotated[List, add]둜
μ •μ˜λ˜μ–΄ μžˆμ–΄ μžλ™μœΌλ‘œ λ¨Έμ§€λ©λ‹ˆλ‹€.
"""
intent = state.detected_intent or "learning"
count = 5 if intent == "debugging" else 3
logger.info("Stack Overflow 검색 μ‹œμž‘: %d개", count)
try:
results = search_stackoverflow(state.user_question, count)
logger.info("Stack Overflowμ—μ„œ %d개 κ²°κ³Ό μˆ˜μ§‘", len(results))
# reducerκ°€ μžλ™μœΌλ‘œ λ¨Έμ§€ν•˜λ―€λ‘œ μƒˆ 결과만 λ°˜ν™˜
return {
"search_results": results,
"intermediate_steps": [f"πŸ” Stack Overflow: {len(results)}개 κ²°κ³Ό"]
}
except Exception as e:
logger.error("Stack Overflow 검색 μ‹€νŒ¨: %s", e)
return {
"intermediate_steps": [f"⚠️ Stack Overflow 검색 μ‹€νŒ¨: {str(e)}"]
}
@trace_node("search_github")
def search_github_node(state: AgentState) -> dict:
"""
GitHub Issues/Discussionsμ—μ„œ 검색을 μˆ˜ν–‰ν•©λ‹ˆλ‹€.
Send APIλ₯Ό ν†΅ν•œ 병렬 κ²€μƒ‰μ˜ μΌλΆ€λ‘œ μ‹€ν–‰λ©λ‹ˆλ‹€.
"""
intent = state.detected_intent or "learning"
count = 5 if intent == "code_review" else 3 if intent == "learning" else 2
logger.info("GitHub 검색 μ‹œμž‘: %d개", count)
try:
results = search_github(state.user_question, count)
logger.info("GitHubμ—μ„œ %d개 κ²°κ³Ό μˆ˜μ§‘", len(results))
# reducerκ°€ μžλ™μœΌλ‘œ λ¨Έμ§€
return {
"search_results": results,
"intermediate_steps": [f"πŸ” GitHub: {len(results)}개 κ²°κ³Ό"]
}
except Exception as e:
logger.error("GitHub 검색 μ‹€νŒ¨: %s", e)
return {
"intermediate_steps": [f"⚠️ GitHub 검색 μ‹€νŒ¨: {str(e)}"]
}
@trace_node("search_official_docs")
def search_official_docs_node(state: AgentState) -> dict:
"""
곡식 λ¬Έμ„œ/Tavilyμ—μ„œ 검색을 μˆ˜ν–‰ν•©λ‹ˆλ‹€.
Send APIλ₯Ό ν†΅ν•œ 병렬 κ²€μƒ‰μ˜ μΌλΆ€λ‘œ μ‹€ν–‰λ©λ‹ˆλ‹€.
"""
intent = state.detected_intent or "learning"
count = 5 if intent == "learning" else 2
logger.info("곡식 λ¬Έμ„œ 검색 μ‹œμž‘: %d개", count)
try:
results = search_official_docs(state.user_question, count)
logger.info("곡식 λ¬Έμ„œμ—μ„œ %d개 κ²°κ³Ό μˆ˜μ§‘", len(results))
# reducerκ°€ μžλ™μœΌλ‘œ λ¨Έμ§€
return {
"search_results": results,
"intermediate_steps": [f"πŸ” 곡식 λ¬Έμ„œ: {len(results)}개 κ²°κ³Ό"]
}
except Exception as e:
logger.error("곡식 λ¬Έμ„œ 검색 μ‹€νŒ¨: %s", e)
return {
"intermediate_steps": [f"⚠️ 곡식 λ¬Έμ„œ 검색 μ‹€νŒ¨: {str(e)}"]
}
@trace_node("collect_results")
def collect_results_node(state: AgentState) -> dict:
"""
병렬 검색 κ²°κ³Όλ₯Ό μˆ˜μ§‘ν•˜κ³  μΉ΄μš΄νŠΈν•©λ‹ˆλ‹€.
Fan-in 포인트: 3개의 병렬 검색 λ…Έλ“œκ°€ λͺ¨λ‘ μ™„λ£Œλœ ν›„ μ‹€ν–‰λ©λ‹ˆλ‹€.
LangGraph 곡식 κ°€μ΄λ“œλΌμΈ: Send API의 fan-in μ§€μ μ—μ„œ κ²°κ³Ό 집계
"""
total_results = len(state.search_results)
logger.info("검색 κ²°κ³Ό μˆ˜μ§‘ μ™„λ£Œ: %d개", total_results)
steps_delta = [
f"πŸ“Š 검색 κ²°κ³Ό μˆ˜μ§‘: 총 {total_results}개"
]
return {
"intermediate_steps": steps_delta
}
@trace_node("evaluate_results")
def evaluate_results_node(state: AgentState) -> dict:
"""
검색 결과의 κ°œμˆ˜μ™€ ν’ˆμ§ˆμ„ λͺ¨λ‘ ν‰κ°€ν•©λ‹ˆλ‹€.
평가 κΈ°μ€€:
1. 개수: μ΅œμ†Œ 2개 이상
2. ν’ˆμ§ˆ: 평균 relevance_score >= 0.6
"""
search_results = state.search_results # 직접 μ‚¬μš© (더 μ•ˆμ „)
refinement_count = state.refinement_count
result_count = len(search_results)
logger.info("검색 κ²°κ³Ό 평가: %d개 (κ°œμ„  횟수: %d)", result_count, refinement_count)
# μ•ˆμ „μž₯치: 이미 1회 κ°œμ„ ν–ˆμœΌλ©΄ 더 이상 κ°œμ„ ν•˜μ§€ μ•ŠμŒ
if refinement_count >= 1:
steps_delta = [
f"⚠️ μ΅œλŒ€ κ°œμ„  횟수 도달 ({refinement_count}회), ν˜„μž¬ 결과둜 μ§„ν–‰"
]
return {
"needs_refinement": False,
"intermediate_steps": steps_delta
}
# 1μ°¨ 평가: 개수
if result_count < 2:
steps_delta = [
f"⚠️ 검색 κ²°κ³Ό λΆ€μ‘± ({result_count}개 < 2개), 쿼리 κ°œμ„  ν•„μš”"
]
return {
"needs_refinement": True,
"intermediate_steps": steps_delta
}
# 2μ°¨ 평가: ν’ˆμ§ˆ (relevance_scoreκ°€ μžˆλŠ” 경우만)
scored_results = [r for r in search_results if r.relevance_score is not None]
if scored_results:
avg_score = sum(r.relevance_score for r in scored_results) / len(scored_results)
# 평균 μ μˆ˜κ°€ 0.5 미만이면 ν’ˆμ§ˆ λΆ€μ‘±
if avg_score < 0.5:
steps_delta = [
f"⚠️ 검색 κ²°κ³Ό ν’ˆμ§ˆ λΆ€μ‘± (평균 점수: {avg_score:.2f} < 0.5), 쿼리 κ°œμ„  ν•„μš”"
]
return {
"needs_refinement": True,
"intermediate_steps": steps_delta
}
steps_delta = [
f"βœ… 검색 κ²°κ³Ό μΆ©λΆ„ ({result_count}개, 평균 점수: {avg_score:.2f}), 필터링 λ‹¨κ³„λ‘œ μ§„ν–‰"
]
else:
# relevance_scoreκ°€ 아직 μ—†μœΌλ©΄ 개수만으둜 νŒλ‹¨
steps_delta = [
f"βœ… 검색 κ²°κ³Ό μΆ©λΆ„ ({result_count}개), 필터링 λ‹¨κ³„λ‘œ μ§„ν–‰"
]
return {
"needs_refinement": False,
"intermediate_steps": steps_delta
}
@trace_node("refine_search")
def refine_search_node(state: AgentState) -> dict:
"""
검색 쿼리λ₯Ό κ°œμ„ ν•©λ‹ˆλ‹€.
Open Deep Research νŒ¨ν„΄:
- LLM이 μ „λž΅μ„ 선택 (ꡬ체화/μΌλ°˜ν™”/λ²ˆμ—­)
- 원본 질문 보쑴 (μ΅œμ’… λ‹΅λ³€ 생성 μ‹œ μ‚¬μš©)
LangGraph 곡식 κ°€μ΄λ“œλΌμΈ:
- μƒνƒœμ— μ›μ‹œ 데이터 μ €μž₯ (μ „λž΅ 정보 포함)
- ν”„λ‘¬ν”„νŠΈλŠ” λ…Έλ“œ λ‚΄μ—μ„œ 동적 생성
"""
user_question = state.user_question
original_question = state.original_question or user_question
result_count = len(state.search_results)
logger.info("검색 쿼리 κ°œμ„  쀑: %s (%d개 κ²°κ³Ό)", user_question[:50], result_count)
refinement_prompt = f"""검색 κ²°κ³Όκ°€ λΆ€μ‘±ν•©λ‹ˆλ‹€. 검색 쿼리λ₯Ό κ°œμ„ ν•˜μ„Έμš”.
원본 질문: {user_question}
ν˜„μž¬ κ²°κ³Ό 수: {result_count}개 (λͺ©ν‘œ: 2개 이상)
κ°œμ„  μ „λž΅ (ν•˜λ‚˜ 선택):
1. MORE_SPECIFIC: 기술적 세뢀사항 μΆ”κ°€
예: "React hooks" β†’ "React useEffect cleanup function dependencies"
2. MORE_GENERAL: 더 넓은 μš©μ–΄ μ‚¬μš©
예: "Spring Cloud Sleuth 2.x trace" β†’ "distributed tracing Spring Boot"
3. TRANSLATE: μ–Έμ–΄ λ³€ν™˜
예: "JWT 인증 κ΅¬ν˜„" β†’ "JWT authentication implementation"
예: "WebSocket connection" β†’ "WebSocket μ—°κ²° 방법"
λ‹€μŒ JSON ν˜•μ‹μœΌλ‘œλ§Œ λ‹΅λ³€ν•˜μ„Έμš”:
{{
"new_query": "κ°œμ„ λœ 검색 쿼리",
"strategy": "MORE_SPECIFIC|MORE_GENERAL|TRANSLATE",
"reasoning": "이 μ „λž΅μ„ μ„ νƒν•œ 이유 1-2λ¬Έμž₯"
}}
JSON 외에 λ‹€λ₯Έ ν…μŠ€νŠΈλŠ” ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”."""
try:
import json
messages_to_llm = [HumanMessage(content=refinement_prompt)]
response = llm.invoke(messages_to_llm)
# JSON νŒŒμ‹±
response_text = response.content.strip()
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0].strip()
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0].strip()
refinement_data = json.loads(response_text)
new_query = refinement_data.get("new_query", user_question)
strategy = refinement_data.get("strategy", "MORE_GENERAL")
reasoning = refinement_data.get("reasoning", "")
steps_delta = [
f"πŸ”„ 쿼리 κ°œμ„ : {strategy}",
f" 이전: {user_question[:50]}...",
f" 이후: {new_query[:50]}...",
f" 이유: {reasoning}"
]
logger.info("쿼리 κ°œμ„  μ™„λ£Œ: %s β†’ %s", user_question[:30], new_query[:30])
return {
"user_question": new_query,
"original_question": original_question,
"refinement_count": state.refinement_count + 1,
"search_results": [], # CRITICAL: 이전 검색 κ²°κ³Ό 제거 ν›„ μž¬κ²€μƒ‰
"intermediate_steps": steps_delta
}
except Exception as e:
logger.error("쿼리 κ°œμ„  μ‹€νŒ¨: %s", e, exc_info=True)
# κΈ°λ³Έ μ „λž΅: 영문 ν‚€μ›Œλ“œ μΆ”μΆœ (κ°„λ‹¨ν•œ fallback)
fallback_query = user_question + " tutorial example"
steps_delta = [
f"⚠️ 쿼리 κ°œμ„  μ‹€νŒ¨, κΈ°λ³Έ μ „λž΅ μ‚¬μš©",
f" 이후: {fallback_query}"
]
return {
"user_question": fallback_query,
"original_question": original_question,
"refinement_count": state.refinement_count + 1,
"search_results": [], # CRITICAL: μ‹€νŒ¨ μ‹œμ—λ„ 이전 검색 κ²°κ³Ό 제거
"intermediate_steps": steps_delta
}
@trace_node("filter_and_score")
def filter_and_score_node(state: AgentState) -> dict:
"""
검색 κ²°κ³Όλ₯Ό ν•„ν„°λ§ν•˜κ³  관련도 점수λ₯Ό λ§€κΉλ‹ˆλ‹€.
- μ΅œμ†Œ 길이 50자 이상, URL μ‘΄μž¬ν•˜λŠ” 결과만 μœ μ§€
- μƒμœ„ 5개 결과에 λŒ€ν•΄ LLM으둜 관련도 평가
- 관련도 순으둜 μ •λ ¬ν•˜μ—¬ μƒμœ„ 10개 선택
"""
search_results = state.search_results
logger.info("검색 κ²°κ³Ό 필터링 쀑: %d개", len(search_results))
# κΈ°λ³Έ 필터링
filtered = [
r for r in search_results
if r.content and len(r.content) >= 50 and r.url
]
logger.info("κΈ°λ³Έ 필터링 ν›„: %d개 κ²°κ³Ό", len(filtered))
# μƒμœ„ 5개 결과만 LLM으둜 점수 λ§€κΈ°κΈ° (λΉ„μš© 절감)
for result in filtered[:5]:
if result.relevance_score is None:
try:
scoring_prompt = f"""질문: {state.user_question}
검색 κ²°κ³Ό: {result.content[:500]}
이 검색 κ²°κ³Όκ°€ μ§ˆλ¬Έμ— μ–Όλ§ˆλ‚˜ 관련이 μžˆλŠ”μ§€ 0.0μ—μ„œ 1.0 μ‚¬μ΄μ˜ 점수둜 ν‰κ°€ν•˜μ„Έμš”.
점수만 숫자둜 λ‹΅ν•˜μ„Έμš”. (예: 0.8)"""
response = llm.invoke([HumanMessage(content=scoring_prompt)])
score_str = response.content.strip()
result.relevance_score = float(score_str)
except Exception as e:
logger.warning("점수 λ§€κΈ°κΈ° μ‹€νŒ¨: %s", e)
result.relevance_score = 0.5
# 관련도 순으둜 μ •λ ¬
filtered.sort(key=lambda r: r.relevance_score or 0, reverse=True)
# μƒμœ„ 5개만 μœ μ§€
top_results = filtered[:5]
subtask_results = dict(state.subtask_results)
subtask_results["filtered_results"] = [r.model_dump() for r in top_results]
steps_delta = [f"βœ‚οΈ 필터링 μ™„λ£Œ: {len(top_results)}개 κ²°κ³Ό 선택"]
logger.info("필터링 μ™„λ£Œ: %d개 κ²°κ³Ό", len(top_results))
return {
"subtask_results": subtask_results,
"intermediate_steps": steps_delta
}
@trace_node("summarize_results")
def summarize_results_node(state: AgentState) -> dict:
"""
ν•„ν„°λ§λœ 각 검색 κ²°κ³Όλ₯Ό 초보 κ°œλ°œμžκ°€ μ΄ν•΄ν•˜κΈ° μ‰½κ²Œ μš”μ•½ν•©λ‹ˆλ‹€.
각 κ²°κ³Όλ₯Ό 2-3λ¬Έμž₯으둜 핡심 λ‚΄μš©λ§Œ μΆ”μΆœν•©λ‹ˆλ‹€.
"""
subtask_results = state.subtask_results
filtered_results = subtask_results.get("filtered_results", [])
logger.info("검색 κ²°κ³Ό μš”μ•½ 쀑: %d개", len(filtered_results))
summaries = []
for result_dict in filtered_results:
try:
summary_prompt = f"""λ‹€μŒ 검색 κ²°κ³Όλ₯Ό 초보 κ°œλ°œμžκ°€ μ΄ν•΄ν•˜κΈ° μ‰½κ²Œ 2-3λ¬Έμž₯으둜 μš”μ•½ν•˜μ„Έμš”:
좜처: {result_dict['source']}
λ‚΄μš©: {result_dict['content'][:1000]}
핡심 λ‚΄μš©λ§Œ 간단λͺ…λ£Œν•˜κ²Œ μš”μ•½ν•˜μ„Έμš”."""
response = llm.invoke([HumanMessage(content=summary_prompt)])
summaries.append({
"source": result_dict['source'],
"url": result_dict['url'],
"summary": response.content.strip(),
"relevance": result_dict.get('relevance_score', 0.5)
})
except Exception as e:
logger.error("μš”μ•½ μ‹€νŒ¨: %s", e)
updated_subtask_results = dict(subtask_results)
updated_subtask_results["summaries"] = summaries
steps_delta = [f"πŸ“ μš”μ•½ μ™„λ£Œ: {len(summaries)}개 κ²°κ³Ό"]
logger.info("μš”μ•½ μ™„λ£Œ: %d개", len(summaries))
return {
"subtask_results": updated_subtask_results,
"intermediate_steps": steps_delta
}
@trace_node("generate_answer")
async def generate_answer_node(state: AgentState) -> dict:
"""
μš”μ•½λœ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ μ΅œμ’… 닡변을 μƒμ„±ν•©λ‹ˆλ‹€.
μ˜λ„λ³„λ‘œ λ‹€λ₯Έ λ‹΅λ³€ ꡬ쑰λ₯Ό μ‚¬μš©ν•˜λ©°, μƒμ„±λœ 닡변은 μΊμ‹œμ— μ €μž₯λ©λ‹ˆλ‹€.
"""
subtask_results = state.subtask_results
summaries = subtask_results.get("summaries", [])
intent = state.detected_intent or "learning"
logger.info("μ΅œμ’… λ‹΅λ³€ 생성 쀑: %s", intent)
# μ˜λ„λ³„ ν”„λ‘¬ν”„νŠΈ ν…œν”Œλ¦Ώ
templates = {
"debugging": """λ‹€μŒ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ 디버깅 μ§ˆλ¬Έμ— λ‹΅λ³€ν•˜μ„Έμš”:
질문: {question}
μˆ˜μ§‘λœ 정보:
{summaries}
λ‹΅λ³€ ꡬ쑰:
1. 문제 μ •μ˜
2. λ°œμƒ 원인
3. ν•΄κ²° 방법 (μ½”λ“œ 예제 포함)
4. μ£Όμ˜μ‚¬ν•­
5. 참고 자료
초보 κ°œλ°œμžλ„ 이해할 수 있게 Markdown ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•˜μ„Έμš”.""",
"learning": """λ‹€μŒ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ ν•™μŠ΅ μ§ˆλ¬Έμ— λ‹΅λ³€ν•˜μ„Έμš”:
질문: {question}
μˆ˜μ§‘λœ 정보:
{summaries}
λ‹΅λ³€ ꡬ쑰:
1. κ°œλ… μ„€λͺ… (간단λͺ…λ£Œ)
2. λ™μž‘ 원리
3. 예제 μ½”λ“œ (주석 포함)
4. 싀무 ν™œμš© 팁
5. μΆ”κ°€ ν•™μŠ΅ 자료
초보 κ°œλ°œμžλ„ 이해할 수 있게 Markdown ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•˜μ„Έμš”.""",
"code_review": """λ‹€μŒ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ μ½”λ“œ 리뷰 μ§ˆλ¬Έμ— λ‹΅λ³€ν•˜μ„Έμš”:
질문: {question}
μˆ˜μ§‘λœ 정보:
{summaries}
λ‹΅λ³€ ꡬ쑰:
1. ν˜„μž¬ μ ‘κ·Ό 방식 뢄석
2. κ°œμ„  포인트
3. λ¦¬νŒ©ν† λ§ 예제
4. 베슀트 ν”„λž™ν‹°μŠ€
5. μ°Έκ³  νŒ¨ν„΄
초보 κ°œλ°œμžλ„ 이해할 수 있게 Markdown ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•˜μ„Έμš”."""
}
template = templates.get(intent, templates["learning"])
# μš”μ•½ ν…μŠ€νŠΈ ν¬λ§·νŒ…
summaries_text = "\n\n".join([
f"좜처: {s['source']} ({s['url']})\nμš”μ•½: {s['summary']}"
for s in summaries
])
# 이전 λŒ€ν™” λ§₯락 μΆ”κ°€ (messages μ‚¬μš©)
context_prefix = ""
messages_history = state.messages
if messages_history and len(messages_history) > 1:
context_prefix = "이전 λŒ€ν™” λ§₯락:\n"
# 졜근 6개 λ©”μ‹œμ§€ (3ν„΄) μ‚¬μš©
for msg in messages_history[-6:]:
if hasattr(msg, 'type'):
if msg.type == "human":
context_prefix += f"μ‚¬μš©μž: {msg.content}\n"
elif msg.type == "ai":
context_prefix += f"AI: {msg.content[:200]}...\n\n"
context_prefix += "---\nν˜„μž¬ 질문:\n"
final_prompt = (context_prefix + template).format(
question=(state.original_question or state.user_question),
summaries=summaries_text
)
updates = {}
steps_delta: List[str] = []
try:
response = llm.invoke([HumanMessage(content=final_prompt)])
final_answer = response.content.strip()
updates["final_answer"] = final_answer
# Phase 3: 쑰건뢀 μΊμ‹œ μ €μž₯
# - clarification: μΊμ‹œ κΈˆμ§€ (κ·Έλž˜ν”„ 상 generate_with_history둜 λΉ μ§€μ§€λ§Œ, λ°©μ–΄μ μœΌλ‘œ ν•œ 번 더 체크)
# - new_topic/independent: μΊμ‹œ κ°€λŠ₯(should_cacheκ°€ True일 λ•Œ)
should_cache = state.should_cache if state.should_cache is not None else True
canonical_question = state.canonical_question
qtype = state.question_type or "independent"
if should_cache and qtype in ["new_topic", "independent"]:
# μΊμ‹œν•  질문: canonical_question μš°μ„ , μ—†μœΌλ©΄ 원본 질문
question_to_cache = canonical_question or state.user_question
await qdrant_manager.save_to_cache(
question=question_to_cache,
answer=final_answer
)
steps_delta.append(f"βœ… μ΅œμ’… λ‹΅λ³€ 생성 μ™„λ£Œ (길이: {len(final_answer)}자)")
steps_delta.append(f"πŸ’Ύ μΊμ‹œ μ €μž₯ μ™„λ£Œ (질문: {question_to_cache[:50]}...)")
logger.info("μ΅œμ’… λ‹΅λ³€ 생성 및 μΊμ‹œ μ €μž₯ μ™„λ£Œ: %s", question_to_cache[:50])
else:
steps_delta.append(f"βœ… μ΅œμ’… λ‹΅λ³€ 생성 μ™„λ£Œ (길이: {len(final_answer)}자)")
steps_delta.append("⚠️ μΊμ‹œ μ €μž₯ μƒλž΅ (독립적이지 μ•Šκ±°λ‚˜ μΌνšŒμ„± 질문)")
logger.info("μ΅œμ’… λ‹΅λ³€ 생성 μ™„λ£Œ (μΊμ‹œ μ €μž₯ μƒλž΅)")
except Exception as e:
logger.error("λ‹΅λ³€ 생성 μ‹€νŒ¨: %s", e, exc_info=True)
updates["final_answer"] = "λ‹΅λ³€ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”."
steps_delta.append(f"❌ λ‹΅λ³€ 생성 μ‹€νŒ¨: {str(e)}")
updates["intermediate_steps"] = steps_delta
# Phase 4: Multi-question handling
# NOTE: AgentStateλŠ” Pydantic(BaseModel)μ΄λ―€λ‘œ dict-style state.get(...) μ‚¬μš© κΈˆμ§€
if state.is_multi_question:
answer_text = updates.get("final_answer")
if answer_text:
# Append to multi_answers (reducer will auto-merge)
updates["multi_answers"] = [{
"index": state.sub_question_index,
"question": state.sub_question_text or state.user_question,
"answer": answer_text
}]
logger.info("닀쀑 질문 λ‹΅λ³€ μΆ”κ°€: Q%d", state.sub_question_index)
return updates
@trace_node("return_cached_answer")
def return_cached_answer_node(state: AgentState) -> dict:
"""
μΊμ‹œ 히트 μ‹œ μ €μž₯된 닡변을 λ°˜ν™˜ν•©λ‹ˆλ‹€.
검색 및 생성 과정을 κ±΄λ„ˆλ›°κ³  μ¦‰μ‹œ 닡변을 μ œκ³΅ν•©λ‹ˆλ‹€.
"""
logger.info("μΊμ‹œλœ λ‹΅λ³€ λ°˜ν™˜")
steps_delta = ["πŸ’Ύ μΊμ‹œλœ λ‹΅λ³€ λ°˜ν™˜ (검색 μƒλž΅)"]
return {
"final_answer": state.cached_result,
"intermediate_steps": steps_delta
}
@trace_node("handle_too_many_questions")
def handle_too_many_questions_node(state: AgentState) -> dict:
"""
3개 이상 질문 μ‹œ μ•ˆλ‚΄ λ©”μ‹œμ§€λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.
λŒ€ν™”λ₯Ό μ’…λ£Œν•˜μ§€ μ•Šκ³ , μ‚¬μš©μžκ°€ λ‹€μ‹œ μ§ˆλ¬Έν•  수 μžˆλ„λ‘ ν•©λ‹ˆλ‹€.
"""
plan = state.plan or {}
error_message = plan.get("error_message", "")
sub_questions = plan.get("sub_questions", [])
logger.info("질문 수 초과: %d개", len(sub_questions))
default_message = """μ£„μ†‘ν•©λ‹ˆλ‹€. ν•œ λ²ˆμ— μ΅œλŒ€ 2개의 μ§ˆλ¬ΈκΉŒμ§€λ§Œ μ²˜λ¦¬ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
λ‹€μŒ 쀑 ν•˜λ‚˜λ₯Ό μ„ νƒν•΄μ„œ λ‹€μ‹œ μ§ˆλ¬Έν•΄ μ£Όμ„Έμš”:
1. **ν•˜λ‚˜μ˜ 주제둜 ν†΅ν•©ν•΄μ„œ 질문**
예: "JWT 인증과 CORS 섀정을 ν•¨κ»˜ κ΅¬ν˜„ν•˜λŠ” 방법"
2. **κ°€μž₯ μ€‘μš”ν•œ 2개 질문만 선택**
예: "JWTκ°€ 뭐야? λ‚΄ μ½”λ“œμ— μ–΄λ–»κ²Œ μ μš©ν•΄?"
3. **μ§ˆλ¬Έμ„ λ‚˜λˆ μ„œ 순차적으둜 질문**
예: λ¨Όμ € "JWTκ°€ 뭐야?" 질문 β†’ λ‹΅λ³€ 확인 β†’ λ‹€μŒ 질문
μ–΄λ–»κ²Œ λ„μ™€λ“œλ¦΄κΉŒμš”?"""
final_message = error_message if error_message else default_message
steps_delta = [
f"⚠️ 질문 수 초과: {len(sub_questions)}개",
"πŸ’¬ μ•ˆλ‚΄ λ©”μ‹œμ§€ 제곡 (λŒ€ν™” 계속 κ°€λŠ₯)"
]
return {
"final_answer": final_message,
"intermediate_steps": steps_delta
}
@trace_node("initiate_dynamic_search")
def initiate_dynamic_search_node(state: AgentState) -> dict:
"""
닀쀑 질문 처리의 μ§„μž… λ…Έλ“œ.
IMPORTANT:
- LangGraphμ—μ„œ `List[Send]`λŠ” **λ…Έλ“œ λ°˜ν™˜κ°’**이 μ•„λ‹ˆλΌ,
`add_conditional_edges(...)`에 μ „λ‹¬ν•˜λŠ” **edge ν•¨μˆ˜ λ°˜ν™˜κ°’**으둜만 μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€.
- λ”°λΌμ„œ 이 λ…Έλ“œλŠ” dict μ—…λ°μ΄νŠΈλ§Œ λ°˜ν™˜ν•˜κ³ ,
μ‹€μ œ fan-out은 별도 edge ν•¨μˆ˜(`fanout_multi_questions`)κ°€ λ‹΄λ‹Ήν•©λ‹ˆλ‹€.
"""
plan = state.plan or {}
sub_questions = plan.get("sub_questions", [])
logger.info("동적 볡제 μ€€λΉ„: %d개 질문", len(sub_questions))
return {
"intermediate_steps": [f"πŸ”€ 닀쀑 질문 fan-out μ€€λΉ„: {len(sub_questions)}개"]
}
def fanout_multi_questions(state: AgentState):
"""
닀쀑 μ§ˆλ¬Έμ„ Send API둜 fan-out ν•©λ‹ˆλ‹€.
λ°˜ν™˜κ°’(List[Send])은 conditional edge ν•¨μˆ˜μ—μ„œλ§Œ ν—ˆμš©λ©λ‹ˆλ‹€.
"""
from langgraph.types import Send
plan = state.plan or {}
sub_questions = plan.get("sub_questions", [])
original_question = state.user_question
messages = state.messages
logger.info("동적 볡제: %d개 μ§ˆλ¬Έμ„ 각각 전체 κ·Έλž˜ν”„λ‘œ μ‹€ν–‰", len(sub_questions))
sends = []
for i, sq in enumerate(sub_questions):
# IMPORTANT: 이 ν”„λ‘œμ νŠΈλŠ” AgentState(BaseModel)λ₯Ό λ…Έλ“œ μž…λ ₯으둜 μ‚¬μš©ν•˜λ―€λ‘œ,
# Send arg도 dictκ°€ μ•„λ‹ˆλΌ AgentState μΈμŠ€ν„΄μŠ€λ‘œ 보내야 ν•©λ‹ˆλ‹€.
child = state.model_copy(deep=True)
# 질문 ꡐ체 + 닀쀑 질문 메타데이터
child.user_question = sq
child.is_multi_question = True
child.sub_question_index = i
child.sub_question_text = sq
child.original_multi_question = original_question
# 곡톡 μœ μ§€ ν•„λ“œ
child.messages = messages
child.plan = plan
# κΈ°μ‘΄ κ·Έλž˜ν”„κ°€ λ‹€μ‹œ μ±„μšΈ ν•„λ“œλ“€μ€ μ΄ˆκΈ°ν™”
child.question_type = None
child.should_cache = None
child.canonical_question = None
child.analysis_reasoning = None
child.cached_result = None
child.detected_intent = None
child.search_results = []
child.subtask_results = {}
child.refinement_count = 0
child.needs_refinement = False
child.original_question = None
child.final_answer = None
child.multi_answers = []
child.intermediate_steps = [f"πŸ”„ 질문 {i+1}/{len(sub_questions)}: {sq[:50]}"]
# 닀쀑 μ§ˆλ¬Έμ€ outer graphμ—μ„œ κΈ°μ‘΄ νŒŒμ΄ν”„λΌμΈ 전체λ₯Ό λ³‘λ ¬λ‘œ 돌리면
# scalar state 채널(question_type λ“±)μ—μ„œ concurrent update 좩돌이 λ‚©λ‹ˆλ‹€.
# λ”°λΌμ„œ worker λ…Έλ“œ μ•ˆμ—μ„œ '단일 질문 κ·Έλž˜ν”„'λ₯Ό λ³„λ„λ‘œ μ‹€ν–‰ν•œ λ’€,
# outer stateμ—λŠ” multi_answers(reducer)만 μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€.
sends.append(Send("run_single_question_worker", child))
return sends
@trace_node("combine_answers")
def combine_answers_node(state: AgentState) -> dict:
"""
Fan-in: λͺ¨λ“  Sendκ°€ μ™„λ£Œλ˜λ©΄ multi_answersλ₯Ό μ‘°ν•©ν•©λ‹ˆλ‹€.
Reducer (Annotated[List[dict], add])κ°€ μžλ™μœΌλ‘œ
λͺ¨λ“  parallel Send의 κ²°κ³Όλ₯Ό multi_answers에 λͺ¨μ•„λ‘‘λ‹ˆλ‹€.
이 λ…Έλ“œλŠ” λ‹¨μˆœνžˆ λͺ¨μ•„μ§„ κ²°κ³Όλ₯Ό μ½μ–΄μ„œ Markdown으둜 μ‘°ν•©ν•©λ‹ˆλ‹€.
"""
answers = state.multi_answers
original_question = state.original_multi_question or state.user_question
if not answers:
logger.error("닀쀑 닡변이 λΉ„μ–΄μžˆμŒ")
return {
"final_answer": "λ‹΅λ³€ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.",
"intermediate_steps": ["❌ multi_answers λΉ„μ–΄μžˆμŒ"]
}
# 인덱슀 순으둜 μ •λ ¬
answers.sort(key=lambda x: x["index"])
# Markdown ν˜•μ‹μœΌλ‘œ μ‘°ν•©
combined_parts = []
for ans in answers:
section = f"""## {ans['index']+1}. {ans['question']}
{ans['answer']}"""
combined_parts.append(section)
combined = "\n\n---\n\n".join(combined_parts)
# 헀더 μΆ”κ°€
header = f"# 닀쀑 질문 λ‹΅λ³€\n\n원본 질문: {original_question}\n\n---\n\n"
final_combined = header + combined
logger.info("닀쀑 λ‹΅λ³€ μ‘°ν•© μ™„λ£Œ: %d개", len(answers))
return {
"final_answer": final_combined,
"intermediate_steps": [f"βœ… {len(answers)}개 λ‹΅λ³€ μ‘°ν•© μ™„λ£Œ"]
}
def _build_search_subgraph_local() -> StateGraph:
"""nodes.py λ‚΄λΆ€μ—μ„œ 단일 질문 κ·Έλž˜ν”„μš© 검색 μ„œλΈŒκ·Έλž˜ν”„λ₯Ό ꡬ성."""
subgraph = StateGraph(AgentState)
subgraph.add_node("filter_and_score", filter_and_score_node)
subgraph.add_node("summarize_results", summarize_results_node)
subgraph.add_edge(START, "filter_and_score")
subgraph.add_edge("filter_and_score", "summarize_results")
subgraph.add_edge("summarize_results", END)
return subgraph.compile()
def _get_single_question_agent():
"""
닀쀑 질문 workerμ—μ„œ μ‚¬μš©ν•  '단일 질문 νŒŒμ΄ν”„λΌμΈ' κ·Έλž˜ν”„λ₯Ό lazy-compile ν•΄μ„œ μΊμ‹±ν•©λ‹ˆλ‹€.
(outer state μΆ©λŒμ„ ν”Όν•˜κΈ° μœ„ν•΄, worker λ‚΄λΆ€μ—μ„œ 별도 κ·Έλž˜ν”„λ₯Ό μ‹€ν–‰)
"""
global _SINGLE_QUESTION_AGENT # type: ignore[name-defined]
try:
return _SINGLE_QUESTION_AGENT # type: ignore[name-defined]
except Exception:
pass
# ---- routing helpers (graph.py 의 단일 질문 흐름과 동일) ----
def _route_after_analysis(s: AgentState) -> Literal["generate_with_history", "check_cache"]:
raw_qtype = s.question_type or "independent"
legacy_map = {"followup": "clarification", "cache_candidate": "independent", "new_search": "independent"}
question_type = legacy_map.get(raw_qtype, raw_qtype)
return "generate_with_history" if question_type == "clarification" else "check_cache"
def _route_after_cache(s: AgentState) -> Literal["return_cached_answer", "classify_intent"]:
return "return_cached_answer" if s.cached_result else "classify_intent"
def _route_after_evaluation(s: AgentState) -> Literal["refine_search", "search_subgraph"]:
if s.needs_refinement and s.refinement_count < 1:
return "refine_search"
return "search_subgraph"
def _initiate_parallel_search(s: AgentState):
return [
Send("search_stackoverflow", s),
Send("search_github", s),
Send("search_official_docs", s),
]
# ---- build ----
g = StateGraph(AgentState)
g.add_node("analyze_question", analyze_question_node)
g.add_node("generate_with_history", generate_with_history_node)
g.add_node("check_cache", check_cache_node)
g.add_node("return_cached_answer", return_cached_answer_node)
g.add_node("classify_intent", classify_intent_node)
g.add_node("search_stackoverflow", search_stackoverflow_node)
g.add_node("search_github", search_github_node)
g.add_node("search_official_docs", search_official_docs_node)
g.add_node("collect_results", collect_results_node)
g.add_node("evaluate_results", evaluate_results_node)
g.add_node("refine_search", refine_search_node)
g.add_node("generate_answer", generate_answer_node)
search_subgraph = _build_search_subgraph_local()
g.add_node("search_subgraph", search_subgraph)
g.add_edge(START, "analyze_question")
g.add_conditional_edges(
"analyze_question",
_route_after_analysis,
{"generate_with_history": "generate_with_history", "check_cache": "check_cache"},
)
g.add_edge("generate_with_history", END)
g.add_conditional_edges(
"check_cache",
_route_after_cache,
{"return_cached_answer": "return_cached_answer", "classify_intent": "classify_intent"},
)
g.add_edge("return_cached_answer", END)
g.add_conditional_edges("classify_intent", _initiate_parallel_search)
g.add_edge("search_stackoverflow", "collect_results")
g.add_edge("search_github", "collect_results")
g.add_edge("search_official_docs", "collect_results")
g.add_edge("collect_results", "evaluate_results")
g.add_conditional_edges(
"evaluate_results",
_route_after_evaluation,
{"refine_search": "refine_search", "search_subgraph": "search_subgraph"},
)
g.add_edge("refine_search", "classify_intent")
g.add_edge("search_subgraph", "generate_answer")
g.add_edge("generate_answer", END)
_SINGLE_QUESTION_AGENT = g.compile()
return _SINGLE_QUESTION_AGENT
@trace_node("run_single_question_worker")
async def run_single_question_worker_node(state: AgentState) -> dict:
"""
닀쀑 질문의 각 μ„œλΈŒ μ§ˆλ¬Έμ„ '단일 질문 κ·Έλž˜ν”„'둜 μ‹€ν–‰ν•œ λ’€,
outer graphμ—λŠ” reducer 채널(multi_answers)만 μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€.
"""
agent = _get_single_question_agent()
# inner 싀행은 multi-question ν”Œλž˜κ·Έλ₯Ό κΊΌμ„œ(=multi_answers append λ°©μ§€)
inner = state.model_copy(deep=True)
inner.is_multi_question = False
inner.multi_answers = []
result = await agent.ainvoke(
{
"user_question": inner.user_question,
"messages": inner.messages,
}
)
answer_text = result.get("final_answer") or ""
return {
"multi_answers": [
{
"index": state.sub_question_index,
"question": state.sub_question_text or state.user_question,
"answer": answer_text,
}
],
"intermediate_steps": [f"βœ… μ„œλΈŒ 질문 {state.sub_question_index + 1} 처리 μ™„λ£Œ"],
}
@trace_node("generate_with_history")
async def generate_with_history_node(state: AgentState) -> dict:
"""
λŒ€ν™” νžˆμŠ€ν† λ¦¬λ§Œ μ‚¬μš©ν•˜μ—¬ 후속 μ§ˆλ¬Έμ— λ‹΅λ³€ν•©λ‹ˆλ‹€.
Phase 2: Follow-up Handler
- μΊμ‹œ 검색 μ•ˆ 함
- μ›Ή 검색 μ•ˆ 함
- μΊμ‹œμ— μ €μž₯ μ•ˆ 함
- messages νžˆμŠ€ν† λ¦¬λ§Œ ν™œμš©
"""
user_question = state.user_question
messages_history = state.messages
logger.info("λŒ€ν™” νžˆμŠ€ν† λ¦¬ 기반 λ‹΅λ³€ 생성: %s", user_question[:50])
# λŒ€ν™” λ§₯락 ꡬ성
context_prompt = "이전 λŒ€ν™”λ₯Ό μ°Έκ³ ν•˜μ—¬ 후속 μ§ˆλ¬Έμ— λ‹΅λ³€ν•˜μ„Έμš”.\n\n"
if messages_history:
context_prompt += "λŒ€ν™” λ‚΄μ—­:\n"
for msg in messages_history[:-1]: # ν˜„μž¬ 질문 μ œμ™Έ
if hasattr(msg, 'type') and hasattr(msg, 'content'):
role = "μ‚¬μš©μž" if msg.type == "human" else "AI"
context_prompt += f"{role}: {msg.content}\n\n"
context_prompt += f"ν˜„μž¬ 질문: {user_question}\n\n"
context_prompt += "이전 λŒ€ν™” λ§₯락을 κ³ λ €ν•˜μ—¬ μžμ„Έν•˜κ³  μΉœμ ˆν•˜κ²Œ λ‹΅λ³€ν•˜μ„Έμš”."
updates = {}
steps_delta: List[str] = []
try:
response = llm.invoke([HumanMessage(content=context_prompt)])
final_answer = response.content.strip()
updates["final_answer"] = final_answer
steps_delta.append(f"πŸ’¬ λŒ€ν™” νžˆμŠ€ν† λ¦¬ 기반 λ‹΅λ³€ 생성 (길이: {len(final_answer)}자)")
steps_delta.append("⚠️ μΊμ‹œ μ €μž₯ μƒλž΅ (보좩 μš”μ²­)")
logger.info("λŒ€ν™” νžˆμŠ€ν† λ¦¬ 기반 λ‹΅λ³€ 생성 μ™„λ£Œ")
except Exception as e:
logger.error("λŒ€ν™” νžˆμŠ€ν† λ¦¬ 기반 λ‹΅λ³€ 생성 μ‹€νŒ¨: %s", e, exc_info=True)
updates["final_answer"] = "λ‹΅λ³€ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”."
steps_delta.append(f"❌ λ‹΅λ³€ 생성 μ‹€νŒ¨: {str(e)}")
updates["intermediate_steps"] = steps_delta
return updates