Spaces:
Runtime error
Runtime error
Download CodeWeaver/src/agent/nodes.py from ise0305/codeweaver-ai: direct link, hf CLI and curl.
- Browser
- Download file 51.7 kB
-
https://huggingface.co/spaces/ise0305/codeweaver-ai/resolve/main/CodeWeaver/src/agent/nodes.py
- Command line
-
hf download hf://spaces/ise0305/codeweaver-ai/CodeWeaver/src/agent/nodes.py
-
curl -L -o nodes.py https://huggingface.co/spaces/ise0305/codeweaver-ai/resolve/main/CodeWeaver/src/agent/nodes.py
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() | |
| 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 | |
| } | |
| 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 | |
| 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 | |
| } | |
| 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 | |
| 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)}"] | |
| } | |
| 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)}"] | |
| } | |
| 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)}"] | |
| } | |
| 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 | |
| } | |
| 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 | |
| } | |
| 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 | |
| } | |
| 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 | |
| } | |
| 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 | |
| } | |
| 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 | |
| def return_cached_answer_node(state: AgentState) -> dict: | |
| """ | |
| μΊμ ννΈ μ μ μ₯λ λ΅λ³μ λ°νν©λλ€. | |
| κ²μ λ° μμ± κ³Όμ μ 건λλ°κ³ μ¦μ λ΅λ³μ μ 곡ν©λλ€. | |
| """ | |
| logger.info("μΊμλ λ΅λ³ λ°ν") | |
| steps_delta = ["πΎ μΊμλ λ΅λ³ λ°ν (κ²μ μλ΅)"] | |
| return { | |
| "final_answer": state.cached_result, | |
| "intermediate_steps": steps_delta | |
| } | |
| 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 | |
| } | |
| 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 | |
| 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 | |
| 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} μ²λ¦¬ μλ£"], | |
| } | |
| 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 | |