Spaces:
Sleeping
Sleeping
ㅅㅎㅇ
commited on
Commit
·
f627d36
1
Parent(s):
3e5c5ab
refactor: split nodes into modules, add core/prompts, switch to sync mode
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +0 -34
- CodeWeaver/README.md +145 -114
- CodeWeaver/pyproject.toml +8 -2
- CodeWeaver/requirements.txt +16 -21
- CodeWeaver/src/agent/__init__.py +7 -12
- CodeWeaver/src/agent/graph.py +76 -317
- CodeWeaver/src/agent/nodes.py +0 -1212
- {hf-space2/CodeWeaver/src/agent → CodeWeaver/src/agent/nodes}/__init__.py +45 -30
- CodeWeaver/src/agent/nodes/analysis.py +187 -0
- CodeWeaver/src/agent/nodes/answer.py +381 -0
- CodeWeaver/src/agent/nodes/common.py +44 -0
- CodeWeaver/src/agent/nodes/planning.py +171 -0
- CodeWeaver/src/agent/nodes/search.py +345 -0
- CodeWeaver/src/agent/routes.py +126 -0
- CodeWeaver/src/agent/state.py +37 -8
- CodeWeaver/src/core/__init__.py +15 -0
- CodeWeaver/src/core/config.py +47 -0
- CodeWeaver/src/core/llm.py +41 -0
- CodeWeaver/src/core/resources.py +86 -0
- CodeWeaver/src/prompts/__init__.py +6 -0
- CodeWeaver/src/prompts/loader.py +144 -0
- CodeWeaver/src/prompts/templates/analysis.yaml +45 -0
- CodeWeaver/src/prompts/templates/answer.yaml +65 -0
- CodeWeaver/src/prompts/templates/planning.yaml +66 -0
- CodeWeaver/src/prompts/templates/search.yaml +25 -0
- CodeWeaver/src/scripts/init_db.py +47 -0
- CodeWeaver/src/scripts/init_qdrant.py +73 -0
- CodeWeaver/src/tools/__init__.py +1 -2
- CodeWeaver/src/tools/{search_tools.py → search.py} +67 -95
- CodeWeaver/src/vector_db/local_embeddings.py +95 -16
- CodeWeaver/src/vector_db/qdrant_client.py +24 -31
- CodeWeaver/ui/app.py +120 -149
- CodeWeaver/uv.lock +0 -0
- hf-space2/CodeWeaver/.env.example +0 -9
- hf-space2/CodeWeaver/.gitignore +0 -23
- hf-space2/CodeWeaver/.python-version +0 -1
- hf-space2/CodeWeaver/IMPLEMENTATION_REPORT.md +0 -175
- hf-space2/CodeWeaver/PHASE3_CHANGES.md +0 -142
- hf-space2/CodeWeaver/PHASE5_SUBGRAPH_REFACTORING.md +0 -320
- hf-space2/CodeWeaver/README.md +0 -118
- hf-space2/CodeWeaver/main.py +0 -6
- hf-space2/CodeWeaver/pyproject.toml +0 -27
- hf-space2/CodeWeaver/requirements.txt +0 -24
- hf-space2/CodeWeaver/src/__init__.py +0 -0
- hf-space2/CodeWeaver/src/agent/graph.py +0 -420
- hf-space2/CodeWeaver/src/agent/nodes.py +0 -1212
- hf-space2/CodeWeaver/src/agent/state.py +0 -141
- hf-space2/CodeWeaver/src/tools/__init__.py +0 -12
- hf-space2/CodeWeaver/src/tools/search_tools.py +0 -217
- hf-space2/CodeWeaver/src/utils/__init__.py +0 -7
.gitignore
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
# Python
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.py[cod]
|
| 4 |
-
*$py.class
|
| 5 |
-
*.so
|
| 6 |
-
.Python
|
| 7 |
-
*.egg-info/
|
| 8 |
-
dist/
|
| 9 |
-
build/
|
| 10 |
-
|
| 11 |
-
# Environment
|
| 12 |
-
.env
|
| 13 |
-
.venv
|
| 14 |
-
env/
|
| 15 |
-
venv/
|
| 16 |
-
ENV/
|
| 17 |
-
|
| 18 |
-
# IDE
|
| 19 |
-
.vscode/
|
| 20 |
-
.idea/
|
| 21 |
-
*.swp
|
| 22 |
-
*.swo
|
| 23 |
-
|
| 24 |
-
# OS
|
| 25 |
-
.DS_Store
|
| 26 |
-
Thumbs.db
|
| 27 |
-
|
| 28 |
-
# Logs
|
| 29 |
-
*.log
|
| 30 |
-
|
| 31 |
-
# Lock files (HF Spaces will install from requirements.txt)
|
| 32 |
-
uv.lock
|
| 33 |
-
poetry.lock
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CodeWeaver/README.md
CHANGED
|
@@ -1,118 +1,149 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
```
|
| 78 |
-
|
| 79 |
-
기본 주소: `http://localhost:7860`
|
| 80 |
-
|
| 81 |
-
## 현재 폴더 구조
|
| 82 |
-
|
| 83 |
-
```
|
| 84 |
-
CodeWeaver/
|
| 85 |
-
├── main.py
|
| 86 |
-
├── pyproject.toml
|
| 87 |
-
├── requirements.txt
|
| 88 |
├── src/
|
| 89 |
│ ├── agent/
|
| 90 |
-
│ │ ├── graph.py
|
| 91 |
-
│ │ ├── nodes.py
|
| 92 |
-
│ │ └── state.py
|
| 93 |
│ ├── tools/
|
| 94 |
-
│ │ └── search_tools.py
|
|
|
|
|
|
|
| 95 |
│ ├── utils/
|
| 96 |
-
│ │ └── tracing.py
|
| 97 |
-
│ └──
|
| 98 |
-
|
| 99 |
-
│
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
🕸️ CodeWeaver: LangGraph 기반 지능형 개발 어시스턴트
|
| 2 |
+
CodeWeaver는 초보 개발자를 위해 설계된 LangGraph 기반의 자율 AI 에이전트입니다.
|
| 3 |
+
단순한 LLM 래퍼가 아닙니다. 사용자의 질문 의도를 파악하고(Planning), 필요한 경우에만 외부 지식을 검색하며(Retrieval), 검색 결과를 재평가(Reranking)하여 최적의 답변을 생성하는 Agentic Workflow를 구현했습니다.
|
| 4 |
+

|
| 5 |
+
|
| 6 |
+

|
| 7 |
+
|
| 8 |
+

|
| 9 |
+
|
| 10 |
+

|
| 11 |
+
|
| 12 |
+

|
| 13 |
+
🚀 핵심 기능 (Key Features)
|
| 14 |
+
1. 지능형 워크플로우 (Agentic Architecture)
|
| 15 |
+
질문 분석 및 계획 (Planning): 사용자의 질문을 분석하여 단순 대화(general_chat)인지, 기술 질문(independent)인지, 추가 설명 요청(clarification)인지 판단합니다.
|
| 16 |
+
다중 질문 처리 (Map-Reduce): "JWT와 CORS가 뭐야?"와 같이 복합적인 질문이 들어오면, 이를 독립적인 하위 질문으로 분해하여 병렬로 처리한 후 답변을 통합합니다.
|
| 17 |
+
Fast Track: 일상적인 인사나 가벼운 대화는 검색 프로세스를 건너뛰고 즉시 응답하여 API 비용을 절감하고 속도를 높입니다.
|
| 18 |
+
2. 고품질 검색 및 평가 (Advanced RAG)
|
| 19 |
+
멀티 소스 검색: StackOverflow(디버깅), GitHub(코드 예제), Tavily(30+ 공식 문서)를 동시에 검색합니다.
|
| 20 |
+
Reranking (Cross-Encoder): 검색된 문서들을 FastEmbed 기반의 **Cross-Encoder(Jina-Reranker)**로 정밀 채점합니다.
|
| 21 |
+
품질 필터링: 관련성 점수 0.35 미만의 문서는 답변 생성에 사용하지 않아 할루시네이션을 방지합니다.
|
| 22 |
+
자동 쿼리 개선 (Refinement): 검색 결과가 부족하거나 품질이 낮을 경우, 에이전트가 스스로 검색어를 영어 기술 용어로 변환하거나 구체화하여 재검색합니다.
|
| 23 |
+
3. 성능 및 안정성 최적화
|
| 24 |
+
영구적 기억 (Persistence): **PostgreSQL (Neon DB)**을 사용하여 서버가 재시작되어도 대화 맥락이 유지됩니다.
|
| 25 |
+
Windows 호환성 (Sync Mode): Windows 환경의 asyncio 이슈(ProactorLoop)를 해결하기 위해, 전체 파이프라인을 동기(Sync) 모드 및 ConnectionPool 기반으로 설계했습니다.
|
| 26 |
+
백그라운드 캐싱: 답변 생성 후 Vector DB(Qdrant)에 저장하는 작업은 Daemon Thread로 비동기 처리하여 사용자 대기 시간(Latency)을 최소화했습니다.
|
| 27 |
+
메모리 최적화: 무거운 sentence-transformers 대신 가벼운 fastembed를 사용하고, 모델 로딩에 Singleton 패턴을 적용했습니다.
|
| 28 |
+
🛠️ 기술 스택 (Tech Stack)
|
| 29 |
+
분류 기술 설명
|
| 30 |
+
Framework LangGraph 에이전트 상태 관리 및 순환 그래프 제어
|
| 31 |
+
LLM Google Gemini 2.5 Flash Lite 추론, 계획, 답변 생성
|
| 32 |
+
Vector DB Qdrant Cloud 질문-답변 의미적 캐싱 (Semantic Cache)
|
| 33 |
+
Embedding BAAI/bge-base-en-v1.5 텍스트 임베딩 (Local)
|
| 34 |
+
Reranker jinaai/jina-reranker-v1-tiny-en 검색 결과 재순위화 (Cross-Encoder)
|
| 35 |
+
Search Tools Tavily, StackExchange, GitHub API 외부 지식 검색
|
| 36 |
+
Database PostgreSQL (Neon) 대화 상태 저장 (Checkpointer)
|
| 37 |
+
UI Gradio 채팅 인터페이스 및 세션 관리
|
| 38 |
+
Dev Tool uv 초고속 패키지 관리 및 의존성 해결
|
| 39 |
+
🏗️ 아키텍처 (Architecture)
|
| 40 |
+
CodeWeaver는 StateGraph를 사용하여 에이전트의 상태를 관리합니다.
|
| 41 |
+
code
|
| 42 |
+
Mermaid
|
| 43 |
+
graph TD
|
| 44 |
+
START --> CreatePlan[Create Plan]
|
| 45 |
+
CreatePlan -->|Single| Analyze[Analyze Question]
|
| 46 |
+
CreatePlan -->|Multi| Parallel[Map: Parallel Subgraphs]
|
| 47 |
+
|
| 48 |
+
subgraph "Single Question Workflow"
|
| 49 |
+
Analyze -->|Intent: General| GenDirect[Generate Answer]
|
| 50 |
+
Analyze -->|Intent: Independent| CheckCache[Check Cache]
|
| 51 |
+
|
| 52 |
+
CheckCache -->|Hit| ReturnCache[Return Cached Answer]
|
| 53 |
+
CheckCache -->|Miss| Classify[Classify Intent]
|
| 54 |
+
|
| 55 |
+
Classify --> SearchParallel[Search: SO / GitHub / Docs]
|
| 56 |
+
SearchParallel --> Evaluate[Evaluate & Rerank]
|
| 57 |
+
|
| 58 |
+
Evaluate -->|Good| GenerateRAG[Generate Answer w/ Context]
|
| 59 |
+
Evaluate -->|Bad| Refine[Refine Query]
|
| 60 |
+
Refine --> Classify
|
| 61 |
+
end
|
| 62 |
+
|
| 63 |
+
Parallel --> Combine[Reduce: Combine Answers]
|
| 64 |
+
GenDirect --> END
|
| 65 |
+
ReturnCache --> END
|
| 66 |
+
GenerateRAG --> END
|
| 67 |
+
Combine --> END
|
| 68 |
+
주요 노드 설명
|
| 69 |
+
create_plan: 사용자 입력을 분석하여 단일 질문인지, 다중 질문인지, 처리 불가능(3개 이상)인지 판단합니다.
|
| 70 |
+
analyze_question: 질문의 성격(일상 대화 vs 기술 질문)을 분류하고 캐시 적격성을 판단합니다.
|
| 71 |
+
evaluate_results: 수집된 검색 결과를 Reranker로 평가합니다. 점수가 낮으면 refine_search로 보냅니다.
|
| 72 |
+
generate_answer: 필터링된 고품질 문서를 바탕으로 초보자 친화적인 답변을 생성합니다. (검색 결과 요약 단계 없이 원본 활용 - Context Stuffing)
|
| 73 |
+
📂 프로젝트 구조
|
| 74 |
+
code
|
| 75 |
+
Bash
|
| 76 |
+
codeweaver/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
├── src/
|
| 78 |
│ ├── agent/
|
| 79 |
+
│ │ ├── graph.py # LangGraph 구조 정의 (Main & Subgraphs)
|
| 80 |
+
│ │ ├── nodes.py # 각 노드의 실행 로직 (LLM 호출, 판단 등)
|
| 81 |
+
│ │ └── state.py # Pydantic 기반 State 정의 (AgentState, WorkerState)
|
| 82 |
│ ├── tools/
|
| 83 |
+
│ │ └── search_tools.py # Tavily, StackOverflow, GitHub 검색 도구
|
| 84 |
+
│ ├── vector_db/
|
| 85 |
+
│ │ └── qdrant_client.py # Qdrant 연동 및 캐시 로직
|
| 86 |
│ ├── utils/
|
| 87 |
+
│ │ └── tracing.py # LangSmith 트레이싱 데코레이터
|
| 88 |
+
│ └── config.py # Pydantic Settings 환경 설정
|
| 89 |
+
├── ui/
|
| 90 |
+
│ └── app.py # Gradio 웹 인터페이스
|
| 91 |
+
├── .env # 환경 변수 (API 키)
|
| 92 |
+
├── pyproject.toml # 프로젝트 및 의존성 설정
|
| 93 |
+
└── uv.lock # 의존성 잠금 파일
|
| 94 |
+
⚙️ 설치 및 실행 (Setup)
|
| 95 |
+
이 프로젝트는 uv 를 사용하여 패키지를 관리합니다.
|
| 96 |
+
1. 필수 요구 사항
|
| 97 |
+
Python 3.10 이상
|
| 98 |
+
uv 설치 필요
|
| 99 |
+
PostgreSQL 데이터베이스 (Neon Serverless 권장)
|
| 100 |
+
Qdrant 클러스터 (Cloud 권장)
|
| 101 |
+
2. 환경 변수 설정
|
| 102 |
+
프로젝트 루트에 .env 파일을 생성하고 아래 내용을 채워주세요.
|
| 103 |
+
code
|
| 104 |
+
Ini
|
| 105 |
+
# Gemini API Key
|
| 106 |
+
GOOGLE_API_KEY=your-google-api-key
|
| 107 |
+
|
| 108 |
+
# Search Tool
|
| 109 |
+
TAVILY_API_KEY=your-tavily-api-key
|
| 110 |
+
|
| 111 |
+
# Vector DB (Qdrant)
|
| 112 |
+
QDRANT_URL=https://your-qdrant-endpoint
|
| 113 |
+
QDRANT_API_KEY=your-qdrant-api-key
|
| 114 |
+
|
| 115 |
+
# LangSmith Tracing (선택 사항)
|
| 116 |
+
LANGCHAIN_TRACING_V2=true
|
| 117 |
+
LANGCHAIN_API_KEY=your_langsmith_api_key_here
|
| 118 |
+
LANGCHAIN_PROJECT=codeweaver
|
| 119 |
+
LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
|
| 120 |
+
|
| 121 |
+
# GitHub API (Rate Limit 완화용, 선택 사항)
|
| 122 |
+
GITHUB_TOKEN=your-github-token
|
| 123 |
+
|
| 124 |
+
# Database Connection (PostgreSQL/Neon)
|
| 125 |
+
POSTGRES_DB_URL=postgresql://user:password@host/dbname
|
| 126 |
+
3. 패키지 설치
|
| 127 |
+
uv를 사용하여 의존성을 동기화합니다. 가상환경이 자동으로 생성됩니다.
|
| 128 |
+
code
|
| 129 |
+
Bash
|
| 130 |
+
uv sync
|
| 131 |
+
4. 실행
|
| 132 |
+
uv run 명령어를 사용하여 앱을 실행합니다.
|
| 133 |
+
code
|
| 134 |
+
Bash
|
| 135 |
+
uv run ui/app.py
|
| 136 |
+
브라우저에서 http://localhost:7860으로 접속하여 사용할 수 있습니다.
|
| 137 |
+
💡 주요 구현 디테일 (Under the Hood)
|
| 138 |
+
Sync Mode & Windows 호환성
|
| 139 |
+
Python의 asyncio는 Windows의 ProactorLoop와 특정 DB 드라이버 간 충돌을 일으킬 수 있습니다. 이를 근본적으로 해결하기 위해 CodeWeaver는 전체 워크플로우를 동기(Sync) 방식으로 구현했습니다.
|
| 140 |
+
psycopg_pool.ConnectionPool을 사용하여 동기 환경에서도 효율적인 DB 연결을 관리합니다.
|
| 141 |
+
LangGraph의 checkpointer 역시 PostgresSaver의 동기 버전을 사용합니다.
|
| 142 |
+
Thread-safe Singleton & Background Caching
|
| 143 |
+
Singleton: TextCrossEncoder 모델과 ConnectionPool은 전역에서 한 번만 초기화되며, threading.Lock을 통해 멀티스레드 환경에서도 안전하게 접근합니다.
|
| 144 |
+
Fire-and-forget: 사용자에게 답변을 표시하는 것과 별개로, 캐시 저장 작업은 daemon=True인 백그라운드 스레드에서 수행되어 응답 지연을 유발하지 않습니다.
|
| 145 |
+
검색 도메인 최적화 (search_tools.py)
|
| 146 |
+
Tavily 검색 시 단순히 웹 전체를 뒤지는 것이 아니라, 개발자에게 신뢰할 수 있는 도메인만 include_domains로 지정하여 검색 품질을 높였습니다.
|
| 147 |
+
포함 도메인: docs.python.org, spring.io, stackoverflow.com, github.com, developer.mozilla.org, platform.openai.com 등 약 30개 이상의 공식 문서 및 커뮤니티.
|
| 148 |
+
🤝 Contributing
|
| 149 |
+
이 프로젝트는 개인 학습 및 연구 목적으로 개발되었습니다. 버그 리포트나 기능 제안은 Issue로 남겨주세요.
|
CodeWeaver/pyproject.toml
CHANGED
|
@@ -15,9 +15,15 @@ dependencies = [
|
|
| 15 |
"langchain-core>=0.3.0",
|
| 16 |
"langchain-google-genai>=2.0.0",
|
| 17 |
"langgraph>=0.2.0",
|
| 18 |
-
"
|
| 19 |
-
"torch>=2.0.0",
|
| 20 |
"gradio==4.44.1",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
]
|
| 22 |
|
| 23 |
[tool.pytest.ini_options]
|
|
|
|
| 15 |
"langchain-core>=0.3.0",
|
| 16 |
"langchain-google-genai>=2.0.0",
|
| 17 |
"langgraph>=0.2.0",
|
| 18 |
+
"fastembed>=0.7.0",
|
|
|
|
| 19 |
"gradio==4.44.1",
|
| 20 |
+
"pydantic>=2.0.0",
|
| 21 |
+
"pydantic-settings>=2.0.0",
|
| 22 |
+
"langgraph-checkpoint-postgres",
|
| 23 |
+
"psycopg-binary",
|
| 24 |
+
"psycopg-pool",
|
| 25 |
+
"pyyaml",
|
| 26 |
+
"jinja2",
|
| 27 |
]
|
| 28 |
|
| 29 |
[tool.pytest.ini_options]
|
CodeWeaver/requirements.txt
CHANGED
|
@@ -1,24 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
langchain-core>=0.3.0
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
qdrant-client>=1.11.0
|
| 9 |
-
|
| 10 |
-
# Search APIs
|
| 11 |
-
tavily-python>=0.5.0
|
| 12 |
-
requests>=2.31.0
|
| 13 |
-
|
| 14 |
-
# Embeddings
|
| 15 |
-
sentence-transformers>=3.0.0
|
| 16 |
-
torch>=2.0.0
|
| 17 |
-
|
| 18 |
-
# UI
|
| 19 |
gradio==4.44.1
|
| 20 |
-
|
| 21 |
-
# Utils
|
| 22 |
-
python-dotenv>=1.0.0
|
| 23 |
pydantic>=2.0.0
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
qdrant-client
|
| 2 |
+
pytest
|
| 3 |
+
pytest-asyncio
|
| 4 |
+
python-dotenv
|
| 5 |
+
tavily-python
|
| 6 |
+
requests
|
| 7 |
+
langsmith>=0.1.0
|
| 8 |
langchain-core>=0.3.0
|
| 9 |
+
langchain-google-genai>=2.0.0
|
| 10 |
+
langgraph>=0.2.0
|
| 11 |
+
fastembed>=0.7.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
gradio==4.44.1
|
|
|
|
|
|
|
|
|
|
| 13 |
pydantic>=2.0.0
|
| 14 |
+
pydantic-settings>=2.0.0
|
| 15 |
+
langgraph-checkpoint-postgres
|
| 16 |
+
psycopg-binary
|
| 17 |
+
psycopg-pool
|
| 18 |
+
pyyaml
|
| 19 |
+
jinja2
|
CodeWeaver/src/agent/__init__.py
CHANGED
|
@@ -6,20 +6,20 @@ LangGraph 기반 개발자 질문 답변 에이전트를 제공합니다.
|
|
| 6 |
주요 컴포넌트:
|
| 7 |
- State: 에이전트 상태 관리
|
| 8 |
- Nodes: 개별 처리 노드
|
| 9 |
-
- Graph: LangGraph 워크플로우
|
| 10 |
"""
|
| 11 |
|
| 12 |
from .state import AgentState, SearchResult
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
from .nodes import (
|
| 15 |
analyze_question_node,
|
| 16 |
check_cache_node,
|
| 17 |
-
classify_intent_node,
|
| 18 |
search_stackoverflow_node,
|
| 19 |
search_github_node,
|
| 20 |
search_official_docs_node,
|
| 21 |
-
filter_and_score_node,
|
| 22 |
-
summarize_results_node,
|
| 23 |
generate_answer_node,
|
| 24 |
return_cached_answer_node,
|
| 25 |
generate_with_history_node,
|
|
@@ -31,21 +31,16 @@ __all__ = [
|
|
| 31 |
"SearchResult",
|
| 32 |
|
| 33 |
# Graph
|
| 34 |
-
"
|
| 35 |
"build_agent_graph",
|
| 36 |
-
"create_agent",
|
| 37 |
|
| 38 |
# Nodes
|
| 39 |
"analyze_question_node",
|
| 40 |
"check_cache_node",
|
| 41 |
-
"classify_intent_node",
|
| 42 |
"search_stackoverflow_node",
|
| 43 |
"search_github_node",
|
| 44 |
"search_official_docs_node",
|
| 45 |
-
"filter_and_score_node",
|
| 46 |
-
"summarize_results_node",
|
| 47 |
"generate_answer_node",
|
| 48 |
"return_cached_answer_node",
|
| 49 |
"generate_with_history_node",
|
| 50 |
-
]
|
| 51 |
-
|
|
|
|
| 6 |
주요 컴포넌트:
|
| 7 |
- State: 에이전트 상태 관리
|
| 8 |
- Nodes: 개별 처리 노드
|
| 9 |
+
- Graph: LangGraph 워크플로우 (get_agent 사용)
|
| 10 |
"""
|
| 11 |
|
| 12 |
from .state import AgentState, SearchResult
|
| 13 |
+
|
| 14 |
+
# [핵심 수정] agent, create_agent 제거 -> get_agent 추가
|
| 15 |
+
from .graph import get_agent, build_agent_graph
|
| 16 |
+
|
| 17 |
from .nodes import (
|
| 18 |
analyze_question_node,
|
| 19 |
check_cache_node,
|
|
|
|
| 20 |
search_stackoverflow_node,
|
| 21 |
search_github_node,
|
| 22 |
search_official_docs_node,
|
|
|
|
|
|
|
| 23 |
generate_answer_node,
|
| 24 |
return_cached_answer_node,
|
| 25 |
generate_with_history_node,
|
|
|
|
| 31 |
"SearchResult",
|
| 32 |
|
| 33 |
# Graph
|
| 34 |
+
"get_agent", # ✅ 변경됨 (agent 대신 사용)
|
| 35 |
"build_agent_graph",
|
|
|
|
| 36 |
|
| 37 |
# Nodes
|
| 38 |
"analyze_question_node",
|
| 39 |
"check_cache_node",
|
|
|
|
| 40 |
"search_stackoverflow_node",
|
| 41 |
"search_github_node",
|
| 42 |
"search_official_docs_node",
|
|
|
|
|
|
|
| 43 |
"generate_answer_node",
|
| 44 |
"return_cached_answer_node",
|
| 45 |
"generate_with_history_node",
|
| 46 |
+
]
|
|
|
CodeWeaver/src/agent/graph.py
CHANGED
|
@@ -1,380 +1,126 @@
|
|
| 1 |
"""
|
| 2 |
CodeWeaver LangGraph 워크플로우 구성.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
✅ Conditional Edges: 질문 유형, 캐시 여부에 따른 분기
|
| 6 |
-
✅ Send API: 3개 검색 노드 병렬 실행 (fan-out/fan-in)
|
| 7 |
-
✅ Subgraph: 단일 질문 처리 파이프라인 + 검색 결과 처리 파이프라인
|
| 8 |
-
✅ Map-Reduce: Send API로 병렬 검색 → 결과 머지
|
| 9 |
-
✅ Checkpointing: MemorySaver로 대화 상태 저장
|
| 10 |
-
✅ Pydantic Typed State: 타입 안전성 보장
|
| 11 |
"""
|
| 12 |
-
|
| 13 |
import logging
|
| 14 |
-
from typing import
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
from langgraph.graph import StateGraph, START, END
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
from src.
|
|
|
|
| 21 |
from src.agent.nodes import (
|
| 22 |
-
analyze_question_node,
|
| 23 |
-
check_cache_node,
|
| 24 |
-
create_plan_node,
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
search_github_node,
|
| 28 |
search_official_docs_node,
|
| 29 |
-
collect_results_node,
|
| 30 |
-
evaluate_results_node,
|
| 31 |
refine_search_node,
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
generate_answer_node,
|
| 35 |
-
return_cached_answer_node,
|
| 36 |
generate_with_history_node,
|
| 37 |
-
handle_too_many_questions_node,
|
| 38 |
combine_answers_node,
|
| 39 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
logger = logging.getLogger(__name__)
|
| 42 |
|
| 43 |
|
| 44 |
-
def build_search_subgraph() -> StateGraph:
|
| 45 |
-
"""
|
| 46 |
-
검색 결과 처리 서브그래프를 구성합니다.
|
| 47 |
-
|
| 48 |
-
흐름: filter_and_score → summarize_results
|
| 49 |
-
|
| 50 |
-
이 서브그래프는 single_question_subgraph 내부에서 사용되므로
|
| 51 |
-
WorkerState를 사용하여 채널 타입 충돌을 방지합니다.
|
| 52 |
-
|
| 53 |
-
Returns:
|
| 54 |
-
컴파일된 서브그래프
|
| 55 |
-
"""
|
| 56 |
-
# 서브그래프 생성 (WorkerState 사용)
|
| 57 |
-
subgraph = StateGraph(WorkerState)
|
| 58 |
-
|
| 59 |
-
# 노드 추가
|
| 60 |
-
subgraph.add_node("filter_and_score", filter_and_score_node)
|
| 61 |
-
subgraph.add_node("summarize_results", summarize_results_node)
|
| 62 |
-
|
| 63 |
-
# 서브그래프 내부 흐름 정의
|
| 64 |
-
# START → filter_and_score → summarize_results → END
|
| 65 |
-
subgraph.add_edge(START, "filter_and_score")
|
| 66 |
-
subgraph.add_edge("filter_and_score", "summarize_results")
|
| 67 |
-
subgraph.add_edge("summarize_results", END)
|
| 68 |
-
|
| 69 |
-
return subgraph.compile()
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def route_after_analysis_worker(state: WorkerState) -> Literal["generate_with_history", "check_cache"]:
|
| 73 |
-
"""
|
| 74 |
-
질문 분석 결과에 따라 다음 노드를 결정합니다 (WorkerState용).
|
| 75 |
-
|
| 76 |
-
Args:
|
| 77 |
-
state: 현재 워커 상태
|
| 78 |
-
|
| 79 |
-
Returns:
|
| 80 |
-
- "generate_with_history": 후속 질문 → 대화 히스토리 기반 답변
|
| 81 |
-
- "check_cache": 독립 질문 → 캐시 확인
|
| 82 |
-
"""
|
| 83 |
-
raw_qtype = state.question_type or "independent"
|
| 84 |
-
legacy_map = {
|
| 85 |
-
"followup": "clarification",
|
| 86 |
-
"cache_candidate": "independent",
|
| 87 |
-
"new_search": "independent",
|
| 88 |
-
}
|
| 89 |
-
question_type = legacy_map.get(raw_qtype, raw_qtype)
|
| 90 |
-
|
| 91 |
-
if question_type == "clarification":
|
| 92 |
-
return "generate_with_history"
|
| 93 |
-
|
| 94 |
-
return "check_cache"
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def route_after_cache_worker(state: WorkerState) -> Literal["return_cached_answer", "classify_intent"]:
|
| 98 |
-
"""
|
| 99 |
-
캐시 히트 여부에 따라 다음 노드를 결정합니다 (WorkerState용).
|
| 100 |
-
|
| 101 |
-
Args:
|
| 102 |
-
state: 현재 워커 상태
|
| 103 |
-
|
| 104 |
-
Returns:
|
| 105 |
-
- "return_cached_answer": 캐시 히트 시 즉시 답변 반환
|
| 106 |
-
- "classify_intent": 캐시 미스 시 의도 분류
|
| 107 |
-
"""
|
| 108 |
-
if state.cached_result:
|
| 109 |
-
return "return_cached_answer"
|
| 110 |
-
else:
|
| 111 |
-
return "classify_intent"
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def route_after_evaluation_worker(state: WorkerState) -> Literal["refine_search", "search_subgraph"]:
|
| 115 |
-
"""
|
| 116 |
-
검색 결과 평가 후 다음 노드를 결정합니다 (WorkerState용).
|
| 117 |
-
|
| 118 |
-
Args:
|
| 119 |
-
state: 현재 워커 상태
|
| 120 |
-
|
| 121 |
-
Returns:
|
| 122 |
-
- "refine_search": 결과 부족 & 개선 횟수 0회 → 쿼리 개선
|
| 123 |
-
- "search_subgraph": 결과 충분 or 개선 횟수 1회 → 필터링 진행
|
| 124 |
-
"""
|
| 125 |
-
needs_refinement = state.needs_refinement
|
| 126 |
-
refinement_count = state.refinement_count
|
| 127 |
-
|
| 128 |
-
if needs_refinement and refinement_count < 1:
|
| 129 |
-
return "refine_search"
|
| 130 |
-
else:
|
| 131 |
-
return "search_subgraph"
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def initiate_parallel_search_worker(state: WorkerState):
|
| 135 |
-
"""
|
| 136 |
-
Send API를 사용하여 3개의 검색 노드를 병렬로 실행합니다 (WorkerState용).
|
| 137 |
-
|
| 138 |
-
Args:
|
| 139 |
-
state: 현재 워커 상태
|
| 140 |
-
|
| 141 |
-
Returns:
|
| 142 |
-
Send 객체 리스트 (fan-out)
|
| 143 |
-
"""
|
| 144 |
-
return [
|
| 145 |
-
Send("search_stackoverflow", state),
|
| 146 |
-
Send("search_github", state),
|
| 147 |
-
Send("search_official_docs", state),
|
| 148 |
-
]
|
| 149 |
-
|
| 150 |
-
|
| 151 |
def build_single_question_subgraph() -> StateGraph:
|
| 152 |
-
"""
|
| 153 |
-
단일 질문 처리 서브그래프.
|
| 154 |
-
|
| 155 |
-
🔧 CRITICAL:
|
| 156 |
-
- WorkerState만 사용
|
| 157 |
-
- 부모 AgentState와 완전히 격리
|
| 158 |
-
- 출력: multi_answers 또는 final_answer만
|
| 159 |
-
"""
|
| 160 |
-
# WorkerState 사용 (AgentState와 완전히 독립)
|
| 161 |
subgraph = StateGraph(WorkerState)
|
| 162 |
|
| 163 |
-
# 노드
|
| 164 |
subgraph.add_node("analyze_question", analyze_question_node)
|
| 165 |
subgraph.add_node("generate_with_history", generate_with_history_node)
|
| 166 |
subgraph.add_node("check_cache", check_cache_node)
|
| 167 |
subgraph.add_node("return_cached_answer", return_cached_answer_node)
|
| 168 |
-
subgraph.add_node("classify_intent", classify_intent_node)
|
| 169 |
|
| 170 |
-
# 병렬 검색 노드
|
| 171 |
subgraph.add_node("search_stackoverflow", search_stackoverflow_node)
|
| 172 |
subgraph.add_node("search_github", search_github_node)
|
| 173 |
subgraph.add_node("search_official_docs", search_official_docs_node)
|
| 174 |
|
| 175 |
-
# 결과 처리 노드
|
| 176 |
subgraph.add_node("collect_results", collect_results_node)
|
| 177 |
subgraph.add_node("evaluate_results", evaluate_results_node)
|
| 178 |
subgraph.add_node("refine_search", refine_search_node)
|
| 179 |
|
| 180 |
-
# 최종 답변 생성
|
| 181 |
subgraph.add_node("generate_answer", generate_answer_node)
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
filter_summarize_subgraph = build_search_subgraph()
|
| 185 |
-
subgraph.add_node("search_subgraph", filter_summarize_subgraph)
|
| 186 |
-
|
| 187 |
-
# ===== 엣지 구성 =====
|
| 188 |
-
|
| 189 |
-
# 1. START → analyze_question
|
| 190 |
subgraph.add_edge(START, "analyze_question")
|
| 191 |
-
|
| 192 |
-
# 2. analyze_question 결과에 따른 분기
|
| 193 |
subgraph.add_conditional_edges(
|
| 194 |
"analyze_question",
|
| 195 |
route_after_analysis_worker,
|
| 196 |
{
|
| 197 |
"generate_with_history": "generate_with_history",
|
| 198 |
"check_cache": "check_cache",
|
|
|
|
| 199 |
}
|
| 200 |
)
|
| 201 |
-
|
| 202 |
-
# 3. generate_with_history → END (대화 히스토리 기반 답변)
|
| 203 |
subgraph.add_edge("generate_with_history", END)
|
| 204 |
|
| 205 |
-
# 4. check_cache 결과에 따른 분기
|
| 206 |
subgraph.add_conditional_edges(
|
| 207 |
"check_cache",
|
| 208 |
route_after_cache_worker,
|
| 209 |
{
|
| 210 |
"return_cached_answer": "return_cached_answer",
|
| 211 |
-
"classify_intent": "classify_intent",
|
| 212 |
}
|
| 213 |
)
|
| 214 |
-
|
| 215 |
-
# 5. return_cached_answer → END (캐시 히트)
|
| 216 |
subgraph.add_edge("return_cached_answer", END)
|
| 217 |
|
| 218 |
-
# 6. classify_intent → 병렬 검색 (Send API)
|
| 219 |
-
subgraph.add_conditional_edges("classify_intent", initiate_parallel_search_worker)
|
| 220 |
-
|
| 221 |
-
# 7. 모든 검색 노드 → collect_results (fan-in)
|
| 222 |
subgraph.add_edge("search_stackoverflow", "collect_results")
|
| 223 |
subgraph.add_edge("search_github", "collect_results")
|
| 224 |
subgraph.add_edge("search_official_docs", "collect_results")
|
| 225 |
|
| 226 |
-
# 8. collect_results → evaluate_results
|
| 227 |
subgraph.add_edge("collect_results", "evaluate_results")
|
| 228 |
-
|
| 229 |
-
# 9. evaluate_results 결과에 따른 분기
|
| 230 |
subgraph.add_conditional_edges(
|
| 231 |
"evaluate_results",
|
| 232 |
route_after_evaluation_worker,
|
| 233 |
{
|
| 234 |
"refine_search": "refine_search",
|
| 235 |
-
"
|
| 236 |
}
|
| 237 |
)
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
subgraph.add_edge("refine_search", "classify_intent")
|
| 241 |
-
|
| 242 |
-
# 11. search_subgraph → generate_answer
|
| 243 |
-
subgraph.add_edge("search_subgraph", "generate_answer")
|
| 244 |
-
|
| 245 |
-
# 12. generate_answer → END
|
| 246 |
subgraph.add_edge("generate_answer", END)
|
| 247 |
|
| 248 |
return subgraph.compile()
|
| 249 |
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
Returns:
|
| 256 |
-
- "handle_too_many_questions": 질문 3개 이상
|
| 257 |
-
- "single_question_subgraph": 단일 주제 (1회 실행)
|
| 258 |
-
- List[Send]: 다중 질문 (N회 병렬 실행)
|
| 259 |
-
"""
|
| 260 |
-
plan = state.plan or {}
|
| 261 |
-
case = plan.get("case", "single_topic")
|
| 262 |
-
|
| 263 |
-
if case == "too_many":
|
| 264 |
-
return "handle_too_many_questions"
|
| 265 |
-
|
| 266 |
-
elif case == "multiple_questions":
|
| 267 |
-
# 다중 질문: Send API로 서브그래프를 여러 번 호출
|
| 268 |
-
sub_questions = plan.get("sub_questions", [])
|
| 269 |
-
messages = state.messages
|
| 270 |
-
|
| 271 |
-
logger.info("다중 질문 처리: %d개 질문을 서브그래프로 병렬 실행", len(sub_questions))
|
| 272 |
-
|
| 273 |
-
sends = []
|
| 274 |
-
for i, sq in enumerate(sub_questions):
|
| 275 |
-
worker_state = WorkerState(
|
| 276 |
-
processing_question=sq,
|
| 277 |
-
messages=messages,
|
| 278 |
-
|
| 279 |
-
# 🔧 [FIX] 이름 변경된 필드로 매핑
|
| 280 |
-
worker_is_multi=True,
|
| 281 |
-
worker_idx=i,
|
| 282 |
-
worker_sub_text=sq,
|
| 283 |
-
)
|
| 284 |
-
sends.append(Send("single_question_subgraph", worker_state))
|
| 285 |
-
|
| 286 |
-
return sends
|
| 287 |
-
|
| 288 |
-
else:
|
| 289 |
-
# 단일 질문
|
| 290 |
-
worker_state = WorkerState(
|
| 291 |
-
processing_question=state.user_question,
|
| 292 |
-
messages=state.messages,
|
| 293 |
-
|
| 294 |
-
# 🔧 [FIX] 기본값 매핑
|
| 295 |
-
worker_is_multi=False,
|
| 296 |
-
worker_idx=0,
|
| 297 |
-
worker_sub_text=None
|
| 298 |
-
)
|
| 299 |
-
return [Send("single_question_subgraph", worker_state)]
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
def route_after_subgraph(state: AgentState) -> Literal["combine_answers", END]:
|
| 303 |
-
"""
|
| 304 |
-
서브그래프 실행 후 다음 노드 결정.
|
| 305 |
-
|
| 306 |
-
- multi_answers가 있으면: 다중 질문 모드 → combine_answers
|
| 307 |
-
- multi_answers가 없으면: 단일 질문 모드 → END
|
| 308 |
-
"""
|
| 309 |
-
# multi_answers에 실제 데이터가 있는지 확인 (reset token 제외)
|
| 310 |
-
has_answers = any(
|
| 311 |
-
isinstance(item, dict) and item.get("__token__") != _MULTI_ANS_RESET_TOKEN
|
| 312 |
-
for item in state.multi_answers
|
| 313 |
-
)
|
| 314 |
-
|
| 315 |
-
if has_answers:
|
| 316 |
-
logger.info("다중 질문 모드: combine_answers로 이동")
|
| 317 |
-
return "combine_answers"
|
| 318 |
-
else:
|
| 319 |
-
logger.info("단일 질문 모드: END로 이동")
|
| 320 |
-
return END
|
| 321 |
-
|
| 322 |
-
|
| 323 |
def build_agent_graph() -> StateGraph:
|
| 324 |
-
"""
|
| 325 |
-
CodeWeaver 에이전트의 메인 그래프를 구성합니다.
|
| 326 |
-
|
| 327 |
-
전체 흐름 (단순화됨):
|
| 328 |
-
1. START → create_plan (질문 유형 및 개수 판단)
|
| 329 |
-
2. 질문 유형에 따른 분기:
|
| 330 |
-
- single_topic: single_question_subgraph (1회) → END
|
| 331 |
-
- multiple_questions: Send API로 single_question_subgraph (2회 병렬) → combine_answers → END
|
| 332 |
-
- too_many: handle_too_many_questions → END
|
| 333 |
-
|
| 334 |
-
핵심 개선사항:
|
| 335 |
-
- ✅ 단일 질문 파이프라인을 재사용 가능한 서브그래프로 추출
|
| 336 |
-
- ✅ 부모 그래프는 계획/분기/병합만 담당
|
| 337 |
-
- ✅ 복잡한 worker 노드 제거
|
| 338 |
-
- ✅ 코드 중복 제거
|
| 339 |
-
- ✅ 구조 명확화: 부모(orchestration) vs 자식(processing)
|
| 340 |
-
|
| 341 |
-
Returns:
|
| 342 |
-
구성된 StateGraph (컴파일 전)
|
| 343 |
-
"""
|
| 344 |
-
# 메인 그래프 생성
|
| 345 |
graph = StateGraph(AgentState)
|
| 346 |
-
|
| 347 |
-
# 노드 추가
|
| 348 |
graph.add_node("create_plan", create_plan_node)
|
| 349 |
graph.add_node("handle_too_many_questions", handle_too_many_questions_node)
|
| 350 |
graph.add_node("combine_answers", combine_answers_node)
|
| 351 |
|
| 352 |
-
# 서브그래프를 노드로 등록
|
| 353 |
single_question_subgraph = build_single_question_subgraph()
|
| 354 |
graph.add_node("single_question_subgraph", single_question_subgraph)
|
| 355 |
|
| 356 |
-
# ===== 엣지 구성 =====
|
| 357 |
-
|
| 358 |
-
# 1. START → create_plan
|
| 359 |
graph.add_edge(START, "create_plan")
|
| 360 |
-
|
| 361 |
-
# 2. create_plan → 분기
|
| 362 |
-
# - single_topic: "single_question_subgraph" → END
|
| 363 |
-
# - multiple_questions: List[Send("single_question_subgraph", WorkerState)] → combine_answers
|
| 364 |
-
# - too_many: "handle_too_many_questions" → END
|
| 365 |
graph.add_conditional_edges("create_plan", route_after_plan)
|
| 366 |
-
|
| 367 |
-
# 3. handle_too_many_questions → END
|
| 368 |
graph.add_edge("handle_too_many_questions", END)
|
| 369 |
|
| 370 |
-
# 4. 🔧 FIX: single_question_subgraph의 출구를 명확히 분리
|
| 371 |
-
# - 단일 질문 (case=single_topic): 무조건 END
|
| 372 |
-
# - 다중 질문 (case=multiple_questions): Send API가 자동으로 combine_answers로 fan-in
|
| 373 |
-
|
| 374 |
-
# 4-1. 단일 질문 경로: single_question_subgraph → END
|
| 375 |
-
# 4-2. 다중 질문 경로: single_question_subgraph → combine_answers (자동 fan-in)
|
| 376 |
-
|
| 377 |
-
# 🔧 해결책: conditional edges로 분기
|
| 378 |
graph.add_conditional_edges(
|
| 379 |
"single_question_subgraph",
|
| 380 |
route_after_subgraph,
|
|
@@ -383,38 +129,51 @@ def build_agent_graph() -> StateGraph:
|
|
| 383 |
END: END,
|
| 384 |
}
|
| 385 |
)
|
| 386 |
-
|
| 387 |
-
# 5. combine_answers → END
|
| 388 |
graph.add_edge("combine_answers", END)
|
| 389 |
-
|
| 390 |
return graph
|
| 391 |
|
| 392 |
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
"""
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
enable_checkpointing: 체크포인트 활성화 여부
|
| 399 |
-
- True: MemorySaver 사용 (개발/테스트용)
|
| 400 |
-
- False: 체크포인트 없이 실행 (상태 저장 불가)
|
| 401 |
-
|
| 402 |
-
Returns:
|
| 403 |
-
컴파일된 실행 가능한 그래프
|
| 404 |
-
|
| 405 |
-
Note:
|
| 406 |
-
프로덕션 환경에서는 MemorySaver 대신
|
| 407 |
-
PostgresSaver, SqliteSaver 등 영구 저장소 사용 권장
|
| 408 |
"""
|
| 409 |
-
|
| 410 |
|
| 411 |
-
if
|
| 412 |
-
|
| 413 |
-
memory = MemorySaver()
|
| 414 |
-
return graph.compile(checkpointer=memory)
|
| 415 |
-
else:
|
| 416 |
-
return graph.compile()
|
| 417 |
|
|
|
|
| 418 |
|
| 419 |
-
#
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
CodeWeaver LangGraph 워크플로우 구성.
|
| 3 |
|
| 4 |
+
그래프 구조 정의만 담당합니다. 라우팅 로직은 routes.py에 있습니다.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
|
|
|
| 6 |
import logging
|
| 7 |
+
from typing import Optional
|
| 8 |
|
| 9 |
+
# LangGraph & LangChain Core
|
| 10 |
+
from langchain_core.runnables import Runnable
|
| 11 |
from langgraph.graph import StateGraph, START, END
|
| 12 |
+
# [수정] Sync 모듈 사용 (Windows 호환성)
|
| 13 |
+
from langgraph.checkpoint.postgres import PostgresSaver
|
| 14 |
+
from psycopg_pool import ConnectionPool
|
| 15 |
|
| 16 |
+
from src.core.config import settings
|
| 17 |
+
from src.agent.state import AgentState, WorkerState
|
| 18 |
from src.agent.nodes import (
|
| 19 |
+
analyze_question_node,
|
| 20 |
+
check_cache_node,
|
| 21 |
+
create_plan_node,
|
| 22 |
+
search_stackoverflow_node,
|
| 23 |
+
search_github_node,
|
|
|
|
| 24 |
search_official_docs_node,
|
| 25 |
+
collect_results_node,
|
| 26 |
+
evaluate_results_node,
|
| 27 |
refine_search_node,
|
| 28 |
+
generate_answer_node,
|
| 29 |
+
return_cached_answer_node,
|
|
|
|
|
|
|
| 30 |
generate_with_history_node,
|
| 31 |
+
handle_too_many_questions_node,
|
| 32 |
combine_answers_node,
|
| 33 |
)
|
| 34 |
+
from src.agent.routes import (
|
| 35 |
+
route_after_analysis_worker,
|
| 36 |
+
route_after_cache_worker,
|
| 37 |
+
route_after_evaluation_worker,
|
| 38 |
+
initiate_parallel_search_worker,
|
| 39 |
+
route_after_plan,
|
| 40 |
+
route_after_subgraph,
|
| 41 |
+
)
|
| 42 |
|
| 43 |
logger = logging.getLogger(__name__)
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def build_single_question_subgraph() -> StateGraph:
|
| 47 |
+
"""단일 질문 처리 서브그래프"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
subgraph = StateGraph(WorkerState)
|
| 49 |
|
| 50 |
+
# 노드 등록
|
| 51 |
subgraph.add_node("analyze_question", analyze_question_node)
|
| 52 |
subgraph.add_node("generate_with_history", generate_with_history_node)
|
| 53 |
subgraph.add_node("check_cache", check_cache_node)
|
| 54 |
subgraph.add_node("return_cached_answer", return_cached_answer_node)
|
|
|
|
| 55 |
|
|
|
|
| 56 |
subgraph.add_node("search_stackoverflow", search_stackoverflow_node)
|
| 57 |
subgraph.add_node("search_github", search_github_node)
|
| 58 |
subgraph.add_node("search_official_docs", search_official_docs_node)
|
| 59 |
|
|
|
|
| 60 |
subgraph.add_node("collect_results", collect_results_node)
|
| 61 |
subgraph.add_node("evaluate_results", evaluate_results_node)
|
| 62 |
subgraph.add_node("refine_search", refine_search_node)
|
| 63 |
|
|
|
|
| 64 |
subgraph.add_node("generate_answer", generate_answer_node)
|
| 65 |
|
| 66 |
+
# 엣지 연결
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
subgraph.add_edge(START, "analyze_question")
|
|
|
|
|
|
|
| 68 |
subgraph.add_conditional_edges(
|
| 69 |
"analyze_question",
|
| 70 |
route_after_analysis_worker,
|
| 71 |
{
|
| 72 |
"generate_with_history": "generate_with_history",
|
| 73 |
"check_cache": "check_cache",
|
| 74 |
+
"generate_answer": "generate_answer",
|
| 75 |
}
|
| 76 |
)
|
|
|
|
|
|
|
| 77 |
subgraph.add_edge("generate_with_history", END)
|
| 78 |
|
|
|
|
| 79 |
subgraph.add_conditional_edges(
|
| 80 |
"check_cache",
|
| 81 |
route_after_cache_worker,
|
| 82 |
{
|
| 83 |
"return_cached_answer": "return_cached_answer",
|
|
|
|
| 84 |
}
|
| 85 |
)
|
|
|
|
|
|
|
| 86 |
subgraph.add_edge("return_cached_answer", END)
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
subgraph.add_edge("search_stackoverflow", "collect_results")
|
| 89 |
subgraph.add_edge("search_github", "collect_results")
|
| 90 |
subgraph.add_edge("search_official_docs", "collect_results")
|
| 91 |
|
|
|
|
| 92 |
subgraph.add_edge("collect_results", "evaluate_results")
|
|
|
|
|
|
|
| 93 |
subgraph.add_conditional_edges(
|
| 94 |
"evaluate_results",
|
| 95 |
route_after_evaluation_worker,
|
| 96 |
{
|
| 97 |
"refine_search": "refine_search",
|
| 98 |
+
"generate_answer": "generate_answer",
|
| 99 |
}
|
| 100 |
)
|
| 101 |
+
# refine_search 후에는 다시 병렬 검색으로 라우팅
|
| 102 |
+
subgraph.add_conditional_edges("refine_search", initiate_parallel_search_worker)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
subgraph.add_edge("generate_answer", END)
|
| 104 |
|
| 105 |
return subgraph.compile()
|
| 106 |
|
| 107 |
|
| 108 |
+
# ------------------------------------------------------------------
|
| 109 |
+
# 그래프 구성
|
| 110 |
+
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def build_agent_graph() -> StateGraph:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
graph = StateGraph(AgentState)
|
|
|
|
|
|
|
| 113 |
graph.add_node("create_plan", create_plan_node)
|
| 114 |
graph.add_node("handle_too_many_questions", handle_too_many_questions_node)
|
| 115 |
graph.add_node("combine_answers", combine_answers_node)
|
| 116 |
|
|
|
|
| 117 |
single_question_subgraph = build_single_question_subgraph()
|
| 118 |
graph.add_node("single_question_subgraph", single_question_subgraph)
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
graph.add_edge(START, "create_plan")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
graph.add_conditional_edges("create_plan", route_after_plan)
|
|
|
|
|
|
|
| 122 |
graph.add_edge("handle_too_many_questions", END)
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
graph.add_conditional_edges(
|
| 125 |
"single_question_subgraph",
|
| 126 |
route_after_subgraph,
|
|
|
|
| 129 |
END: END,
|
| 130 |
}
|
| 131 |
)
|
|
|
|
|
|
|
| 132 |
graph.add_edge("combine_answers", END)
|
|
|
|
| 133 |
return graph
|
| 134 |
|
| 135 |
|
| 136 |
+
# ------------------------------------------------------------------
|
| 137 |
+
# 3. 에이전트 생성 (동기 DB 연결 - Windows 호환성 해결)
|
| 138 |
+
# ------------------------------------------------------------------
|
| 139 |
+
|
| 140 |
+
# 전역 변��
|
| 141 |
+
_agent: Optional[Runnable] = None
|
| 142 |
+
_pool: Optional[ConnectionPool] = None # Sync Pool
|
| 143 |
+
|
| 144 |
+
def get_agent() -> Runnable:
|
| 145 |
"""
|
| 146 |
+
동기 DB 풀을 사용하는 에이전트를 반환합니다.
|
| 147 |
+
주의: 함수 자체는 동기(def)이지만, 반환된 에이전트(CompiledGraph)는
|
| 148 |
+
ainvoke를 지원합니다 (DB 저장만 동기로 수행).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
"""
|
| 150 |
+
global _agent, _pool
|
| 151 |
|
| 152 |
+
if _agent is not None:
|
| 153 |
+
return _agent
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
logger.info("🔌 DB 연결 및 에이전트 초기화 중 (Sync Mode)...")
|
| 156 |
|
| 157 |
+
# 1. 그래프 빌드
|
| 158 |
+
graph = build_agent_graph()
|
| 159 |
+
|
| 160 |
+
# 2. 동기 연결 풀 생성
|
| 161 |
+
# Windows ProactorLoop와 충돌하지 않음
|
| 162 |
+
safe_url = settings.postgres_db_url.split("@")[-1] if "@" in settings.postgres_db_url else "..."
|
| 163 |
+
logger.info(f"Target DB: {safe_url}")
|
| 164 |
+
|
| 165 |
+
_pool = ConnectionPool(
|
| 166 |
+
conninfo=settings.postgres_db_url,
|
| 167 |
+
min_size=1,
|
| 168 |
+
max_size=20,
|
| 169 |
+
kwargs={"autocommit": True}
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# 3. 동기 체크포인터 연결
|
| 173 |
+
checkpointer = PostgresSaver(_pool)
|
| 174 |
+
|
| 175 |
+
# 4. 컴파일
|
| 176 |
+
_agent = graph.compile(checkpointer=checkpointer)
|
| 177 |
+
|
| 178 |
+
logger.info("✅ 에이전트 준비 완료")
|
| 179 |
+
return _agent
|
CodeWeaver/src/agent/nodes.py
DELETED
|
@@ -1,1212 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
CodeWeaver LangGraph 노드 구현.
|
| 3 |
-
|
| 4 |
-
각 노드는 AgentState 또는 WorkerState를 받아 처리하고 업데이트된 상태를 반환합니다.
|
| 5 |
-
모든 노드는 LangSmith를 통해 자동으로 추적됩니다.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import asyncio
|
| 9 |
-
import logging
|
| 10 |
-
import os
|
| 11 |
-
from typing import List, Literal, Optional, Union
|
| 12 |
-
|
| 13 |
-
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
| 14 |
-
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 15 |
-
from langgraph.graph import StateGraph, START, END
|
| 16 |
-
from langgraph.types import Send
|
| 17 |
-
|
| 18 |
-
from src.agent.state import AgentState, WorkerState, SearchResult
|
| 19 |
-
from src.agent.state import _MULTI_ANS_RESET_TOKEN
|
| 20 |
-
from src.tools.search_tools import (
|
| 21 |
-
search_github,
|
| 22 |
-
search_official_docs,
|
| 23 |
-
search_stackoverflow,
|
| 24 |
-
)
|
| 25 |
-
from src.utils.tracing import trace_node
|
| 26 |
-
from src.vector_db.qdrant_client import QdrantManager
|
| 27 |
-
|
| 28 |
-
logger = logging.getLogger(__name__)
|
| 29 |
-
|
| 30 |
-
# LLM 초기화 (Gemini 2.5 Flash)
|
| 31 |
-
llm = ChatGoogleGenerativeAI(
|
| 32 |
-
model="gemini-2.5-flash-lite",
|
| 33 |
-
temperature=0.7,
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
# Qdrant 매니저 초기화
|
| 37 |
-
qdrant_manager = QdrantManager()
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
# ==================== 부모 그래프 노드 (AgentState 사용) ====================
|
| 41 |
-
|
| 42 |
-
@trace_node("create_plan")
|
| 43 |
-
def create_plan_node(state: AgentState) -> dict:
|
| 44 |
-
"""
|
| 45 |
-
질문을 분석하여 유형과 개수를 판단합니다.
|
| 46 |
-
|
| 47 |
-
Case:
|
| 48 |
-
- single_topic: 하나의 주제 (서브그래프 1회)
|
| 49 |
-
- multiple_questions: 독립 질문 2개 (Send API로 서브그래프 2회 병렬)
|
| 50 |
-
- too_many: 독립 질문 3개 이상 (에러 메시지)
|
| 51 |
-
"""
|
| 52 |
-
user_question = state.user_question
|
| 53 |
-
logger.info("질문 분석 및 계획 수립 중: %s", user_question[:50])
|
| 54 |
-
|
| 55 |
-
def _extract_question_candidates(text: str) -> List[str]:
|
| 56 |
-
"""입력 문자열에서 '질문 후보'를 최대한 보수적으로 추출합니다(3개 이상 감지용)."""
|
| 57 |
-
import re
|
| 58 |
-
|
| 59 |
-
if not text:
|
| 60 |
-
return []
|
| 61 |
-
|
| 62 |
-
t = text.strip()
|
| 63 |
-
# 1) 물음표 기반 분리
|
| 64 |
-
parts = re.split(r"[??]+", t)
|
| 65 |
-
candidates = [p.strip() for p in parts if p.strip()]
|
| 66 |
-
if len(candidates) >= 2 and re.search(r"[??]", t):
|
| 67 |
-
return candidates
|
| 68 |
-
|
| 69 |
-
# 2) 줄바꿈/번호 매기기 기반
|
| 70 |
-
lines = [ln.strip() for ln in re.split(r"[\r\n]+", t) if ln.strip()]
|
| 71 |
-
numbered = []
|
| 72 |
-
for ln in lines:
|
| 73 |
-
if re.match(r"^\s*(\d+[\.\)]|[-*])\s+", ln):
|
| 74 |
-
numbered.append(re.sub(r"^\s*(\d+[\.\)]|[-*])\s+", "", ln).strip())
|
| 75 |
-
if len(numbered) >= 2:
|
| 76 |
-
return numbered
|
| 77 |
-
|
| 78 |
-
# 3) 구분자 기반(세미콜론)
|
| 79 |
-
semi = [p.strip() for p in t.split(";") if p.strip()]
|
| 80 |
-
if len(semi) >= 2:
|
| 81 |
-
return semi
|
| 82 |
-
|
| 83 |
-
return [t]
|
| 84 |
-
|
| 85 |
-
def _hard_guard_too_many(text: str) -> Optional[dict]:
|
| 86 |
-
"""
|
| 87 |
-
하드 가드: 사용자가 '질문 3개 이상'을 한 번에 던진 것으로 확실한 경우,
|
| 88 |
-
LLM 분류와 무관하게 too_many로 강제합니다.
|
| 89 |
-
"""
|
| 90 |
-
import re
|
| 91 |
-
|
| 92 |
-
if not text:
|
| 93 |
-
return None
|
| 94 |
-
|
| 95 |
-
# 가장 확실한 기준: 물음표가 3개 이상
|
| 96 |
-
qmarks = len(re.findall(r"[??]", text))
|
| 97 |
-
if qmarks >= 3:
|
| 98 |
-
candidates = _extract_question_candidates(text)
|
| 99 |
-
msg = "죄송합니다. 질문은 한 번에 최대 2개까지 가능합니다. 가장 중요한 2개만 골라서 다시 질문해 주세요."
|
| 100 |
-
return {
|
| 101 |
-
"case": "too_many",
|
| 102 |
-
"sub_questions": candidates,
|
| 103 |
-
"reasoning": f"물음표가 {qmarks}개로, 3개 이상의 독립 질문으로 판단했습니다.",
|
| 104 |
-
"error_message": msg,
|
| 105 |
-
"steps_note": f"⚠️ 질문 수 초과 감지(물음표 {qmarks}개) → too_many로 강제",
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
# 번호 매기기/리스트로 3개 이상
|
| 109 |
-
candidates = _extract_question_candidates(text)
|
| 110 |
-
if len(candidates) >= 3:
|
| 111 |
-
msg = "죄송합니다. 질문은 한 번에 최대 2개까지 가능합니다. 가장 중요한 2개만 골라서 다시 질문해 주세요."
|
| 112 |
-
return {
|
| 113 |
-
"case": "too_many",
|
| 114 |
-
"sub_questions": candidates,
|
| 115 |
-
"reasoning": f"질문 후보가 {len(candidates)}개로 감지되어 3개 이상 질문으로 판단했습니다.",
|
| 116 |
-
"error_message": msg,
|
| 117 |
-
"steps_note": f"⚠️ 질문 수 초과 감지(후보 {len(candidates)}개) → too_many로 강제",
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
return None
|
| 121 |
-
|
| 122 |
-
# 하드 가드(결정론적) – LLM이 잘못 분류하더라도 3개 이상이면 무조건 차단
|
| 123 |
-
hard = _hard_guard_too_many(user_question)
|
| 124 |
-
if hard:
|
| 125 |
-
steps_delta = [
|
| 126 |
-
f"📋 계획 타입: {hard['case']}",
|
| 127 |
-
f" 서브질문: {len(hard['sub_questions'])}개",
|
| 128 |
-
f" 이유: {hard['reasoning']}",
|
| 129 |
-
hard["steps_note"],
|
| 130 |
-
]
|
| 131 |
-
logger.info("계획 수립 완료(하드 가드): too_many, %d개 서브질��", len(hard["sub_questions"]))
|
| 132 |
-
return {
|
| 133 |
-
"plan": {
|
| 134 |
-
"case": hard["case"],
|
| 135 |
-
"sub_questions": hard["sub_questions"],
|
| 136 |
-
"reasoning": hard["reasoning"],
|
| 137 |
-
"error_message": hard["error_message"],
|
| 138 |
-
},
|
| 139 |
-
"is_multi_question": False,
|
| 140 |
-
"sub_question_index": 0,
|
| 141 |
-
"sub_question_text": None,
|
| 142 |
-
"original_multi_question": None,
|
| 143 |
-
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
|
| 144 |
-
"intermediate_steps": steps_delta,
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
plan_prompt = f"""질문을 분석하여 유형과 개수를 판단하세요.
|
| 148 |
-
|
| 149 |
-
질문: {user_question}
|
| 150 |
-
|
| 151 |
-
**중요**: sub_questions의 용도는 case에 따라 다릅니다!
|
| 152 |
-
|
| 153 |
-
**Case 1: single_topic** (하나의 주제)
|
| 154 |
-
- 예: "Spring Security JWT 인증 구현"
|
| 155 |
-
→ sub_questions: ["개념", "구현", "예제"]
|
| 156 |
-
→ 용도: 답변 섹션 구조 (검색은 원본 질문으로 1회만)
|
| 157 |
-
→ 검색: "Spring Security JWT 인증 구현"
|
| 158 |
-
|
| 159 |
-
- 예: "React hooks 완벽 가이드"
|
| 160 |
-
→ sub_questions: ["hooks란", "주요 hooks", "실무 패턴"]
|
| 161 |
-
→ 용도: 답변 섹션 구조
|
| 162 |
-
→ 검색: "React hooks 완벽 가이드"
|
| 163 |
-
|
| 164 |
-
**Case 2: multiple_questions** (여러 독립 질문, 최대 2개)
|
| 165 |
-
- 예: "JWT가 뭐야? CORS는?"
|
| 166 |
-
→ sub_questions: ["JWT가 뭐야?", "CORS는?"]
|
| 167 |
-
→ 용도: 각 질문마다 별도 검색
|
| 168 |
-
→ 검색: "JWT가 뭐야?" (1회), "CORS는?" (1회)
|
| 169 |
-
|
| 170 |
-
- 예: "Docker 사용법은? Redis 설치는?"
|
| 171 |
-
→ sub_questions: ["Docker 사용법은?", "Redis 설치는?"]
|
| 172 |
-
→ 용도: 각 질문마다 별도 검색
|
| 173 |
-
|
| 174 |
-
**Case 3: too_many** (3개 이상 질문)
|
| 175 |
-
- 예: "JWT? CORS? Docker?"
|
| 176 |
-
→ 너무 많아서 처리 불가
|
| 177 |
-
→ error_message 제공
|
| 178 |
-
|
| 179 |
-
규칙:
|
| 180 |
-
- single_topic: sub_questions는 짧은 키워드/구절 (1-5개)
|
| 181 |
-
- multiple_questions: sub_questions는 완전한 문장 (정확히 2개만)
|
| 182 |
-
- too_many: 3개 이상이면 이 케이스로 분류
|
| 183 |
-
|
| 184 |
-
다음 JSON 형식으로만 답변하세요:
|
| 185 |
-
{{
|
| 186 |
-
"case": "single_topic|multiple_questions|too_many",
|
| 187 |
-
"sub_questions": [...],
|
| 188 |
-
"reasoning": "이 케이스로 판단한 이유",
|
| 189 |
-
"error_message": "..." (too_many인 경우만, 그 외는 빈 문자열)
|
| 190 |
-
}}
|
| 191 |
-
|
| 192 |
-
JSON 외에 다른 텍스트는 포함하지 마세요."""
|
| 193 |
-
|
| 194 |
-
try:
|
| 195 |
-
import json
|
| 196 |
-
|
| 197 |
-
messages_to_llm = [HumanMessage(content=plan_prompt)]
|
| 198 |
-
response = llm.invoke(messages_to_llm)
|
| 199 |
-
|
| 200 |
-
# JSON 파싱
|
| 201 |
-
response_text = response.content.strip()
|
| 202 |
-
|
| 203 |
-
# JSON 블록 추출
|
| 204 |
-
if "```json" in response_text:
|
| 205 |
-
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 206 |
-
elif "```" in response_text:
|
| 207 |
-
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 208 |
-
|
| 209 |
-
plan_data = json.loads(response_text)
|
| 210 |
-
|
| 211 |
-
case = plan_data.get("case", "single_topic")
|
| 212 |
-
sub_questions = plan_data.get("sub_questions", [user_question])
|
| 213 |
-
reasoning = plan_data.get("reasoning", "")
|
| 214 |
-
error_message = plan_data.get("error_message", "")
|
| 215 |
-
|
| 216 |
-
# LLM 결과를 받은 뒤에도 한 번 더 하드 가드 적용 (안전장치)
|
| 217 |
-
hard2 = _hard_guard_too_many(user_question)
|
| 218 |
-
if hard2:
|
| 219 |
-
case = hard2["case"]
|
| 220 |
-
sub_questions = hard2["sub_questions"]
|
| 221 |
-
reasoning = hard2["reasoning"]
|
| 222 |
-
error_message = hard2["error_message"]
|
| 223 |
-
|
| 224 |
-
# 유효성 검증
|
| 225 |
-
if not sub_questions or len(sub_questions) == 0:
|
| 226 |
-
sub_questions = [user_question]
|
| 227 |
-
case = "single_topic"
|
| 228 |
-
|
| 229 |
-
# multiple_questions일 때 2개 제한 강제
|
| 230 |
-
if case == "multiple_questions" and len(sub_questions) > 2:
|
| 231 |
-
sub_questions = sub_questions[:2]
|
| 232 |
-
reasoning += " (질문 수 제한: 최대 2개)"
|
| 233 |
-
|
| 234 |
-
steps_delta = [
|
| 235 |
-
f"📋 계획 타입: {case}",
|
| 236 |
-
f" 서브질문: {len(sub_questions)}개",
|
| 237 |
-
f" 이유: {reasoning}"
|
| 238 |
-
]
|
| 239 |
-
|
| 240 |
-
logger.info("계획 수립 완료: %s, %d개 서브질문", case, len(sub_questions))
|
| 241 |
-
|
| 242 |
-
return {
|
| 243 |
-
"plan": {
|
| 244 |
-
"case": case,
|
| 245 |
-
"sub_questions": sub_questions,
|
| 246 |
-
"reasoning": reasoning,
|
| 247 |
-
"error_message": error_message
|
| 248 |
-
},
|
| 249 |
-
"is_multi_question": False,
|
| 250 |
-
"sub_question_index": 0,
|
| 251 |
-
"sub_question_text": None,
|
| 252 |
-
"original_multi_question": None,
|
| 253 |
-
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
|
| 254 |
-
"intermediate_steps": steps_delta
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
except Exception as e:
|
| 258 |
-
logger.error("계획 수립 실패: %s", e, exc_info=True)
|
| 259 |
-
|
| 260 |
-
# 기본값: 원본 질문 그대로 사용
|
| 261 |
-
steps_delta = [
|
| 262 |
-
"⚠️ 계획 수립 실패, 기본값 사용: single_topic"
|
| 263 |
-
]
|
| 264 |
-
|
| 265 |
-
return {
|
| 266 |
-
"plan": {
|
| 267 |
-
"case": "single_topic",
|
| 268 |
-
"sub_questions": [user_question],
|
| 269 |
-
"reasoning": "계획 수립 실패, 기본값 사용",
|
| 270 |
-
"error_message": ""
|
| 271 |
-
},
|
| 272 |
-
"is_multi_question": False,
|
| 273 |
-
"sub_question_index": 0,
|
| 274 |
-
"sub_question_text": None,
|
| 275 |
-
"original_multi_question": None,
|
| 276 |
-
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
|
| 277 |
-
"intermediate_steps": steps_delta
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
@trace_node("handle_too_many_questions")
|
| 282 |
-
def handle_too_many_questions_node(state: AgentState) -> dict:
|
| 283 |
-
"""3개 이상 질문 시 안내 메시지를 반환합니다."""
|
| 284 |
-
plan = state.plan or {}
|
| 285 |
-
error_message = plan.get("error_message", "")
|
| 286 |
-
sub_questions = plan.get("sub_questions", [])
|
| 287 |
-
|
| 288 |
-
logger.info("질문 수 초과: %d개", len(sub_questions))
|
| 289 |
-
|
| 290 |
-
default_message = """죄송합니다. 한 번에 최대 2개의 질문까지만 처리할 수 있습니다.
|
| 291 |
-
|
| 292 |
-
다음 중 하나를 선택해서 다시 질문해 주세요:
|
| 293 |
-
|
| 294 |
-
1. **하나의 주제로 통합해서 질문**
|
| 295 |
-
예: "JWT 인증과 CORS 설정을 함께 구현하는 방법"
|
| 296 |
-
|
| 297 |
-
2. **가장 중요한 2개 질문만 선택**
|
| 298 |
-
예: "JWT가 뭐야? 내 코드에 어떻게 적용해?"
|
| 299 |
-
|
| 300 |
-
3. **질문을 나눠서 순차적으로 질문**
|
| 301 |
-
예: 먼저 "JWT가 뭐야?" 질문 → 답변 확인 → 다음 질문
|
| 302 |
-
|
| 303 |
-
어떻게 도와드릴까요?"""
|
| 304 |
-
|
| 305 |
-
final_message = error_message if error_message else default_message
|
| 306 |
-
|
| 307 |
-
steps_delta = [
|
| 308 |
-
f"⚠️ 질문 수 초과: {len(sub_questions)}개",
|
| 309 |
-
"💬 안내 메시지 제공 (대화 계속 가능)"
|
| 310 |
-
]
|
| 311 |
-
|
| 312 |
-
return {
|
| 313 |
-
"final_answer": final_message,
|
| 314 |
-
"intermediate_steps": steps_delta
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
@trace_node("combine_answers")
|
| 319 |
-
def combine_answers_node(state: AgentState) -> dict:
|
| 320 |
-
"""
|
| 321 |
-
Fan-in: 모든 Send가 완료되면 multi_answers를 조합합니다.
|
| 322 |
-
"""
|
| 323 |
-
answers = state.multi_answers
|
| 324 |
-
original_question = state.original_multi_question or state.user_question
|
| 325 |
-
|
| 326 |
-
if not answers:
|
| 327 |
-
logger.error("다중 답변이 비어있음")
|
| 328 |
-
return {
|
| 329 |
-
"final_answer": "답변 생성에 실패했습니다. 다시 시도해 주세요.",
|
| 330 |
-
"intermediate_steps": ["❌ multi_answers 비어있음"]
|
| 331 |
-
}
|
| 332 |
-
|
| 333 |
-
# 인덱스 순으로 정렬
|
| 334 |
-
answers.sort(key=lambda x: x["index"])
|
| 335 |
-
|
| 336 |
-
# Markdown 형식으로 조합
|
| 337 |
-
combined_parts = []
|
| 338 |
-
for ans in answers:
|
| 339 |
-
section = f"""## {ans['index']+1}. {ans['question']}
|
| 340 |
-
|
| 341 |
-
{ans['answer']}"""
|
| 342 |
-
combined_parts.append(section)
|
| 343 |
-
|
| 344 |
-
combined = "\n\n---\n\n".join(combined_parts)
|
| 345 |
-
|
| 346 |
-
# 헤더 추가
|
| 347 |
-
header = f"# 다중 질문 답변\n\n원본 질문: {original_question}\n\n---\n\n"
|
| 348 |
-
final_combined = header + combined
|
| 349 |
-
|
| 350 |
-
logger.info("다중 답변 조합 완료: %d개", len(answers))
|
| 351 |
-
|
| 352 |
-
return {
|
| 353 |
-
"final_answer": final_combined,
|
| 354 |
-
"intermediate_steps": [f"✅ {len(answers)}개 답변 조합 완료"]
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
# ==================== 서브그래프 노드 (WorkerState 사용) ====================
|
| 359 |
-
|
| 360 |
-
@trace_node("analyze_question")
|
| 361 |
-
async def analyze_question_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 362 |
-
"""
|
| 363 |
-
질문을 분석하여 유형을 분류하고 캐시 적격성을 판단합니다.
|
| 364 |
-
|
| 365 |
-
🔧 FIX: 다중 질문 모드일 때는 messages를 무시하고 독립 질문으로만 분석
|
| 366 |
-
"""
|
| 367 |
-
# 🔧 [FIX] WorkerState일 경우 processing_question 사용
|
| 368 |
-
if isinstance(state, WorkerState):
|
| 369 |
-
user_question = state.processing_question
|
| 370 |
-
# 🔧 [FIX] 이름 변경된 필드 사용
|
| 371 |
-
is_multi = state.worker_is_multi
|
| 372 |
-
else:
|
| 373 |
-
user_question = state.user_question
|
| 374 |
-
is_multi = getattr(state, 'is_multi_question', False)
|
| 375 |
-
|
| 376 |
-
messages = state.messages
|
| 377 |
-
|
| 378 |
-
# 대화 맥락 구성 (다중 질문 모드가 아닐 때만)
|
| 379 |
-
has_history = messages and len(messages) > 1 and not is_multi
|
| 380 |
-
context_info = ""
|
| 381 |
-
|
| 382 |
-
if has_history:
|
| 383 |
-
context_info = "\n이전 대화 맥락:\n"
|
| 384 |
-
for msg in messages[-4:-1]:
|
| 385 |
-
if hasattr(msg, 'type') and hasattr(msg, 'content'):
|
| 386 |
-
role = "사용자" if msg.type == "human" else "AI"
|
| 387 |
-
context_info += f"{role}: {msg.content[:100]}\n"
|
| 388 |
-
|
| 389 |
-
# 🔧 다중 질문 모드 강제 처리
|
| 390 |
-
if is_multi:
|
| 391 |
-
context_info = "\n⚠️ 주의: 이 질문은 다중 질문의 일부입니다. 독립적인 질문으로만 판단하세요.\n"
|
| 392 |
-
|
| 393 |
-
analysis_prompt = f"""질문을 분석하여 유형을 분류하고, 캐시 적격성을 판단하세요.
|
| 394 |
-
|
| 395 |
-
{context_info}
|
| 396 |
-
현재 질문: {user_question}
|
| 397 |
-
|
| 398 |
-
분류 기준:
|
| 399 |
-
|
| 400 |
-
1. **clarification** (보충/형식 변경 요청)
|
| 401 |
-
- 이전 답변/대화 내용을 바탕으로 "설명 방식"을 바꾸거나 보충을 요청
|
| 402 |
-
- 예: "좀 더 쉽게 설명해줘", "예제 코드로 보여줘", "한 줄로 요약해줘"
|
| 403 |
-
- should_cache = false, canonical_question = null
|
| 404 |
-
|
| 405 |
-
2. **new_topic** (대화 중 새 개념 질문)
|
| 406 |
-
- 대화가 이어지는 중이지만, 질문 자체가 독립적으로 성립하는 '새 개념/정의/비교/사용법' 질문
|
| 407 |
-
- 예: "Event Listener는 뭐야?", "CORS가 뭐야?"
|
| 408 |
-
- should_cache = true, canonical_question 생성
|
| 409 |
-
|
| 410 |
-
3. **independent** (완전 독립 질문)
|
| 411 |
-
- 이전 대화 없이도 이해 가능한 일반 질문
|
| 412 |
-
- 예: "Spring Security가 뭐야?", "Docker Compose 사용법은?"
|
| 413 |
-
- should_cache = true, canonical_question 생성
|
| 414 |
-
|
| 415 |
-
다음 JSON 형식으로만 답변하세요:
|
| 416 |
-
{{
|
| 417 |
-
"question_type": "clarification|new_topic|independent",
|
| 418 |
-
"should_cache": true|false,
|
| 419 |
-
"reasoning": "분류 이유 1-2문장",
|
| 420 |
-
"canonical_question": "캐시할 정규화된 질문 (should_cache가 true인 경우에만, 아니면 null)"
|
| 421 |
-
}}
|
| 422 |
-
|
| 423 |
-
JSON 외에 다른 텍스트는 포함하지 마세요."""
|
| 424 |
-
|
| 425 |
-
try:
|
| 426 |
-
messages_to_llm = [HumanMessage(content=analysis_prompt)]
|
| 427 |
-
response = llm.invoke(messages_to_llm)
|
| 428 |
-
|
| 429 |
-
import json
|
| 430 |
-
response_text = response.content.strip()
|
| 431 |
-
|
| 432 |
-
if "```json" in response_text:
|
| 433 |
-
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 434 |
-
elif "```" in response_text:
|
| 435 |
-
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 436 |
-
|
| 437 |
-
analysis = json.loads(response_text)
|
| 438 |
-
|
| 439 |
-
question_type = analysis.get("question_type", "independent")
|
| 440 |
-
should_cache = analysis.get("should_cache", False)
|
| 441 |
-
reasoning = analysis.get("reasoning", "")
|
| 442 |
-
canonical_question = analysis.get("canonical_question", user_question)
|
| 443 |
-
|
| 444 |
-
# 유효성 검증
|
| 445 |
-
if question_type not in ["clarification", "new_topic", "independent"]:
|
| 446 |
-
question_type = "independent"
|
| 447 |
-
|
| 448 |
-
# 🔧 CRITICAL: 다중 질문 모드일 때는 무조건 independent로 강제
|
| 449 |
-
if is_multi and question_type == "clarification":
|
| 450 |
-
logger.warning("다중 질문 모드에서 clarification 감지 → independent로 강제 변경")
|
| 451 |
-
question_type = "independent"
|
| 452 |
-
should_cache = True
|
| 453 |
-
reasoning = "다중 질문 모드: 독립 질문으로 강제 분류"
|
| 454 |
-
|
| 455 |
-
# 정책 보정
|
| 456 |
-
if question_type == "clarification":
|
| 457 |
-
should_cache = False
|
| 458 |
-
canonical_question = None
|
| 459 |
-
else:
|
| 460 |
-
if canonical_question is None or (isinstance(canonical_question, str) and not canonical_question.strip()):
|
| 461 |
-
canonical_question = user_question
|
| 462 |
-
|
| 463 |
-
steps_delta = [
|
| 464 |
-
"__RESET_STEPS__",
|
| 465 |
-
f"🔍 질문 분석: {question_type} (캐시 여부: {should_cache})",
|
| 466 |
-
]
|
| 467 |
-
|
| 468 |
-
return {
|
| 469 |
-
"question_type": question_type,
|
| 470 |
-
"should_cache": should_cache,
|
| 471 |
-
"analysis_reasoning": reasoning,
|
| 472 |
-
"canonical_question": canonical_question if should_cache else None,
|
| 473 |
-
"intermediate_steps": steps_delta
|
| 474 |
-
}
|
| 475 |
-
|
| 476 |
-
except Exception as e:
|
| 477 |
-
logger.error("질문 분석 실패: %s", e, exc_info=True)
|
| 478 |
-
|
| 479 |
-
steps_delta = [
|
| 480 |
-
"__RESET_STEPS__",
|
| 481 |
-
"⚠️ 질문 분석 실패, 기본값 사용: independent",
|
| 482 |
-
]
|
| 483 |
-
|
| 484 |
-
return {
|
| 485 |
-
"question_type": "independent",
|
| 486 |
-
"should_cache": True,
|
| 487 |
-
"analysis_reasoning": "분석 실패, 기본값 사용",
|
| 488 |
-
"canonical_question": user_question,
|
| 489 |
-
"intermediate_steps": steps_delta
|
| 490 |
-
}
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
@trace_node("check_cache")
|
| 494 |
-
async def check_cache_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 495 |
-
"""벡터 DB 캐시에서 유사한 질문을 검색합니다."""
|
| 496 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 497 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 498 |
-
question_for_lookup = state.canonical_question or current_q
|
| 499 |
-
logger.info("캐시 확인 중: %s", question_for_lookup[:50])
|
| 500 |
-
|
| 501 |
-
try:
|
| 502 |
-
cached_result = await qdrant_manager.search_cache(
|
| 503 |
-
question=question_for_lookup,
|
| 504 |
-
threshold=0.85
|
| 505 |
-
)
|
| 506 |
-
|
| 507 |
-
updates = {}
|
| 508 |
-
steps_delta: List[str] = []
|
| 509 |
-
|
| 510 |
-
if cached_result:
|
| 511 |
-
updates["cached_result"] = cached_result
|
| 512 |
-
steps_delta.append(f"✅ 캐시 히트 (답변 길이: {len(cached_result)}자)")
|
| 513 |
-
logger.info("캐시 히트")
|
| 514 |
-
else:
|
| 515 |
-
updates["cached_result"] = None
|
| 516 |
-
steps_delta.append("❌ 캐시 미스: 새로운 검색 필요")
|
| 517 |
-
logger.info("캐시 미스")
|
| 518 |
-
|
| 519 |
-
except Exception as e:
|
| 520 |
-
logger.error("캐시 확인 실패: %s", e, exc_info=True)
|
| 521 |
-
updates["cached_result"] = None
|
| 522 |
-
steps_delta.append(f"⚠️ 캐시 확인 오류: {str(e)}")
|
| 523 |
-
|
| 524 |
-
updates["intermediate_steps"] = steps_delta
|
| 525 |
-
return updates
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
@trace_node("return_cached_answer")
|
| 529 |
-
def return_cached_answer_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 530 |
-
"""캐시 히트 시 저장된 답변을 반환합니다."""
|
| 531 |
-
logger.info("캐시된 답변 반환")
|
| 532 |
-
|
| 533 |
-
cached_answer = state.cached_result
|
| 534 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 535 |
-
|
| 536 |
-
if is_multi:
|
| 537 |
-
return {
|
| 538 |
-
"multi_answers": [{
|
| 539 |
-
"index": state.worker_idx,
|
| 540 |
-
"question": state.worker_sub_text or state.processing_question,
|
| 541 |
-
"answer": cached_answer
|
| 542 |
-
}]
|
| 543 |
-
}
|
| 544 |
-
else:
|
| 545 |
-
# 🔧 [FIX] messages에 AIMessage 추가하여 히스토리 저장 보장
|
| 546 |
-
steps_delta = ["💾 캐시된 답변 반환 (검색 생략)"]
|
| 547 |
-
return {
|
| 548 |
-
"final_answer": cached_answer,
|
| 549 |
-
"messages": [AIMessage(content=cached_answer)], # 👈 핵심 수정
|
| 550 |
-
"intermediate_steps": steps_delta
|
| 551 |
-
}
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
@trace_node("generate_with_history")
|
| 555 |
-
async def generate_with_history_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 556 |
-
"""
|
| 557 |
-
대화 히스토리만 사용하여 후속 질문에 답변합니다.
|
| 558 |
-
|
| 559 |
-
수정 사항:
|
| 560 |
-
1. 문맥 오염 방지: 바로 직전의 대화(질문+답변)만 참조하도록 슬라이싱 적용
|
| 561 |
-
2. 히스토리 저장: AIMessage 반환 추가 (대화 끊김 방지)
|
| 562 |
-
"""
|
| 563 |
-
# 1. 현재 질문 추출
|
| 564 |
-
user_question = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 565 |
-
messages_history = state.messages
|
| 566 |
-
|
| 567 |
-
logger.info("대화 히스토리 기반 답변 생성: %s", user_question[:50])
|
| 568 |
-
|
| 569 |
-
# 2. 대화 맥락 구성 (Context Pollution 방지)
|
| 570 |
-
context_prompt = "이전 대화를 참고하여 후속 질문에 답변하세요.\n\n"
|
| 571 |
-
|
| 572 |
-
# [핵심] 현재 질문을 제외한 과거 기록 중 '가장 최근 2개(직전 질문+답변)'만 참조
|
| 573 |
-
prev_messages = messages_history[:-1] if messages_history else []
|
| 574 |
-
recent_context = prev_messages[-2:] if prev_messages else []
|
| 575 |
-
|
| 576 |
-
if recent_context:
|
| 577 |
-
context_prompt += "직전 대화 내역:\n"
|
| 578 |
-
for msg in recent_context:
|
| 579 |
-
if hasattr(msg, 'type') and hasattr(msg, 'content'):
|
| 580 |
-
role = "사용자" if msg.type == "human" else "AI"
|
| 581 |
-
context_prompt += f"{role}: {msg.content}\n\n"
|
| 582 |
-
|
| 583 |
-
context_prompt += f"현재 질문: {user_question}\n\n"
|
| 584 |
-
context_prompt += "위의 '직전 대화 내역'에만 집중하여 답변하세요. 그 외의 이전 주제나 불필요한 맥락은 언급하지 마세요."
|
| 585 |
-
|
| 586 |
-
updates = {}
|
| 587 |
-
steps_delta: List[str] = []
|
| 588 |
-
|
| 589 |
-
try:
|
| 590 |
-
# 3. LLM 호출
|
| 591 |
-
response = llm.invoke([HumanMessage(content=context_prompt)])
|
| 592 |
-
final_answer = response.content.strip()
|
| 593 |
-
|
| 594 |
-
# 4. 상태 업데이트
|
| 595 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 596 |
-
|
| 597 |
-
if is_multi:
|
| 598 |
-
# 다중 질문 모드 (예외적 상황)
|
| 599 |
-
return {
|
| 600 |
-
"multi_answers": [{
|
| 601 |
-
"index": state.worker_idx,
|
| 602 |
-
"question": state.worker_sub_text or user_question,
|
| 603 |
-
"answer": final_answer
|
| 604 |
-
}]
|
| 605 |
-
}
|
| 606 |
-
else:
|
| 607 |
-
# 단일 질문 모드 (정상 케이스)
|
| 608 |
-
updates["final_answer"] = final_answer
|
| 609 |
-
# [핵심] 대화 히스토리에 AI 답변을 추가하여 다음 턴에서 참조 가능하게 함
|
| 610 |
-
updates["messages"] = [AIMessage(content=final_answer)]
|
| 611 |
-
|
| 612 |
-
steps_delta.append(f"💬 대화 히스토리 기반 답변 생성 (길이: {len(final_answer)}자)")
|
| 613 |
-
steps_delta.append("⚠️ 캐시 저장 생략 (보충 요청)")
|
| 614 |
-
|
| 615 |
-
logger.info("대화 히스토리 기반 답변 생성 완료")
|
| 616 |
-
|
| 617 |
-
except Exception as e:
|
| 618 |
-
logger.error("대화 히스토리 기반 답변 생성 실패: %s", e, exc_info=True)
|
| 619 |
-
|
| 620 |
-
if is_multi:
|
| 621 |
-
return {
|
| 622 |
-
"multi_answers": [{
|
| 623 |
-
"index": state.worker_idx,
|
| 624 |
-
"question": state.worker_sub_text or user_question,
|
| 625 |
-
"answer": "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 626 |
-
}]
|
| 627 |
-
}
|
| 628 |
-
else:
|
| 629 |
-
updates["final_answer"] = "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 630 |
-
steps_delta.append(f"❌ 답변 생성 실패: {str(e)}")
|
| 631 |
-
|
| 632 |
-
updates["intermediate_steps"] = steps_delta
|
| 633 |
-
return updates
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
@trace_node("classify_intent")
|
| 637 |
-
def classify_intent_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 638 |
-
"""
|
| 639 |
-
LLM을 사용하여 사용자 질문의 의도를 분류합니다.
|
| 640 |
-
|
| 641 |
-
🔧 CRITICAL:
|
| 642 |
-
- refined_question이 있으면 그것을 사용, 없으면 user_question 사용
|
| 643 |
-
- WorkerState 필드만 반환 (부모 AgentState와 충돌 방지)
|
| 644 |
-
- ❌ 절대 반환하면 안 되는 것들: user_question, messages
|
| 645 |
-
"""
|
| 646 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 647 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 648 |
-
question_to_classify = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 649 |
-
|
| 650 |
-
logger.info("의도 분류 중: %s", question_to_classify[:50])
|
| 651 |
-
|
| 652 |
-
classification_prompt = f"""질문을 다음 세 가지 의도 중 하나로 분류하세요:
|
| 653 |
-
|
| 654 |
-
1. debugging: 에러 해결, 버그 수정, 문제 해결
|
| 655 |
-
2. learning: 개념 학습, 원리 이해, 튜토리얼
|
| 656 |
-
3. code_review: 코드 개선, 리팩토링, 베스트 프랙티스
|
| 657 |
-
|
| 658 |
-
질문: {question_to_classify}
|
| 659 |
-
|
| 660 |
-
반드시 debugging, learning, code_review 중 하나만 답하세요."""
|
| 661 |
-
|
| 662 |
-
updates = {}
|
| 663 |
-
steps_delta: List[str] = []
|
| 664 |
-
|
| 665 |
-
try:
|
| 666 |
-
messages = [
|
| 667 |
-
SystemMessage(content="당신은 개발자 질문을 분류하는 전문가입니다."),
|
| 668 |
-
HumanMessage(content=classification_prompt)
|
| 669 |
-
]
|
| 670 |
-
|
| 671 |
-
response = llm.invoke(messages)
|
| 672 |
-
intent_raw = response.content.strip().lower()
|
| 673 |
-
|
| 674 |
-
# 유효한 의도로 정규화
|
| 675 |
-
valid_intents = ["debugging", "learning", "code_review"]
|
| 676 |
-
intent = next((i for i in valid_intents if i in intent_raw), "learning")
|
| 677 |
-
|
| 678 |
-
updates["detected_intent"] = intent
|
| 679 |
-
steps_delta.append(f"🎯 의도 분류: {intent}")
|
| 680 |
-
logger.info("의도 분류 완료: %s", intent)
|
| 681 |
-
|
| 682 |
-
except Exception as e:
|
| 683 |
-
logger.error("의도 분류 실패: %s", e, exc_info=True)
|
| 684 |
-
updates["detected_intent"] = "learning"
|
| 685 |
-
steps_delta.append("⚠️ 의도 분류 실패, 기본값 사용: learning")
|
| 686 |
-
|
| 687 |
-
updates["intermediate_steps"] = steps_delta
|
| 688 |
-
|
| 689 |
-
# 🔧 CRITICAL: WorkerState 필드만 반환
|
| 690 |
-
# ✅ OK: detected_intent, intermediate_steps
|
| 691 |
-
# ❌ 절대 반환하면 안 됨: user_question, messages
|
| 692 |
-
return updates
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
@trace_node("search_stackoverflow")
|
| 696 |
-
def search_stackoverflow_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 697 |
-
"""Stack Overflow에서 검색을 수행합니다."""
|
| 698 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 699 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 700 |
-
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 701 |
-
|
| 702 |
-
intent = state.detected_intent or "learning"
|
| 703 |
-
count = 5 if intent == "debugging" else 3
|
| 704 |
-
|
| 705 |
-
logger.info("Stack Overflow 검색 시작: %d개", count)
|
| 706 |
-
|
| 707 |
-
try:
|
| 708 |
-
results = search_stackoverflow(question_to_use, count)
|
| 709 |
-
logger.info("Stack Overflow에서 %d개 결과 수집", len(results))
|
| 710 |
-
|
| 711 |
-
# 🔧 FIX: intermediate_steps 제거
|
| 712 |
-
return {
|
| 713 |
-
"search_results": results,
|
| 714 |
-
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 715 |
-
}
|
| 716 |
-
except Exception as e:
|
| 717 |
-
logger.error("Stack Overflow 검색 실패: %s", e)
|
| 718 |
-
return {}
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
@trace_node("search_github")
|
| 722 |
-
def search_github_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 723 |
-
"""GitHub Issues/Discussions에서 검색을 수행합니다."""
|
| 724 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 725 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 726 |
-
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 727 |
-
|
| 728 |
-
intent = state.detected_intent or "learning"
|
| 729 |
-
count = 5 if intent == "code_review" else 3 if intent == "learning" else 2
|
| 730 |
-
|
| 731 |
-
logger.info("GitHub 검색 시작: %d개", count)
|
| 732 |
-
|
| 733 |
-
try:
|
| 734 |
-
results = search_github(question_to_use, count)
|
| 735 |
-
logger.info("GitHub에서 %d개 결과 수집", len(results))
|
| 736 |
-
|
| 737 |
-
# 🔧 FIX: intermediate_steps 제거
|
| 738 |
-
return {
|
| 739 |
-
"search_results": results,
|
| 740 |
-
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 741 |
-
}
|
| 742 |
-
except Exception as e:
|
| 743 |
-
logger.error("GitHub 검색 실패: %s", e)
|
| 744 |
-
return {}
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
@trace_node("search_official_docs")
|
| 748 |
-
def search_official_docs_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 749 |
-
"""공식 문서/Tavily에서 검색을 수행합니다."""
|
| 750 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 751 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 752 |
-
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 753 |
-
|
| 754 |
-
intent = state.detected_intent or "learning"
|
| 755 |
-
count = 5 if intent == "learning" else 2
|
| 756 |
-
|
| 757 |
-
logger.info("공식 문서 검색 시작: %d개", count)
|
| 758 |
-
|
| 759 |
-
try:
|
| 760 |
-
results = search_official_docs(question_to_use, count)
|
| 761 |
-
logger.info("공식 문서에서 %d개 결과 수집", len(results))
|
| 762 |
-
|
| 763 |
-
# 🔧 FIX: intermediate_steps 제거
|
| 764 |
-
return {
|
| 765 |
-
"search_results": results,
|
| 766 |
-
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 767 |
-
}
|
| 768 |
-
except Exception as e:
|
| 769 |
-
logger.error("공식 문서 검색 실패: %s", e)
|
| 770 |
-
return {}
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
@trace_node("collect_results")
|
| 774 |
-
def collect_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 775 |
-
"""병렬 검색 결과를 수집하고 카운트합니다."""
|
| 776 |
-
total_results = len(state.search_results)
|
| 777 |
-
|
| 778 |
-
logger.info("검색 결과 수집 완료: %d개", total_results)
|
| 779 |
-
|
| 780 |
-
# 🔧 FIX: 로그만 찍고, intermediate_steps는 업데이트하지 않음
|
| 781 |
-
# (병렬 노드에서 intermediate_steps 업데이트 시 충돌 발생)
|
| 782 |
-
|
| 783 |
-
return {} # 빈 딕셔너리 반환 (상태 변경 없음)
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
@trace_node("evaluate_results")
|
| 787 |
-
def evaluate_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 788 |
-
"""검색 결과의 개수와 품질을 모두 평가합니다."""
|
| 789 |
-
search_results = state.search_results
|
| 790 |
-
refinement_count = state.refinement_count
|
| 791 |
-
|
| 792 |
-
result_count = len(search_results)
|
| 793 |
-
|
| 794 |
-
logger.info("검색 결과 평가: %d개 (개선 횟수: %d)", result_count, refinement_count)
|
| 795 |
-
|
| 796 |
-
# 안전장치: 이미 1회 개선했으면 더 이상 개선하지 않음
|
| 797 |
-
if refinement_count >= 1:
|
| 798 |
-
steps_delta = [
|
| 799 |
-
f"⚠️ 최대 개선 횟수 도달 ({refinement_count}회), 현재 결과로 진행"
|
| 800 |
-
]
|
| 801 |
-
return {
|
| 802 |
-
"needs_refinement": False,
|
| 803 |
-
"intermediate_steps": steps_delta
|
| 804 |
-
}
|
| 805 |
-
|
| 806 |
-
# 1차 평가: 개수
|
| 807 |
-
if result_count < 2:
|
| 808 |
-
steps_delta = [
|
| 809 |
-
f"⚠️ 검색 결과 부족 ({result_count}개 < 2개), 쿼리 개선 필요"
|
| 810 |
-
]
|
| 811 |
-
return {
|
| 812 |
-
"needs_refinement": True,
|
| 813 |
-
"intermediate_steps": steps_delta
|
| 814 |
-
}
|
| 815 |
-
|
| 816 |
-
# 2차 평가: 품질
|
| 817 |
-
scored_results = [r for r in search_results if r.relevance_score is not None]
|
| 818 |
-
|
| 819 |
-
if scored_results:
|
| 820 |
-
avg_score = sum(r.relevance_score for r in scored_results) / len(scored_results)
|
| 821 |
-
|
| 822 |
-
if avg_score < 0.5:
|
| 823 |
-
steps_delta = [
|
| 824 |
-
f"⚠️ 검색 결과 품질 부족 (평균 점수: {avg_score:.2f} < 0.5), 쿼리 개선 필요"
|
| 825 |
-
]
|
| 826 |
-
return {
|
| 827 |
-
"needs_refinement": True,
|
| 828 |
-
"intermediate_steps": steps_delta
|
| 829 |
-
}
|
| 830 |
-
|
| 831 |
-
steps_delta = [
|
| 832 |
-
f"✅ 검색 결과 충분 ({result_count}개, 평균 점수: {avg_score:.2f}), 필터링 단계로 진행"
|
| 833 |
-
]
|
| 834 |
-
else:
|
| 835 |
-
steps_delta = [
|
| 836 |
-
f"✅ 검색 결과 충분 ({result_count}개), 필터링 단계로 진행"
|
| 837 |
-
]
|
| 838 |
-
|
| 839 |
-
return {
|
| 840 |
-
"needs_refinement": False,
|
| 841 |
-
"intermediate_steps": steps_delta
|
| 842 |
-
}
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
@trace_node("refine_search")
|
| 846 |
-
def refine_search_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 847 |
-
"""
|
| 848 |
-
검색 쿼리를 개선합니다.
|
| 849 |
-
|
| 850 |
-
🔧 CRITICAL:
|
| 851 |
-
- user_question을 직접 업데이트하지 않고, refined_question에 저장
|
| 852 |
-
- 부모 AgentState와 충돌 방지를 위해 WorkerState 필드만 반환
|
| 853 |
-
- ❌ 절대 반환하면 안 되는 것들: user_question, messages, final_answer
|
| 854 |
-
"""
|
| 855 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 856 |
-
user_question = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 857 |
-
original_question = state.original_question or user_question
|
| 858 |
-
result_count = len(state.search_results)
|
| 859 |
-
|
| 860 |
-
logger.info("검색 쿼리 개선 중: %s (%d개 결과)", user_question[:50], result_count)
|
| 861 |
-
|
| 862 |
-
refinement_prompt = f"""검색 결과가 부족합니다. 검색 쿼리를 개선하세요.
|
| 863 |
-
|
| 864 |
-
원본 질문: {user_question}
|
| 865 |
-
현재 결과 수: {result_count}개 (목표: 2개 이상)
|
| 866 |
-
|
| 867 |
-
개선 전략 (하나 선택):
|
| 868 |
-
1. MORE_SPECIFIC: 기술적 세부사항 추가
|
| 869 |
-
2. MORE_GENERAL: 더 넓은 용어 사용
|
| 870 |
-
3. TRANSLATE: 언어 변환
|
| 871 |
-
|
| 872 |
-
다음 JSON 형식으로만 답변하세요:
|
| 873 |
-
{{
|
| 874 |
-
"new_query": "개선된 검색 쿼리",
|
| 875 |
-
"strategy": "MORE_SPECIFIC|MORE_GENERAL|TRANSLATE",
|
| 876 |
-
"reasoning": "이 전략을 선택한 이유 1-2문장"
|
| 877 |
-
}}
|
| 878 |
-
|
| 879 |
-
JSON 외에 다른 텍스트는 포함하지 마세요."""
|
| 880 |
-
|
| 881 |
-
try:
|
| 882 |
-
import json
|
| 883 |
-
|
| 884 |
-
messages_to_llm = [HumanMessage(content=refinement_prompt)]
|
| 885 |
-
response = llm.invoke(messages_to_llm)
|
| 886 |
-
|
| 887 |
-
response_text = response.content.strip()
|
| 888 |
-
if "```json" in response_text:
|
| 889 |
-
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 890 |
-
elif "```" in response_text:
|
| 891 |
-
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 892 |
-
|
| 893 |
-
refinement_data = json.loads(response_text)
|
| 894 |
-
|
| 895 |
-
new_query = refinement_data.get("new_query", user_question)
|
| 896 |
-
strategy = refinement_data.get("strategy", "MORE_GENERAL")
|
| 897 |
-
reasoning = refinement_data.get("reasoning", "")
|
| 898 |
-
|
| 899 |
-
steps_delta = [
|
| 900 |
-
f"🔄 쿼리 개선: {strategy}",
|
| 901 |
-
f" 이전: {user_question[:50]}...",
|
| 902 |
-
f" 이후: {new_query[:50]}...",
|
| 903 |
-
f" 이유: {reasoning}"
|
| 904 |
-
]
|
| 905 |
-
|
| 906 |
-
logger.info("쿼리 개선 완료: %s → %s", user_question[:30], new_query[:30])
|
| 907 |
-
|
| 908 |
-
# 🔧 CRITICAL: WorkerState 필드만 반환 (부모 AgentState와 충돌 방지)
|
| 909 |
-
return {
|
| 910 |
-
"refined_question": new_query, # ✅ WorkerState 필드
|
| 911 |
-
"original_question": original_question, # ✅ WorkerState 필드
|
| 912 |
-
"refinement_count": state.refinement_count + 1, # ✅ WorkerState 필드
|
| 913 |
-
"search_results": [], # ✅ WorkerState 필드 (reducer 있음)
|
| 914 |
-
"intermediate_steps": steps_delta # ✅ WorkerState 필드
|
| 915 |
-
|
| 916 |
-
# ❌ 절대 반환하면 안 되는 것들:
|
| 917 |
-
# "user_question": ..., # 부모 AgentState와 충돌!
|
| 918 |
-
# "messages": ..., # 부모 AgentState와 충돌!
|
| 919 |
-
# "final_answer": ..., # 너무 이른 시점!
|
| 920 |
-
}
|
| 921 |
-
|
| 922 |
-
except Exception as e:
|
| 923 |
-
logger.error("쿼리 개선 실패: %s", e, exc_info=True)
|
| 924 |
-
|
| 925 |
-
fallback_query = user_question + " tutorial example"
|
| 926 |
-
|
| 927 |
-
steps_delta = [
|
| 928 |
-
f"⚠️ 쿼리 개선 실패, 기본 전략 사용",
|
| 929 |
-
f" 이후: {fallback_query}"
|
| 930 |
-
]
|
| 931 |
-
|
| 932 |
-
# 🔧 CRITICAL: WorkerState 필드만 반환
|
| 933 |
-
return {
|
| 934 |
-
"refined_question": fallback_query, # ✅ WorkerState 필드
|
| 935 |
-
"original_question": original_question, # ✅ WorkerState 필드
|
| 936 |
-
"refinement_count": state.refinement_count + 1, # ✅ WorkerState 필드
|
| 937 |
-
"search_results": [], # ✅ WorkerState 필드 (reducer 있음)
|
| 938 |
-
"intermediate_steps": steps_delta # ✅ WorkerState 필드
|
| 939 |
-
}
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
@trace_node("filter_and_score")
|
| 943 |
-
def filter_and_score_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 944 |
-
"""검색 결과를 필터링하고 관련도 점수를 매깁니다."""
|
| 945 |
-
search_results = state.search_results
|
| 946 |
-
logger.info("검색 결과 필터링 중: %d개", len(search_results))
|
| 947 |
-
|
| 948 |
-
# 기본 필터링
|
| 949 |
-
filtered = [
|
| 950 |
-
r for r in search_results
|
| 951 |
-
if r.content and len(r.content) >= 50 and r.url
|
| 952 |
-
]
|
| 953 |
-
|
| 954 |
-
logger.info("기본 필터링 후: %d개 결과", len(filtered))
|
| 955 |
-
|
| 956 |
-
# 상위 5개 결과만 LLM으로 점수 매기기
|
| 957 |
-
# 🔧 [FIX] scoring_prompt 내부에서 질문 참조 시 수정
|
| 958 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 959 |
-
|
| 960 |
-
for result in filtered[:5]:
|
| 961 |
-
if result.relevance_score is None:
|
| 962 |
-
try:
|
| 963 |
-
scoring_prompt = f"""질문: {current_q}
|
| 964 |
-
|
| 965 |
-
검색 결과: {result.content[:500]}
|
| 966 |
-
|
| 967 |
-
이 검색 결과가 질문에 얼마나 관련이 있는지 0.0에서 1.0 사이의 점수로 평가하세요.
|
| 968 |
-
점수만 숫자로 답하세요. (예: 0.8)"""
|
| 969 |
-
|
| 970 |
-
response = llm.invoke([HumanMessage(content=scoring_prompt)])
|
| 971 |
-
score_str = response.content.strip()
|
| 972 |
-
result.relevance_score = float(score_str)
|
| 973 |
-
|
| 974 |
-
except Exception as e:
|
| 975 |
-
logger.warning("점수 매기기 실패: %s", e)
|
| 976 |
-
result.relevance_score = 0.5
|
| 977 |
-
|
| 978 |
-
# 관련도 순으로 정렬
|
| 979 |
-
filtered.sort(key=lambda r: r.relevance_score or 0, reverse=True)
|
| 980 |
-
|
| 981 |
-
# 상위 5개만 유지
|
| 982 |
-
top_results = filtered[:5]
|
| 983 |
-
|
| 984 |
-
subtask_results = dict(state.subtask_results)
|
| 985 |
-
subtask_results["filtered_results"] = [r.model_dump() for r in top_results]
|
| 986 |
-
|
| 987 |
-
steps_delta = [f"✂️ 필터링 완료: {len(top_results)}개 결과 선택"]
|
| 988 |
-
|
| 989 |
-
logger.info("필터링 완료: %d개 결과", len(top_results))
|
| 990 |
-
|
| 991 |
-
return {
|
| 992 |
-
"subtask_results": subtask_results,
|
| 993 |
-
"intermediate_steps": steps_delta
|
| 994 |
-
}
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
@trace_node("summarize_results")
|
| 998 |
-
def summarize_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 999 |
-
"""필터링된 각 검색 결과를 초보 개발자가 이해하기 쉽게 요약합니다."""
|
| 1000 |
-
subtask_results = state.subtask_results
|
| 1001 |
-
filtered_results = subtask_results.get("filtered_results", [])
|
| 1002 |
-
logger.info("검색 결과 요약 중: %d개", len(filtered_results))
|
| 1003 |
-
|
| 1004 |
-
summaries = []
|
| 1005 |
-
|
| 1006 |
-
for result_dict in filtered_results:
|
| 1007 |
-
try:
|
| 1008 |
-
summary_prompt = f"""다음 검색 결과를 초보 개발자가 이해하기 쉽게 2-3문장으로 요약하세요:
|
| 1009 |
-
|
| 1010 |
-
출처: {result_dict['source']}
|
| 1011 |
-
내용: {result_dict['content'][:1000]}
|
| 1012 |
-
|
| 1013 |
-
핵심 내용만 간단명료하게 요약하세요."""
|
| 1014 |
-
|
| 1015 |
-
response = llm.invoke([HumanMessage(content=summary_prompt)])
|
| 1016 |
-
|
| 1017 |
-
summaries.append({
|
| 1018 |
-
"source": result_dict['source'],
|
| 1019 |
-
"url": result_dict['url'],
|
| 1020 |
-
"summary": response.content.strip(),
|
| 1021 |
-
"relevance": result_dict.get('relevance_score', 0.5)
|
| 1022 |
-
})
|
| 1023 |
-
|
| 1024 |
-
except Exception as e:
|
| 1025 |
-
logger.error("요약 실패: %s", e)
|
| 1026 |
-
|
| 1027 |
-
updated_subtask_results = dict(subtask_results)
|
| 1028 |
-
updated_subtask_results["summaries"] = summaries
|
| 1029 |
-
|
| 1030 |
-
steps_delta = [f"📝 요약 완료: {len(summaries)}개 결과"]
|
| 1031 |
-
|
| 1032 |
-
logger.info("요약 완료: %d개", len(summaries))
|
| 1033 |
-
|
| 1034 |
-
return {
|
| 1035 |
-
"subtask_results": updated_subtask_results,
|
| 1036 |
-
"intermediate_steps": steps_delta
|
| 1037 |
-
}
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
@trace_node("generate_answer")
|
| 1041 |
-
async def generate_answer_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 1042 |
-
"""
|
| 1043 |
-
요약된 정보를 바탕으로 최종 답변을 생성합니다.
|
| 1044 |
-
|
| 1045 |
-
수정 사항:
|
| 1046 |
-
1. 다중 질문 모드에서도 캐시 저장 로직이 실행되도록 순서 변경
|
| 1047 |
-
2. 단일 질문 모드에서 AIMessage 반환 (히스토리 저장)
|
| 1048 |
-
"""
|
| 1049 |
-
subtask_results = state.subtask_results
|
| 1050 |
-
summaries = subtask_results.get("summaries", [])
|
| 1051 |
-
intent = state.detected_intent or "learning"
|
| 1052 |
-
|
| 1053 |
-
# 변수 접근
|
| 1054 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 1055 |
-
|
| 1056 |
-
logger.info("최종 답변 생성 중: %s (질문: %s)", intent, current_q[:30])
|
| 1057 |
-
|
| 1058 |
-
# 1. 의도별 프롬프트 템플릿
|
| 1059 |
-
templates = {
|
| 1060 |
-
"debugging": """다음 정보를 바탕으로 디버깅 질문에 답변하세요:
|
| 1061 |
-
|
| 1062 |
-
질문: {question}
|
| 1063 |
-
|
| 1064 |
-
수집된 정보:
|
| 1065 |
-
{summaries}
|
| 1066 |
-
|
| 1067 |
-
답변 구조:
|
| 1068 |
-
1. 문제 정의
|
| 1069 |
-
2. 발생 원인
|
| 1070 |
-
3. 해결 방법 (코드 예제 포함)
|
| 1071 |
-
4. 주의사항
|
| 1072 |
-
5. 참고 자료
|
| 1073 |
-
|
| 1074 |
-
초보 개발자도 이해할 수 있게 Markdown 형식으로 작성하세요.""",
|
| 1075 |
-
|
| 1076 |
-
"learning": """다음 정보를 바탕으로 학습 질문에 답변하세요:
|
| 1077 |
-
|
| 1078 |
-
질문: {question}
|
| 1079 |
-
|
| 1080 |
-
수집된 정보:
|
| 1081 |
-
{summaries}
|
| 1082 |
-
|
| 1083 |
-
답변 구조:
|
| 1084 |
-
1. 개념 설명 (간단명료)
|
| 1085 |
-
2. 동작 원리
|
| 1086 |
-
3. 예제 코드 (주석포함)
|
| 1087 |
-
4. 실무 활용 팁
|
| 1088 |
-
5. 추가 학습 자료
|
| 1089 |
-
|
| 1090 |
-
초보 개발자도 이해할 수 있게 Markdown 형식으로 작성하세요.""",
|
| 1091 |
-
|
| 1092 |
-
"code_review": """다음 정보를 바탕으로 코드 리뷰 질문에 답변하세요:
|
| 1093 |
-
|
| 1094 |
-
질문: {question}
|
| 1095 |
-
|
| 1096 |
-
수집된 정보:
|
| 1097 |
-
{summaries}
|
| 1098 |
-
|
| 1099 |
-
답변 구조:
|
| 1100 |
-
1. 현재 접근 방식 분석
|
| 1101 |
-
2. 개선 포인트
|
| 1102 |
-
3. 리팩토링 예제
|
| 1103 |
-
4. 베스트 프랙티스
|
| 1104 |
-
5. 참고 패턴
|
| 1105 |
-
|
| 1106 |
-
초보 개발자도 이해할 수 있게 Markdown 형식으로 작성하세요."""
|
| 1107 |
-
}
|
| 1108 |
-
|
| 1109 |
-
template = templates.get(intent, templates["learning"])
|
| 1110 |
-
|
| 1111 |
-
# 2. 요약 텍스트 포맷팅
|
| 1112 |
-
summaries_text = "\n\n".join([
|
| 1113 |
-
f"출처: {s['source']} ({s['url']})\n요약: {s['summary']}"
|
| 1114 |
-
for s in summaries
|
| 1115 |
-
])
|
| 1116 |
-
|
| 1117 |
-
# 3. 이전 대화 맥락 추가 (Context Pollution 방지: 최근 1개만 참고용으로)
|
| 1118 |
-
context_prefix = ""
|
| 1119 |
-
messages_history = state.messages
|
| 1120 |
-
if messages_history and len(messages_history) > 1:
|
| 1121 |
-
# 검색 기반 답변이므로 이전 대화는 아주 최소한만 참조 (직전 1개)
|
| 1122 |
-
prev_msg = messages_history[-2] if len(messages_history) >= 2 else None
|
| 1123 |
-
if prev_msg:
|
| 1124 |
-
context_prefix = f"이전 대화 맥락(참고): {prev_msg.content[:200]}...\n---\n"
|
| 1125 |
-
|
| 1126 |
-
final_prompt = (context_prefix + template).format(
|
| 1127 |
-
question=(state.original_question or current_q),
|
| 1128 |
-
summaries=summaries_text
|
| 1129 |
-
)
|
| 1130 |
-
|
| 1131 |
-
updates = {}
|
| 1132 |
-
steps_delta: List[str] = []
|
| 1133 |
-
|
| 1134 |
-
try:
|
| 1135 |
-
# 4. LLM 호출
|
| 1136 |
-
response = llm.invoke([HumanMessage(content=final_prompt)])
|
| 1137 |
-
final_answer = response.content.strip()
|
| 1138 |
-
|
| 1139 |
-
# 5. 캐시 저장 로직 (DRY - 중복 방지 함수)
|
| 1140 |
-
should_cache = state.should_cache if state.should_cache is not None else True
|
| 1141 |
-
canonical_question = state.canonical_question
|
| 1142 |
-
qtype = state.question_type or "independent"
|
| 1143 |
-
question_to_cache = canonical_question or current_q
|
| 1144 |
-
|
| 1145 |
-
async def _try_cache_save():
|
| 1146 |
-
"""조건 충족 시 Qdrant에 캐시 저장"""
|
| 1147 |
-
if should_cache and qtype in ["new_topic", "independent"]:
|
| 1148 |
-
try:
|
| 1149 |
-
await qdrant_manager.save_to_cache(
|
| 1150 |
-
question=question_to_cache,
|
| 1151 |
-
answer=final_answer
|
| 1152 |
-
)
|
| 1153 |
-
logger.info("✅ 캐시 저장 완료: %s", question_to_cache[:30])
|
| 1154 |
-
return True
|
| 1155 |
-
except Exception as cache_err:
|
| 1156 |
-
logger.error("캐시 저장 실패: %s", cache_err)
|
| 1157 |
-
return False
|
| 1158 |
-
return False
|
| 1159 |
-
|
| 1160 |
-
# 6. 결과 반환 및 분기 처리
|
| 1161 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 1162 |
-
|
| 1163 |
-
if is_multi:
|
| 1164 |
-
# [핵심] 다중 질문 모드: Return하기 '전에' 캐시 저장 시도
|
| 1165 |
-
await _try_cache_save()
|
| 1166 |
-
|
| 1167 |
-
logger.info("다중 질문 모드: 답변을 multi_answers에 추가")
|
| 1168 |
-
return {
|
| 1169 |
-
"multi_answers": [{
|
| 1170 |
-
"index": state.worker_idx,
|
| 1171 |
-
"question": state.worker_sub_text or current_q,
|
| 1172 |
-
"answer": final_answer
|
| 1173 |
-
}]
|
| 1174 |
-
}
|
| 1175 |
-
|
| 1176 |
-
else:
|
| 1177 |
-
# 단일 질문 모드
|
| 1178 |
-
updates["final_answer"] = final_answer
|
| 1179 |
-
# [핵심] 대화 히스토리에 AI 답변 추가
|
| 1180 |
-
updates["messages"] = [AIMessage(content=final_answer)]
|
| 1181 |
-
|
| 1182 |
-
# 캐시 저장 시도
|
| 1183 |
-
saved = await _try_cache_save()
|
| 1184 |
-
|
| 1185 |
-
if saved:
|
| 1186 |
-
steps_delta.append(f"✅ 최종 답변 생성 완료 (길이: {len(final_answer)}자)")
|
| 1187 |
-
steps_delta.append(f"💾 캐시 저장 완료 (질문: {question_to_cache[:50]}...)")
|
| 1188 |
-
else:
|
| 1189 |
-
steps_delta.append(f"✅ 최종 답변 생성 완료 (길이: {len(final_answer)}자)")
|
| 1190 |
-
steps_delta.append("⚠️ 캐시 저장 생략 (독립적이지 않거나 일회성 질문)")
|
| 1191 |
-
logger.info("최종 답변 생성 완료 (캐시 저장 생략)")
|
| 1192 |
-
|
| 1193 |
-
updates["intermediate_steps"] = steps_delta
|
| 1194 |
-
return updates
|
| 1195 |
-
|
| 1196 |
-
except Exception as e:
|
| 1197 |
-
logger.error("답변 생성 실패: %s", e, exc_info=True)
|
| 1198 |
-
|
| 1199 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 1200 |
-
if is_multi:
|
| 1201 |
-
return {
|
| 1202 |
-
"multi_answers": [{
|
| 1203 |
-
"index": state.worker_idx,
|
| 1204 |
-
"question": state.worker_sub_text or current_q,
|
| 1205 |
-
"answer": "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 1206 |
-
}]
|
| 1207 |
-
}
|
| 1208 |
-
else:
|
| 1209 |
-
updates["final_answer"] = "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 1210 |
-
steps_delta.append(f"❌ 답변 생성 실패: {str(e)}")
|
| 1211 |
-
updates["intermediate_steps"] = steps_delta
|
| 1212 |
-
return updates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{hf-space2/CodeWeaver/src/agent → CodeWeaver/src/agent/nodes}/__init__.py
RENAMED
|
@@ -1,51 +1,66 @@
|
|
| 1 |
-
"""
|
| 2 |
-
CodeWeaver 에이전트 모듈.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
- Graph: LangGraph 워크플로우
|
| 10 |
-
"""
|
| 11 |
|
| 12 |
-
|
| 13 |
-
from .
|
| 14 |
-
from .nodes import (
|
| 15 |
analyze_question_node,
|
| 16 |
check_cache_node,
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
search_stackoverflow_node,
|
| 19 |
search_github_node,
|
| 20 |
search_official_docs_node,
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
generate_answer_node,
|
| 24 |
-
return_cached_answer_node,
|
| 25 |
generate_with_history_node,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
__all__ = [
|
| 29 |
-
#
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
|
| 33 |
-
# Graph
|
| 34 |
-
"agent",
|
| 35 |
-
"build_agent_graph",
|
| 36 |
-
"create_agent",
|
| 37 |
-
|
| 38 |
-
# Nodes
|
| 39 |
"analyze_question_node",
|
| 40 |
"check_cache_node",
|
| 41 |
-
|
| 42 |
"search_stackoverflow_node",
|
| 43 |
"search_github_node",
|
| 44 |
"search_official_docs_node",
|
| 45 |
-
"
|
| 46 |
-
"
|
|
|
|
|
|
|
| 47 |
"generate_answer_node",
|
| 48 |
-
"return_cached_answer_node",
|
| 49 |
"generate_with_history_node",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
]
|
| 51 |
|
|
|
|
| 1 |
+
"""노드 모듈 - LangGraph 노드 함수들."""
|
|
|
|
| 2 |
|
| 3 |
+
# Planning nodes (AgentState 사용)
|
| 4 |
+
from src.agent.nodes.planning import (
|
| 5 |
+
create_plan_node,
|
| 6 |
+
handle_too_many_questions_node,
|
| 7 |
+
)
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
# Analysis nodes
|
| 10 |
+
from src.agent.nodes.analysis import (
|
|
|
|
| 11 |
analyze_question_node,
|
| 12 |
check_cache_node,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# Search nodes
|
| 16 |
+
from src.agent.nodes.search import (
|
| 17 |
search_stackoverflow_node,
|
| 18 |
search_github_node,
|
| 19 |
search_official_docs_node,
|
| 20 |
+
collect_results_node,
|
| 21 |
+
evaluate_results_node,
|
| 22 |
+
refine_search_node,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Answer nodes
|
| 26 |
+
from src.agent.nodes.answer import (
|
| 27 |
generate_answer_node,
|
|
|
|
| 28 |
generate_with_history_node,
|
| 29 |
+
combine_answers_node,
|
| 30 |
+
return_cached_answer_node,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Common utilities
|
| 34 |
+
from src.agent.nodes.common import (
|
| 35 |
+
invoke_llm_with_timeout,
|
| 36 |
+
TIMEOUT_ANALYSIS,
|
| 37 |
+
TIMEOUT_SUMMARY,
|
| 38 |
+
TIMEOUT_GENERATION,
|
| 39 |
)
|
| 40 |
|
| 41 |
__all__ = [
|
| 42 |
+
# Planning
|
| 43 |
+
"create_plan_node",
|
| 44 |
+
"handle_too_many_questions_node",
|
| 45 |
+
# Analysis
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"analyze_question_node",
|
| 47 |
"check_cache_node",
|
| 48 |
+
# Search
|
| 49 |
"search_stackoverflow_node",
|
| 50 |
"search_github_node",
|
| 51 |
"search_official_docs_node",
|
| 52 |
+
"collect_results_node",
|
| 53 |
+
"evaluate_results_node",
|
| 54 |
+
"refine_search_node",
|
| 55 |
+
# Answer
|
| 56 |
"generate_answer_node",
|
|
|
|
| 57 |
"generate_with_history_node",
|
| 58 |
+
"combine_answers_node",
|
| 59 |
+
"return_cached_answer_node",
|
| 60 |
+
# Common
|
| 61 |
+
"invoke_llm_with_timeout",
|
| 62 |
+
"TIMEOUT_ANALYSIS",
|
| 63 |
+
"TIMEOUT_SUMMARY",
|
| 64 |
+
"TIMEOUT_GENERATION",
|
| 65 |
]
|
| 66 |
|
CodeWeaver/src/agent/nodes/analysis.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""질문 분석 및 캐시 확인 노드 모듈."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List, Union
|
| 5 |
+
|
| 6 |
+
from langchain_core.messages import HumanMessage
|
| 7 |
+
|
| 8 |
+
from src.agent.state import AgentState, WorkerState
|
| 9 |
+
from src.utils.tracing import trace_node
|
| 10 |
+
from src.core.resources import get_qdrant_manager
|
| 11 |
+
from src.prompts.loader import load_prompt
|
| 12 |
+
from src.agent.nodes.common import invoke_llm_with_timeout, TIMEOUT_ANALYSIS
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@trace_node("analyze_question")
|
| 18 |
+
def analyze_question_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 19 |
+
"""
|
| 20 |
+
질문을 분석하여 유형을 분류하고 캐시 적격성을 판단합니다.
|
| 21 |
+
|
| 22 |
+
🔧 FIX: 다중 질문 모드일 때는 messages를 무시하고 독립 질문으로만 분석
|
| 23 |
+
"""
|
| 24 |
+
# 🔧 [FIX] WorkerState일 경우 processing_question 사용
|
| 25 |
+
if isinstance(state, WorkerState):
|
| 26 |
+
user_question = state.processing_question
|
| 27 |
+
# 🔧 [FIX] 이름 변경된 필드 사용
|
| 28 |
+
is_multi = state.worker_is_multi
|
| 29 |
+
else:
|
| 30 |
+
user_question = state.user_question
|
| 31 |
+
is_multi = getattr(state, 'is_multi_question', False)
|
| 32 |
+
|
| 33 |
+
messages = state.messages
|
| 34 |
+
|
| 35 |
+
# 대화 맥락 구성 (다중 질문 모드가 아닐 때만)
|
| 36 |
+
has_history = messages and len(messages) > 1 and not is_multi
|
| 37 |
+
context_info = ""
|
| 38 |
+
|
| 39 |
+
if has_history:
|
| 40 |
+
context_info = "\n이전 대화 맥락:\n"
|
| 41 |
+
for msg in messages[-4:-1]:
|
| 42 |
+
if hasattr(msg, 'type') and hasattr(msg, 'content'):
|
| 43 |
+
role = "사용자" if msg.type == "human" else "AI"
|
| 44 |
+
context_info += f"{role}: {msg.content[:100]}\n"
|
| 45 |
+
|
| 46 |
+
# 🔧 다중 질문 모드 강제 처리
|
| 47 |
+
if is_multi:
|
| 48 |
+
context_info = "\n⚠️ 주의: 이 질문은 다중 질문의 일부입니다. 독립적인 질문으로만 판단하세요.\n"
|
| 49 |
+
|
| 50 |
+
analysis_prompt = load_prompt(
|
| 51 |
+
"analysis",
|
| 52 |
+
"analysis_prompt",
|
| 53 |
+
context_info=context_info,
|
| 54 |
+
user_question=user_question
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
import json
|
| 59 |
+
messages_to_llm = [HumanMessage(content=analysis_prompt)]
|
| 60 |
+
response_text = invoke_llm_with_timeout(
|
| 61 |
+
messages_to_llm,
|
| 62 |
+
TIMEOUT_ANALYSIS,
|
| 63 |
+
"질문 분석"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
if "```json" in response_text:
|
| 67 |
+
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 68 |
+
elif "```" in response_text:
|
| 69 |
+
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 70 |
+
|
| 71 |
+
analysis = json.loads(response_text)
|
| 72 |
+
|
| 73 |
+
question_type = analysis.get("question_type", "independent")
|
| 74 |
+
should_cache = analysis.get("should_cache", False)
|
| 75 |
+
reasoning = analysis.get("reasoning", "")
|
| 76 |
+
canonical_question = analysis.get("canonical_question", user_question)
|
| 77 |
+
refined_query = analysis.get("refined_query", None)
|
| 78 |
+
|
| 79 |
+
# 유효성 검증
|
| 80 |
+
if question_type not in ["clarification", "general_chat", "independent"]:
|
| 81 |
+
question_type = "independent"
|
| 82 |
+
|
| 83 |
+
# 🔧 CRITICAL: 다중 질문 모드일 때는 무조건 independent로 강제
|
| 84 |
+
if is_multi and question_type == "clarification":
|
| 85 |
+
logger.warning("다중 질문 모드에서 clarification 감지 → independent로 강제 변경")
|
| 86 |
+
question_type = "independent"
|
| 87 |
+
should_cache = True
|
| 88 |
+
reasoning = "다중 질문 모드: 독립 질문으로 강제 분류"
|
| 89 |
+
|
| 90 |
+
# [수정] general_chat일 경우 처리
|
| 91 |
+
if question_type == "general_chat":
|
| 92 |
+
should_cache = False
|
| 93 |
+
canonical_question = None
|
| 94 |
+
refined_query = None
|
| 95 |
+
elif question_type == "clarification":
|
| 96 |
+
should_cache = False
|
| 97 |
+
canonical_question = None
|
| 98 |
+
refined_query = None
|
| 99 |
+
else:
|
| 100 |
+
if canonical_question is None or (isinstance(canonical_question, str) and not canonical_question.strip()):
|
| 101 |
+
canonical_question = user_question
|
| 102 |
+
# independent인데 refined_query가 없으면 기본값으로 사용
|
| 103 |
+
if not refined_query or not refined_query.strip():
|
| 104 |
+
refined_query = user_question
|
| 105 |
+
|
| 106 |
+
steps_delta = [
|
| 107 |
+
"__RESET_STEPS__",
|
| 108 |
+
f"🔍 질문 분석: {question_type} (캐시 여부: {should_cache})",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
"question_type": question_type,
|
| 113 |
+
"should_cache": should_cache,
|
| 114 |
+
"analysis_reasoning": reasoning,
|
| 115 |
+
"canonical_question": canonical_question if should_cache else None,
|
| 116 |
+
"refined_question": refined_query, # 검색 쿼리로 사용
|
| 117 |
+
"intermediate_steps": steps_delta
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
except RuntimeError as e:
|
| 121 |
+
# 타임아웃 또는 기타 LLM 호출 실패
|
| 122 |
+
logger.error("질문 분석 실패: %s", e)
|
| 123 |
+
steps_delta = [
|
| 124 |
+
"__RESET_STEPS__",
|
| 125 |
+
"⚠️ 질문 분석 실패, 기본값 사용: independent",
|
| 126 |
+
]
|
| 127 |
+
return {
|
| 128 |
+
"question_type": "independent",
|
| 129 |
+
"should_cache": True,
|
| 130 |
+
"analysis_reasoning": "LLM 호출 실패로 인한 기본값 사용",
|
| 131 |
+
"canonical_question": user_question,
|
| 132 |
+
"refined_question": user_question, # 기본값으로 원본 질문 사용
|
| 133 |
+
"intermediate_steps": steps_delta
|
| 134 |
+
}
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error("질문 분석 실패: %s", e, exc_info=True)
|
| 137 |
+
|
| 138 |
+
steps_delta = [
|
| 139 |
+
"__RESET_STEPS__",
|
| 140 |
+
"⚠️ 질문 분석 실패, 기본값 사용: independent",
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"question_type": "independent",
|
| 145 |
+
"should_cache": True,
|
| 146 |
+
"analysis_reasoning": "분석 실패, 기본값 사용",
|
| 147 |
+
"canonical_question": user_question,
|
| 148 |
+
"refined_question": user_question, # 기본값으로 원본 질문 사용
|
| 149 |
+
"intermediate_steps": steps_delta
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@trace_node("check_cache")
|
| 154 |
+
def check_cache_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 155 |
+
"""벡터 DB 캐시에서 유사한 질문을 검색합니다."""
|
| 156 |
+
# 🔧 [FIX] 변수 접근 수정
|
| 157 |
+
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 158 |
+
question_for_lookup = state.canonical_question or current_q
|
| 159 |
+
logger.info("캐시 확인 중: %s", question_for_lookup[:50])
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
qdrant_manager = get_qdrant_manager()
|
| 163 |
+
cached_result = qdrant_manager.search_cache(
|
| 164 |
+
question=question_for_lookup,
|
| 165 |
+
threshold=0.95
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
updates = {}
|
| 169 |
+
steps_delta: List[str] = []
|
| 170 |
+
|
| 171 |
+
if cached_result:
|
| 172 |
+
updates["cached_result"] = cached_result
|
| 173 |
+
steps_delta.append(f"✅ 캐시 히트 (답변 길이: {len(cached_result)}자)")
|
| 174 |
+
logger.info("캐시 히트")
|
| 175 |
+
else:
|
| 176 |
+
updates["cached_result"] = None
|
| 177 |
+
steps_delta.append("❌ 캐시 미스: 새로운 검색 필요")
|
| 178 |
+
logger.info("캐시 미스")
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error("캐시 확인 실패: %s", e, exc_info=True)
|
| 182 |
+
updates["cached_result"] = None
|
| 183 |
+
steps_delta.append(f"⚠️ 캐시 확인 오류: {str(e)}")
|
| 184 |
+
|
| 185 |
+
updates["intermediate_steps"] = steps_delta
|
| 186 |
+
return updates
|
| 187 |
+
|
CodeWeaver/src/agent/nodes/answer.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""답변 생성 및 조합 노드 모듈."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import threading
|
| 5 |
+
from typing import List, Union
|
| 6 |
+
|
| 7 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 8 |
+
|
| 9 |
+
from src.agent.state import AgentState, WorkerState, MultiAnswerData
|
| 10 |
+
from src.utils.tracing import trace_node
|
| 11 |
+
from src.core.resources import get_qdrant_manager
|
| 12 |
+
from src.prompts.loader import load_prompt
|
| 13 |
+
from src.agent.nodes.common import invoke_llm_with_timeout, TIMEOUT_GENERATION
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@trace_node("combine_answers")
|
| 19 |
+
def combine_answers_node(state: AgentState) -> dict:
|
| 20 |
+
"""
|
| 21 |
+
Fan-in: 모든 Send가 완료되면 multi_answers를 조합합니다.
|
| 22 |
+
"""
|
| 23 |
+
answers = state.multi_answers
|
| 24 |
+
original_question = state.original_multi_question or state.user_question
|
| 25 |
+
|
| 26 |
+
if not answers:
|
| 27 |
+
logger.error("다중 답변이 비어있음")
|
| 28 |
+
return {
|
| 29 |
+
"final_answer": "답변 생성에 실패했습니다. 다시 시도해 주세요.",
|
| 30 |
+
"intermediate_steps": ["❌ multi_answers 비어있음"]
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# 인덱스 순으로 정렬
|
| 34 |
+
answers.sort(key=lambda x: x.index)
|
| 35 |
+
|
| 36 |
+
# Markdown 형식으로 조합
|
| 37 |
+
combined_parts = []
|
| 38 |
+
for ans in answers:
|
| 39 |
+
section = f"""## {ans.index+1}. {ans.question}
|
| 40 |
+
|
| 41 |
+
{ans.answer}"""
|
| 42 |
+
combined_parts.append(section)
|
| 43 |
+
|
| 44 |
+
combined = "\n\n---\n\n".join(combined_parts)
|
| 45 |
+
|
| 46 |
+
# 헤더 추가
|
| 47 |
+
header = f"# 다중 질문 답변\n\n원본 질문: {original_question}\n\n---\n\n"
|
| 48 |
+
final_combined = header + combined
|
| 49 |
+
|
| 50 |
+
logger.info("다중 답변 조합 완료: %d개", len(answers))
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
"final_answer": final_combined,
|
| 54 |
+
"messages": [AIMessage(content=final_combined)],
|
| 55 |
+
"intermediate_steps": [f"✅ {len(answers)}개 답변 조합 완료"]
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@trace_node("return_cached_answer")
|
| 60 |
+
def return_cached_answer_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 61 |
+
"""캐시 히트 시 저장된 답변을 반환합니다."""
|
| 62 |
+
logger.info("캐시된 답변 반환")
|
| 63 |
+
|
| 64 |
+
cached_answer = state.cached_result
|
| 65 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 66 |
+
|
| 67 |
+
if is_multi:
|
| 68 |
+
return {
|
| 69 |
+
"multi_answers": [MultiAnswerData(
|
| 70 |
+
index=state.worker_idx,
|
| 71 |
+
question=state.worker_sub_text or state.processing_question,
|
| 72 |
+
answer=cached_answer
|
| 73 |
+
)]
|
| 74 |
+
}
|
| 75 |
+
else:
|
| 76 |
+
# 🔧 [FIX] messages에 AIMessage 추가하여 히스토리 저장 보장
|
| 77 |
+
steps_delta = ["💾 캐시된 답변 반환 (검색 생략)"]
|
| 78 |
+
return {
|
| 79 |
+
"final_answer": cached_answer,
|
| 80 |
+
"messages": [AIMessage(content=cached_answer)], # 👈 핵심 수정
|
| 81 |
+
"intermediate_steps": steps_delta
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@trace_node("generate_with_history")
|
| 86 |
+
def generate_with_history_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 87 |
+
"""
|
| 88 |
+
대화 히스토리만 사용하여 후속 질문에 답변합니다.
|
| 89 |
+
|
| 90 |
+
수정 사항:
|
| 91 |
+
1. 문맥 오염 방지: 바로 직전의 대화(질문+답변)만 참조하도록 슬라이싱 적용
|
| 92 |
+
2. 히스토리 저장: AIMessage 반환 추가 (대화 끊김 방지)
|
| 93 |
+
"""
|
| 94 |
+
# 1. 현재 질문 추출
|
| 95 |
+
user_question = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 96 |
+
messages_history = state.messages
|
| 97 |
+
|
| 98 |
+
logger.info("대화 히스토리 기반 답변 생성: %s", user_question[:50])
|
| 99 |
+
|
| 100 |
+
# 2. 대화 맥락 구성 (Context Pollution 방지)
|
| 101 |
+
# [핵심] 현재 질문을 제외한 과거 기록 중 '가장 최근 2개(직전 질문+답변)'만 참조
|
| 102 |
+
prev_messages = messages_history[:-1] if messages_history else []
|
| 103 |
+
recent_context = prev_messages[-20:] if prev_messages else []
|
| 104 |
+
|
| 105 |
+
# recent_context를 문자열로 변환
|
| 106 |
+
recent_context_str = ""
|
| 107 |
+
if recent_context:
|
| 108 |
+
recent_context_str = "직전 대화 내역:\n"
|
| 109 |
+
for msg in recent_context:
|
| 110 |
+
if hasattr(msg, 'type') and hasattr(msg, 'content'):
|
| 111 |
+
role = "사용자" if msg.type == "human" else "AI"
|
| 112 |
+
recent_context_str += f"{role}: {msg.content}\n\n"
|
| 113 |
+
|
| 114 |
+
# 템플릿에서 프롬프트 로드
|
| 115 |
+
context_prompt = load_prompt(
|
| 116 |
+
"answer",
|
| 117 |
+
"context_prompt_base",
|
| 118 |
+
user_question=user_question,
|
| 119 |
+
recent_context=recent_context_str
|
| 120 |
+
)
|
| 121 |
+
updates = {}
|
| 122 |
+
steps_delta: List[str] = []
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# 3. LLM 호출
|
| 126 |
+
final_answer = invoke_llm_with_timeout(
|
| 127 |
+
[HumanMessage(content=context_prompt)],
|
| 128 |
+
TIMEOUT_GENERATION,
|
| 129 |
+
"대화 히스토리 기반 답변 생성"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# 4. 상태 업데이트
|
| 133 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 134 |
+
|
| 135 |
+
if is_multi:
|
| 136 |
+
# 다중 질문 모드 (예외적 상황)
|
| 137 |
+
return {
|
| 138 |
+
"multi_answers": [MultiAnswerData(
|
| 139 |
+
index=state.worker_idx,
|
| 140 |
+
question=state.worker_sub_text or user_question,
|
| 141 |
+
answer=final_answer
|
| 142 |
+
)]
|
| 143 |
+
}
|
| 144 |
+
else:
|
| 145 |
+
# 단일 질문 모드 (정상 케이스)
|
| 146 |
+
updates["final_answer"] = final_answer
|
| 147 |
+
# [핵심] 대화 히스토리에 AI 답변을 추가하여 다음 턴에서 참조 가능하게 함
|
| 148 |
+
updates["messages"] = [AIMessage(content=final_answer)]
|
| 149 |
+
|
| 150 |
+
steps_delta.append(f"💬 대화 히스토리 기반 답변 생성 (길이: {len(final_answer)}자)")
|
| 151 |
+
steps_delta.append("⚠️ 캐시 저장 생략 (보충 요청)")
|
| 152 |
+
|
| 153 |
+
logger.info("대화 히스토리 기반 답변 생성 완료")
|
| 154 |
+
|
| 155 |
+
except RuntimeError as e:
|
| 156 |
+
# 타임아웃 또는 기타 LLM 호출 실패
|
| 157 |
+
error_message = f"답변 생성에 실패했습니다: {str(e)}. 다시 시도해 주세요."
|
| 158 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 159 |
+
if is_multi:
|
| 160 |
+
return {
|
| 161 |
+
"multi_answers": [MultiAnswerData(
|
| 162 |
+
index=state.worker_idx,
|
| 163 |
+
question=state.worker_sub_text or user_question,
|
| 164 |
+
answer=error_message
|
| 165 |
+
)]
|
| 166 |
+
}
|
| 167 |
+
else:
|
| 168 |
+
updates["final_answer"] = error_message
|
| 169 |
+
steps_delta.append(f"❌ 답변 생성 실패")
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error("대화 히스토리 기반 답변 생성 실패: %s", e, exc_info=True)
|
| 172 |
+
|
| 173 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 174 |
+
if is_multi:
|
| 175 |
+
return {
|
| 176 |
+
"multi_answers": [MultiAnswerData(
|
| 177 |
+
index=state.worker_idx,
|
| 178 |
+
question=state.worker_sub_text or user_question,
|
| 179 |
+
answer="답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 180 |
+
)]
|
| 181 |
+
}
|
| 182 |
+
else:
|
| 183 |
+
updates["final_answer"] = "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 184 |
+
steps_delta.append(f"❌ 답변 생성 실패: {str(e)}")
|
| 185 |
+
|
| 186 |
+
updates["intermediate_steps"] = steps_delta
|
| 187 |
+
return updates
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@trace_node("generate_answer")
|
| 191 |
+
def generate_answer_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 192 |
+
"""
|
| 193 |
+
요약된 정보를 바탕으로 최종 답변을 생성합니다.
|
| 194 |
+
|
| 195 |
+
수정 사항:
|
| 196 |
+
1. 다중 질문 모드에서도 캐시 저장 로직이 실행되도록 순서 변경
|
| 197 |
+
2. 단일 질문 모드에서 AIMessage 반환 (히스토리 저장)
|
| 198 |
+
"""
|
| 199 |
+
# 필터링된 검색 결과 사용
|
| 200 |
+
results = state.filtered_search_results
|
| 201 |
+
question_type = state.question_type or "independent"
|
| 202 |
+
|
| 203 |
+
# [수정] 리스트 이름 변경 (summaries -> results)
|
| 204 |
+
has_valid_info = len(results) > 0
|
| 205 |
+
|
| 206 |
+
# 변수 접근
|
| 207 |
+
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 208 |
+
|
| 209 |
+
logger.info("최종 답변 생성 중 (질문: %s)", current_q[:30])
|
| 210 |
+
|
| 211 |
+
# [핵심 추가 1] 최근 대화 내역 포맷팅 (최근 20개 정도)
|
| 212 |
+
# 일반 대화일 때 '기억'을 제공하기 위함
|
| 213 |
+
# HumanMessage는 제외: 사용자 질문은 {question}에 이미 포함되어 있고,
|
| 214 |
+
# AI 답변 패턴(인사말, 요약) 반복 문제를 방지하기 위함
|
| 215 |
+
messages = state.messages
|
| 216 |
+
recent_history = ""
|
| 217 |
+
if messages and len(messages) > 0:
|
| 218 |
+
# 시스템 메시지 및 HumanMessage 제외, AI 답변만 포함 (최근 20개)
|
| 219 |
+
visible_msgs = messages[-20:]
|
| 220 |
+
for msg in visible_msgs:
|
| 221 |
+
if hasattr(msg, 'content') and not isinstance(msg, HumanMessage):
|
| 222 |
+
# HumanMessage 제외, AIMessage만 포함
|
| 223 |
+
recent_history += f"AI: {msg.content}\n"
|
| 224 |
+
|
| 225 |
+
# 템플릿 선택: general_chat인 경우만 별도 처리
|
| 226 |
+
if question_type == "general_chat":
|
| 227 |
+
# 일반 대화 템플릿 로드
|
| 228 |
+
template = load_prompt(
|
| 229 |
+
"answer",
|
| 230 |
+
"general_chat_template",
|
| 231 |
+
history=recent_history if recent_history else "(이전 대화 내역 없음)",
|
| 232 |
+
question=state.original_question or current_q
|
| 233 |
+
)
|
| 234 |
+
has_valid_info = False # general_chat은 검색 결과 없음
|
| 235 |
+
system_instruction = "" # general_chat은 별도 지시 없음
|
| 236 |
+
else:
|
| 237 |
+
# 검색 결과 유무에 따라 분기 처리
|
| 238 |
+
if has_valid_info:
|
| 239 |
+
# [수정] 원본 content 사용 (너무 길면 1500자 제한)
|
| 240 |
+
summaries_text = "\n\n".join([
|
| 241 |
+
f"출처: {r.source} ({r.url or ''})\n내용: {r.content[:1500]}"
|
| 242 |
+
for r in results
|
| 243 |
+
])
|
| 244 |
+
system_instruction = "" # 특별한 지시 없음
|
| 245 |
+
else:
|
| 246 |
+
# Fallback 케이스: 검색 결과 없음 -> LLM 지식 활용
|
| 247 |
+
summaries_text = "(검색 결과 부족)"
|
| 248 |
+
|
| 249 |
+
# LLM에게 지식 활용 허용 (프롬프트)
|
| 250 |
+
system_instruction = load_prompt("answer", "fallback_system_instruction")
|
| 251 |
+
|
| 252 |
+
# 기술 질문 템플릿 로드
|
| 253 |
+
template = load_prompt(
|
| 254 |
+
"answer",
|
| 255 |
+
"technical_template",
|
| 256 |
+
question=state.original_question or current_q,
|
| 257 |
+
summaries=summaries_text
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# 3. 이전 대화 맥락 추가 (Context Pollution 방지: 최근 1개만 참고용으로)
|
| 261 |
+
context_prefix = ""
|
| 262 |
+
messages_history = state.messages
|
| 263 |
+
if messages_history and len(messages_history) > 1:
|
| 264 |
+
# 검색 기반 답변이므로 이전 대화는 아주 최소한만 참조 (직전 1개)
|
| 265 |
+
prev_msg = messages_history[-2] if len(messages_history) >= 2 else None
|
| 266 |
+
if prev_msg:
|
| 267 |
+
context_prefix = f"이전 대화 맥락(참고): {prev_msg.content[:200]}...\n---\n"
|
| 268 |
+
|
| 269 |
+
# 템플릿은 이미 Jinja2로 렌더링되었으므로 context_prefix만 추가
|
| 270 |
+
final_prompt = context_prefix + template
|
| 271 |
+
|
| 272 |
+
# 시스템 지시 추가
|
| 273 |
+
final_prompt += system_instruction
|
| 274 |
+
|
| 275 |
+
updates = {}
|
| 276 |
+
steps_delta: List[str] = []
|
| 277 |
+
|
| 278 |
+
try:
|
| 279 |
+
# 4. LLM 호출
|
| 280 |
+
final_answer = invoke_llm_with_timeout(
|
| 281 |
+
[HumanMessage(content=final_prompt)],
|
| 282 |
+
TIMEOUT_GENERATION,
|
| 283 |
+
"답변 생성"
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# 5. 캐시 저장 로직 (백그라운드 실행용 함수 정의)
|
| 287 |
+
def _background_cache_save(question_text: str, answer_text: str, q_type: str, do_cache: bool):
|
| 288 |
+
"""백그라운드 스레드에서 실행될 함수"""
|
| 289 |
+
if do_cache and q_type == "independent":
|
| 290 |
+
try:
|
| 291 |
+
# 주의: qdrant_manager가 thread-safe한지 확인 필요 (보통 클라이언트는 안전함)
|
| 292 |
+
# 별도 세션이 필요하다면 여기서 생성해야 함
|
| 293 |
+
qdrant_manager = get_qdrant_manager()
|
| 294 |
+
qdrant_manager.save_to_cache(
|
| 295 |
+
question=question_text,
|
| 296 |
+
answer=answer_text
|
| 297 |
+
)
|
| 298 |
+
logger.info("✅ [Background] 캐시 저장 완료: %s", question_text[:30])
|
| 299 |
+
except Exception as cache_err:
|
| 300 |
+
logger.error("❌ [Background] 캐시 저장 실패: %s", cache_err)
|
| 301 |
+
|
| 302 |
+
# 저장에 필요한 데이터 준비
|
| 303 |
+
should_cache = state.should_cache if state.should_cache is not None else True
|
| 304 |
+
canonical_question = state.canonical_question
|
| 305 |
+
qtype = state.question_type or "independent"
|
| 306 |
+
question_to_cache = canonical_question or current_q
|
| 307 |
+
|
| 308 |
+
# [수정] Threading을 이용한 비동기 처리 (Fire-and-forget)
|
| 309 |
+
# daemon=True로 설정하여 메인 프로세스 종료 시 함께 종료되도록 함
|
| 310 |
+
cache_thread = threading.Thread(
|
| 311 |
+
target=_background_cache_save,
|
| 312 |
+
args=(question_to_cache, final_answer, qtype, should_cache),
|
| 313 |
+
daemon=True
|
| 314 |
+
)
|
| 315 |
+
cache_thread.start()
|
| 316 |
+
|
| 317 |
+
# 6. 결과 반환 및 분기 처리
|
| 318 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 319 |
+
|
| 320 |
+
if is_multi:
|
| 321 |
+
logger.info("다중 질문 모드: 답변을 multi_answers에 추가")
|
| 322 |
+
return {
|
| 323 |
+
"multi_answers": [MultiAnswerData(
|
| 324 |
+
index=state.worker_idx,
|
| 325 |
+
question=state.worker_sub_text or current_q,
|
| 326 |
+
answer=final_answer
|
| 327 |
+
)]
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
else:
|
| 331 |
+
# 단일 질문 모드
|
| 332 |
+
updates["final_answer"] = final_answer
|
| 333 |
+
# [핵심] 대화 히스토리에 AI 답변 추가
|
| 334 |
+
updates["messages"] = [AIMessage(content=final_answer)]
|
| 335 |
+
|
| 336 |
+
# 캐시 저장은 백그라운드에서 처리됨
|
| 337 |
+
steps_delta.append(f"✅ 최종 답변 생성 완료 (길이: {len(final_answer)}자)")
|
| 338 |
+
if should_cache and qtype == "independent":
|
| 339 |
+
steps_delta.append("💾 캐시 저장 백그라운드 요청됨")
|
| 340 |
+
else:
|
| 341 |
+
steps_delta.append("⚠️ 캐시 저장 생략 (독립적이지 않거나 일회성 질문)")
|
| 342 |
+
|
| 343 |
+
updates["intermediate_steps"] = steps_delta
|
| 344 |
+
return updates
|
| 345 |
+
|
| 346 |
+
except RuntimeError as e:
|
| 347 |
+
# 타임아웃 또는 기타 LLM 호출 실패
|
| 348 |
+
logger.error("답변 생성 실패: %s", e)
|
| 349 |
+
error_message = f"답변 생성에 실패했습니다: {str(e)}. 다시 시도해 주세요."
|
| 350 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 351 |
+
if is_multi:
|
| 352 |
+
return {
|
| 353 |
+
"multi_answers": [MultiAnswerData(
|
| 354 |
+
index=state.worker_idx,
|
| 355 |
+
question=state.worker_sub_text or current_q,
|
| 356 |
+
answer=error_message
|
| 357 |
+
)]
|
| 358 |
+
}
|
| 359 |
+
else:
|
| 360 |
+
updates["final_answer"] = error_message
|
| 361 |
+
steps_delta.append("❌ 답변 생성 실패")
|
| 362 |
+
updates["intermediate_steps"] = steps_delta
|
| 363 |
+
return updates
|
| 364 |
+
except Exception as e:
|
| 365 |
+
logger.error("답변 생성 실패: %s", e, exc_info=True)
|
| 366 |
+
|
| 367 |
+
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 368 |
+
if is_multi:
|
| 369 |
+
return {
|
| 370 |
+
"multi_answers": [MultiAnswerData(
|
| 371 |
+
index=state.worker_idx,
|
| 372 |
+
question=state.worker_sub_text or current_q,
|
| 373 |
+
answer="답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 374 |
+
)]
|
| 375 |
+
}
|
| 376 |
+
else:
|
| 377 |
+
updates["final_answer"] = "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 378 |
+
steps_delta.append(f"❌ 답변 생성 실패: {str(e)}")
|
| 379 |
+
updates["intermediate_steps"] = steps_delta
|
| 380 |
+
return updates
|
| 381 |
+
|
CodeWeaver/src/agent/nodes/common.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""공통 헬퍼 함수 모듈."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 7 |
+
|
| 8 |
+
from src.core.llm import get_llm
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# 타임아웃 설정 (초)
|
| 13 |
+
TIMEOUT_ANALYSIS = 30.0 # 의도 분류/분석
|
| 14 |
+
TIMEOUT_SUMMARY = 40.0 # 요약
|
| 15 |
+
TIMEOUT_GENERATION = 50.0 # 일반 답변 생성
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def invoke_llm_with_timeout(
|
| 19 |
+
messages: List[HumanMessage | SystemMessage],
|
| 20 |
+
timeout: float,
|
| 21 |
+
operation_name: str = "LLM 호출"
|
| 22 |
+
) -> str:
|
| 23 |
+
"""
|
| 24 |
+
LLM 호출을 실행하고 예외를 처리합니다. (동기 버전)
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
messages: LLM에 전달할 메시지 리스트
|
| 28 |
+
timeout: 타임아웃 시간 (초) - 동기 모드에서는 참고용
|
| 29 |
+
operation_name: 작업 이름 (로깅용)
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
LLM 응답 텍스트
|
| 33 |
+
|
| 34 |
+
Raises:
|
| 35 |
+
RuntimeError: 예외 발생 시
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
llm = get_llm()
|
| 39 |
+
response = llm.invoke(messages)
|
| 40 |
+
return response.content.strip()
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error("%s 실패: %s", operation_name, e, exc_info=True)
|
| 43 |
+
raise RuntimeError(f"{operation_name} 실패: {str(e)}") from e
|
| 44 |
+
|
CodeWeaver/src/agent/nodes/planning.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""계획 수립 노드 모듈 (AgentState 사용)."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 7 |
+
|
| 8 |
+
from src.agent.state import AgentState, PlanData, MultiAnswerData
|
| 9 |
+
from src.agent.state import _MULTI_ANS_RESET_TOKEN
|
| 10 |
+
from src.utils.tracing import trace_node
|
| 11 |
+
from src.prompts.loader import load_prompt
|
| 12 |
+
from src.agent.nodes.common import invoke_llm_with_timeout, TIMEOUT_ANALYSIS
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@trace_node("create_plan")
|
| 18 |
+
def create_plan_node(state: AgentState) -> dict:
|
| 19 |
+
"""
|
| 20 |
+
질문을 분석하여 유형과 개수를 판단합니다. (LLM 전용)
|
| 21 |
+
|
| 22 |
+
Case:
|
| 23 |
+
- single_topic: 하나의 주제 (서브그래프 1회)
|
| 24 |
+
- multiple_questions: 독립 질문 2개 (Send API로 서브그래프 2회 병렬)
|
| 25 |
+
- too_many: 독립 질문 3개 이상 (에러 메시지)
|
| 26 |
+
"""
|
| 27 |
+
user_question = state.user_question
|
| 28 |
+
logger.info("질문 분석 및 계획 수립 중: %s", user_question[:50])
|
| 29 |
+
|
| 30 |
+
# 길이 제한: 악의적인 긴 입력 방지
|
| 31 |
+
if len(user_question) > 10000:
|
| 32 |
+
logger.warning("질문이 너무 깁니다 (%d자). 10,000자로 제한합니다.", len(user_question))
|
| 33 |
+
user_question = user_question[:10000]
|
| 34 |
+
|
| 35 |
+
# 프롬프트를 보강하여 LLM에게 명확한 기준 제시
|
| 36 |
+
plan_prompt = load_prompt("planning", "plan_prompt", user_question=user_question)
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
import json
|
| 40 |
+
|
| 41 |
+
messages_to_llm = [HumanMessage(content=plan_prompt)]
|
| 42 |
+
response_text = invoke_llm_with_timeout(
|
| 43 |
+
messages_to_llm,
|
| 44 |
+
TIMEOUT_ANALYSIS,
|
| 45 |
+
"계획 수립"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# JSON 파싱
|
| 49 |
+
if "```json" in response_text:
|
| 50 |
+
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 51 |
+
elif "```" in response_text:
|
| 52 |
+
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 53 |
+
|
| 54 |
+
plan_data = json.loads(response_text)
|
| 55 |
+
|
| 56 |
+
case = plan_data.get("case", "single_topic")
|
| 57 |
+
questions = plan_data.get("questions", [user_question])
|
| 58 |
+
reasoning = plan_data.get("reasoning", "")
|
| 59 |
+
error_message = plan_data.get("error_message", "")
|
| 60 |
+
|
| 61 |
+
# 유효성 검증
|
| 62 |
+
if not questions or len(questions) == 0:
|
| 63 |
+
questions = [user_question]
|
| 64 |
+
case = "single_topic"
|
| 65 |
+
|
| 66 |
+
# 안전장치: LLM이 multiple_questions라고 했는데 questions가 1개뿐이면 보정
|
| 67 |
+
if case == "multiple_questions" and len(questions) < 2:
|
| 68 |
+
logger.warning("multiple_questions로 분류되었지만 questions가 %d개뿐입니다. single_topic으로 보정합니다.", len(questions))
|
| 69 |
+
case = "single_topic"
|
| 70 |
+
questions = [user_question]
|
| 71 |
+
|
| 72 |
+
# 안전장치: LLM이 multiple_questions라고 했는데 questions가 3개 이상이면 too_many로 보정
|
| 73 |
+
if case == "multiple_questions" and len(questions) > 2:
|
| 74 |
+
logger.warning("multiple_questions로 분류되었지만 questions가 %d개입니다. too_many로 보정합니다.", len(questions))
|
| 75 |
+
case = "too_many"
|
| 76 |
+
error_message = "죄송합니다. 질문은 한 번에 최대 2개까지 가능합니다. 가장 중요한 2개만 골라서 다시 질문해 주세요."
|
| 77 |
+
reasoning = f"질문이 {len(questions)}개로 감지되어 too_many로 보정했습니다."
|
| 78 |
+
|
| 79 |
+
steps_delta = [
|
| 80 |
+
f"📋 계획 타입: {case}",
|
| 81 |
+
f" 질문: {len(questions)}개",
|
| 82 |
+
f" 이유: {reasoning}"
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
logger.info("계획 수립 완료: %s, %d개 질문", case, len(questions))
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"plan": PlanData(
|
| 89 |
+
case=case,
|
| 90 |
+
questions=questions,
|
| 91 |
+
reasoning=reasoning,
|
| 92 |
+
error_message=error_message
|
| 93 |
+
),
|
| 94 |
+
"is_multi_question": False,
|
| 95 |
+
"sub_question_index": 0,
|
| 96 |
+
"sub_question_text": None,
|
| 97 |
+
"original_multi_question": None,
|
| 98 |
+
"multi_answers": [MultiAnswerData(token=_MULTI_ANS_RESET_TOKEN)],
|
| 99 |
+
"intermediate_steps": steps_delta
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
except RuntimeError as e:
|
| 103 |
+
# 타임아웃 또는 기타 LLM 호출 실패
|
| 104 |
+
logger.error("계획 수립 실패: %s", e)
|
| 105 |
+
steps_delta = [
|
| 106 |
+
f"⚠️ 계획 수립 실패, 기본값 사용: single_topic"
|
| 107 |
+
]
|
| 108 |
+
return {
|
| 109 |
+
"plan": PlanData(
|
| 110 |
+
case="single_topic",
|
| 111 |
+
questions=[user_question],
|
| 112 |
+
reasoning="LLM 호출 실패로 인한 기본값 사용",
|
| 113 |
+
error_message=""
|
| 114 |
+
),
|
| 115 |
+
"is_multi_question": False,
|
| 116 |
+
"sub_question_index": 0,
|
| 117 |
+
"sub_question_text": None,
|
| 118 |
+
"original_multi_question": None,
|
| 119 |
+
"multi_answers": [MultiAnswerData(token=_MULTI_ANS_RESET_TOKEN)],
|
| 120 |
+
"intermediate_steps": steps_delta
|
| 121 |
+
}
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error("계획 수립 실패: %s", e, exc_info=True)
|
| 124 |
+
|
| 125 |
+
# 기본값: 원본 질문 그대로 사용
|
| 126 |
+
steps_delta = [
|
| 127 |
+
"⚠️ 계획 수립 실패, 기본값 사용: single_topic"
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"plan": PlanData(
|
| 132 |
+
case="single_topic",
|
| 133 |
+
questions=[user_question],
|
| 134 |
+
reasoning="계획 수립 실패, 기본값 사용",
|
| 135 |
+
error_message=""
|
| 136 |
+
),
|
| 137 |
+
"is_multi_question": False,
|
| 138 |
+
"sub_question_index": 0,
|
| 139 |
+
"sub_question_text": None,
|
| 140 |
+
"original_multi_question": None,
|
| 141 |
+
"multi_answers": [MultiAnswerData(token=_MULTI_ANS_RESET_TOKEN)],
|
| 142 |
+
"intermediate_steps": steps_delta
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@trace_node("handle_too_many_questions")
|
| 147 |
+
def handle_too_many_questions_node(state: AgentState) -> dict:
|
| 148 |
+
"""3개 이상 질문 시 안내 메시지를 반환합니다."""
|
| 149 |
+
if state.plan is None:
|
| 150 |
+
error_message = ""
|
| 151 |
+
questions = []
|
| 152 |
+
else:
|
| 153 |
+
error_message = state.plan.error_message
|
| 154 |
+
questions = state.plan.questions
|
| 155 |
+
|
| 156 |
+
logger.info("질문 수 초과: %d개", len(questions))
|
| 157 |
+
|
| 158 |
+
default_message = load_prompt("planning", "too_many_questions_message")
|
| 159 |
+
|
| 160 |
+
final_message = error_message if error_message else default_message
|
| 161 |
+
|
| 162 |
+
steps_delta = [
|
| 163 |
+
f"⚠️ 질문 수 초과: {len(questions)}개",
|
| 164 |
+
"💬 안내 메시지 제공 (대화 계속 가능)"
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
"final_answer": final_message,
|
| 169 |
+
"intermediate_steps": steps_delta
|
| 170 |
+
}
|
| 171 |
+
|
CodeWeaver/src/agent/nodes/search.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""검색 및 도구 실행 노드 모듈."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List, Union
|
| 5 |
+
|
| 6 |
+
from langchain_core.messages import HumanMessage
|
| 7 |
+
|
| 8 |
+
from src.agent.state import AgentState, WorkerState, SearchResult
|
| 9 |
+
from src.tools.search import (
|
| 10 |
+
search_github,
|
| 11 |
+
search_official_docs,
|
| 12 |
+
search_stackoverflow,
|
| 13 |
+
)
|
| 14 |
+
from src.utils.tracing import trace_node
|
| 15 |
+
from src.core.resources import get_reranker
|
| 16 |
+
from src.prompts.loader import load_prompt
|
| 17 |
+
from src.agent.nodes.common import invoke_llm_with_timeout, TIMEOUT_ANALYSIS
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@trace_node("search_stackoverflow")
|
| 23 |
+
def search_stackoverflow_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 24 |
+
"""Stack Overflow에서 검색을 수행합니다."""
|
| 25 |
+
# 🔧 [FIX] 변수 접근 수정
|
| 26 |
+
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 27 |
+
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 28 |
+
|
| 29 |
+
logger.info("Stack Overflow 검색 시작 (쿼리: %s)", question_to_use[:50])
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
results = search_stackoverflow(question_to_use)
|
| 33 |
+
logger.info("Stack Overflow에서 %d개 결과 수집", len(results))
|
| 34 |
+
|
| 35 |
+
# 🔧 FIX: intermediate_steps 제거
|
| 36 |
+
return {
|
| 37 |
+
"search_results": results,
|
| 38 |
+
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 39 |
+
}
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error("Stack Overflow 검색 실패: %s", e)
|
| 42 |
+
return {}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@trace_node("search_github")
|
| 46 |
+
def search_github_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 47 |
+
"""GitHub Issues/Discussions에서 검색을 수행합니다."""
|
| 48 |
+
# 🔧 [FIX] 변수 접근 수정
|
| 49 |
+
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 50 |
+
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 51 |
+
|
| 52 |
+
logger.info("GitHub 검색 시작 (쿼리: %s)", question_to_use[:50])
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
results = search_github(question_to_use)
|
| 56 |
+
logger.info("GitHub에서 %d개 결과 수집", len(results))
|
| 57 |
+
|
| 58 |
+
# 🔧 FIX: intermediate_steps 제거
|
| 59 |
+
return {
|
| 60 |
+
"search_results": results,
|
| 61 |
+
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 62 |
+
}
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error("GitHub 검색 실패: %s", e)
|
| 65 |
+
return {}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@trace_node("search_official_docs")
|
| 69 |
+
def search_official_docs_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 70 |
+
"""공식 문서/Tavily에서 검색을 수행합니다."""
|
| 71 |
+
# 🔧 [FIX] 변수 접근 수정
|
| 72 |
+
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 73 |
+
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 74 |
+
|
| 75 |
+
logger.info("공식 문서 검색 시작 (쿼리: %s)", question_to_use[:50])
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
results = search_official_docs(question_to_use)
|
| 79 |
+
logger.info("공식 문서에서 %d개 결과 수집", len(results))
|
| 80 |
+
|
| 81 |
+
# 🔧 FIX: intermediate_steps 제거
|
| 82 |
+
return {
|
| 83 |
+
"search_results": results,
|
| 84 |
+
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 85 |
+
}
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error("공식 문서 검색 실패: %s", e)
|
| 88 |
+
return {}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@trace_node("collect_results")
|
| 92 |
+
def collect_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 93 |
+
"""병렬 검색 결과를 수집하고 카운트합니다."""
|
| 94 |
+
total_results = len(state.search_results)
|
| 95 |
+
|
| 96 |
+
logger.info("검색 결과 수집 완료: %d개", total_results)
|
| 97 |
+
|
| 98 |
+
# 🔧 FIX: 로그만 찍고, intermediate_steps는 업데이트하지 않음
|
| 99 |
+
# (병렬 노드에서 intermediate_steps 업데이트 시 충돌 발생)
|
| 100 |
+
|
| 101 |
+
return {} # 빈 딕셔너리 반환 (상태 변경 없음)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@trace_node("evaluate_results")
|
| 105 |
+
def evaluate_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 106 |
+
"""검색 결과의 개수와 품질을 모두 평가합니다. Reranking을 수행하여 점수를 계산하고 저장합니다."""
|
| 107 |
+
search_results = state.search_results
|
| 108 |
+
refinement_count = state.refinement_count
|
| 109 |
+
|
| 110 |
+
# 쿼리 준비
|
| 111 |
+
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 112 |
+
query_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 113 |
+
|
| 114 |
+
result_count = len(search_results)
|
| 115 |
+
|
| 116 |
+
logger.info("검색 결과 평가: %d개 (개선 횟수: %d)", result_count, refinement_count)
|
| 117 |
+
|
| 118 |
+
# 1. 개수 평가
|
| 119 |
+
needs_refinement = False
|
| 120 |
+
steps_delta = []
|
| 121 |
+
|
| 122 |
+
if result_count < 2:
|
| 123 |
+
needs_refinement = True
|
| 124 |
+
steps_delta.append("⚠️ 검색 결과 개수 부족")
|
| 125 |
+
|
| 126 |
+
# 2. 품질 평가 (Reranking 수행 - 항상 실행)
|
| 127 |
+
try:
|
| 128 |
+
# [수정] Thread-safe한 함수 호출
|
| 129 |
+
reranker = get_reranker()
|
| 130 |
+
|
| 131 |
+
# 기본 필터링 (내용이 너무 짧거나 URL 없는 것 제외)
|
| 132 |
+
filtered = [
|
| 133 |
+
r for r in search_results
|
| 134 |
+
if r.content and len(r.content) >= 50 and r.url
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
if not filtered:
|
| 138 |
+
needs_refinement = True
|
| 139 |
+
steps_delta.append("⚠️ 기본 필터링 후 결과 없음")
|
| 140 |
+
# 필터링된 결과가 없어도 점수는 매기지 않고 반환
|
| 141 |
+
reset_marker = SearchResult(source="__RESET__", content="", url="")
|
| 142 |
+
# refinement_count >= 1이면 needs_refinement 강제로 False
|
| 143 |
+
if refinement_count >= 1:
|
| 144 |
+
needs_refinement = False
|
| 145 |
+
steps_delta.append("⚠️ 재검색 결과이므로 강제 통과")
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"needs_refinement": needs_refinement,
|
| 149 |
+
"search_results": [reset_marker],
|
| 150 |
+
"filtered_search_results": [], # 결과 없음
|
| 151 |
+
"intermediate_steps": steps_delta
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
# 문서 추출
|
| 155 |
+
docs = [r.content for r in filtered]
|
| 156 |
+
|
| 157 |
+
# 점수 계산 (항상 수행)
|
| 158 |
+
scores = list(reranker.rerank(query_to_use, docs))
|
| 159 |
+
max_score = max(scores) if scores else 0.0
|
| 160 |
+
|
| 161 |
+
# 점수를 원본 객체에 할당
|
| 162 |
+
scored_results = []
|
| 163 |
+
for i, res in enumerate(filtered):
|
| 164 |
+
# 복사본을 만들어서 점수 할당 (안전성 확보)
|
| 165 |
+
new_res = res.model_copy()
|
| 166 |
+
new_res.relevance_score = float(scores[i])
|
| 167 |
+
scored_results.append(new_res)
|
| 168 |
+
|
| 169 |
+
# [핵심] 0.35 미만이면 다시 검색 (Refine) - 하지만 refinement_count >= 1이면 무시
|
| 170 |
+
THRESHOLD = 0.35
|
| 171 |
+
if max_score < THRESHOLD:
|
| 172 |
+
needs_refinement = True
|
| 173 |
+
steps_delta.append(f"⚠️ 검색 품질 미달 (Max: {max_score:.2f})")
|
| 174 |
+
else:
|
| 175 |
+
steps_delta.append(f"✅ 품질 통과 (Max: {max_score:.2f})")
|
| 176 |
+
|
| 177 |
+
# [핵심] refinement_count >= 1이면 needs_refinement 강제로 False 설정
|
| 178 |
+
if refinement_count >= 1:
|
| 179 |
+
needs_refinement = False
|
| 180 |
+
steps_delta.append("⚠️ 재검색 결과이므로 강제 통과 (무한 루프 방지)")
|
| 181 |
+
|
| 182 |
+
# [핵심 최적화] 점수가 매겨진 결과로 State 업데이트 (항상 반환)
|
| 183 |
+
# Reset 토큰을 사용하여 기존(점수 없는) 리스트를 덮어씁니다.
|
| 184 |
+
reset_marker = SearchResult(source="__RESET__", content="", url="")
|
| 185 |
+
|
| 186 |
+
return_dict = {
|
| 187 |
+
"needs_refinement": needs_refinement,
|
| 188 |
+
"search_results": [reset_marker] + scored_results, # 점수 매긴 걸로 교체
|
| 189 |
+
"intermediate_steps": steps_delta
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
# 필터링 로직 통합: needs_refinement가 False일 때만 수행
|
| 193 |
+
if not needs_refinement:
|
| 194 |
+
# Reset 토큰 제거
|
| 195 |
+
filtered_results = [r for r in scored_results if r.source != "__RESET__"]
|
| 196 |
+
|
| 197 |
+
# 0.35점 미만 필터링
|
| 198 |
+
THRESHOLD = 0.35
|
| 199 |
+
filtered = [
|
| 200 |
+
r for r in filtered_results
|
| 201 |
+
if r.relevance_score is not None and r.relevance_score >= THRESHOLD
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
# 정렬 (점수 높은 순)
|
| 205 |
+
filtered.sort(key=lambda r: r.relevance_score, reverse=True)
|
| 206 |
+
|
| 207 |
+
# 상위 5개 선택
|
| 208 |
+
top_results = filtered[:5]
|
| 209 |
+
|
| 210 |
+
# filtered_search_results에 저장 (객체 리스트 그대로)
|
| 211 |
+
return_dict["filtered_search_results"] = top_results
|
| 212 |
+
|
| 213 |
+
# 로깅
|
| 214 |
+
steps_delta.append(f"⚡ 필터링 완료: {len(filtered_results)}개 → {len(top_results)}개")
|
| 215 |
+
if not top_results:
|
| 216 |
+
steps_delta.append("⚠️ 품질 기준(0.35)을 넘는 검색 결과가 없음 → LLM 지식 의존")
|
| 217 |
+
else:
|
| 218 |
+
# needs_refinement가 True인 경우 빈 리스트 반환
|
| 219 |
+
return_dict["filtered_search_results"] = []
|
| 220 |
+
|
| 221 |
+
return return_dict
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error("Evaluate Reranking Error: %s", e, exc_info=True)
|
| 225 |
+
# 에러 시에도 refinement_count >= 1이면 needs_refinement = False
|
| 226 |
+
if refinement_count >= 1:
|
| 227 |
+
needs_refinement = False
|
| 228 |
+
else:
|
| 229 |
+
needs_refinement = False # 에러 시 일단 진행
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
"needs_refinement": needs_refinement,
|
| 233 |
+
"filtered_search_results": [], # 에러 시 빈 리스트
|
| 234 |
+
"intermediate_steps": [f"⚠️ Reranking 실패, 기본 진행: {str(e)}"]
|
| 235 |
+
} # 에러 시 일단 진행
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@trace_node("refine_search")
|
| 239 |
+
def refine_search_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 240 |
+
"""
|
| 241 |
+
검색 쿼리를 개선합니다.
|
| 242 |
+
|
| 243 |
+
🔧 CRITICAL:
|
| 244 |
+
- user_question을 직접 업데이트하지 않고, refined_question��� 저장
|
| 245 |
+
- 부모 AgentState와 충돌 방지를 위해 WorkerState 필드만 반환
|
| 246 |
+
- ❌ 절대 반환하면 안 되는 것들: user_question, messages, final_answer
|
| 247 |
+
"""
|
| 248 |
+
# 🔧 [FIX] 변수 접근 수정
|
| 249 |
+
user_question = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 250 |
+
original_question = state.original_question or user_question
|
| 251 |
+
result_count = len(state.search_results)
|
| 252 |
+
|
| 253 |
+
logger.info("검색 쿼리 개선 중: %s (%d개 결과)", user_question[:50], result_count)
|
| 254 |
+
|
| 255 |
+
refinement_prompt = load_prompt(
|
| 256 |
+
"search",
|
| 257 |
+
"refinement_prompt",
|
| 258 |
+
user_question=user_question,
|
| 259 |
+
result_count=result_count
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
try:
|
| 263 |
+
import json
|
| 264 |
+
|
| 265 |
+
messages_to_llm = [HumanMessage(content=refinement_prompt)]
|
| 266 |
+
response_text = invoke_llm_with_timeout(
|
| 267 |
+
messages_to_llm,
|
| 268 |
+
TIMEOUT_ANALYSIS,
|
| 269 |
+
"쿼리 개선"
|
| 270 |
+
)
|
| 271 |
+
if "```json" in response_text:
|
| 272 |
+
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 273 |
+
elif "```" in response_text:
|
| 274 |
+
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 275 |
+
|
| 276 |
+
refinement_data = json.loads(response_text)
|
| 277 |
+
|
| 278 |
+
new_query = refinement_data.get("new_query", user_question)
|
| 279 |
+
strategy = refinement_data.get("strategy", "MORE_GENERAL")
|
| 280 |
+
reasoning = refinement_data.get("reasoning", "")
|
| 281 |
+
|
| 282 |
+
steps_delta = [
|
| 283 |
+
f"🔄 쿼리 개선: {strategy}",
|
| 284 |
+
f" 이전: {user_question[:50]}...",
|
| 285 |
+
f" 이후: {new_query[:50]}...",
|
| 286 |
+
f" 이유: {reasoning}"
|
| 287 |
+
]
|
| 288 |
+
|
| 289 |
+
logger.info("쿼리 개선 완료: %s → %s", user_question[:30], new_query[:30])
|
| 290 |
+
|
| 291 |
+
# 🔧 CRITICAL: WorkerState 필드만 반환 (부모 AgentState와 충돌 방지)
|
| 292 |
+
# Reset 토큰을 포함하여 기존 검색 결과를 초기화 (재검색 시 누적 방지)
|
| 293 |
+
reset_marker = SearchResult(source="__RESET__", content="", url="")
|
| 294 |
+
|
| 295 |
+
return {
|
| 296 |
+
"refined_question": new_query, # ✅ WorkerState 필드
|
| 297 |
+
"original_question": original_question, # ✅ WorkerState 필드
|
| 298 |
+
"refinement_count": state.refinement_count + 1, # ✅ WorkerState 필드
|
| 299 |
+
"search_results": [reset_marker], # ✅ Reset 토큰으로 기존 결과 초기화
|
| 300 |
+
"intermediate_steps": steps_delta # ✅ WorkerState 필드
|
| 301 |
+
|
| 302 |
+
# ❌ 절대 반환하면 안 되는 것들:
|
| 303 |
+
# "user_question": ..., # 부모 AgentState와 충돌!
|
| 304 |
+
# "messages": ..., # 부모 AgentState와 충돌!
|
| 305 |
+
# "final_answer": ..., # 너무 이른 시점!
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
except RuntimeError as timeout_err:
|
| 309 |
+
if "시간이 초과" in str(timeout_err):
|
| 310 |
+
logger.error("쿼리 개선 타임아웃 (%d초 초과)", TIMEOUT_ANALYSIS)
|
| 311 |
+
fallback_query = user_question + " tutorial example"
|
| 312 |
+
steps_delta = [
|
| 313 |
+
f"⚠️ 쿼리 개선 타임아웃 ({TIMEOUT_ANALYSIS}초), 기본 전략 사용",
|
| 314 |
+
f" 이후: {fallback_query}"
|
| 315 |
+
]
|
| 316 |
+
# Reset 토큰으로 기존 결과 초기화
|
| 317 |
+
reset_marker = SearchResult(source="__RESET__", content="", url="")
|
| 318 |
+
return {
|
| 319 |
+
"refined_question": fallback_query,
|
| 320 |
+
"original_question": original_question,
|
| 321 |
+
"refinement_count": state.refinement_count + 1,
|
| 322 |
+
"search_results": [reset_marker], # Reset 토큰으로 기존 결과 초기화
|
| 323 |
+
"intermediate_steps": steps_delta
|
| 324 |
+
}
|
| 325 |
+
except Exception as e:
|
| 326 |
+
logger.error("쿼리 개선 실패: %s", e, exc_info=True)
|
| 327 |
+
|
| 328 |
+
fallback_query = user_question + " tutorial example"
|
| 329 |
+
|
| 330 |
+
steps_delta = [
|
| 331 |
+
f"⚠️ 쿼리 개선 실패, 기본 전략 사용",
|
| 332 |
+
f" 이후: {fallback_query}"
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
# 🔧 CRITICAL: WorkerState 필드만 반환
|
| 336 |
+
# Reset 토큰으로 기존 결과 초기화
|
| 337 |
+
reset_marker = SearchResult(source="__RESET__", content="", url="")
|
| 338 |
+
return {
|
| 339 |
+
"refined_question": fallback_query, # ✅ WorkerState 필드
|
| 340 |
+
"original_question": original_question, # ✅ WorkerState 필드
|
| 341 |
+
"refinement_count": state.refinement_count + 1, # ✅ WorkerState 필드
|
| 342 |
+
"search_results": [reset_marker], # ✅ Reset 토큰으로 기존 결과 초기화
|
| 343 |
+
"intermediate_steps": steps_delta # ✅ WorkerState 필드
|
| 344 |
+
}
|
| 345 |
+
|
CodeWeaver/src/agent/routes.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""라우팅 로직 모듈.
|
| 2 |
+
|
| 3 |
+
조건부 엣지에서 사용되는 라우팅 함수들을 정의합니다.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
from langgraph.graph import END
|
| 10 |
+
from langgraph.types import Send
|
| 11 |
+
|
| 12 |
+
from src.agent.state import AgentState, WorkerState, _MULTI_ANS_RESET_TOKEN
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def route_after_analysis_worker(state: WorkerState) -> Literal["generate_with_history", "generate_answer", "check_cache"]:
|
| 18 |
+
"""질문 분석 후 라우팅"""
|
| 19 |
+
raw_qtype = state.question_type or "independent"
|
| 20 |
+
# new_topic 통합으로 인해 independent로 매핑
|
| 21 |
+
legacy_map = {
|
| 22 |
+
"followup": "clarification",
|
| 23 |
+
"cache_candidate": "independent",
|
| 24 |
+
"new_search": "independent",
|
| 25 |
+
"new_topic": "independent"
|
| 26 |
+
}
|
| 27 |
+
question_type = legacy_map.get(raw_qtype, raw_qtype)
|
| 28 |
+
|
| 29 |
+
if question_type == "clarification":
|
| 30 |
+
return "generate_with_history"
|
| 31 |
+
|
| 32 |
+
if question_type == "general_chat":
|
| 33 |
+
return "generate_answer"
|
| 34 |
+
|
| 35 |
+
return "check_cache"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def route_after_cache_worker(state: WorkerState):
|
| 39 |
+
"""
|
| 40 |
+
캐시 확인 후 라우팅.
|
| 41 |
+
캐시 히트 시 답변 반환, 캐시 미스 시 병렬 검색으로 직접 라우팅.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
str: "return_cached_answer" (캐시 히트 시)
|
| 45 |
+
List[Send]: 병렬 검색 Send 객체 리스트 (캐시 미스 시)
|
| 46 |
+
"""
|
| 47 |
+
if state.cached_result:
|
| 48 |
+
return "return_cached_answer"
|
| 49 |
+
else:
|
| 50 |
+
# 캐시 미스 시 병렬 검색으로 직접 라우팅 (Send 객체 리스트 반환)
|
| 51 |
+
return initiate_parallel_search_worker(state)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def route_after_evaluation_worker(state: WorkerState) -> Literal["refine_search", "generate_answer"]:
|
| 55 |
+
"""검색 결과 평가 후 라우팅"""
|
| 56 |
+
needs_refinement = state.needs_refinement
|
| 57 |
+
refinement_count = state.refinement_count
|
| 58 |
+
|
| 59 |
+
if needs_refinement and refinement_count < 1:
|
| 60 |
+
return "refine_search"
|
| 61 |
+
else:
|
| 62 |
+
return "generate_answer"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def initiate_parallel_search_worker(state: WorkerState):
|
| 66 |
+
"""병렬 검색을 위한 Send 객체 리스트 생성"""
|
| 67 |
+
return [
|
| 68 |
+
Send("search_stackoverflow", state),
|
| 69 |
+
Send("search_github", state),
|
| 70 |
+
Send("search_official_docs", state),
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def route_after_plan(state: AgentState):
|
| 75 |
+
"""create_plan 결과에 따른 라우팅"""
|
| 76 |
+
if state.plan is None:
|
| 77 |
+
case = "single_topic"
|
| 78 |
+
questions = []
|
| 79 |
+
else:
|
| 80 |
+
case = state.plan.case
|
| 81 |
+
questions = state.plan.questions
|
| 82 |
+
|
| 83 |
+
if case == "too_many":
|
| 84 |
+
return "handle_too_many_questions"
|
| 85 |
+
|
| 86 |
+
elif case == "multiple_questions":
|
| 87 |
+
messages = state.messages
|
| 88 |
+
|
| 89 |
+
logger.info("다중 질문 처리: %d개 질문 병렬 실행", len(questions))
|
| 90 |
+
|
| 91 |
+
sends = []
|
| 92 |
+
for i, sq in enumerate(questions):
|
| 93 |
+
worker_state = WorkerState(
|
| 94 |
+
processing_question=sq,
|
| 95 |
+
messages=messages,
|
| 96 |
+
worker_is_multi=True,
|
| 97 |
+
worker_idx=i,
|
| 98 |
+
worker_sub_text=sq,
|
| 99 |
+
)
|
| 100 |
+
sends.append(Send("single_question_subgraph", worker_state))
|
| 101 |
+
|
| 102 |
+
return sends
|
| 103 |
+
|
| 104 |
+
else:
|
| 105 |
+
worker_state = WorkerState(
|
| 106 |
+
processing_question=state.user_question,
|
| 107 |
+
messages=state.messages,
|
| 108 |
+
worker_is_multi=False,
|
| 109 |
+
worker_idx=0,
|
| 110 |
+
worker_sub_text=None
|
| 111 |
+
)
|
| 112 |
+
return [Send("single_question_subgraph", worker_state)]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def route_after_subgraph(state: AgentState):
|
| 116 |
+
"""서브그래프 완료 후 라우팅"""
|
| 117 |
+
has_answers = any(
|
| 118 |
+
item.token != _MULTI_ANS_RESET_TOKEN
|
| 119 |
+
for item in state.multi_answers
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
if has_answers:
|
| 123 |
+
return "combine_answers"
|
| 124 |
+
else:
|
| 125 |
+
return END
|
| 126 |
+
|
CodeWeaver/src/agent/state.py
CHANGED
|
@@ -19,12 +19,12 @@ def merge_intermediate_steps(old: List[str], new: List[str]) -> List[str]:
|
|
| 19 |
return old + new
|
| 20 |
|
| 21 |
|
| 22 |
-
def merge_multi_answers(old: List[
|
| 23 |
"""multi_answers reducer."""
|
| 24 |
if not new:
|
| 25 |
return old
|
| 26 |
head = new[0]
|
| 27 |
-
if
|
| 28 |
return new[1:]
|
| 29 |
return old + new
|
| 30 |
|
|
@@ -33,7 +33,17 @@ def merge_search_results(old: List["SearchResult"], new: List["SearchResult"]) -
|
|
| 33 |
"""
|
| 34 |
search_results reducer.
|
| 35 |
병렬 검색 노드들이 동시에 search_results를 업데이트할 수 있도록 병합 로직 제공.
|
|
|
|
| 36 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return old + new
|
| 38 |
|
| 39 |
|
|
@@ -45,6 +55,22 @@ class SearchResult(BaseModel):
|
|
| 45 |
relevance_score: Optional[float] = Field(default=None, description="관련도 점수")
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
class AgentState(BaseModel):
|
| 49 |
"""부모 그래프 전용 상태."""
|
| 50 |
|
|
@@ -65,7 +91,7 @@ class AgentState(BaseModel):
|
|
| 65 |
)
|
| 66 |
|
| 67 |
# Planning
|
| 68 |
-
plan: Optional[
|
| 69 |
default=None,
|
| 70 |
description="질문 분해 계획"
|
| 71 |
)
|
|
@@ -75,7 +101,7 @@ class AgentState(BaseModel):
|
|
| 75 |
sub_question_index: int = Field(default=0)
|
| 76 |
sub_question_text: Optional[str] = Field(default=None)
|
| 77 |
original_multi_question: Optional[str] = Field(default=None)
|
| 78 |
-
multi_answers: Annotated[List[
|
| 79 |
default_factory=list,
|
| 80 |
description="다중 질문의 각 답변 리스트"
|
| 81 |
)
|
|
@@ -101,12 +127,11 @@ class WorkerState(BaseModel):
|
|
| 101 |
|
| 102 |
# === 서브그래프 내부 전용 필드 ===
|
| 103 |
# (이 필드들은 서브그래프 내부에서만 사용, 부모에게 전달 안 됨)
|
| 104 |
-
question_type: Optional[Literal["clarification", "
|
| 105 |
should_cache: Optional[bool] = None
|
| 106 |
canonical_question: Optional[str] = None
|
| 107 |
analysis_reasoning: Optional[str] = None
|
| 108 |
cached_result: Optional[str] = None
|
| 109 |
-
detected_intent: Optional[Literal["debugging", "learning", "code_review"]] = None
|
| 110 |
|
| 111 |
# 검색 결과 (병렬 업데이트 가능하도록 reducer 적용)
|
| 112 |
search_results: Annotated[List[SearchResult], merge_search_results] = Field(
|
|
@@ -114,7 +139,11 @@ class WorkerState(BaseModel):
|
|
| 114 |
description="병렬 검색 결과 (reducer로 자동 병합)"
|
| 115 |
)
|
| 116 |
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
# 쿼리 개선 (이 필드들은 refine_search_node만 업데이트)
|
| 120 |
needs_refinement: bool = False
|
|
@@ -132,7 +161,7 @@ class WorkerState(BaseModel):
|
|
| 132 |
# 이 필드들은 부모 AgentState에도 존재하며, Reducer가 있거나 충돌이 허용되는 필드여야 함
|
| 133 |
final_answer: Optional[str] = None
|
| 134 |
|
| 135 |
-
multi_answers: Annotated[List[
|
| 136 |
default_factory=list,
|
| 137 |
description="다중 질문 답변용"
|
| 138 |
)
|
|
|
|
| 19 |
return old + new
|
| 20 |
|
| 21 |
|
| 22 |
+
def merge_multi_answers(old: List["MultiAnswerData"], new: List["MultiAnswerData"]) -> List["MultiAnswerData"]:
|
| 23 |
"""multi_answers reducer."""
|
| 24 |
if not new:
|
| 25 |
return old
|
| 26 |
head = new[0]
|
| 27 |
+
if head.token == _MULTI_ANS_RESET_TOKEN:
|
| 28 |
return new[1:]
|
| 29 |
return old + new
|
| 30 |
|
|
|
|
| 33 |
"""
|
| 34 |
search_results reducer.
|
| 35 |
병렬 검색 노드들이 동시에 search_results를 업데이트할 수 있도록 병합 로직 제공.
|
| 36 |
+
Reset 토큰(__RESET__)이 있으면 기존 리스트를 교체합니다.
|
| 37 |
"""
|
| 38 |
+
if not new:
|
| 39 |
+
return old
|
| 40 |
+
|
| 41 |
+
# Reset 토큰 확인 (첫 번째 요소가 __RESET__인 경우)
|
| 42 |
+
if new and isinstance(new[0], SearchResult) and new[0].source == "__RESET__":
|
| 43 |
+
# Reset 토큰 이후의 결과만 반환 (기존 리스트 교체)
|
| 44 |
+
return new[1:]
|
| 45 |
+
|
| 46 |
+
# 일반 병합
|
| 47 |
return old + new
|
| 48 |
|
| 49 |
|
|
|
|
| 55 |
relevance_score: Optional[float] = Field(default=None, description="관련도 점수")
|
| 56 |
|
| 57 |
|
| 58 |
+
class PlanData(BaseModel):
|
| 59 |
+
"""질문 분석 및 계획 데이터"""
|
| 60 |
+
case: Literal["single_topic", "multiple_questions", "too_many"] = "single_topic"
|
| 61 |
+
questions: List[str] = Field(default_factory=list)
|
| 62 |
+
reasoning: str = ""
|
| 63 |
+
error_message: str = ""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class MultiAnswerData(BaseModel):
|
| 67 |
+
"""다중 질문에 대한 개별 답변 데이터"""
|
| 68 |
+
index: int = 0
|
| 69 |
+
question: str = ""
|
| 70 |
+
answer: str = ""
|
| 71 |
+
token: Optional[str] = None # 리듀서 리셋 토큰용 (__RESET_MULTI_ANS__)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
class AgentState(BaseModel):
|
| 75 |
"""부모 그래프 전용 상태."""
|
| 76 |
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
# Planning
|
| 94 |
+
plan: Optional["PlanData"] = Field(
|
| 95 |
default=None,
|
| 96 |
description="질문 분해 계획"
|
| 97 |
)
|
|
|
|
| 101 |
sub_question_index: int = Field(default=0)
|
| 102 |
sub_question_text: Optional[str] = Field(default=None)
|
| 103 |
original_multi_question: Optional[str] = Field(default=None)
|
| 104 |
+
multi_answers: Annotated[List["MultiAnswerData"], merge_multi_answers] = Field(
|
| 105 |
default_factory=list,
|
| 106 |
description="다중 질문의 각 답변 리스트"
|
| 107 |
)
|
|
|
|
| 127 |
|
| 128 |
# === 서브그래프 내부 전용 필드 ===
|
| 129 |
# (이 필드들은 서브그래프 내부에서만 사용, 부모에게 전달 안 됨)
|
| 130 |
+
question_type: Optional[Literal["clarification", "general_chat", "independent"]] = None
|
| 131 |
should_cache: Optional[bool] = None
|
| 132 |
canonical_question: Optional[str] = None
|
| 133 |
analysis_reasoning: Optional[str] = None
|
| 134 |
cached_result: Optional[str] = None
|
|
|
|
| 135 |
|
| 136 |
# 검색 결과 (병렬 업데이트 가능하도록 reducer 적용)
|
| 137 |
search_results: Annotated[List[SearchResult], merge_search_results] = Field(
|
|
|
|
| 139 |
description="병렬 검색 결과 (reducer로 자동 병합)"
|
| 140 |
)
|
| 141 |
|
| 142 |
+
# 필터링된 최종 검색 결과 (evaluate_results_node에서 설정)
|
| 143 |
+
filtered_search_results: List["SearchResult"] = Field(
|
| 144 |
+
default_factory=list,
|
| 145 |
+
description="평가 및 필터링이 완료된 최종 검색 결과 데이터 리스트"
|
| 146 |
+
)
|
| 147 |
|
| 148 |
# 쿼리 개선 (이 필드들은 refine_search_node만 업데이트)
|
| 149 |
needs_refinement: bool = False
|
|
|
|
| 161 |
# 이 필드들은 부모 AgentState에도 존재하며, Reducer가 있거나 충돌이 허용되는 필드여야 함
|
| 162 |
final_answer: Optional[str] = None
|
| 163 |
|
| 164 |
+
multi_answers: Annotated[List["MultiAnswerData"], merge_multi_answers] = Field(
|
| 165 |
default_factory=list,
|
| 166 |
description="다중 질문 답변용"
|
| 167 |
)
|
CodeWeaver/src/core/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""코어 모듈 - 앱의 심장부 (싱글톤, 설정, 팩토리)."""
|
| 2 |
+
|
| 3 |
+
from src.core.config import settings, EMBEDDING_MODEL_NAME, EMBEDDING_DIMENSION
|
| 4 |
+
from src.core.llm import get_llm
|
| 5 |
+
from src.core.resources import get_reranker, get_qdrant_manager
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"settings",
|
| 9 |
+
"EMBEDDING_MODEL_NAME",
|
| 10 |
+
"EMBEDDING_DIMENSION",
|
| 11 |
+
"get_llm",
|
| 12 |
+
"get_reranker",
|
| 13 |
+
"get_qdrant_manager",
|
| 14 |
+
]
|
| 15 |
+
|
CodeWeaver/src/core/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""설정 관리 모듈.
|
| 2 |
+
|
| 3 |
+
pydantic-settings를 사용하여 환경 변수를 타입 안전하게 관리합니다.
|
| 4 |
+
필수 환경 변수가 없으면 앱 시작 시 검증 오류가 발생합니다.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic import Field
|
| 8 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
"""애플리케이션 설정 클래스.
|
| 13 |
+
|
| 14 |
+
모든 환경 변수는 .env 파일 또는 시스템 환경 변수에서 로드됩니다.
|
| 15 |
+
필수 변수가 없으면 ValidationError가 발생합니다.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
# 필수 환경 변수
|
| 19 |
+
google_api_key: str = Field(..., description="Google Gemini API 키")
|
| 20 |
+
qdrant_url: str = Field(..., description="Qdrant Cloud URL")
|
| 21 |
+
qdrant_api_key: str = Field(..., description="Qdrant Cloud API 키")
|
| 22 |
+
tavily_api_key: str = Field(..., description="Tavily API 키 (공식 문서 검색용)")
|
| 23 |
+
|
| 24 |
+
# 선택적 환경 변수
|
| 25 |
+
github_token: str | None = Field(default=None, description="GitHub API 토큰 (rate limit 완화용)")
|
| 26 |
+
langchain_tracing_v2: str | None = Field(default=None, description="LangSmith 트레이싱 활성화 여부")
|
| 27 |
+
langchain_api_key: str | None = Field(default=None, description="LangSmith API 키")
|
| 28 |
+
|
| 29 |
+
# Postgres 연결 문자열
|
| 30 |
+
postgres_db_url: str = Field(..., alias="POSTGRES_DB_URL")
|
| 31 |
+
|
| 32 |
+
model_config = SettingsConfigDict(
|
| 33 |
+
env_file=".env",
|
| 34 |
+
env_file_encoding="utf-8",
|
| 35 |
+
case_sensitive=False,
|
| 36 |
+
extra="ignore",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# 전역 설정 인스턴스
|
| 41 |
+
# 앱 시작 시 환경 변수 검증이 수행됩니다.
|
| 42 |
+
settings = Settings()
|
| 43 |
+
|
| 44 |
+
# 임베딩 모델 설정 (코드에 직접 설정 - 런타임 변경 불필요)
|
| 45 |
+
EMBEDDING_MODEL_NAME = "BAAI/bge-base-en-v1.5"
|
| 46 |
+
EMBEDDING_DIMENSION = 768 # bge-base-en-v1.5의 차원
|
| 47 |
+
|
CodeWeaver/src/core/llm.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM 인스턴스 생성 및 관리 모듈.
|
| 2 |
+
|
| 3 |
+
싱글톤 패턴을 사용하여 LLM 인스턴스를 중앙 집중식으로 관리합니다.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 10 |
+
|
| 11 |
+
from src.core.config import settings
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# LLM 싱글톤 인스턴스
|
| 16 |
+
_llm: Optional[ChatGoogleGenerativeAI] = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_llm() -> ChatGoogleGenerativeAI:
|
| 20 |
+
"""
|
| 21 |
+
LLM 인스턴스를 반환합니다. (싱글톤 패턴)
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
ChatGoogleGenerativeAI 인스턴스
|
| 25 |
+
"""
|
| 26 |
+
global _llm
|
| 27 |
+
|
| 28 |
+
if _llm is not None:
|
| 29 |
+
return _llm
|
| 30 |
+
|
| 31 |
+
logger.info("🤖 LLM 초기화 중 (Gemini 2.5 Flash Lite)...")
|
| 32 |
+
|
| 33 |
+
_llm = ChatGoogleGenerativeAI(
|
| 34 |
+
model="gemini-2.5-flash-lite",
|
| 35 |
+
temperature=0.7,
|
| 36 |
+
google_api_key=settings.google_api_key,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger.info("✅ LLM 초기화 완료")
|
| 40 |
+
return _llm
|
| 41 |
+
|
CodeWeaver/src/core/resources.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""무거운 리소스 관리 모듈 (Reranker, DB Client 등).
|
| 2 |
+
|
| 3 |
+
싱글톤 패턴을 사용하여 무거운 리소스 인스턴스를 중앙 집중식으로 관리합니다.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import threading
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from fastembed.rerank.cross_encoder import TextCrossEncoder
|
| 11 |
+
from src.vector_db.qdrant_client import QdrantManager
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# ==================== Reranker 싱글톤 ====================
|
| 16 |
+
|
| 17 |
+
_reranker: Optional[TextCrossEncoder] = None
|
| 18 |
+
_reranker_lock = threading.Lock() # 🔒 스레드 락
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_reranker() -> TextCrossEncoder:
|
| 22 |
+
"""
|
| 23 |
+
Reranker 모델을 Lazy Loading 방식으로 가져옵니다.
|
| 24 |
+
Thread-safe한 Double-Checked Locking 패턴을 적용하여 중복 로딩을 방지합니다.
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
TextCrossEncoder 인스턴스
|
| 28 |
+
"""
|
| 29 |
+
global _reranker
|
| 30 |
+
|
| 31 |
+
# 1. 먼저 락 없이 빠르게 체크 (이미 로딩된 경우 성능 저하 방지)
|
| 32 |
+
if _reranker is not None:
|
| 33 |
+
return _reranker
|
| 34 |
+
|
| 35 |
+
# 2. 로딩이 안 된 경우에만 락 획득
|
| 36 |
+
with _reranker_lock:
|
| 37 |
+
# 3. 락 획득 후 다시 한번 체크 (다른 스레드가 그 사이 로딩했을 수 있음)
|
| 38 |
+
if _reranker is None:
|
| 39 |
+
logger.info("⚡ Reranker 모델 로딩 중 (jinaai/jina-reranker-v1-tiny-en)...")
|
| 40 |
+
try:
|
| 41 |
+
# 모델 로드 (최초 1회 실행)
|
| 42 |
+
_reranker = TextCrossEncoder(model_name="jinaai/jina-reranker-v1-tiny-en")
|
| 43 |
+
logger.info("⚡ Reranker 모델 로딩 완료")
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error("Reranker 모델 로딩 실패: %s", e)
|
| 46 |
+
raise e
|
| 47 |
+
|
| 48 |
+
return _reranker
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ==================== QdrantManager 싱글톤 ====================
|
| 52 |
+
|
| 53 |
+
_qdrant_manager: Optional[QdrantManager] = None
|
| 54 |
+
_qdrant_lock = threading.Lock() # 🔒 스레드 락
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_qdrant_manager(collection_name: str = "CodeWeaver") -> QdrantManager:
|
| 58 |
+
"""
|
| 59 |
+
QdrantManager 인스턴스를 반환합니다. (싱글톤 패턴)
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
collection_name: 컬렉션 이름 (기본값: "CodeWeaver")
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
QdrantManager 인스턴스
|
| 66 |
+
"""
|
| 67 |
+
global _qdrant_manager
|
| 68 |
+
|
| 69 |
+
# 1. 먼저 락 없이 빠르게 체크
|
| 70 |
+
if _qdrant_manager is not None:
|
| 71 |
+
return _qdrant_manager
|
| 72 |
+
|
| 73 |
+
# 2. 로딩이 안 된 경우에만 락 획득
|
| 74 |
+
with _qdrant_lock:
|
| 75 |
+
# 3. 락 획득 후 다시 한번 체크
|
| 76 |
+
if _qdrant_manager is None:
|
| 77 |
+
logger.info("🗄️ QdrantManager 초기화 중...")
|
| 78 |
+
try:
|
| 79 |
+
_qdrant_manager = QdrantManager(collection_name=collection_name)
|
| 80 |
+
logger.info("✅ QdrantManager 초기화 완료")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error("QdrantManager 초기화 실패: %s", e)
|
| 83 |
+
raise e
|
| 84 |
+
|
| 85 |
+
return _qdrant_manager
|
| 86 |
+
|
CodeWeaver/src/prompts/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""프롬프트 템플릿 관리 모듈."""
|
| 2 |
+
|
| 3 |
+
from src.prompts.loader import load_prompt
|
| 4 |
+
|
| 5 |
+
__all__ = ["load_prompt"]
|
| 6 |
+
|
CodeWeaver/src/prompts/loader.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""프롬프트 템플릿 로더 모듈.
|
| 2 |
+
|
| 3 |
+
YAML 파일에서 프롬프트를 로드하고 Jinja2 템플릿으로 렌더링합니다.
|
| 4 |
+
파일 I/O 최적화를 위해 템플릿 캐싱을 적용합니다.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Dict, Any
|
| 11 |
+
|
| 12 |
+
import yaml
|
| 13 |
+
from jinja2 import Template, TemplateError
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# 템플릿 캐시 (파일 경로 -> 파싱된 YAML 딕셔너리)
|
| 18 |
+
_template_cache: Dict[str, Dict[str, str]] = {}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_template_path(template_name: str) -> Path:
|
| 22 |
+
"""템플릿 파일 경로를 반환합니다.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
template_name: 템플릿 파일 이름 (확장자 제외, 예: "planning")
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
템플릿 파일의 전체 경로
|
| 29 |
+
"""
|
| 30 |
+
# 현재 파일의 디렉토리 기준으로 templates 디렉토리 찾기
|
| 31 |
+
current_dir = Path(__file__).parent
|
| 32 |
+
template_file = current_dir / "templates" / f"{template_name}.yaml"
|
| 33 |
+
return template_file
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _load_yaml_file(template_name: str) -> Dict[str, str]:
|
| 37 |
+
"""YAML 파일을 로드하고 캐시에 저장합니다.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
template_name: 템플릿 파일 이름 (확장자 제외)
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
YAML 파일의 내용 (딕셔너리)
|
| 44 |
+
|
| 45 |
+
Raises:
|
| 46 |
+
FileNotFoundError: 템플릿 파일이 없을 때
|
| 47 |
+
yaml.YAMLError: YAML 파싱 오류
|
| 48 |
+
"""
|
| 49 |
+
template_path = _get_template_path(template_name)
|
| 50 |
+
|
| 51 |
+
# 캐시 확인
|
| 52 |
+
cache_key = str(template_path)
|
| 53 |
+
if cache_key in _template_cache:
|
| 54 |
+
return _template_cache[cache_key]
|
| 55 |
+
|
| 56 |
+
# 파일 존재 확인
|
| 57 |
+
if not template_path.exists():
|
| 58 |
+
raise FileNotFoundError(
|
| 59 |
+
f"프롬프트 템플릿 파일을 찾을 수 없습니다: {template_path}"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# YAML 파일 로드
|
| 63 |
+
try:
|
| 64 |
+
with open(template_path, "r", encoding="utf-8") as f:
|
| 65 |
+
content = yaml.safe_load(f)
|
| 66 |
+
|
| 67 |
+
if not isinstance(content, dict):
|
| 68 |
+
raise ValueError(
|
| 69 |
+
f"YAML 파일은 딕셔너리 형태여야 합니다: {template_path}"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# 캐시에 저장
|
| 73 |
+
_template_cache[cache_key] = content
|
| 74 |
+
logger.debug(f"프롬프트 템플릿 로드 완료: {template_name}")
|
| 75 |
+
|
| 76 |
+
return content
|
| 77 |
+
|
| 78 |
+
except yaml.YAMLError as e:
|
| 79 |
+
logger.error(f"YAML 파싱 오류 ({template_path}): {e}")
|
| 80 |
+
raise
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"템플릿 파일 로드 실패 ({template_path}): {e}")
|
| 83 |
+
raise
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def load_prompt(template_name: str, prompt_key: str, **kwargs: Any) -> str:
|
| 87 |
+
"""프롬프트 템플릿을 로드하고 변수를 치환하여 반환합니다.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
template_name: 템플릿 파일 이름 (확장자 제외, 예: "planning")
|
| 91 |
+
prompt_key: YAML 파일 내의 프롬프트 키 (예: "plan_prompt")
|
| 92 |
+
**kwargs: 템플릿 변수 (Jinja2 변수로 전달됨)
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
렌더링된 프롬프트 문자열
|
| 96 |
+
|
| 97 |
+
Raises:
|
| 98 |
+
FileNotFoundError: 템플릿 파일이 없을 때
|
| 99 |
+
KeyError: prompt_key가 YAML 파일에 없을 때
|
| 100 |
+
TemplateError: Jinja2 템플릿 렌더링 오류
|
| 101 |
+
"""
|
| 102 |
+
# YAML 파일 로드
|
| 103 |
+
yaml_content = _load_yaml_file(template_name)
|
| 104 |
+
|
| 105 |
+
# 프롬프트 키 확인
|
| 106 |
+
if prompt_key not in yaml_content:
|
| 107 |
+
available_keys = ", ".join(yaml_content.keys())
|
| 108 |
+
raise KeyError(
|
| 109 |
+
f"프롬프트 키 '{prompt_key}'를 찾을 수 없습니다. "
|
| 110 |
+
f"사용 가능한 키: {available_keys} (템플릿: {template_name})"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# 프롬프트 템플릿 가져오기
|
| 114 |
+
prompt_template = yaml_content[prompt_key]
|
| 115 |
+
|
| 116 |
+
if not isinstance(prompt_template, str):
|
| 117 |
+
raise ValueError(
|
| 118 |
+
f"프롬프트 값은 문자열이어야 합니다: {template_name}.{prompt_key}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Jinja2 템플릿 렌더링
|
| 122 |
+
try:
|
| 123 |
+
template = Template(prompt_template)
|
| 124 |
+
rendered = template.render(**kwargs)
|
| 125 |
+
return rendered
|
| 126 |
+
|
| 127 |
+
except TemplateError as e:
|
| 128 |
+
logger.error(
|
| 129 |
+
f"템플릿 렌더링 오류 ({template_name}.{prompt_key}): {e}"
|
| 130 |
+
)
|
| 131 |
+
raise
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.error(
|
| 134 |
+
f"프롬프트 로드 실패 ({template_name}.{prompt_key}): {e}"
|
| 135 |
+
)
|
| 136 |
+
raise
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def clear_cache():
|
| 140 |
+
"""템플릿 캐시를 초기화합니다. (주로 테스트용)"""
|
| 141 |
+
global _template_cache
|
| 142 |
+
_template_cache.clear()
|
| 143 |
+
logger.debug("프롬프트 템플릿 캐시 초기화됨")
|
| 144 |
+
|
CodeWeaver/src/prompts/templates/analysis.yaml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
analysis_prompt: |
|
| 2 |
+
질문을 분석하세요.
|
| 3 |
+
|
| 4 |
+
{{ context_info }}
|
| 5 |
+
현재 질문: {{ user_question }}
|
| 6 |
+
|
| 7 |
+
분류 기준:
|
| 8 |
+
|
| 9 |
+
1. **clarification** (보충/형식 변경 요청)
|
| 10 |
+
- 이전 답변/대화 내용을 바탕으로 "설명 방식"을 바꾸거나 보충을 요청
|
| 11 |
+
- 예: "좀 더 쉽게 설명해줘", "예제 코드로 보여줘", "한 줄로 요약해줘"
|
| 12 |
+
- should_cache = false, canonical_question = null, refined_query = null
|
| 13 |
+
|
| 14 |
+
2. **general_chat** (일상 대화, 정체성, 주제 이탈)
|
| 15 |
+
- 일상적인 인사: "안녕", "반가워", "고마워"
|
| 16 |
+
- AI 정체성 질문: "너 누구야?", "뭐 할 줄 알아?"
|
| 17 |
+
- 대화 내용 요약 질문: "지금까지 무슨 얘기 했지?", "대화 내용 요약해줘"
|
| 18 |
+
- **주제 이탈(Off-topic):** 날씨, 음식 메뉴, 연예인, 주식, 일반 상식 등 **프로그래밍/개발과 무관한 모든 질문**
|
| 19 |
+
- 예: "내일 날씨 알려줘", "저녁 메뉴 추천해줘", "주식 시세 알려줘"
|
| 20 |
+
- should_cache = false, canonical_question = null, refined_query = null
|
| 21 |
+
|
| 22 |
+
3. **independent** (검색이 필요한 기술 질문)
|
| 23 |
+
- 새로운 주제의 개념, 사용법, 에러 해결 등 기술적인 질문
|
| 24 |
+
- 대화 도중 화제가 전환되어 나오는 새로운 질문도 포함
|
| 25 |
+
- 이전 대화의 문맥(대명사 등)이 있다면 이를 반영하여 '완전한 질문'으로 재구성해야 함
|
| 26 |
+
- 예: "Spring Security가 뭐야?", "Docker Compose 사용법은?"
|
| 27 |
+
- 예 (화제 전환): "그럼 Session이랑은 뭐가 달라?" → "JWT와 Session 기반 인증의 차이점은 무엇인가?"로 재구성
|
| 28 |
+
- should_cache = true, canonical_question 생성 필수
|
| 29 |
+
- **refined_query 필수**: 검색 엔진(StackOverflow, GitHub, MDN 등)에 최적화된 영어 검색 쿼리 생성
|
| 30 |
+
* 한국어 기술 용어는 영어 원문을 함께 포함 (예: "Thread 스레드", "Django 장고")
|
| 31 |
+
* 에러 메시지나 코드 스니펫이 있으면 핵심 키워드만 추출
|
| 32 |
+
* 예: "JWT 인증 구현" → "JWT authentication implementation"
|
| 33 |
+
* 예: "스레드 동기화 문제" → "Thread synchronization problem"
|
| 34 |
+
|
| 35 |
+
다음 JSON 형식으로만 답변하세요:
|
| 36 |
+
{
|
| 37 |
+
"question_type": "clarification|general_chat|independent",
|
| 38 |
+
"should_cache": true|false,
|
| 39 |
+
"reasoning": "분류 이유 1-2문장",
|
| 40 |
+
"canonical_question": "캐시할 정규화된 질문 (should_cache가 true인 경우에만, 아니면 null)",
|
| 41 |
+
"refined_query": "영어 검색 쿼리 (independent인 경우에만, 아니면 null)"
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
JSON 외에 다른 텍스트는 포함하지 마세요.
|
| 45 |
+
|
CodeWeaver/src/prompts/templates/answer.yaml
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
general_chat_template: |
|
| 2 |
+
당신은 개발자 어시스턴트 **'CodeWeaver'**입니다.
|
| 3 |
+
|
| 4 |
+
**이전 대화 내역 (Context):**
|
| 5 |
+
{{ history }}
|
| 6 |
+
|
| 7 |
+
질문: {{ question }}
|
| 8 |
+
|
| 9 |
+
지침:
|
| 10 |
+
1. **대화 맥락 유지**: 위 '이전 대화 내역'은 참고용입니다. 필요할 때만 자연스럽게 활용하세요.
|
| 11 |
+
- 사용자가 "아까 말한 거"라고 하면 내역을 보고 추론하세요.
|
| 12 |
+
- "내 이름이 뭐였지?"처럼 명시적으로 물어볼 때만 내역을 확인하세요.
|
| 13 |
+
- **중요**: 이전 대화를 자동으로 요약하거나 언급하지 마세요. 사용자가 명시적으로 요청한 경우에만 요약하세요.
|
| 14 |
+
|
| 15 |
+
2. **인사말 반복 금지**: 이전 대화에서 인사말이 있었다고 해서 매번 "안녕하세요! CodeWeaver입니다. 😊" 같은 인사말을 반복하지 마세요.
|
| 16 |
+
- 첫 대화가 아니면 불필요한 인사말을 생략하고 직접적으로 답변하세요.
|
| 17 |
+
|
| 18 |
+
3. **일상적인 인사/정체성**: 친절하고 전문적인 톤으로 답하세요. 하지만 간결하고 직접적으로 답변하세요.
|
| 19 |
+
|
| 20 |
+
4. **개발 외 질문 거절**: 날씨, 연예인 등은 정중히 거절하세요.
|
| 21 |
+
|
| 22 |
+
5. **없는 기억 지어내지 않기**: 내역에 없으면 솔직히 모른다고 하세요.
|
| 23 |
+
|
| 24 |
+
위 지침에 따라 답변을 생성하세요.
|
| 25 |
+
|
| 26 |
+
technical_template: |
|
| 27 |
+
당신은 개발자 어시스턴트입니다. 다음 검색 결과를 바탕으로 질문에 답변하세요.
|
| 28 |
+
|
| 29 |
+
질문: {{ question }}
|
| 30 |
+
|
| 31 |
+
수집된 정보:
|
| 32 |
+
{{ summaries }}
|
| 33 |
+
|
| 34 |
+
지침:
|
| 35 |
+
- 검색 결과를 바탕으로 개념을 설명하거나 에러를 해결하세요.
|
| 36 |
+
- 코드 예제가 필요하면 제공하세요.
|
| 37 |
+
- 초보 개발자도 이해할 수 있게 명확하고 간결하게 작성하세요.
|
| 38 |
+
- Markdown 형식으로 작성하세요.
|
| 39 |
+
|
| 40 |
+
fallback_system_instruction: |
|
| 41 |
+
지침:
|
| 42 |
+
- 현재 제공된 '수집된 정보'가 부족합니다.
|
| 43 |
+
- **검색 결과에 의존하지 말고, 당신의 프로그래밍 지식을 활용하여 답변하세요.**
|
| 44 |
+
- 질문이 명확한 기술 개념(예: Thread, Loop)이라면 상세히 설명하세요.
|
| 45 |
+
- 질문이 너무 모호하거나 최신 라이브러리 버전에 관한 것이라면, 솔직하게 모른다고 하거나 일반적인 원리만 설명하세요.
|
| 46 |
+
|
| 47 |
+
context_prompt_base: |
|
| 48 |
+
이전 대화를 참고하여 후속 질문에 답변하세요.
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
지침:
|
| 52 |
+
1. 사용자의 질문이 '이전 대화'와 맥락이 이어진다면, 대화 내역의 정보를 적극 활용하세요.
|
| 53 |
+
2. 만약 질문이 이전 대화와 전혀 상관없는 새로운 주제라면, 이전 내역을 무시하고 질문 자체에 집중하세요.
|
| 54 |
+
|
| 55 |
+
{{ recent_context }}
|
| 56 |
+
|
| 57 |
+
현재 질문: {{ user_question }}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
지침:
|
| 61 |
+
1. **오직 '현재 질문'에 대해서만 답변하세요.**
|
| 62 |
+
2. '직전 대화 내역'은 문맥 파악 용도로만 사용하세요.
|
| 63 |
+
3. 과거에 답변하지 못했거나 거절했던 질문이 있더라도, 사용자가 다시 묻지 않았다면 **절대 다시 언급하거나 답변하지 마세요.**
|
| 64 |
+
4. 현재 질문에 집중하여 명확하고 간결하게 답변하세요.
|
| 65 |
+
|
CodeWeaver/src/prompts/templates/planning.yaml
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
plan_prompt: |
|
| 2 |
+
사용자의 입력을 분석하여 질문 유형과 개수를 판단하세요.
|
| 3 |
+
|
| 4 |
+
입력 텍스트:
|
| 5 |
+
---
|
| 6 |
+
{{ user_question }}
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
**분석 규칙 (매우 중요)**:
|
| 10 |
+
1. **코드 무시**: 입력에 포함된 코드 블록, import 구문, 에러 로그 등은 질문 개수로 세지 마세요. 오직 사용자가 자연어로 물어본 문장만 질문으로 간주하세요.
|
| 11 |
+
- 예: "import java.util.*; 이거 왜 에러나?" → 질문 1개 (코드는 무시)
|
| 12 |
+
- 예: "이 코드 문제점은? ```python\nprint('hello')\n```" → 질문 1개 (코드는 무시)
|
| 13 |
+
2. **배경 설명 무시**: "내가 지금 Spring을 쓰고 있는데...", "프로젝트에서..." 같은 배경 설명은 질문이 아닙니다.
|
| 14 |
+
3. **개수 제한**:
|
| 15 |
+
- 질문이 1개면: "single_topic"
|
| 16 |
+
- 질문이 2개면: "multiple_questions"
|
| 17 |
+
- 질문이 3개 이상이면: "too_many" (처리 불가)
|
| 18 |
+
|
| 19 |
+
**Case 정의**:
|
| 20 |
+
- **single_topic**: 하나의 명확한 주제.
|
| 21 |
+
- 예: "Spring Security 설정법 알려줘"
|
| 22 |
+
- 예: "이 코드 왜 에러 나?"
|
| 23 |
+
- 예: "JWT 인증 구현 방법"
|
| 24 |
+
- questions: 답변 섹션 구조를 위한 키워드/구절 (1-5개)
|
| 25 |
+
|
| 26 |
+
- **multiple_questions**: 서로 다른 독립적인 주제 2개.
|
| 27 |
+
- 예: "JWT는 뭐야? 그리고 Redis는 어떻게 설치해?"
|
| 28 |
+
- 예: "CORS가 뭐야? JWT는?"
|
| 29 |
+
- **문맥 보존 (매우 중요)**: 대명사나 문맥 참조("C랑은", "그럼", "그것은" 등)를 해석할 때 이전 질문의 맥락을 반영해야 합니다.
|
| 30 |
+
- 잘못된 예: "파이썬은 어떤 언어야? C랑은 어떻게 달라?" → ["파이썬은 어떤 언어인가?", "C++와 C의 차이점은 무엇인가?"] ❌
|
| 31 |
+
- 올바른 예: "파이썬은 어떤 언어야? C랑은 어떻게 달라?" → ["파이썬은 어떤 언어인가?", "파이썬과 C의 차이점은 무엇인가?"] ✅
|
| 32 |
+
- **완전한 질문 재구성**: 각 질문이 독립적으로 이해 가능하도록 완전한 문장으로 재구성하세요.
|
| 33 |
+
- 대명사나 생략된 주어가 있으면 이전 질문의 맥락을 반영하여 명확하게 재구성
|
| 34 |
+
- 예: "Spring이 뭐야? 그럼 Django는?" → ["Spring이란 무엇인가?", "Spring과 Django의 차이점은 무엇인가?"]
|
| 35 |
+
- questions: 완전한 질문 문장 2개 (정확히 2개만, 각 질문이 독립적으로 이해 가능해야 함)
|
| 36 |
+
|
| 37 |
+
- **too_many**: 질문이 3개 이상 나열됨.
|
| 38 |
+
- 예: "JWT? CORS? Docker? Redis?"
|
| 39 |
+
- error_message: 사용자에게 안내 메시지 작성
|
| 40 |
+
|
| 41 |
+
다음 JSON 형식으로만 답변하세요:
|
| 42 |
+
{
|
| 43 |
+
"case": "single_topic|multiple_questions|too_many",
|
| 44 |
+
"questions": ["질문1", "질문2"] 또는 ["키워드1", "키워드2"],
|
| 45 |
+
"reasoning": "판단 이유 (1-2문장)",
|
| 46 |
+
"error_message": "..." (too_many인 경우만 메시지 작성, 그 외는 빈 문자열)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
JSON 외에 다른 텍스트는 포함하지 마세요.
|
| 50 |
+
|
| 51 |
+
too_many_questions_message: |
|
| 52 |
+
죄송합니다. 한 번에 최대 2개의 질문까지만 처리할 수 있습니다.
|
| 53 |
+
|
| 54 |
+
다음 중 하나를 선택해서 다시 질문해 주세요:
|
| 55 |
+
|
| 56 |
+
1. **하나의 주제로 통합해서 질문**
|
| 57 |
+
예: "JWT 인증과 CORS 설정을 함께 구현하는 방법"
|
| 58 |
+
|
| 59 |
+
2. **가장 중요한 2개 질문만 선택**
|
| 60 |
+
예: "JWT가 뭐야? 내 코드에 어떻게 적용해?"
|
| 61 |
+
|
| 62 |
+
3. **질문을 나눠서 순차적으로 질문**
|
| 63 |
+
예: 먼저 "JWT가 뭐야?" 질문 → 답변 확인 → 다음 질문
|
| 64 |
+
|
| 65 |
+
어떻게 도와드릴까요?
|
| 66 |
+
|
CodeWeaver/src/prompts/templates/search.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
refinement_prompt: |
|
| 2 |
+
검색 결과가 부족합니다. 검색 쿼리를 개선하세요.
|
| 3 |
+
|
| 4 |
+
원본 질문: {{ user_question }}
|
| 5 |
+
현재 결과 수: {{ result_count }}개 (목표: 2개 이상)
|
| 6 |
+
|
| 7 |
+
**핵심 지침 (영어 병기)**:
|
| 8 |
+
검색 엔진(StackOverflow, MDN 등)은 영어 쿼리에 훨씬 더 정확하게 반응합니다.
|
| 9 |
+
한국어 기술 용어가 있다면 **반드시 영어 원문을 함께 적어주세요.**
|
| 10 |
+
(예: "스레드" -> "Thread 스레드", "장고" -> "Django 장고", "N+1 문제" -> "N+1 problem")
|
| 11 |
+
|
| 12 |
+
개선 전략 (하나 선택):
|
| 13 |
+
1. MORE_SPECIFIC: 기술적 세부사항을 추가하고 영어 용어 병기
|
| 14 |
+
2. MORE_GENERAL: 더 넓은 범위의 용어로 변경
|
| 15 |
+
3. TRANSLATE: 한국어 질문을 영어 위주의 검색 쿼리로 변환
|
| 16 |
+
|
| 17 |
+
다음 JSON 형식으로만 답변하세요:
|
| 18 |
+
{
|
| 19 |
+
"new_query": "영어 용어가 포함된 개선된 쿼리",
|
| 20 |
+
"strategy": "MORE_SPECIFIC|MORE_GENERAL|TRANSLATE",
|
| 21 |
+
"reasoning": "이 전략을 선택한 이유 1-2문장"
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
JSON 외에 다른 텍스트는 포함하지 마세요.
|
| 25 |
+
|
CodeWeaver/src/scripts/init_db.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DB 초기화 스크립트.
|
| 3 |
+
LangGraph 상태 저장을 위한 필수 테이블(checkpoints 등)을 생성합니다.
|
| 4 |
+
배포 전 또는 최초 1회 실행하면 됩니다.
|
| 5 |
+
실행: uv run python src/scripts/init_db.py
|
| 6 |
+
"""
|
| 7 |
+
import sys
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# 프로젝트 루트 경로 추가 (src 모듈 import 위해)
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 13 |
+
|
| 14 |
+
from psycopg_pool import ConnectionPool
|
| 15 |
+
from langgraph.checkpoint.postgres import PostgresSaver
|
| 16 |
+
from src.core.config import settings
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(level=logging.INFO)
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
def init_db():
|
| 22 |
+
logger.info("🚧 DB 테이블 초기화 시작...")
|
| 23 |
+
|
| 24 |
+
# 보안을 위해 URL 마스킹
|
| 25 |
+
safe_url = settings.postgres_db_url.split("@")[-1] if "@" in settings.postgres_db_url else "..."
|
| 26 |
+
logger.info(f"Target DB: {safe_url}")
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
# 동기 연결 풀 생성 (setup은 1회성 작업이므로 동기가 편함)
|
| 30 |
+
with ConnectionPool(
|
| 31 |
+
conninfo=settings.postgres_db_url,
|
| 32 |
+
min_size=1,
|
| 33 |
+
max_size=1,
|
| 34 |
+
kwargs={"autocommit": True}
|
| 35 |
+
) as pool:
|
| 36 |
+
checkpointer = PostgresSaver(pool)
|
| 37 |
+
checkpointer.setup()
|
| 38 |
+
|
| 39 |
+
logger.info("✅ DB 테이블 생성(Setup) 완료!")
|
| 40 |
+
logger.info("이제 앱을 실행할 수 있습니다.")
|
| 41 |
+
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"❌ DB 초기화 실패: {e}")
|
| 44 |
+
raise
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
init_db()
|
CodeWeaver/src/scripts/init_qdrant.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Qdrant 컬렉션 초기화 스크립트.
|
| 3 |
+
Qdrant 벡터 캐시를 위한 컬렉션을 생성합니다.
|
| 4 |
+
배포 전 또는 최초 1회 실행하면 됩니다.
|
| 5 |
+
실행: uv run python src/scripts/init_qdrant.py
|
| 6 |
+
"""
|
| 7 |
+
import sys
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# 프로젝트 루트 경로 추가 (src 모듈 import 위해)
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 13 |
+
|
| 14 |
+
from qdrant_client import QdrantClient, models
|
| 15 |
+
from src.core.config import settings
|
| 16 |
+
from src.vector_db.local_embeddings import LocalEmbeddingManager
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(level=logging.INFO)
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def init_qdrant(collection_name: str = "CodeWeaver") -> None:
|
| 23 |
+
"""Qdrant 컬렉션을 초기화한다.
|
| 24 |
+
|
| 25 |
+
컬렉션이 존재하지 않으면 생성하고, 이미 존재하면 스킵합니다.
|
| 26 |
+
"""
|
| 27 |
+
logger.info("🚧 Qdrant 컬렉션 초기화 시작...")
|
| 28 |
+
|
| 29 |
+
# 보안을 위해 URL 마스킹
|
| 30 |
+
safe_url = settings.qdrant_url.split("@")[-1] if "@" in settings.qdrant_url else settings.qdrant_url
|
| 31 |
+
logger.info(f"Target Qdrant: {safe_url}")
|
| 32 |
+
logger.info(f"Collection: {collection_name}")
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
# Qdrant 클라이언트 생성
|
| 36 |
+
client = QdrantClient(
|
| 37 |
+
url=settings.qdrant_url,
|
| 38 |
+
api_key=settings.qdrant_api_key,
|
| 39 |
+
timeout=30,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# 컬렉션 존재 여부 확인
|
| 43 |
+
exists = client.collection_exists(collection_name)
|
| 44 |
+
|
| 45 |
+
if exists:
|
| 46 |
+
logger.info(f"✅ 컬렉션 '{collection_name}' 이미 존재합니다.")
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
# 임베딩 모델의 차원을 동적으로 가져옴
|
| 50 |
+
embedding_manager = LocalEmbeddingManager()
|
| 51 |
+
embedding_dim = embedding_manager.get_embedding_dimension()
|
| 52 |
+
|
| 53 |
+
# 컬렉션 생성
|
| 54 |
+
client.create_collection(
|
| 55 |
+
collection_name=collection_name,
|
| 56 |
+
vectors_config=models.VectorParams(
|
| 57 |
+
size=embedding_dim, # fastembed 모델의 실제 차원
|
| 58 |
+
distance=models.Distance.COSINE,
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
logger.info(f"✅ Qdrant 컬렉션 생성 완료: {collection_name} (벡터 차원: {embedding_dim})")
|
| 63 |
+
logger.info("이제 앱을 실행할 수 있습니다.")
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"❌ Qdrant 컬렉션 초기화 실패: {e}")
|
| 67 |
+
raise
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
init_qdrant()
|
| 72 |
+
|
| 73 |
+
|
CodeWeaver/src/tools/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from .
|
| 2 |
search_github,
|
| 3 |
search_official_docs,
|
| 4 |
search_stackoverflow,
|
|
@@ -9,4 +9,3 @@ __all__ = [
|
|
| 9 |
"search_github",
|
| 10 |
"search_official_docs",
|
| 11 |
]
|
| 12 |
-
|
|
|
|
| 1 |
+
from .search import (
|
| 2 |
search_github,
|
| 3 |
search_official_docs,
|
| 4 |
search_stackoverflow,
|
|
|
|
| 9 |
"search_github",
|
| 10 |
"search_official_docs",
|
| 11 |
]
|
|
|
CodeWeaver/src/tools/{search_tools.py → search.py}
RENAMED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import logging
|
| 2 |
-
import
|
| 3 |
import time
|
| 4 |
from typing import List
|
| 5 |
|
|
@@ -7,6 +7,7 @@ import requests
|
|
| 7 |
from tavily import TavilyClient # type: ignore[import]
|
| 8 |
|
| 9 |
from src.agent.state import SearchResult
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
|
@@ -27,57 +28,52 @@ def search_stackoverflow(query: str, limit: int = 3) -> List[SearchResult]:
|
|
| 27 |
return []
|
| 28 |
|
| 29 |
try:
|
|
|
|
|
|
|
| 30 |
url = "https://api.stackexchange.com/2.3/search/advanced"
|
| 31 |
params = {
|
| 32 |
"q": query,
|
| 33 |
-
"order": "desc",
|
| 34 |
-
"sort": "votes",
|
| 35 |
"site": "stackoverflow",
|
|
|
|
|
|
|
| 36 |
"pagesize": limit,
|
| 37 |
-
"filter": "withbody",
|
| 38 |
}
|
| 39 |
|
| 40 |
response = requests.get(url, params=params, timeout=10)
|
| 41 |
response.raise_for_status()
|
| 42 |
-
|
| 43 |
data = response.json()
|
| 44 |
-
items = data.get("items", [])
|
| 45 |
|
| 46 |
results = []
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
for item in items:
|
| 50 |
title = item.get("title", "")
|
| 51 |
-
body = item.get("body", "")
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
# 정규화: 0-1 범위로 변환
|
| 56 |
-
relevance = min(score / max(max_score, 1), 1.0) if max_score > 0 else 0.5
|
| 57 |
|
| 58 |
results.append(
|
| 59 |
SearchResult(
|
| 60 |
-
source="
|
| 61 |
content=content,
|
| 62 |
-
url=item.get("
|
| 63 |
-
relevance_score=relevance,
|
| 64 |
)
|
| 65 |
)
|
| 66 |
|
| 67 |
-
logger.info("Stack Overflow 검색
|
| 68 |
-
|
| 69 |
-
# Rate limit 준수
|
| 70 |
-
time.sleep(1)
|
| 71 |
-
|
| 72 |
return results
|
| 73 |
|
|
|
|
|
|
|
|
|
|
| 74 |
except Exception as e:
|
| 75 |
-
logger.error("Stack Overflow 검색
|
| 76 |
return []
|
| 77 |
|
| 78 |
|
| 79 |
def search_github(query: str, limit: int = 3) -> List[SearchResult]:
|
| 80 |
-
"""GitHub에서 관련
|
| 81 |
|
| 82 |
Args:
|
| 83 |
query: 검색 쿼리
|
|
@@ -90,76 +86,59 @@ def search_github(query: str, limit: int = 3) -> List[SearchResult]:
|
|
| 90 |
logger.warning("GitHub 검색: 빈 쿼리")
|
| 91 |
return []
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try:
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
params = {
|
| 100 |
-
"q":
|
| 101 |
-
"sort": "
|
| 102 |
"per_page": limit,
|
| 103 |
}
|
| 104 |
|
| 105 |
-
headers =
|
| 106 |
-
"Accept": "application/vnd.github.v3+json",
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
# GitHub 토큰이 있으면 Authorization 헤더 추가
|
| 110 |
-
github_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 111 |
-
if github_token:
|
| 112 |
-
headers["Authorization"] = f"token {github_token}"
|
| 113 |
-
logger.debug("GitHub 토큰 사용 (인증된 요청)")
|
| 114 |
-
else:
|
| 115 |
-
logger.warning(
|
| 116 |
-
"GITHUB_TOKEN이 설정되지 않음 - rate limit 제한적 (60 req/hr). "
|
| 117 |
-
"토큰 설정 시 5,000 req/hr로 증가"
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
response = requests.get(url, params=params, headers=headers, timeout=10)
|
| 121 |
response.raise_for_status()
|
| 122 |
-
|
| 123 |
data = response.json()
|
| 124 |
-
items = data.get("items", [])
|
| 125 |
|
| 126 |
results = []
|
| 127 |
-
for item in items:
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
|
| 132 |
results.append(
|
| 133 |
SearchResult(
|
| 134 |
source="GitHub",
|
| 135 |
content=content,
|
| 136 |
-
url=item.get("html_url"),
|
| 137 |
-
relevance_score=0.8, # GitHub 결과는 일반적으로 높은 관련도
|
| 138 |
)
|
| 139 |
)
|
| 140 |
|
| 141 |
-
logger.info("GitHub 검색
|
| 142 |
-
|
| 143 |
-
# Rate limit 준수
|
| 144 |
-
time.sleep(1)
|
| 145 |
-
|
| 146 |
return results
|
| 147 |
|
| 148 |
-
except requests.exceptions.
|
| 149 |
-
|
| 150 |
-
logger.warning("GitHub API rate limit 초과")
|
| 151 |
-
elif e.response.status_code == 401:
|
| 152 |
-
logger.warning("GitHub API 인증 실패 (토큰이 없거나 잘못됨). 토큰 없이 계속 진행합니다.")
|
| 153 |
-
else:
|
| 154 |
-
logger.error("GitHub 검색 HTTP 에러: %s", e, exc_info=True)
|
| 155 |
return []
|
| 156 |
except Exception as e:
|
| 157 |
-
logger.error("GitHub 검색
|
| 158 |
return []
|
| 159 |
|
| 160 |
|
| 161 |
def search_official_docs(query: str, limit: int = 3) -> List[SearchResult]:
|
| 162 |
-
"""
|
| 163 |
|
| 164 |
Args:
|
| 165 |
query: 검색 쿼리
|
|
@@ -169,49 +148,42 @@ def search_official_docs(query: str, limit: int = 3) -> List[SearchResult]:
|
|
| 169 |
SearchResult 리스트 (실패 시 빈 리스트)
|
| 170 |
"""
|
| 171 |
if not query.strip():
|
| 172 |
-
logger.warning("
|
| 173 |
-
return []
|
| 174 |
-
|
| 175 |
-
api_key = os.getenv("TAVILY_API_KEY", "").strip()
|
| 176 |
-
if not api_key:
|
| 177 |
-
logger.error("TAVILY_API_KEY 환경 변수가 설정되어 있지 않습니다.")
|
| 178 |
return []
|
| 179 |
|
| 180 |
try:
|
| 181 |
-
|
| 182 |
|
| 183 |
-
|
|
|
|
|
|
|
| 184 |
query=query,
|
| 185 |
-
search_depth="basic",
|
| 186 |
max_results=limit,
|
| 187 |
-
include_domains=[
|
| 188 |
-
"docs.python.org",
|
| 189 |
-
"docs.oracle.com",
|
| 190 |
-
"spring.io/guides",
|
| 191 |
-
"developer.mozilla.org",
|
| 192 |
-
"reactjs.org/docs",
|
| 193 |
-
],
|
| 194 |
)
|
| 195 |
|
| 196 |
results = []
|
| 197 |
-
for
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
results.append(
|
| 203 |
SearchResult(
|
| 204 |
-
source="
|
| 205 |
-
content=content,
|
| 206 |
url=url,
|
| 207 |
-
relevance_score=score,
|
| 208 |
)
|
| 209 |
)
|
| 210 |
|
| 211 |
-
logger.info("
|
| 212 |
return results
|
| 213 |
|
| 214 |
except Exception as e:
|
| 215 |
-
logger.error("
|
| 216 |
return []
|
| 217 |
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import re
|
| 3 |
import time
|
| 4 |
from typing import List
|
| 5 |
|
|
|
|
| 7 |
from tavily import TavilyClient # type: ignore[import]
|
| 8 |
|
| 9 |
from src.agent.state import SearchResult
|
| 10 |
+
from src.core.config import settings
|
| 11 |
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
|
|
|
| 28 |
return []
|
| 29 |
|
| 30 |
try:
|
| 31 |
+
# Stack Overflow API v2.3 사용
|
| 32 |
+
# https://api.stackexchange.com/docs/search
|
| 33 |
url = "https://api.stackexchange.com/2.3/search/advanced"
|
| 34 |
params = {
|
| 35 |
"q": query,
|
|
|
|
|
|
|
| 36 |
"site": "stackoverflow",
|
| 37 |
+
"sort": "relevance",
|
| 38 |
+
"order": "desc",
|
| 39 |
"pagesize": limit,
|
| 40 |
+
"filter": "withbody", # 본문 포함
|
| 41 |
}
|
| 42 |
|
| 43 |
response = requests.get(url, params=params, timeout=10)
|
| 44 |
response.raise_for_status()
|
|
|
|
| 45 |
data = response.json()
|
|
|
|
| 46 |
|
| 47 |
results = []
|
| 48 |
+
for item in data.get("items", [])[:limit]:
|
| 49 |
+
# 제목과 본문을 결합하여 content 생성
|
|
|
|
| 50 |
title = item.get("title", "")
|
| 51 |
+
body = item.get("body", "")
|
| 52 |
+
# HTML 태그 제거 (간단한 정규식)
|
| 53 |
+
body_clean = re.sub(r"<[^>]+>", "", body)
|
| 54 |
+
content = f"{title}\n\n{body_clean[:500]}" # 본문은 500자로 제한
|
|
|
|
|
|
|
| 55 |
|
| 56 |
results.append(
|
| 57 |
SearchResult(
|
| 58 |
+
source="StackOverflow",
|
| 59 |
content=content,
|
| 60 |
+
url=f"https://stackoverflow.com/questions/{item.get('question_id')}",
|
|
|
|
| 61 |
)
|
| 62 |
)
|
| 63 |
|
| 64 |
+
logger.info("Stack Overflow 검색 완료: %d개 결과", len(results))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return results
|
| 66 |
|
| 67 |
+
except requests.exceptions.RequestException as e:
|
| 68 |
+
logger.error("Stack Overflow 검색 실패: %s", e)
|
| 69 |
+
return []
|
| 70 |
except Exception as e:
|
| 71 |
+
logger.error("Stack Overflow 검색 중 예상치 못한 오류: %s", e, exc_info=True)
|
| 72 |
return []
|
| 73 |
|
| 74 |
|
| 75 |
def search_github(query: str, limit: int = 3) -> List[SearchResult]:
|
| 76 |
+
"""GitHub Issues/Discussions에서 관련 내용을 검색한다.
|
| 77 |
|
| 78 |
Args:
|
| 79 |
query: 검색 쿼리
|
|
|
|
| 86 |
logger.warning("GitHub 검색: 빈 쿼리")
|
| 87 |
return []
|
| 88 |
|
| 89 |
+
github_token = settings.github_token
|
| 90 |
+
|
| 91 |
+
if not github_token:
|
| 92 |
+
logger.warning("GitHub 토큰이 없어 검색을 건너뜁니다.")
|
| 93 |
+
return []
|
| 94 |
+
|
| 95 |
try:
|
| 96 |
+
# GitHub Search API 사용
|
| 97 |
+
# https://docs.github.com/en/rest/search/search
|
| 98 |
+
url = "https://api.github.com/search/issues"
|
| 99 |
+
headers = {
|
| 100 |
+
"Accept": "application/vnd.github+json",
|
| 101 |
+
"Authorization": f"Bearer {github_token}",
|
| 102 |
+
}
|
| 103 |
params = {
|
| 104 |
+
"q": f"{query} is:issue is:open", # 열린 이슈만 검색
|
| 105 |
+
"sort": "relevance",
|
| 106 |
"per_page": limit,
|
| 107 |
}
|
| 108 |
|
| 109 |
+
response = requests.get(url, headers=headers, params=params, timeout=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
response.raise_for_status()
|
|
|
|
| 111 |
data = response.json()
|
|
|
|
| 112 |
|
| 113 |
results = []
|
| 114 |
+
for item in data.get("items", [])[:limit]:
|
| 115 |
+
title = item.get("title", "")
|
| 116 |
+
body = item.get("body", "") or ""
|
| 117 |
+
# 본문이 너무 길면 잘라냄
|
| 118 |
+
body_clean = body[:500] if body else ""
|
| 119 |
+
content = f"{title}\n\n{body_clean}"
|
| 120 |
|
| 121 |
results.append(
|
| 122 |
SearchResult(
|
| 123 |
source="GitHub",
|
| 124 |
content=content,
|
| 125 |
+
url=item.get("html_url", ""),
|
|
|
|
| 126 |
)
|
| 127 |
)
|
| 128 |
|
| 129 |
+
logger.info("GitHub 검색 완료: %d개 결과", len(results))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
return results
|
| 131 |
|
| 132 |
+
except requests.exceptions.RequestException as e:
|
| 133 |
+
logger.error("GitHub 검색 실패: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
return []
|
| 135 |
except Exception as e:
|
| 136 |
+
logger.error("GitHub 검색 중 예상치 못한 오류: %s", e, exc_info=True)
|
| 137 |
return []
|
| 138 |
|
| 139 |
|
| 140 |
def search_official_docs(query: str, limit: int = 3) -> List[SearchResult]:
|
| 141 |
+
"""공식 문서 및 웹 검색을 수행한다 (Tavily API 사용).
|
| 142 |
|
| 143 |
Args:
|
| 144 |
query: 검색 쿼리
|
|
|
|
| 148 |
SearchResult 리스트 (실패 시 빈 리스트)
|
| 149 |
"""
|
| 150 |
if not query.strip():
|
| 151 |
+
logger.warning("공식 문서 검색: 빈 쿼리")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return []
|
| 153 |
|
| 154 |
try:
|
| 155 |
+
tavily_client = TavilyClient(api_key=settings.tavily_api_key)
|
| 156 |
|
| 157 |
+
# Tavily Search API 호출
|
| 158 |
+
# https://docs.tavily.com/python-client
|
| 159 |
+
response = tavily_client.search(
|
| 160 |
query=query,
|
| 161 |
+
search_depth="basic", # basic | advanced
|
| 162 |
max_results=limit,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
)
|
| 164 |
|
| 165 |
results = []
|
| 166 |
+
for result in response.get("results", [])[:limit]:
|
| 167 |
+
title = result.get("title", "")
|
| 168 |
+
content = result.get("content", "")
|
| 169 |
+
url = result.get("url", "")
|
| 170 |
+
|
| 171 |
+
# content가 너무 길면 잘라냄
|
| 172 |
+
if len(content) > 1000:
|
| 173 |
+
content = content[:1000] + "..."
|
| 174 |
|
| 175 |
results.append(
|
| 176 |
SearchResult(
|
| 177 |
+
source="OfficialDocs",
|
| 178 |
+
content=f"{title}\n\n{content}",
|
| 179 |
url=url,
|
|
|
|
| 180 |
)
|
| 181 |
)
|
| 182 |
|
| 183 |
+
logger.info("공식 문서 검색 완료: %d개 결과", len(results))
|
| 184 |
return results
|
| 185 |
|
| 186 |
except Exception as e:
|
| 187 |
+
logger.error("공식 문서 검색 실패: %s", e, exc_info=True)
|
| 188 |
return []
|
| 189 |
|
CodeWeaver/src/vector_db/local_embeddings.py
CHANGED
|
@@ -1,34 +1,113 @@
|
|
| 1 |
"""
|
| 2 |
로컬 임베딩 관리 모듈.
|
| 3 |
|
| 4 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import logging
|
|
|
|
| 8 |
from typing import List
|
| 9 |
|
| 10 |
-
from
|
|
|
|
|
|
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
|
| 15 |
class LocalEmbeddingManager:
|
| 16 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
dim = self.model.get_sentence_embedding_dimension()
|
| 22 |
-
logger.info("로컬 임베딩 모델 로딩 완료 (차원: %d)", dim)
|
| 23 |
|
| 24 |
-
def
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
로컬 임베딩 관리 모듈.
|
| 3 |
|
| 4 |
+
fastembed를 사용하여 빠르고 가벼운 임베딩을 생성합니다.
|
| 5 |
+
Singleton 패턴을 적용하여 모델이 한 번만 로드되도록 보장합니다.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import logging
|
| 9 |
+
import threading
|
| 10 |
from typing import List
|
| 11 |
|
| 12 |
+
from fastembed import TextEmbedding
|
| 13 |
+
|
| 14 |
+
from src.core.config import settings, EMBEDDING_MODEL_NAME, EMBEDDING_DIMENSION
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
|
| 19 |
class LocalEmbeddingManager:
|
| 20 |
+
"""fastembed 기반 로컬 임베딩 생성기 (Singleton 패턴).
|
| 21 |
+
|
| 22 |
+
Thread-safe Singleton 패턴을 사용하여 모델이 한 번만 로드되도록 보장합니다.
|
| 23 |
+
여러 인스턴스 생성 시에도 동일한 모델 인스턴스를 공유합니다.
|
| 24 |
+
"""
|
| 25 |
|
| 26 |
+
_instance: "LocalEmbeddingManager | None" = None
|
| 27 |
+
_lock: threading.Lock = threading.Lock()
|
| 28 |
+
_initialized: bool = False
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
def __new__(cls) -> "LocalEmbeddingManager":
|
| 31 |
+
"""Singleton 패턴: 인스턴스가 이미 존재하면 기존 인스턴스를 반환합니다."""
|
| 32 |
+
if cls._instance is None:
|
| 33 |
+
with cls._lock:
|
| 34 |
+
# Double-checked locking pattern
|
| 35 |
+
if cls._instance is None:
|
| 36 |
+
cls._instance = super().__new__(cls)
|
| 37 |
+
return cls._instance
|
| 38 |
|
| 39 |
+
def __init__(self) -> None:
|
| 40 |
+
"""모델이 아직 초기화되지 않았으면 초기화합니다."""
|
| 41 |
+
if not LocalEmbeddingManager._initialized:
|
| 42 |
+
with self._lock:
|
| 43 |
+
# Double-checked locking pattern
|
| 44 |
+
if not LocalEmbeddingManager._initialized:
|
| 45 |
+
self._initialize()
|
| 46 |
+
LocalEmbeddingManager._initialized = True
|
| 47 |
|
| 48 |
+
def _initialize(self) -> None:
|
| 49 |
+
"""임베딩 모델을 로드합니다."""
|
| 50 |
+
logger.info("로컬 임베딩 모델 로딩 중: %s (fastembed)", EMBEDDING_MODEL_NAME)
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
self.model = TextEmbedding(model_name=EMBEDDING_MODEL_NAME)
|
| 54 |
+
self.embedding_dimension = EMBEDDING_DIMENSION
|
| 55 |
+
logger.info("로컬 임베딩 모델 로딩 완료: %s (차원: %d)", EMBEDDING_MODEL_NAME, self.embedding_dimension)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error("임베딩 모델 로딩 실패: %s", e, exc_info=True)
|
| 58 |
+
raise RuntimeError(f"임베딩 모델 로딩 실패: {e}") from e
|
| 59 |
+
|
| 60 |
+
def get_embedding_dimension(self) -> int:
|
| 61 |
+
"""임베딩 벡터의 차원을 반환합니다.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
임베딩 벡터의 차원 (설정에서 지정된 값)
|
| 65 |
+
"""
|
| 66 |
+
return self.embedding_dimension
|
| 67 |
|
| 68 |
+
def get_embedding(self, text: str) -> List[float]:
|
| 69 |
+
"""단일 텍스트를 임베딩합니다.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
text: 임베딩할 텍스트
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
임베딩 벡터 (List[float])
|
| 76 |
+
"""
|
| 77 |
+
try:
|
| 78 |
+
# fastembed의 embed()는 제너레이터를 반환하므로 list로 변환
|
| 79 |
+
embeddings = list(self.model.embed([text]))
|
| 80 |
+
if not embeddings:
|
| 81 |
+
raise ValueError("임베딩 결과가 비어있습니다")
|
| 82 |
+
# 단일 텍스트이므로 첫 번째 결과만 반환
|
| 83 |
+
embedding = embeddings[0]
|
| 84 |
+
# numpy array일 수 있으므로 list로 변환
|
| 85 |
+
return embedding.tolist() if hasattr(embedding, "tolist") else list(embedding)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error("임베딩 생성 실패: %s", e, exc_info=True)
|
| 88 |
+
raise RuntimeError(f"임베딩 생성 실패: {e}") from e
|
| 89 |
+
|
| 90 |
+
def get_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
|
| 91 |
+
"""배치 텍스트를 임베딩합니다.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
texts: 임베딩할 텍스트 리스트
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
임베딩 벡터 리스트 (List[List[float]])
|
| 98 |
+
"""
|
| 99 |
+
if not texts:
|
| 100 |
+
return []
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
# fastembed의 embed()는 제너레이터를 반환하므로 list로 변환
|
| 104 |
+
embeddings = list(self.model.embed(texts))
|
| 105 |
+
# 각 ��베딩을 list로 변환
|
| 106 |
+
result = []
|
| 107 |
+
for embedding in embeddings:
|
| 108 |
+
# numpy array일 수 있으므로 list로 변환
|
| 109 |
+
result.append(embedding.tolist() if hasattr(embedding, "tolist") else list(embedding))
|
| 110 |
+
return result
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error("배치 임베딩 생성 실패: %s", e, exc_info=True)
|
| 113 |
+
raise RuntimeError(f"배치 임베딩 생성 실패: {e}") from e
|
CodeWeaver/src/vector_db/qdrant_client.py
CHANGED
|
@@ -1,16 +1,12 @@
|
|
| 1 |
-
import hashlib
|
| 2 |
import logging
|
| 3 |
-
import
|
| 4 |
from typing import Dict, List, Optional
|
| 5 |
|
| 6 |
-
from dotenv import load_dotenv # type: ignore[import]
|
| 7 |
from qdrant_client import QdrantClient, models
|
| 8 |
|
|
|
|
| 9 |
from src.vector_db.local_embeddings import LocalEmbeddingManager
|
| 10 |
|
| 11 |
-
# .env 파일에서 환경 변수 로드 (로컬 개발 편의성)
|
| 12 |
-
load_dotenv()
|
| 13 |
-
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
|
|
@@ -23,13 +19,9 @@ class QdrantManager:
|
|
| 23 |
|
| 24 |
def __init__(self, collection_name: str = "CodeWeaver") -> None:
|
| 25 |
"""Qdrant Cloud 클라이언트를 초기화하고 컬렉션을 준비한다."""
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
if not qdrant_url or not qdrant_api_key:
|
| 30 |
-
raise ValueError(
|
| 31 |
-
"QDRANT_URL 및 QDRANT_API_KEY 환경 변수가 모두 설정되어 있어야 합니다."
|
| 32 |
-
)
|
| 33 |
|
| 34 |
# Qdrant Cloud 공식 가이드와 유사한 초기화 형태 사용
|
| 35 |
# https://qdrant.tech/documentation/tutorials-and-examples/cloud-inference-hybrid-search/
|
|
@@ -60,19 +52,21 @@ class QdrantManager:
|
|
| 60 |
return
|
| 61 |
|
| 62 |
try:
|
|
|
|
|
|
|
| 63 |
self.client.create_collection(
|
| 64 |
collection_name=self.collection_name,
|
| 65 |
vectors_config=models.VectorParams(
|
| 66 |
-
size=
|
| 67 |
distance=models.Distance.COSINE,
|
| 68 |
),
|
| 69 |
)
|
| 70 |
-
logger.info("Qdrant 컬렉션 생성 완료: %s", self.collection_name)
|
| 71 |
except Exception as e:
|
| 72 |
logger.error("Qdrant 컬렉션 생성 실패: %s", e, exc_info=True)
|
| 73 |
raise
|
| 74 |
|
| 75 |
-
|
| 76 |
"""로컬 임베딩 모델을 사용해 텍스트 임베딩을 생성한다."""
|
| 77 |
try:
|
| 78 |
embedding = self.embedding_manager.get_embedding(text)
|
|
@@ -82,17 +76,17 @@ class QdrantManager:
|
|
| 82 |
logger.error("임베딩 생성 실패: %s", e, exc_info=True)
|
| 83 |
raise
|
| 84 |
|
| 85 |
-
|
| 86 |
self,
|
| 87 |
question: str,
|
| 88 |
-
threshold: float = 0.
|
| 89 |
) -> Optional[str]:
|
| 90 |
"""질문에 대한 캐시된 답변을 Qdrant에서 검색한다.
|
| 91 |
|
| 92 |
threshold보다 높은 score를 가진 결과가 있을 때만 answer를 반환한다.
|
| 93 |
"""
|
| 94 |
try:
|
| 95 |
-
embedding =
|
| 96 |
except Exception:
|
| 97 |
# 이미 get_embedding 내부에서 로그를 남기므로 여기서는 조용히 실패 처리
|
| 98 |
return None
|
|
@@ -137,34 +131,33 @@ class QdrantManager:
|
|
| 137 |
logger.info("캐시 히트이지만 payload에 answer가 없습니다. payload=%s", payload)
|
| 138 |
return None
|
| 139 |
|
|
|
|
| 140 |
logger.info(
|
| 141 |
-
"캐시 히트: score=%.4f,
|
| 142 |
score,
|
| 143 |
question,
|
|
|
|
| 144 |
len(str(answer)),
|
| 145 |
)
|
| 146 |
return str(answer)
|
| 147 |
|
| 148 |
-
|
| 149 |
"""질문-답변 쌍을 Qdrant 캐시에 저장한다.
|
| 150 |
|
| 151 |
동일한 질문에 대해서는 deterministic ID를 사용하여,
|
| 152 |
upsert 시 기존 엔트리를 덮어쓰게 함으로써 중복을 방지한다.
|
| 153 |
"""
|
| 154 |
try:
|
| 155 |
-
embedding =
|
| 156 |
except Exception:
|
| 157 |
# 임베딩 실패 시 캐시에 저장하지 않는다.
|
| 158 |
logger.warning("임베딩 실패로 인해 캐시에 저장하지 않음. question=%s", question)
|
| 159 |
return
|
| 160 |
|
| 161 |
-
#
|
| 162 |
-
# → 동일 질문 = 동일
|
| 163 |
-
#
|
| 164 |
-
|
| 165 |
-
# 따라서 sha256 hex(64자)를 그대로 쓰지 않고, 앞 32자를 UUID 포맷으로 변환해 사용한다.
|
| 166 |
-
digest = hashlib.sha256(question.encode("utf-8")).hexdigest()
|
| 167 |
-
point_id = f"{digest[:8]}-{digest[8:12]}-{digest[12:16]}-{digest[16:20]}-{digest[20:32]}"
|
| 168 |
|
| 169 |
# 기존 엔트리 존재 시(덮어쓰기) 로그를 남긴다. 실패해도 upsert는 계속 시도.
|
| 170 |
try:
|
|
@@ -194,7 +187,7 @@ class QdrantManager:
|
|
| 194 |
points=[point],
|
| 195 |
)
|
| 196 |
logger.info(
|
| 197 |
-
"Qdrant 캐시에 저장 완료 (
|
| 198 |
point_id,
|
| 199 |
len(question),
|
| 200 |
len(answer),
|
|
@@ -202,7 +195,7 @@ class QdrantManager:
|
|
| 202 |
except Exception as e:
|
| 203 |
logger.error("Qdrant 캐시 저장 실패: %s", e, exc_info=True)
|
| 204 |
|
| 205 |
-
|
| 206 |
"""현재 컬렉션의 캐시 통계를 반환한다."""
|
| 207 |
try:
|
| 208 |
info = self.client.get_collection(self.collection_name)
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import uuid
|
| 3 |
from typing import Dict, List, Optional
|
| 4 |
|
|
|
|
| 5 |
from qdrant_client import QdrantClient, models
|
| 6 |
|
| 7 |
+
from src.core.config import settings
|
| 8 |
from src.vector_db.local_embeddings import LocalEmbeddingManager
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
|
|
|
|
| 19 |
|
| 20 |
def __init__(self, collection_name: str = "CodeWeaver") -> None:
|
| 21 |
"""Qdrant Cloud 클라이언트를 초기화하고 컬렉션을 준비한다."""
|
| 22 |
+
# pydantic-settings가 필수 변수 검증을 수행하므로 별도 검증 불필요
|
| 23 |
+
qdrant_url = settings.qdrant_url
|
| 24 |
+
qdrant_api_key = settings.qdrant_api_key
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# Qdrant Cloud 공식 가이드와 유사한 초기화 형태 사용
|
| 27 |
# https://qdrant.tech/documentation/tutorials-and-examples/cloud-inference-hybrid-search/
|
|
|
|
| 52 |
return
|
| 53 |
|
| 54 |
try:
|
| 55 |
+
# 임베딩 모델의 차원을 동적으로 가져옴
|
| 56 |
+
embedding_dim = self.embedding_manager.get_embedding_dimension()
|
| 57 |
self.client.create_collection(
|
| 58 |
collection_name=self.collection_name,
|
| 59 |
vectors_config=models.VectorParams(
|
| 60 |
+
size=embedding_dim, # fastembed 모델의 실제 차원
|
| 61 |
distance=models.Distance.COSINE,
|
| 62 |
),
|
| 63 |
)
|
| 64 |
+
logger.info("Qdrant 컬렉션 생성 완료: %s (벡터 차원: %d)", self.collection_name, embedding_dim)
|
| 65 |
except Exception as e:
|
| 66 |
logger.error("Qdrant 컬렉션 생성 실패: %s", e, exc_info=True)
|
| 67 |
raise
|
| 68 |
|
| 69 |
+
def get_embedding(self, text: str) -> List[float]:
|
| 70 |
"""로컬 임베딩 모델을 사용해 텍스트 임베딩을 생성한다."""
|
| 71 |
try:
|
| 72 |
embedding = self.embedding_manager.get_embedding(text)
|
|
|
|
| 76 |
logger.error("임베딩 생성 실패: %s", e, exc_info=True)
|
| 77 |
raise
|
| 78 |
|
| 79 |
+
def search_cache(
|
| 80 |
self,
|
| 81 |
question: str,
|
| 82 |
+
threshold: float = 0.95,
|
| 83 |
) -> Optional[str]:
|
| 84 |
"""질문에 대한 캐시된 답변을 Qdrant에서 검색한다.
|
| 85 |
|
| 86 |
threshold보다 높은 score를 가진 결과가 있을 때만 answer를 반환한다.
|
| 87 |
"""
|
| 88 |
try:
|
| 89 |
+
embedding = self.get_embedding(question)
|
| 90 |
except Exception:
|
| 91 |
# 이미 get_embedding 내부에서 로그를 남기므로 여기서는 조용히 실패 처리
|
| 92 |
return None
|
|
|
|
| 131 |
logger.info("캐시 히트이지만 payload에 answer가 없습니다. payload=%s", payload)
|
| 132 |
return None
|
| 133 |
|
| 134 |
+
matched_question = payload.get("question", "알 수 없음")
|
| 135 |
logger.info(
|
| 136 |
+
"캐시 히트: score=%.4f, searched_question=%s, matched_question=%s, answer_length=%d",
|
| 137 |
score,
|
| 138 |
question,
|
| 139 |
+
matched_question,
|
| 140 |
len(str(answer)),
|
| 141 |
)
|
| 142 |
return str(answer)
|
| 143 |
|
| 144 |
+
def save_to_cache(self, question: str, answer: str) -> None:
|
| 145 |
"""질문-답변 쌍을 Qdrant 캐시에 저장한다.
|
| 146 |
|
| 147 |
동일한 질문에 대해서는 deterministic ID를 사용하여,
|
| 148 |
upsert 시 기존 엔트리를 덮어쓰게 함으로써 중복을 방지한다.
|
| 149 |
"""
|
| 150 |
try:
|
| 151 |
+
embedding = self.get_embedding(question)
|
| 152 |
except Exception:
|
| 153 |
# 임베딩 실패 시 캐시에 저장하지 않는다.
|
| 154 |
logger.warning("임베딩 실패로 인해 캐시에 저장하지 않음. question=%s", question)
|
| 155 |
return
|
| 156 |
|
| 157 |
+
# 질문 기반 deterministic UUID 사용
|
| 158 |
+
# → 동일 질문 = 동일 UUID → upsert가 덮어쓰기로 동작 → 중복 방지
|
| 159 |
+
# uuid5()는 표준 UUID 형식(RFC 4122)을 사용하며, 동일한 namespace와 name에 대해 항상 동일한 UUID를 생성합니다.
|
| 160 |
+
point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, question))
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
# 기존 엔트리 존재 시(덮어쓰기) 로그를 남긴다. 실패해도 upsert는 계속 시도.
|
| 163 |
try:
|
|
|
|
| 187 |
points=[point],
|
| 188 |
)
|
| 189 |
logger.info(
|
| 190 |
+
"Qdrant 캐시에 저장 완료 (UUID로 중복 방지): point_id=%s, question_length=%d, answer_length=%d",
|
| 191 |
point_id,
|
| 192 |
len(question),
|
| 193 |
len(answer),
|
|
|
|
| 195 |
except Exception as e:
|
| 196 |
logger.error("Qdrant 캐시 저장 실패: %s", e, exc_info=True)
|
| 197 |
|
| 198 |
+
def get_cache_stats(self) -> Dict[str, int]:
|
| 199 |
"""현재 컬렉션의 캐시 통계를 반환한다."""
|
| 200 |
try:
|
| 201 |
info = self.client.get_collection(self.collection_name)
|
CodeWeaver/ui/app.py
CHANGED
|
@@ -1,34 +1,30 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import logging
|
| 3 |
-
import os
|
| 4 |
import sys
|
| 5 |
-
import
|
| 6 |
from pathlib import Path
|
|
|
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
-
# 환경 변수 로드
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
# 프로젝트 루트를 경로에 추가
|
| 15 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 16 |
|
| 17 |
-
from src.agent.graph import
|
| 18 |
from src.agent.state import AgentState
|
| 19 |
|
| 20 |
-
# 로깅 설정 (WARNING 이상만 출력)
|
| 21 |
logging.basicConfig(
|
| 22 |
level=logging.WARNING,
|
| 23 |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 24 |
)
|
| 25 |
|
| 26 |
-
# 외부 라이브러리
|
| 27 |
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 28 |
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 29 |
logging.getLogger("langsmith").setLevel(logging.WARNING)
|
| 30 |
-
|
| 31 |
-
# CodeWeaver 모듈 로그도 WARNING만 (로그 비활성화)
|
| 32 |
logging.getLogger("src.agent").setLevel(logging.WARNING)
|
| 33 |
logging.getLogger("src.tools").setLevel(logging.WARNING)
|
| 34 |
logging.getLogger("src.vector_db").setLevel(logging.WARNING)
|
|
@@ -36,40 +32,38 @@ logging.getLogger("src.vector_db").setLevel(logging.WARNING)
|
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
|
| 38 |
|
| 39 |
-
|
| 40 |
message: str,
|
| 41 |
history: list,
|
| 42 |
thread_id: str,
|
| 43 |
) -> str:
|
| 44 |
"""
|
| 45 |
사용자 메시지를 처리하고 에이전트 응답을 반환합니다.
|
| 46 |
-
|
| 47 |
-
Args:
|
| 48 |
-
message: 사용자 입력 메시지
|
| 49 |
-
history: 대화 내역 (Gradio 자동 관리)
|
| 50 |
-
thread_id: 세션별 고유 ID (MemorySaver가 대화 맥락 추적에 사용)
|
| 51 |
-
|
| 52 |
-
Returns:
|
| 53 |
-
에이전트의 최종 답변
|
| 54 |
"""
|
| 55 |
if not message or not message.strip():
|
| 56 |
return "질문을 입력해주세요."
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
try:
|
| 59 |
-
# 초기 상태 생성
|
| 60 |
from langchain_core.messages import HumanMessage
|
| 61 |
|
| 62 |
initial_state = AgentState(
|
| 63 |
user_question=message.strip(),
|
| 64 |
messages=[HumanMessage(content=message.strip())],
|
| 65 |
-
conversation_history=history[-5:] if history else None, # 최근 5턴만 전달
|
| 66 |
)
|
| 67 |
|
| 68 |
-
#
|
| 69 |
-
|
| 70 |
|
| 71 |
-
#
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
# 최종 답변 추출
|
| 75 |
final_answer = result.get("final_answer", "답변을 생성하지 못했습니다.")
|
|
@@ -84,11 +78,7 @@ async def chat(
|
|
| 84 |
def create_demo() -> gr.Blocks:
|
| 85 |
"""Gradio 인터페이스를 생성합니다."""
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
# CSS 스타일 (깔끔한 디자인)
|
| 90 |
-
# - Gradio 기본 CSS가 .contain/.gradio-container 폭을 덮어쓰는 경우가 있어
|
| 91 |
-
# 둘 다 !important로 고정하여 "처음부터 넓은 폭"을 확실히 유지합니다.
|
| 92 |
css = """
|
| 93 |
.gradio-container {
|
| 94 |
max-width: 1280px !important;
|
|
@@ -111,148 +101,130 @@ def create_demo() -> gr.Blocks:
|
|
| 111 |
) as demo:
|
| 112 |
|
| 113 |
gr.Markdown("""
|
| 114 |
-
#
|
| 115 |
-
###
|
| 116 |
-
|
| 117 |
-
초보 개발자를 위한 친절한 AI 도우미입니다.
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
- ✅ 개념 학습
|
| 122 |
-
- ✅ 코드 리뷰 및 개선 제안
|
| 123 |
-
- ✅ **다중 질문 처리** (최대 2개까지 동시 처리)
|
| 124 |
-
- ✅ **대화 맥락 이해** (이전 대화를 참고한 후속 질문 답변)
|
| 125 |
-
- ✅ **스마트 캐싱** (유사 질문 즉시 답변)
|
| 126 |
-
- ✅ **자동 검색 개선** (결과 부족 시 쿼리 자동 최적화)
|
| 127 |
-
|
| 128 |
-
💬 개발 관련 질문을 자유롭게 해보세요!
|
| 129 |
-
- 단일 질문: "Spring Boot JPA N+1 문제 해결 방법은?"
|
| 130 |
-
- 다중 질문: "JWT가 뭐야? CORS는?" (최대 2개)
|
| 131 |
-
- 후속 질문: "좀 더 쉽게 설명해줘" (이전 답변 참고)
|
| 132 |
""")
|
| 133 |
|
| 134 |
-
#
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
# 채팅 인터페이스
|
| 138 |
chatbot_interface = gr.ChatInterface(
|
| 139 |
fn=chat,
|
| 140 |
-
examples=None,
|
| 141 |
-
chatbot=gr.Chatbot(height=
|
| 142 |
textbox=gr.Textbox(
|
| 143 |
-
placeholder="질문을 입력하세요...",
|
| 144 |
container=False,
|
| 145 |
scale=7
|
| 146 |
),
|
| 147 |
retry_btn=None,
|
| 148 |
undo_btn=None,
|
| 149 |
clear_btn="🗑️ 대화 초기화",
|
| 150 |
-
additional_inputs=[
|
| 151 |
)
|
| 152 |
|
| 153 |
-
# Clear 버튼
|
| 154 |
def reset_session():
|
| 155 |
-
|
| 156 |
-
return new_id
|
| 157 |
|
| 158 |
-
# Clear 버튼에 세션 리셋 핸들러 추가
|
| 159 |
if chatbot_interface.clear_btn:
|
| 160 |
chatbot_interface.clear_btn.click(
|
| 161 |
reset_session,
|
| 162 |
None,
|
| 163 |
-
|
| 164 |
queue=False
|
| 165 |
)
|
| 166 |
|
| 167 |
-
#
|
| 168 |
-
gr.Markdown("###
|
| 169 |
-
example_questions = [
|
| 170 |
-
"Spring Boot JPA N+1 문제 해결 방법은?",
|
| 171 |
-
"ImportError: No module named 'requests' 해결 방법",
|
| 172 |
-
"Docker Compose 설정 예제를 알려주세요",
|
| 173 |
-
"이 코드를 개선해주세요: for i in range(len(arr)): print(arr[i])",
|
| 174 |
-
"JWT가 뭐야? CORS는?", # 다중 질문 예시
|
| 175 |
-
]
|
| 176 |
-
|
| 177 |
with gr.Row():
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
- **검색 API**: Stack Overflow, GitHub, Tavily
|
| 199 |
-
- **프레임워크**: LangGraph
|
| 200 |
-
|
| 201 |
-
### 주요 기능
|
| 202 |
-
- 🔍 **병렬 검색**: Stack Overflow, GitHub, 공식 문서 동시 검색
|
| 203 |
-
- 💾 **의미적 캐싱**: 유사 질문(임계값 0.85 이상) 즉시 답변
|
| 204 |
-
- 🎯 **의도 기반 라우팅**: debugging/learning/code_review 자동 분류
|
| 205 |
-
- 🔄 **자동 쿼리 개선**: 검색 결과 부족 시 최대 1회 자동 최적화
|
| 206 |
-
- 📝 **초보자 친화 답변**: 의도별 맞춤형 답변 구조
|
| 207 |
-
- 🔀 **다중 질문 처리**: 독립 질문 2개까지 병렬 처리
|
| 208 |
-
- 💬 **대화 맥락 이해**: clarification 질문은 히스토리 기반 답변
|
| 209 |
-
|
| 210 |
-
### LangGraph로 구현한 핵심 기능
|
| 211 |
-
1. ✅ **Conditional Edges**: 질문 유형/캐시 여부/검색 결과에 따른 동적 라우팅
|
| 212 |
-
2. ✅ **Send API**: 3개 검색 소스 병렬 실행 (fan-out/fan-in)
|
| 213 |
-
3. ✅ **Subgraph**: 검색 결과 필터링 및 요약 파이프라인
|
| 214 |
-
4. ✅ **Map-Reduce**: 다중 질문 처리 시 각 질문별 독립 실행 후 결과 통합
|
| 215 |
-
5. ✅ **Checkpointing**: MemorySaver로 대화 상태 저장 및 재개
|
| 216 |
-
6. ✅ **Pydantic Typed State**: 타입 안전한 상태 관리
|
| 217 |
-
|
| 218 |
-
### GitHub
|
| 219 |
-
[프로젝트 소스코드](https://github.com/shin-heewon/codeweaver)
|
| 220 |
-
""")
|
| 221 |
-
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
with gr.Accordion("💡 사용 팁", open=False):
|
| 226 |
gr.Markdown("""
|
| 227 |
-
###
|
| 228 |
-
|
| 229 |
-
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
-
|
| 233 |
-
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
-
|
| 253 |
-
- 사용 중인 언어/프레임워크를 명시하세요
|
| 254 |
-
- 시도했던 해결 방법을 함께 알려주세요
|
| 255 |
-
- ���색 결과가 부족하면 자동으로 쿼리를 개선합니다 (최대 1회)
|
| 256 |
""")
|
| 257 |
|
| 258 |
return demo
|
|
@@ -263,10 +235,9 @@ app = create_demo()
|
|
| 263 |
|
| 264 |
|
| 265 |
if __name__ == "__main__":
|
| 266 |
-
# 로컬 실행
|
| 267 |
app.launch(
|
| 268 |
server_name="0.0.0.0",
|
| 269 |
server_port=7860,
|
| 270 |
-
share=False,
|
| 271 |
-
show_api=False,
|
| 272 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
+
import logging
|
| 3 |
from pathlib import Path
|
| 4 |
+
import uuid
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
|
| 9 |
+
# 환경 변수 로드
|
| 10 |
load_dotenv()
|
| 11 |
|
| 12 |
# 프로젝트 루트를 경로에 추가
|
| 13 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 14 |
|
| 15 |
+
from src.agent.graph import get_agent
|
| 16 |
from src.agent.state import AgentState
|
| 17 |
|
| 18 |
+
# 로깅 설정 (WARNING 이상만 출력 - 노이즈 제거)
|
| 19 |
logging.basicConfig(
|
| 20 |
level=logging.WARNING,
|
| 21 |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 22 |
)
|
| 23 |
|
| 24 |
+
# 외부 라이브러리 및 내부 모듈 로그 레벨 조정
|
| 25 |
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 26 |
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 27 |
logging.getLogger("langsmith").setLevel(logging.WARNING)
|
|
|
|
|
|
|
| 28 |
logging.getLogger("src.agent").setLevel(logging.WARNING)
|
| 29 |
logging.getLogger("src.tools").setLevel(logging.WARNING)
|
| 30 |
logging.getLogger("src.vector_db").setLevel(logging.WARNING)
|
|
|
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
| 34 |
|
| 35 |
+
def chat(
|
| 36 |
message: str,
|
| 37 |
history: list,
|
| 38 |
thread_id: str,
|
| 39 |
) -> str:
|
| 40 |
"""
|
| 41 |
사용자 메시지를 처리하고 에이전트 응답을 반환합니다.
|
| 42 |
+
(Sync Mode for Windows Compatibility)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
"""
|
| 44 |
if not message or not message.strip():
|
| 45 |
return "질문을 입력해주세요."
|
| 46 |
|
| 47 |
+
# 세션 ID가 비어있으면 랜덤 생성
|
| 48 |
+
if not thread_id or not thread_id.strip():
|
| 49 |
+
thread_id = str(uuid.uuid4())
|
| 50 |
+
|
| 51 |
try:
|
| 52 |
+
# 초기 상태 생성
|
| 53 |
from langchain_core.messages import HumanMessage
|
| 54 |
|
| 55 |
initial_state = AgentState(
|
| 56 |
user_question=message.strip(),
|
| 57 |
messages=[HumanMessage(content=message.strip())],
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
+
# 에이전트 인스턴스 가져오기 (Singleton)
|
| 61 |
+
agent = get_agent()
|
| 62 |
|
| 63 |
+
# Sync 모드로 실행 (Windows 호환성 및 안정성)
|
| 64 |
+
# thread_id를 통해 PostgresSaver에서 이전 대화 상태를 불러옴
|
| 65 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 66 |
+
result = agent.invoke(initial_state, config=config)
|
| 67 |
|
| 68 |
# 최종 답변 추출
|
| 69 |
final_answer = result.get("final_answer", "답변을 생성하지 못했습니다.")
|
|
|
|
| 78 |
def create_demo() -> gr.Blocks:
|
| 79 |
"""Gradio 인터페이스를 생성합니다."""
|
| 80 |
|
| 81 |
+
# CSS 스타일 (화면 너비 고정 및 가독성 향상)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
css = """
|
| 83 |
.gradio-container {
|
| 84 |
max-width: 1280px !important;
|
|
|
|
| 101 |
) as demo:
|
| 102 |
|
| 103 |
gr.Markdown("""
|
| 104 |
+
# 🕸️ CodeWeaver
|
| 105 |
+
### 초보 개발자를 위한 지능형 AI 멘토
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
질문을 분석하고, 계획을 세우고, 다양한 기술 문서를 참고하여 답변하는 LangGraph 기반 에이전트���니다.
|
| 108 |
+
개발 중 마주치는 에러, 개념 질문, 코드 리뷰 등 무엇이든 물어보세요.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
""")
|
| 110 |
|
| 111 |
+
# 1. 사용 가이드
|
| 112 |
+
with gr.Accordion("📝 사용 가이드 & 팁 (먼저 읽어보세요)", open=False):
|
| 113 |
+
gr.Markdown("""
|
| 114 |
+
### 💡 질문 잘하는 법
|
| 115 |
+
- **구체적으로**: `"파이썬 에러"` (X) -> `"KeyError: 'data' 해결 방법은?"` (O)
|
| 116 |
+
- **상황 설명**: 언어와 프레임워크를 함께 말해주면 정확도가 올라갑니다.
|
| 117 |
+
|
| 118 |
+
### 🚀 주요 기능 활용법
|
| 119 |
+
1. **Fast Track (빠른 대화)**
|
| 120 |
+
- `"안녕"`, `"너 누구야?"` 같은 일상 대화는 검색 없이 즉시 답변합니다.
|
| 121 |
+
2. **다중 질문 처리 (Map-Reduce)**
|
| 122 |
+
- `"JWT가 뭐야? 그리고 CORS는?"` 처럼 **최대 2개** 질문을 한 번에 할 수 있습니다.
|
| 123 |
+
3. **문맥 이해 (History)**
|
| 124 |
+
- `"좀 더 쉽게 설명해줘"` 또는 `"예제 코드로 보여줘"`라고 하면 이전 답변을 바탕으로 보충 설명합니다.
|
| 125 |
+
4. **쿼리 자동 개선**
|
| 126 |
+
- 검색 결과가 부족하면 에이전트가 스스로 개선(기술 용어를 영어로 변환하는 등)여 재검색합니다.
|
| 127 |
+
|
| 128 |
+
### 💾 대화 저장 및 영속성 확인 (Persistence)
|
| 129 |
+
- **Session ID**가 같으면 브라우저를 닫았다가 다시 접속해도 대화 내용이 유지됩니다. (PostgreSQL DB 저장)
|
| 130 |
+
- **테스트 방법**:
|
| 131 |
+
1. 대화를 나눈 후 **Session ID**를 복사해둡니다.
|
| 132 |
+
2. 페이지를 새로고침하거나 브라우저를 재시작합니다.
|
| 133 |
+
3. Session ID 입력창에 복사한 ID를 넣고 대화를 시도합니다.
|
| 134 |
+
4. **`"우리 대화 요약해줘"`**라고 물어보면 DB에 저장된 기록을 불러와 답변합니다.
|
| 135 |
+
- 새로운 주제로 대화하려면 반드시 `🗑️ 대화 초기화` 버튼을 누르세요.
|
| 136 |
+
""")
|
| 137 |
+
|
| 138 |
+
# 2. Session ID 입력창
|
| 139 |
+
with gr.Row():
|
| 140 |
+
session_id_input = gr.Textbox(
|
| 141 |
+
label="Session ID (이 ID가 같으면 대화가 유지됩니다)",
|
| 142 |
+
value=str(uuid.uuid4()),
|
| 143 |
+
interactive=True,
|
| 144 |
+
placeholder="세션 ID를 입력하거나 그대로 두세요"
|
| 145 |
+
)
|
| 146 |
|
| 147 |
+
# 3. 채팅 인터페이스
|
| 148 |
chatbot_interface = gr.ChatInterface(
|
| 149 |
fn=chat,
|
| 150 |
+
examples=None,
|
| 151 |
+
chatbot=gr.Chatbot(height=550, show_copy_button=True),
|
| 152 |
textbox=gr.Textbox(
|
| 153 |
+
placeholder="개발 관련 질문을 입력하세요... (예: 'React useEffect 무한 루프 해결법')",
|
| 154 |
container=False,
|
| 155 |
scale=7
|
| 156 |
),
|
| 157 |
retry_btn=None,
|
| 158 |
undo_btn=None,
|
| 159 |
clear_btn="🗑️ 대화 초기화",
|
| 160 |
+
additional_inputs=[session_id_input],
|
| 161 |
)
|
| 162 |
|
| 163 |
+
# Clear 버튼 핸들러
|
| 164 |
def reset_session():
|
| 165 |
+
return str(uuid.uuid4())
|
|
|
|
| 166 |
|
|
|
|
| 167 |
if chatbot_interface.clear_btn:
|
| 168 |
chatbot_interface.clear_btn.click(
|
| 169 |
reset_session,
|
| 170 |
None,
|
| 171 |
+
session_id_input,
|
| 172 |
queue=False
|
| 173 |
)
|
| 174 |
|
| 175 |
+
# 4. 예시 질문
|
| 176 |
+
gr.Markdown("### 💡 추천 질문 예시")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
with gr.Row():
|
| 178 |
+
# 디버깅
|
| 179 |
+
gr.Button("Python KeyError: 'data' 해결법", variant="primary", size="sm").click(
|
| 180 |
+
fn=lambda: "Python 딕셔너리에서 KeyError: 'data' 에러가 나는데 해결 방법 알려줘",
|
| 181 |
+
outputs=[chatbot_interface.textbox]
|
| 182 |
+
)
|
| 183 |
+
# 프론트엔드 이슈
|
| 184 |
+
gr.Button("React useEffect 무한 루프 원인", variant="primary", size="sm").click(
|
| 185 |
+
fn=lambda: "React useEffect에서 무한 루프가 발생하는 이유와 해결 방법은?",
|
| 186 |
+
outputs=[chatbot_interface.textbox]
|
| 187 |
+
)
|
| 188 |
+
# 다중 질문 (변경된 예시)
|
| 189 |
+
gr.Button("REST API vs GraphQL (다중 질문)", variant="secondary", size="sm").click(
|
| 190 |
+
fn=lambda: "REST API가 뭐야? 그리고 GraphQL은?",
|
| 191 |
+
outputs=[chatbot_interface.textbox]
|
| 192 |
+
)
|
| 193 |
+
# 개념 학습
|
| 194 |
+
gr.Button("Spring Boot JPA N+1 문제", variant="secondary", size="sm").click(
|
| 195 |
+
fn=lambda: "Spring Boot JPA N+1 문제 해결 방법은?",
|
| 196 |
+
outputs=[chatbot_interface.textbox]
|
| 197 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
# 5. 시스템 정보 (업데이트된 리팩토링 내용 반영)
|
| 200 |
+
with gr.Accordion("📊 시스템 아키텍처 및 상세 기술 정보", open=False):
|
|
|
|
| 201 |
gr.Markdown("""
|
| 202 |
+
### 🏗️ LangGraph 구현 패턴 (핵심 기능)
|
| 203 |
+
CodeWeaver는 LangGraph의 고급 패턴을 활용하여 자율 에이전트를 구현했습니다.
|
| 204 |
+
- **Conditional Edges**: 질문 유형, 캐시 히트, 검색 품질에 따른 동적 라우팅
|
| 205 |
+
- **Send API (Map-Reduce)**: 다중 질문의 병렬 실행(Fan-out) 및 결과 통합(Fan-in)
|
| 206 |
+
- **Subgraph**: 검색-평가-정제 과정의 모듈화
|
| 207 |
+
- **Active Self-Correction**: 검색 결과 부족 시 쿼리 자동 정제(Refinement)
|
| 208 |
+
- **Persistence**: 대화 상태의 저장 및 복구
|
| 209 |
+
|
| 210 |
+
### ⚡ 최신 리팩토링 및 최적화 사항 (Technical Highlights)
|
| 211 |
+
1. **아키텍처 안정화 (Sync Mode 전환)**
|
| 212 |
+
- **배경**: Windows(`ProactorEventLoop`)와 `psycopg 3` 드라이버, `Gradio` 간의 Event Loop 충돌 발생.
|
| 213 |
+
- **해결**: 불안정한 비동기 처리 대신 **동기(Sync) 모드**로 아키텍처를 전환하고 `ConnectionPool`을 적용하여 OS 제약 없는 안정적인 실행 환경 확보.
|
| 214 |
+
2. **데이터 영속성 (Persistence)**
|
| 215 |
+
- 기존 In-Memory 방식을 **Neon Serverless PostgreSQL**로 교체하여, 서버 재시작 시에도 대화 맥락이 영구 보존되도록 개선.
|
| 216 |
+
3. **검색 품질 고도화 (Reranking)**
|
| 217 |
+
- **Cross-Encoder(Jina-Reranker)** 도입: 검색 결과의 문맥 유사도를 정밀 채점하여 정확도 대폭 향상 (Threshold 0.35 필터링).
|
| 218 |
+
4. **응답 속도 최적화**
|
| 219 |
+
- **Context Stuffing**: 검색 결과 요약 단계를 제거하고 원본 문맥을 활용하여 Latency 단축.
|
| 220 |
+
- **Non-blocking Caching**: 캐시 저장 로직을 백그라운드 스레드로 분리.
|
| 221 |
+
- **Fast Track**: 일상 대화 즉시 응답 처리.
|
| 222 |
+
|
| 223 |
+
### 🛠️ Tech Stack
|
| 224 |
+
- **Core**: LangGraph, LangChain, Python 3.12
|
| 225 |
+
- **AI Model**: Google Gemini 2.5 Flash Lite
|
| 226 |
+
- **Search**: Tavily (30+ Docs), StackOverflow, GitHub, Jina Reranker
|
| 227 |
+
- **Infra**: Neon Serverless PostgreSQL, Qdrant Cloud
|
|
|
|
|
|
|
|
|
|
| 228 |
""")
|
| 229 |
|
| 230 |
return demo
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
if __name__ == "__main__":
|
|
|
|
| 238 |
app.launch(
|
| 239 |
server_name="0.0.0.0",
|
| 240 |
server_port=7860,
|
| 241 |
+
share=False,
|
| 242 |
+
show_api=False,
|
| 243 |
+
)
|
CodeWeaver/uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
hf-space2/CodeWeaver/.env.example
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
GOOGLE_API_KEY=your-google-api-key
|
| 2 |
-
TAVILY_API_KEY=your-tavily-api-key
|
| 3 |
-
QDRANT_URL=https://your-qdrant-endpoint
|
| 4 |
-
QDRANT_API_KEY=your-qdr
|
| 5 |
-
LANGCHAIN_TRACING_V2=true
|
| 6 |
-
LANGCHAIN_API_KEY=your_langsmith_api_key_here
|
| 7 |
-
LANGCHAIN_PROJECT=codeweaver
|
| 8 |
-
LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
|
| 9 |
-
GITHUB_TOKEN=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/.gitignore
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
# Python-generated files
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.py[oc]
|
| 4 |
-
build/
|
| 5 |
-
dist/
|
| 6 |
-
wheels/
|
| 7 |
-
*.egg-info
|
| 8 |
-
|
| 9 |
-
# Virtual environments
|
| 10 |
-
.venv
|
| 11 |
-
|
| 12 |
-
# Environment variables (민감한 정보 포함)
|
| 13 |
-
.env
|
| 14 |
-
|
| 15 |
-
# IDE
|
| 16 |
-
.vscode/
|
| 17 |
-
.idea/
|
| 18 |
-
*.swp
|
| 19 |
-
*.swo
|
| 20 |
-
|
| 21 |
-
# OS
|
| 22 |
-
.DS_Store
|
| 23 |
-
Thumbs.db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/.python-version
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
3.12
|
|
|
|
|
|
hf-space2/CodeWeaver/IMPLEMENTATION_REPORT.md
DELETED
|
@@ -1,175 +0,0 @@
|
|
| 1 |
-
# CodeWeaver Phase 3 구현 완료 보고서
|
| 2 |
-
|
| 3 |
-
## 완료 날짜
|
| 4 |
-
2024-12-20
|
| 5 |
-
|
| 6 |
-
## 구현 목표
|
| 7 |
-
Open Deep Research 패턴을 적용하여 검색 품질과 답변 정확도를 향상
|
| 8 |
-
|
| 9 |
-
## 구현된 기능
|
| 10 |
-
|
| 11 |
-
### 1. 항상 질문 분해 (create_plan_node)
|
| 12 |
-
- **위치**: `src/agent/nodes.py:203-287`
|
| 13 |
-
- **동작**: 모든 질문을 1-5개의 서브 질문으로 분해
|
| 14 |
-
- **전략**:
|
| 15 |
-
- 단순 질문 → 1개 서브 질문
|
| 16 |
-
- 복잡 질문 → 3-5개 서브 질문
|
| 17 |
-
- **LLM 사용**: JSON 구조화된 출력
|
| 18 |
-
|
| 19 |
-
### 2. 검색 결과 수집 (collect_results_node)
|
| 20 |
-
- **위치**: `src/agent/nodes.py:461-479`
|
| 21 |
-
- **역할**: Fan-in 포인트, 3개 병렬 검색 노드의 결과 집계
|
| 22 |
-
- **출력**: `len(search_results)` 기준으로 원시 결과 수 평가 (필드 저장 제거)
|
| 23 |
-
|
| 24 |
-
### 3. 검색 결과 평가 (evaluate_results_node)
|
| 25 |
-
- **위치**: `src/agent/nodes.py:482-533`
|
| 26 |
-
- **임계값**: 2개 미만이면 개선 필요
|
| 27 |
-
- **안전장치**: refinement_count >= 1이면 무조건 진행
|
| 28 |
-
- **출력**: `needs_refinement` (boolean)
|
| 29 |
-
|
| 30 |
-
### 4. 스마트 쿼리 개선 (refine_search_node)
|
| 31 |
-
- **위치**: `src/agent/nodes.py:536-633`
|
| 32 |
-
- **전략 선택** (LLM):
|
| 33 |
-
- MORE_SPECIFIC: 기술적 세부사항 추가
|
| 34 |
-
- MORE_GENERAL: 더 넓은 용어 사용
|
| 35 |
-
- TRANSLATE: 언어 변환
|
| 36 |
-
- **원본 보존**: `original_question` 필드에 저장
|
| 37 |
-
|
| 38 |
-
### 5. 그래프 재구성
|
| 39 |
-
- **위치**: `src/agent/graph.py:200-330`
|
| 40 |
-
- **새로운 엣지**:
|
| 41 |
-
- `check_cache` → `create_plan` (캐시 미스 시)
|
| 42 |
-
- `create_plan` → `classify_intent`
|
| 43 |
-
- `search_*` → `collect_results` (fan-in)
|
| 44 |
-
- `collect_results` → `evaluate_results`
|
| 45 |
-
- `evaluate_results` → `refine_search` or `search_subgraph`
|
| 46 |
-
- `refine_search` → `classify_intent` (루프)
|
| 47 |
-
|
| 48 |
-
### 6. 상태 스키마 확장
|
| 49 |
-
- **위치**: `src/agent/state.py:127-143`
|
| 50 |
-
- **추가 필드**:
|
| 51 |
-
```python
|
| 52 |
-
plan: Optional[Dict[str, Any]]
|
| 53 |
-
needs_refinement: bool
|
| 54 |
-
refinement_count: int
|
| 55 |
-
original_question: Optional[str]
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
## 테스트 결과
|
| 59 |
-
|
| 60 |
-
### 통합 테스트 (test_new_features.py)
|
| 61 |
-
- ✅ 테스트 1: 단순 질문 - 정상 동작
|
| 62 |
-
- ✅ 테스트 2: 복잡 질문 - 정상 동작
|
| 63 |
-
- ✅ 테스트 3: 결과 부족 시나리오 - 쿼리 개선 확인
|
| 64 |
-
- ✅ 테스트 4: 개선 제한 - 최대 1회 보장
|
| 65 |
-
|
| 66 |
-
### 실행 통계
|
| 67 |
-
```
|
| 68 |
-
[PASS] Passed: 4/4
|
| 69 |
-
[FAIL] Failed: 0/4
|
| 70 |
-
[SUCCESS] All tests passed!
|
| 71 |
-
```
|
| 72 |
-
|
| 73 |
-
### 실제 동작 검증
|
| 74 |
-
```
|
| 75 |
-
INFO:src.agent.nodes:질문 분해 계획 수립 중
|
| 76 |
-
INFO:src.agent.nodes:계획 수립 완료: 4개 서브 질문
|
| 77 |
-
INFO:src.agent.nodes:검색 결과 수집 완료: 0개
|
| 78 |
-
INFO:src.agent.nodes:검색 결과 평가: 0개 (개선 횟수: 0)
|
| 79 |
-
INFO:src.agent.nodes:쿼리 개선 중
|
| 80 |
-
INFO:src.agent.nodes:쿼리 개선 완료
|
| 81 |
-
INFO:src.agent.nodes:검색 결과 수집 완료: 11개
|
| 82 |
-
INFO:src.agent.nodes:검색 결과 평가: 11개 (개선 횟수: 1)
|
| 83 |
-
```
|
| 84 |
-
|
| 85 |
-
## 준수한 LangGraph 공식 가이드라인
|
| 86 |
-
|
| 87 |
-
### 1. 노드는 한 가지 일만 수행 ✅
|
| 88 |
-
- 각 노드가 단일 책임 원칙 준수
|
| 89 |
-
- `create_plan`: 질문 분해만
|
| 90 |
-
- `evaluate_results`: 평가만 (라우팅 X)
|
| 91 |
-
|
| 92 |
-
### 2. 상태에 원시 데이터 저장 ✅
|
| 93 |
-
- 포맷된 텍스트 X
|
| 94 |
-
- 계산 가능한 값 X
|
| 95 |
-
- 순수 데이터만 저장
|
| 96 |
-
|
| 97 |
-
### 3. 프롬프트는 노드 내에서 생성 ✅
|
| 98 |
-
- 상태에 프롬프트 템플릿 저장 X
|
| 99 |
-
- 각 노드에서 동적 생성
|
| 100 |
-
|
| 101 |
-
### 4. Send API로 병렬 실행 ✅
|
| 102 |
-
- 3개 검색 노드 동시 실행
|
| 103 |
-
- reducer로 자동 머지
|
| 104 |
-
|
| 105 |
-
### 5. 체크포인팅 지원 ✅
|
| 106 |
-
- 모든 노드 경계에서 상태 저장
|
| 107 |
-
- 언제든 재개 가능
|
| 108 |
-
|
| 109 |
-
## 성능 개선 지표
|
| 110 |
-
|
| 111 |
-
### 검색 품질
|
| 112 |
-
- Before: 단일 검색 → 결과 0개 시 실패
|
| 113 |
-
- After: 자동 개선 → 재검색 → 성공률 ↑
|
| 114 |
-
|
| 115 |
-
### 답변 정확도
|
| 116 |
-
- Before: 모호한 검색어 → 부적절한 결과
|
| 117 |
-
- After: 질문 분해 + 쿼리 개선 → 정확도 ↑
|
| 118 |
-
|
| 119 |
-
### 안정성
|
| 120 |
-
- Before: 무한 루프 가능성
|
| 121 |
-
- After: refinement_count 제한으로 보장
|
| 122 |
-
|
| 123 |
-
## 파일 변경 요약
|
| 124 |
-
|
| 125 |
-
### 수정된 파일 (3개)
|
| 126 |
-
1. `src/agent/state.py` - 5개 필드 추가
|
| 127 |
-
2. `src/agent/nodes.py` - 4개 노드 추가/수정
|
| 128 |
-
3. `src/agent/graph.py` - 엣지 재구성, 2개 라우팅 함수 추가
|
| 129 |
-
|
| 130 |
-
### 추가된 파일 (3개)
|
| 131 |
-
1. `test_new_features.py` - 통합 테스트
|
| 132 |
-
2. `PHASE3_CHANGES.md` - 변경사항 문서
|
| 133 |
-
3. `demo_phase3.py` - 데모 스크립트
|
| 134 |
-
|
| 135 |
-
### 수정된 문서 (1개)
|
| 136 |
-
1. `README.md` - Phase 3 섹션 추가
|
| 137 |
-
|
| 138 |
-
## 코드 통계
|
| 139 |
-
- 추가된 라인: ~500줄
|
| 140 |
-
- 수정된 라인: ~50줄
|
| 141 |
-
- 테스트 커버리지: 4개 시나리오
|
| 142 |
-
|
| 143 |
-
## 다음 단계 제안
|
| 144 |
-
|
| 145 |
-
### 단기 (1-2주)
|
| 146 |
-
1. 서브 질문별 병렬 검색 구현
|
| 147 |
-
2. 적응형 임계값 (질문 복잡도 기반)
|
| 148 |
-
3. UI에 계획 수립 단계 표시
|
| 149 |
-
|
| 150 |
-
### 중기 (1-2개월)
|
| 151 |
-
1. 개선 전략 학습 시스템
|
| 152 |
-
2. 다단계 개선 (최대 2-3회)
|
| 153 |
-
3. 성능 모니터링 대시보드
|
| 154 |
-
|
| 155 |
-
### 장기 (3-6개월)
|
| 156 |
-
1. 다국어 지원 강화
|
| 157 |
-
2. 도메인별 전문화
|
| 158 |
-
3. 사용자 피드백 기반 개선
|
| 159 |
-
|
| 160 |
-
## 알려진 제한사항
|
| 161 |
-
|
| 162 |
-
1. **캐시 우선순위**: 캐시 히트 시 계획 수립 건���뜀 (의도된 동작)
|
| 163 |
-
2. **Windows 콘솔**: 이모지 인코딩 이슈 (로직은 정상)
|
| 164 |
-
3. **GitHub API**: 일부 쿼리에서 422 에러 (외부 API 제약)
|
| 165 |
-
|
| 166 |
-
## 결론
|
| 167 |
-
|
| 168 |
-
✅ Open Deep Research 패턴 성공적으로 적용
|
| 169 |
-
✅ 모든 테스트 통과
|
| 170 |
-
✅ LangGraph 공식 가이드라인 준수
|
| 171 |
-
✅ 기존 기능 완벽 호환
|
| 172 |
-
|
| 173 |
-
Phase 3 구현이 완료되었으며, 프로덕션 배포 준비가 완료되었습니다.
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/PHASE3_CHANGES.md
DELETED
|
@@ -1,142 +0,0 @@
|
|
| 1 |
-
# Phase 3: Open Deep Research 패턴 적용
|
| 2 |
-
|
| 3 |
-
## 개요
|
| 4 |
-
|
| 5 |
-
CodeWeaver에 [Open Deep Research](https://github.com/langchain-ai/open_deep_research) 패턴을 적용하여 검색 품질과 답변 정확도를 향상시켰습니다.
|
| 6 |
-
|
| 7 |
-
## 변경된 파일
|
| 8 |
-
|
| 9 |
-
### 1. `src/agent/state.py`
|
| 10 |
-
**추가된 필드:**
|
| 11 |
-
```python
|
| 12 |
-
# Planning & Refinement (Phase 3)
|
| 13 |
-
plan: Optional[Dict[str, Any]] # 질문 분해 계획
|
| 14 |
-
needs_refinement: bool # 쿼리 개선 필요 여부
|
| 15 |
-
needs_refinement: bool # 쿼리 개선 필요 여부
|
| 16 |
-
refinement_count: int # 개선 시도 횟수 (최대 1회)
|
| 17 |
-
original_question: Optional[str] # 원본 질문 보존
|
| 18 |
-
```
|
| 19 |
-
|
| 20 |
-
### 2. `src/agent/nodes.py`
|
| 21 |
-
**추가된 노드 (4개):**
|
| 22 |
-
- `create_plan_node`: 모든 질문을 서브 질문으로 분해
|
| 23 |
-
- `collect_results_node`: 병렬 검색 결과 수집 (fan-in)
|
| 24 |
-
- `evaluate_results_node`: 결과 수 평가 (< 2개면 개선 필요)
|
| 25 |
-
- `refine_search_node`: LLM 기반 쿼리 개선 (전략 선택)
|
| 26 |
-
|
| 27 |
-
### 3. `src/agent/graph.py`
|
| 28 |
-
**수정된 라우팅:**
|
| 29 |
-
- `route_after_cache`: 캐시 미스 시 → `create_plan` (기존: `classify_intent`)
|
| 30 |
-
- `route_after_evaluation`: 새로운 라우팅 함수 추가
|
| 31 |
-
- 결과 부족 & refinement_count=0 → `refine_search`
|
| 32 |
-
- 결과 충분 or refinement_count=1 → `search_subgraph`
|
| 33 |
-
|
| 34 |
-
**추가된 엣지:**
|
| 35 |
-
- `create_plan` → `classify_intent`
|
| 36 |
-
- `search_*` → `collect_results` (fan-in)
|
| 37 |
-
- `collect_results` → `evaluate_results`
|
| 38 |
-
- `evaluate_results` ⟲ `refine_search` → `classify_intent` (루프)
|
| 39 |
-
|
| 40 |
-
## 새로운 워크플로우
|
| 41 |
-
|
| 42 |
-
### Before (Phase 2)
|
| 43 |
-
```
|
| 44 |
-
check_cache → classify_intent → parallel_search → search_subgraph → generate_answer
|
| 45 |
-
```
|
| 46 |
-
|
| 47 |
-
### After (Phase 3)
|
| 48 |
-
```
|
| 49 |
-
check_cache → create_plan → classify_intent → parallel_search
|
| 50 |
-
→ collect_results → evaluate_results
|
| 51 |
-
├─ < 2 results → refine_search ⟲ classify_intent (최대 1회)
|
| 52 |
-
└─ >= 2 results → search_subgraph → generate_answer
|
| 53 |
-
```
|
| 54 |
-
|
| 55 |
-
## 핵심 설계 원칙 (LangGraph 공식 가이드라인)
|
| 56 |
-
|
| 57 |
-
### 1. 노드는 한 가지 일만 수행
|
| 58 |
-
✅ `create_plan`: 질문 분해만
|
| 59 |
-
✅ `collect_results`: 결과 수집만
|
| 60 |
-
✅ `evaluate_results`: 평가만 (라우팅 X)
|
| 61 |
-
✅ `refine_search`: 쿼리 개선만
|
| 62 |
-
|
| 63 |
-
### 2. 라우팅은 conditional_edges에서
|
| 64 |
-
```python
|
| 65 |
-
graph.add_conditional_edges(
|
| 66 |
-
"evaluate_results",
|
| 67 |
-
route_after_evaluation, # 라우팅 함수
|
| 68 |
-
{
|
| 69 |
-
"refine_search": "refine_search",
|
| 70 |
-
"search_subgraph": "search_subgraph"
|
| 71 |
-
}
|
| 72 |
-
)
|
| 73 |
-
```
|
| 74 |
-
|
| 75 |
-
### 3. 상태에는 원시 데이터만 저장
|
| 76 |
-
```python
|
| 77 |
-
# ✅ Good: 원시 데이터
|
| 78 |
-
search_results: list[SearchResult]
|
| 79 |
-
needs_refinement: bool
|
| 80 |
-
|
| 81 |
-
# ❌ Bad: 계산된 값이나 포맷된 텍스트
|
| 82 |
-
formatted_prompt: str
|
| 83 |
-
```
|
| 84 |
-
|
| 85 |
-
### 4. 프롬프트는 노드 내에서 동적 생성
|
| 86 |
-
```python
|
| 87 |
-
def refine_search_node(state: AgentState) -> dict:
|
| 88 |
-
# ✅ 노드 내에서 동적으로 프롬프트 구성
|
| 89 |
-
refinement_prompt = f"""
|
| 90 |
-
Original question: {state.user_question}
|
| 91 |
-
Current results: {len(state.search_results)}
|
| 92 |
-
...
|
| 93 |
-
"""
|
| 94 |
-
```
|
| 95 |
-
|
| 96 |
-
## 테스트 결과
|
| 97 |
-
|
| 98 |
-
### 통과한 시나리오
|
| 99 |
-
1. ✅ 단순 질문: 1개 서브 질문 생성 → 정상 진행
|
| 100 |
-
2. ✅ 복잡 질문: 3-5개 서브 질문 생성 → 정상 진행
|
| 101 |
-
3. ✅ 결과 부족: < 2개 결과 → 쿼리 개선 → 재검색
|
| 102 |
-
4. ✅ 개선 제한: refinement_count 최대 1회 보장
|
| 103 |
-
|
| 104 |
-
### 실행 로그 예시
|
| 105 |
-
```
|
| 106 |
-
INFO:src.agent.nodes:질문 분해 계획 수립 중: What is GraphQL endpoint design pattern?
|
| 107 |
-
INFO:src.agent.nodes:계획 수립 완료: 4개 서브 질문
|
| 108 |
-
INFO:src.agent.nodes:검색 결과 수집 완료: 0개
|
| 109 |
-
INFO:src.agent.nodes:검색 결과 평가: 0개 (개선 횟수: 0)
|
| 110 |
-
INFO:src.agent.nodes:쿼리 개선 중: What is GraphQL endpoint design pattern? (0개 결과)
|
| 111 |
-
INFO:src.agent.nodes:쿼리 개선 완료: GraphQL API design best practices
|
| 112 |
-
INFO:src.agent.nodes:검색 결과 수집 완료: 11개
|
| 113 |
-
INFO:src.agent.nodes:검색 결과 평가: 11개 (개선 횟수: 1)
|
| 114 |
-
```
|
| 115 |
-
|
| 116 |
-
## 성능 개선
|
| 117 |
-
|
| 118 |
-
### 검색 품질
|
| 119 |
-
- **Before**: 단일 검색 → 결과 부족 시 실패
|
| 120 |
-
- **After**: 결과 부족 시 자동 개선 → 재검색
|
| 121 |
-
|
| 122 |
-
### 답변 정확도
|
| 123 |
-
- **Before**: 모호한 질문 → 부정확한 검색
|
| 124 |
-
- **After**: 서브 질문 분해 → 더 구체적인 검색
|
| 125 |
-
|
| 126 |
-
### 안정성
|
| 127 |
-
- **Before**: 무한 루프 가능성
|
| 128 |
-
- **After**: refinement_count 제한으로 보장
|
| 129 |
-
|
| 130 |
-
## 향후 개선 방향
|
| 131 |
-
|
| 132 |
-
1. **서브 질문 병렬 검색**: 현재는 전체 질문으로 검색, 각 서브 질문별 검색으로 확장
|
| 133 |
-
2. **적응형 임계값**: 현재 고정값 2개 → 질문 복잡도에 따라 동적 조정
|
| 134 |
-
3. **개선 전략 학습**: LLM 선택 → 과거 성공 전략 기반 추천
|
| 135 |
-
4. **다단계 개선**: 최대 1회 → 2-3회로 확장 (순환 감지 추가)
|
| 136 |
-
|
| 137 |
-
## 참고 자료
|
| 138 |
-
|
| 139 |
-
- [LangGraph Official Guide: Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph)
|
| 140 |
-
- [Open Deep Research GitHub](https://github.com/langchain-ai/open_deep_research)
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/PHASE5_SUBGRAPH_REFACTORING.md
DELETED
|
@@ -1,320 +0,0 @@
|
|
| 1 |
-
# Phase 5: 서브그래프 리팩토링 완료 보고서
|
| 2 |
-
|
| 3 |
-
## 개요
|
| 4 |
-
|
| 5 |
-
복잡하게 얽힌 다중 질문 처리 로직을 단순화하기 위해, **analyze_question부터 generate_answer까지를 독립된 서브그래프로 추출**하고, 부모 그래프는 계획/분기/병합만 담당하도록 구조를 개선했습니다.
|
| 6 |
-
|
| 7 |
-
---
|
| 8 |
-
|
| 9 |
-
## 목표 달성 여부
|
| 10 |
-
|
| 11 |
-
✅ **모든 목표 달성 완료**
|
| 12 |
-
|
| 13 |
-
1. ✅ 단일 질문 파이프라인을 재사용 가능한 서브그래프로 추출
|
| 14 |
-
2. ✅ 부모 그래프 단순화 (orchestration만 담당)
|
| 15 |
-
3. ✅ 복잡한 worker 노드 및 중복 그래프 빌더 제거
|
| 16 |
-
4. ✅ 구조 명확화: 부모(orchestration) vs 자식(processing)
|
| 17 |
-
|
| 18 |
-
---
|
| 19 |
-
|
| 20 |
-
## 변경 사항
|
| 21 |
-
|
| 22 |
-
### 1. 새로운 서브그래프: `build_single_question_subgraph()`
|
| 23 |
-
|
| 24 |
-
**파일**: [`src/agent/graph.py`](src/agent/graph.py)
|
| 25 |
-
|
| 26 |
-
```python
|
| 27 |
-
def build_single_question_subgraph() -> StateGraph:
|
| 28 |
-
"""
|
| 29 |
-
단일 질문 처리 파이프라인 서브그래프를 구성합니다.
|
| 30 |
-
|
| 31 |
-
진입점: analyze_question (START → analyze_question)
|
| 32 |
-
출구: generate_answer 또는 generate_with_history 또는 return_cached_answer (→ END)
|
| 33 |
-
|
| 34 |
-
흐름:
|
| 35 |
-
1. analyze_question → 질문 분석
|
| 36 |
-
- clarification: generate_with_history → END
|
| 37 |
-
- new_topic/independent: check_cache
|
| 38 |
-
2. check_cache → 캐시 확인
|
| 39 |
-
- 히트: return_cached_answer → END
|
| 40 |
-
- 미스: classify_intent
|
| 41 |
-
3. classify_intent → 병렬 검색 (Send API)
|
| 42 |
-
4. 검색 결과 수집 → 평가 → 필터링 → 요약 → 답변 생성
|
| 43 |
-
"""
|
| 44 |
-
```
|
| 45 |
-
|
| 46 |
-
**포함 노드**:
|
| 47 |
-
- analyze_question, generate_with_history
|
| 48 |
-
- check_cache, return_cached_answer
|
| 49 |
-
- classify_intent
|
| 50 |
-
- search_stackoverflow, search_github, search_official_docs (병렬)
|
| 51 |
-
- collect_results, evaluate_results, refine_search
|
| 52 |
-
- search_subgraph (중첩 서브그래프: filter + summarize)
|
| 53 |
-
- generate_answer
|
| 54 |
-
|
| 55 |
-
---
|
| 56 |
-
|
| 57 |
-
### 2. 단순화된 메인 그래프: `build_agent_graph()`
|
| 58 |
-
|
| 59 |
-
**변경 전 (Phase 4)**: 60+ 개의 노드와 엣지로 복잡하게 얽힘
|
| 60 |
-
|
| 61 |
-
**변경 후 (Phase 5)**: 4개의 노드만으로 단순화
|
| 62 |
-
|
| 63 |
-
```python
|
| 64 |
-
def build_agent_graph() -> StateGraph:
|
| 65 |
-
"""
|
| 66 |
-
CodeWeaver 에이전트의 메인 그래프를 구성합니다.
|
| 67 |
-
|
| 68 |
-
전체 흐름 (단순화됨):
|
| 69 |
-
1. START → create_plan (질문 유형 및 개수 판단)
|
| 70 |
-
2. 질문 유형에 따른 분기:
|
| 71 |
-
- single_topic: single_question_subgraph (1회) → END
|
| 72 |
-
- multiple_questions: Send API로 single_question_subgraph (2회 병렬) → combine_answers → END
|
| 73 |
-
- too_many: handle_too_many_questions → END
|
| 74 |
-
"""
|
| 75 |
-
|
| 76 |
-
graph = StateGraph(AgentState)
|
| 77 |
-
|
| 78 |
-
# 노드 추가 (4개만!)
|
| 79 |
-
graph.add_node("create_plan", create_plan_node)
|
| 80 |
-
graph.add_node("handle_too_many_questions", handle_too_many_questions_node)
|
| 81 |
-
graph.add_node("combine_answers", combine_answers_node)
|
| 82 |
-
graph.add_node("collect_subgraph_result", collect_subgraph_result_node)
|
| 83 |
-
|
| 84 |
-
# 서브그래프를 노드로 등록
|
| 85 |
-
single_question_subgraph = build_single_question_subgraph()
|
| 86 |
-
graph.add_node("single_question_subgraph", single_question_subgraph)
|
| 87 |
-
|
| 88 |
-
# 간단한 엣지 구성
|
| 89 |
-
graph.add_edge(START, "create_plan")
|
| 90 |
-
graph.add_conditional_edges("create_plan", route_after_plan)
|
| 91 |
-
graph.add_edge("handle_too_many_questions", END)
|
| 92 |
-
graph.add_conditional_edges("single_question_subgraph", ...)
|
| 93 |
-
graph.add_edge("collect_subgraph_result", "combine_answers")
|
| 94 |
-
graph.add_edge("combine_answers", END)
|
| 95 |
-
|
| 96 |
-
return graph
|
| 97 |
-
```
|
| 98 |
-
|
| 99 |
-
---
|
| 100 |
-
|
| 101 |
-
### 3. 개선된 라우팅: `route_after_plan()`
|
| 102 |
-
|
| 103 |
-
**변경 전**: `initiate_dynamic_search` 노드 → `fanout_multi_questions` 함수 → `run_single_question_worker_node` → 내부에서 별도 그래프 실행
|
| 104 |
-
|
| 105 |
-
**변경 후**: Send API로 서브그래프를 직접 호출
|
| 106 |
-
|
| 107 |
-
```python
|
| 108 |
-
def route_after_plan(state: AgentState):
|
| 109 |
-
"""
|
| 110 |
-
create_plan 결과에 따라 다음 노드를 결정합니다.
|
| 111 |
-
|
| 112 |
-
Returns:
|
| 113 |
-
- "handle_too_many_questions": 질문 3개 이상
|
| 114 |
-
- "single_question_subgraph": 단일 주제
|
| 115 |
-
- List[Send]: 다중 질문 (2개) → 서브그래프 병렬 실행
|
| 116 |
-
"""
|
| 117 |
-
plan = state.plan or {}
|
| 118 |
-
case = plan.get("case", "single_topic")
|
| 119 |
-
|
| 120 |
-
if case == "too_many":
|
| 121 |
-
return "handle_too_many_questions"
|
| 122 |
-
elif case == "multiple_questions":
|
| 123 |
-
sub_questions = plan.get("sub_questions", [])
|
| 124 |
-
sends = []
|
| 125 |
-
for i, sq in enumerate(sub_questions):
|
| 126 |
-
child_state = state.model_copy(deep=True)
|
| 127 |
-
child_state.user_question = sq
|
| 128 |
-
child_state.is_multi_question = True
|
| 129 |
-
child_state.sub_question_index = i
|
| 130 |
-
# ... 최소 필드 설정 ...
|
| 131 |
-
sends.append(Send("single_question_subgraph", child_state))
|
| 132 |
-
return sends
|
| 133 |
-
else:
|
| 134 |
-
return "single_question_subgraph"
|
| 135 |
-
```
|
| 136 |
-
|
| 137 |
-
---
|
| 138 |
-
|
| 139 |
-
### 4. 제거된 코드 (300+ 줄)
|
| 140 |
-
|
| 141 |
-
**파일**: [`src/agent/nodes.py`](src/agent/nodes.py)
|
| 142 |
-
|
| 143 |
-
#### 제거된 함수:
|
| 144 |
-
- ❌ `_build_search_subgraph_local()` - graph.py의 것 사용
|
| 145 |
-
- ❌ `_get_single_question_agent()` - 공식 서브그래프로 대체 (100+ 줄)
|
| 146 |
-
- ❌ `run_single_question_worker_node()` - 더 이상 필요 없음
|
| 147 |
-
- ❌ `initiate_dynamic_search_node()` - 단순 분기로 대체
|
| 148 |
-
- ❌ `fanout_multi_questions()` - route_after_plan에 통합
|
| 149 |
-
|
| 150 |
-
#### 추가된 함수:
|
| 151 |
-
- ✅ `collect_subgraph_result_node()` - 서브그래프 결과를 multi_answers에 추가
|
| 152 |
-
|
| 153 |
-
---
|
| 154 |
-
|
| 155 |
-
## 새로운 아키텍처
|
| 156 |
-
|
| 157 |
-
```mermaid
|
| 158 |
-
graph TD
|
| 159 |
-
START[START] --> plan[create_plan]
|
| 160 |
-
|
| 161 |
-
plan -->|too_many| tooMany[handle_too_many_questions]
|
| 162 |
-
plan -->|single_topic| subgraph1[single_question_subgraph]
|
| 163 |
-
plan -->|multiple_2| fanout[Send API]
|
| 164 |
-
|
| 165 |
-
tooMany --> END
|
| 166 |
-
|
| 167 |
-
fanout -.Send Q1.-> subgraph2[single_question_subgraph]
|
| 168 |
-
fanout -.Send Q2.-> subgraph3[single_question_subgraph]
|
| 169 |
-
|
| 170 |
-
subgraph2 --> collect2[collect_subgraph_result]
|
| 171 |
-
subgraph3 --> collect3[collect_subgraph_result]
|
| 172 |
-
|
| 173 |
-
collect2 --> combine[combine_answers]
|
| 174 |
-
collect3 --> combine
|
| 175 |
-
|
| 176 |
-
combine --> END
|
| 177 |
-
subgraph1 --> END
|
| 178 |
-
|
| 179 |
-
subgraph SingleQuestionSubgraph
|
| 180 |
-
analyze[analyze_question] --> cache[check_cache]
|
| 181 |
-
cache --> classify[classify_intent]
|
| 182 |
-
classify --> search[Parallel Search]
|
| 183 |
-
search --> collect[collect_results]
|
| 184 |
-
collect --> eval[evaluate_results]
|
| 185 |
-
eval --> filter[search_subgraph]
|
| 186 |
-
filter --> generate[generate_answer]
|
| 187 |
-
end
|
| 188 |
-
```
|
| 189 |
-
|
| 190 |
-
---
|
| 191 |
-
|
| 192 |
-
## 개선 효과
|
| 193 |
-
|
| 194 |
-
### 1. 코드 품질
|
| 195 |
-
- ✅ **300+ 줄 제거**: 중복 그래프 빌드 로직 완전 삭제
|
| 196 |
-
- ✅ **재사용성 향상**: 단일 질문 파이프라인을 독립된 서브그래프로 캡슐화
|
| 197 |
-
- ✅ **유지보수성 향상**: 역할 분리 명확 (orchestration vs processing)
|
| 198 |
-
|
| 199 |
-
### 2. 구조 명확화
|
| 200 |
-
- **부모 그래프 (orchestration)**:
|
| 201 |
-
- 질문 유형 판단
|
| 202 |
-
- 분기 결정
|
| 203 |
-
- 결과 병합
|
| 204 |
-
|
| 205 |
-
- **자식 서브그래프 (processing)**:
|
| 206 |
-
- 질문 분석
|
| 207 |
-
- 캐시 확인
|
| 208 |
-
- 검색 실행
|
| 209 |
-
- 답변 생성
|
| 210 |
-
|
| 211 |
-
### 3. 확장성
|
| 212 |
-
- ✅ 질문 3개 이상도 쉽게 대응 가능 (Send 리스트만 확장)
|
| 213 |
-
- ✅ 서브그래프 단위로 독립 테스트 가능
|
| 214 |
-
- ✅ 디버깅 용이: 특정 질문 문제 시 해당 서브그래프만 확인
|
| 215 |
-
|
| 216 |
-
---
|
| 217 |
-
|
| 218 |
-
## 검증 결과
|
| 219 |
-
|
| 220 |
-
### 구조 검증
|
| 221 |
-
```
|
| 222 |
-
============================================================
|
| 223 |
-
Phase 5: 서브그래프 리팩토링 구조 검증
|
| 224 |
-
============================================================
|
| 225 |
-
✅ graph.py 구문 검증 성공
|
| 226 |
-
|
| 227 |
-
[필수 함수 검증]
|
| 228 |
-
✅ build_search_subgraph
|
| 229 |
-
✅ build_single_question_subgraph
|
| 230 |
-
✅ route_after_plan
|
| 231 |
-
✅ build_agent_graph
|
| 232 |
-
✅ create_agent
|
| 233 |
-
|
| 234 |
-
[제거된 함수 검증]
|
| 235 |
-
✅ route_after_generate - 정상 제거됨
|
| 236 |
-
|
| 237 |
-
[Import 검증]
|
| 238 |
-
✅ initiate_dynamic_search_node - import 제거됨
|
| 239 |
-
✅ fanout_multi_questions - import 제거됨
|
| 240 |
-
✅ run_single_question_worker_node - import 제거됨
|
| 241 |
-
✅ collect_subgraph_result_node - import 추가됨
|
| 242 |
-
|
| 243 |
-
[메인 그래프 노드 검증]
|
| 244 |
-
✅ create_plan
|
| 245 |
-
✅ handle_too_many_questions
|
| 246 |
-
✅ combine_answers
|
| 247 |
-
✅ collect_subgraph_result
|
| 248 |
-
✅ single_question_subgraph
|
| 249 |
-
|
| 250 |
-
============================================================
|
| 251 |
-
nodes.py 구조 검증
|
| 252 |
-
============================================================
|
| 253 |
-
✅ nodes.py 구문 검증 성공
|
| 254 |
-
|
| 255 |
-
[제거된 함수 검증]
|
| 256 |
-
✅ _build_search_subgraph_local - 정상 제거됨
|
| 257 |
-
✅ _get_single_question_agent - 정상 제거됨
|
| 258 |
-
✅ run_single_question_worker_node - 정상 제거됨
|
| 259 |
-
✅ initiate_dynamic_search_node - 정상 제거됨
|
| 260 |
-
✅ fanout_multi_questions - 정상 제거됨
|
| 261 |
-
|
| 262 |
-
[추가된 함수 검증]
|
| 263 |
-
✅ collect_subgraph_result_node
|
| 264 |
-
|
| 265 |
-
============================================================
|
| 266 |
-
검증 결과 요약
|
| 267 |
-
============================================================
|
| 268 |
-
✅ 성공: graph.py 구조
|
| 269 |
-
✅ 성공: nodes.py 구조
|
| 270 |
-
|
| 271 |
-
🎉 모든 검증 통과! 리팩토링이 성공적으로 완료되었습니다.
|
| 272 |
-
```
|
| 273 |
-
|
| 274 |
-
---
|
| 275 |
-
|
| 276 |
-
## 변경된 파일 목록
|
| 277 |
-
|
| 278 |
-
1. **src/agent/graph.py**
|
| 279 |
-
- ✅ `build_single_question_subgraph()` 추가 (100+ 줄)
|
| 280 |
-
- ✅ `route_after_plan()` 개선
|
| 281 |
-
- ✅ `build_agent_graph()` 단순화 (200+ 줄 → 50 줄)
|
| 282 |
-
- ✅ `route_after_generate()` 제거
|
| 283 |
-
- ✅ Import 정리
|
| 284 |
-
|
| 285 |
-
2. **src/agent/nodes.py**
|
| 286 |
-
- ✅ `collect_subgraph_result_node()` 추가
|
| 287 |
-
- ❌ `_build_search_subgraph_local()` 제거
|
| 288 |
-
- ❌ `_get_single_question_agent()` 제거 (100+ 줄)
|
| 289 |
-
- ❌ `run_single_question_worker_node()` 제거
|
| 290 |
-
- ❌ `initiate_dynamic_search_node()` 제거
|
| 291 |
-
- ❌ `fanout_multi_questions()` 제거
|
| 292 |
-
|
| 293 |
-
3. **hf-space/CodeWeaver/src/agent/**
|
| 294 |
-
- ✅ graph.py 동기화 완료
|
| 295 |
-
- ✅ nodes.py 동기화 완료
|
| 296 |
-
|
| 297 |
-
---
|
| 298 |
-
|
| 299 |
-
## 다음 단계
|
| 300 |
-
|
| 301 |
-
이 리팩토링으로 **Phase 5**가 완료되었으며, 다음 개선 사항을 고려할 수 있습니다:
|
| 302 |
-
|
| 303 |
-
1. **질문 3개 이상 지원**: `route_after_plan()`에서 Send 리스트만 확장
|
| 304 |
-
2. **서브그래프 단위 테스트**: 독립된 파이프라인 검증
|
| 305 |
-
3. **캐싱 전략 ���선**: 서브그래프 결과 캐싱
|
| 306 |
-
4. **성능 최적화**: 병렬 실행 효율성 분석
|
| 307 |
-
|
| 308 |
-
---
|
| 309 |
-
|
| 310 |
-
## 결론
|
| 311 |
-
|
| 312 |
-
✅ **모든 목표 달성**
|
| 313 |
-
- 단일 질문 파이프라인을 재사용 가능한 서브그래프로 추출
|
| 314 |
-
- 부모 그래프는 orchestration만 담당 (4개 노드)
|
| 315 |
-
- 300+ 줄의 중복 코드 제거
|
| 316 |
-
- 구조 명확화 및 확장성 향상
|
| 317 |
-
|
| 318 |
-
이 리팩토링으로 CodeWeaver의 아키텍처가 **단순하고**, **명확하며**, **확장 가능한** 구조로 개선되었습니다.
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/README.md
DELETED
|
@@ -1,118 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: CodeWeaver
|
| 3 |
-
emoji: 🤖
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: "4.44.1"
|
| 8 |
-
app_file: ui/app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
license: mit
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
# CodeWeaver
|
| 14 |
-
|
| 15 |
-
LangGraph 기반의 **개발자 Q&A 에이전트**입니다. 질문을 분석하고(후속/독립), **캐시(Qdrant)**를 우선 확인한 뒤 캐시 미스일 때 **3개 소스(Stack Overflow / GitHub / 공식 문서(Tavily))를 병렬 검색**해 답변을 생성합니다. 서로 독립적인 질문이 2개 들어오면 **동적으로 2개 파이프라인을 병렬 실행**해 통합 답변을 제공합니다.
|
| 16 |
-
|
| 17 |
-
## 핵심 기능(현재 코드 기준)
|
| 18 |
-
|
| 19 |
-
- **질문 개수 감지**: 1개(단일 주제) / 2개(독립 질문 2개) / 3개 이상(거절 안내)
|
| 20 |
-
- **질문 타입 분석**: `clarification`이면 검색/캐시 없이 **대화 히스토리 기반 답변**
|
| 21 |
-
- **의미적 캐싱**: Qdrant에 질문-답변을 저장하고 유사 질문을 빠르게 재사용(임계값 0.85)
|
| 22 |
-
- **병렬 검색**: Stack Overflow / GitHub / Tavily(공식 문서 도메인 제한) 동시 검색
|
| 23 |
-
- **검색 품질 보정**: 결과가 부족하면 **쿼리 개선을 최대 1회** 수행
|
| 24 |
-
- **서브그래프 처리**: 검색 결과를 필터링/점수화 후 요약 → 최종 답변 생성
|
| 25 |
-
|
| 26 |
-
## 문서
|
| 27 |
-
|
| 28 |
-
- 아키텍처/동작 원리: `../ARCHITECTURE.md`
|
| 29 |
-
- 다중 질문 병렬 처리 설계(배경 설명): `../DYNAMIC_PARALLEL_SEARCH.md`
|
| 30 |
-
|
| 31 |
-
## 빠른 시작
|
| 32 |
-
|
| 33 |
-
### 1) 설치
|
| 34 |
-
|
| 35 |
-
아래는 저장소 루트가 아니라 **`CodeWeaver/` 디렉터리 기준** 예시입니다.
|
| 36 |
-
|
| 37 |
-
```bash
|
| 38 |
-
cd CodeWeaver
|
| 39 |
-
|
| 40 |
-
# uv 사용(권장)
|
| 41 |
-
uv sync
|
| 42 |
-
|
| 43 |
-
# 또는 pip 사용
|
| 44 |
-
pip install -r requirements.txt
|
| 45 |
-
```
|
| 46 |
-
|
| 47 |
-
> `sentence-transformers`가 최초 실행 시 `BAAI/bge-m3` 모델을 다운로드할 수 있습니다(네트워크 필요).
|
| 48 |
-
|
| 49 |
-
### 2) 환경 변수 설정(.env)
|
| 50 |
-
|
| 51 |
-
`CodeWeaver/.env` 파일을 만들고 아래를 설정하세요(필수/선택 구분).
|
| 52 |
-
|
| 53 |
-
```bash
|
| 54 |
-
# 필수: Gemini (LLM)
|
| 55 |
-
GOOGLE_API_KEY=your_google_api_key
|
| 56 |
-
|
| 57 |
-
# 필수: Tavily (공식 문서 검색)
|
| 58 |
-
TAVILY_API_KEY=your_tavily_api_key
|
| 59 |
-
|
| 60 |
-
# 필수: Qdrant Cloud (캐시)
|
| 61 |
-
QDRANT_URL=https://xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.us-east-1-0.aws.cloud.qdrant.io
|
| 62 |
-
QDRANT_API_KEY=your_qdrant_api_key
|
| 63 |
-
|
| 64 |
-
# 선택: GitHub API rate limit 완화
|
| 65 |
-
GITHUB_TOKEN=your_github_token
|
| 66 |
-
|
| 67 |
-
# 선택: LangSmith 트레이싱
|
| 68 |
-
LANGCHAIN_TRACING_V2=true
|
| 69 |
-
LANGCHAIN_API_KEY=your_langsmith_api_key
|
| 70 |
-
```
|
| 71 |
-
|
| 72 |
-
### 3) 실행(Gradio UI)
|
| 73 |
-
|
| 74 |
-
```bash
|
| 75 |
-
cd CodeWeaver
|
| 76 |
-
python ui/app.py
|
| 77 |
-
```
|
| 78 |
-
|
| 79 |
-
기본 주소: `http://localhost:7860`
|
| 80 |
-
|
| 81 |
-
## 현재 폴더 구조
|
| 82 |
-
|
| 83 |
-
```
|
| 84 |
-
CodeWeaver/
|
| 85 |
-
├── main.py
|
| 86 |
-
├── pyproject.toml
|
| 87 |
-
├── requirements.txt
|
| 88 |
-
├── src/
|
| 89 |
-
│ ├── agent/
|
| 90 |
-
│ │ ├── graph.py # LangGraph 메인 그래프(라우팅/병렬화)
|
| 91 |
-
│ │ ├── nodes.py # 각 노드 구현
|
| 92 |
-
│ │ └── state.py # AgentState + reducer 정의
|
| 93 |
-
│ ├── tools/
|
| 94 |
-
│ │ └── search_tools.py # StackOverflow/GitHub/Tavily 검색
|
| 95 |
-
│ ├── utils/
|
| 96 |
-
│ │ └── tracing.py # trace_node 데코레이터(LangSmith 연동)
|
| 97 |
-
│ └── vector_db/
|
| 98 |
-
│ ├── qdrant_client.py # Qdrant 캐시 관리
|
| 99 |
-
│ └── local_embeddings.py # bge-m3 로컬 임베딩
|
| 100 |
-
└── ui/
|
| 101 |
-
└── app.py # Gradio UI (실제 엔트리)
|
| 102 |
-
```
|
| 103 |
-
|
| 104 |
-
## 동작 흐름(요약)
|
| 105 |
-
|
| 106 |
-
- `START → create_plan`
|
| 107 |
-
- **3개 이상**이면 안내 메시지 반환
|
| 108 |
-
- **2개**면 각 질문을 worker에서 단일 파이프라인으로 실행 후 결합
|
| 109 |
-
- **1개**면 아래 단일 파이프라인 수행
|
| 110 |
-
- 단일 파이프라인:
|
| 111 |
-
- `analyze_question`
|
| 112 |
-
- `clarification`이면 `generate_with_history`로 즉시 답변
|
| 113 |
-
- 그 외: `check_cache` → hit면 반환, miss면 `classify_intent`
|
| 114 |
-
- `classify_intent` → 3소스 병렬 검색 → `collect_results` → `evaluate_results`
|
| 115 |
-
- 필요 시 `refine_search` 1회 → 재검색
|
| 116 |
-
- `filter_and_score → summarize_results → generate_answer`(+조건부 캐시 저장)
|
| 117 |
-
|
| 118 |
-
자세한 원리는 `../ARCHITECTURE.md`를 참고하세요.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/main.py
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
def main():
|
| 2 |
-
print("Hello from codeweaver!")
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
if __name__ == "__main__":
|
| 6 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/pyproject.toml
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 1 |
-
[project]
|
| 2 |
-
name = "codeweaver"
|
| 3 |
-
version = "0.1.0"
|
| 4 |
-
description = "Add your description here"
|
| 5 |
-
readme = "README.md"
|
| 6 |
-
requires-python = ">=3.12"
|
| 7 |
-
dependencies = [
|
| 8 |
-
"qdrant-client",
|
| 9 |
-
"pytest",
|
| 10 |
-
"pytest-asyncio",
|
| 11 |
-
"python-dotenv",
|
| 12 |
-
"tavily-python",
|
| 13 |
-
"requests",
|
| 14 |
-
"langsmith>=0.1.0",
|
| 15 |
-
"langchain-core>=0.3.0",
|
| 16 |
-
"langchain-google-genai>=2.0.0",
|
| 17 |
-
"langgraph>=0.2.0",
|
| 18 |
-
"sentence-transformers>=3.0.0",
|
| 19 |
-
"torch>=2.0.0",
|
| 20 |
-
"gradio==4.44.1",
|
| 21 |
-
]
|
| 22 |
-
|
| 23 |
-
[tool.pytest.ini_options]
|
| 24 |
-
pythonpath = ["."]
|
| 25 |
-
markers = [
|
| 26 |
-
"slow: 실제 API 호출이 필요한 느린 테스트 (--slow 옵션으로 실행)",
|
| 27 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/requirements.txt
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
# LangGraph & LangChain
|
| 2 |
-
langgraph>=0.2.0
|
| 3 |
-
langchain-google-genai>=2.0.0
|
| 4 |
-
langchain-core>=0.3.0
|
| 5 |
-
langsmith>=0.2.0
|
| 6 |
-
|
| 7 |
-
# Vector DB
|
| 8 |
-
qdrant-client>=1.11.0
|
| 9 |
-
|
| 10 |
-
# Search APIs
|
| 11 |
-
tavily-python>=0.5.0
|
| 12 |
-
requests>=2.31.0
|
| 13 |
-
|
| 14 |
-
# Embeddings
|
| 15 |
-
sentence-transformers>=3.0.0
|
| 16 |
-
torch>=2.0.0
|
| 17 |
-
|
| 18 |
-
# UI
|
| 19 |
-
gradio==4.44.1
|
| 20 |
-
|
| 21 |
-
# Utils
|
| 22 |
-
python-dotenv>=1.0.0
|
| 23 |
-
pydantic>=2.0.0
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/src/__init__.py
DELETED
|
File without changes
|
hf-space2/CodeWeaver/src/agent/graph.py
DELETED
|
@@ -1,420 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
CodeWeaver LangGraph 워크플로우 구성.
|
| 3 |
-
|
| 4 |
-
LangGraph 6가지 핵심 기능 완벽 구현:
|
| 5 |
-
✅ Conditional Edges: 질문 유형, 캐시 여부에 따른 분기
|
| 6 |
-
✅ Send API: 3개 검색 노드 병렬 실행 (fan-out/fan-in)
|
| 7 |
-
✅ Subgraph: 단일 질문 처리 파이프라인 + 검색 결과 처리 파이프라인
|
| 8 |
-
✅ Map-Reduce: Send API로 병렬 검색 → 결과 머지
|
| 9 |
-
✅ Checkpointing: MemorySaver로 대화 상태 저장
|
| 10 |
-
✅ Pydantic Typed State: 타입 안전성 보장
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import logging
|
| 14 |
-
from typing import Literal
|
| 15 |
-
|
| 16 |
-
from langgraph.checkpoint.memory import MemorySaver
|
| 17 |
-
from langgraph.graph import StateGraph, START, END
|
| 18 |
-
from langgraph.types import Send
|
| 19 |
-
|
| 20 |
-
from src.agent.state import AgentState, WorkerState, _MULTI_ANS_RESET_TOKEN
|
| 21 |
-
from src.agent.nodes import (
|
| 22 |
-
analyze_question_node,
|
| 23 |
-
check_cache_node,
|
| 24 |
-
create_plan_node,
|
| 25 |
-
classify_intent_node,
|
| 26 |
-
search_stackoverflow_node,
|
| 27 |
-
search_github_node,
|
| 28 |
-
search_official_docs_node,
|
| 29 |
-
collect_results_node,
|
| 30 |
-
evaluate_results_node,
|
| 31 |
-
refine_search_node,
|
| 32 |
-
filter_and_score_node,
|
| 33 |
-
summarize_results_node,
|
| 34 |
-
generate_answer_node,
|
| 35 |
-
return_cached_answer_node,
|
| 36 |
-
generate_with_history_node,
|
| 37 |
-
handle_too_many_questions_node,
|
| 38 |
-
combine_answers_node,
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
logger = logging.getLogger(__name__)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def build_search_subgraph() -> StateGraph:
|
| 45 |
-
"""
|
| 46 |
-
검색 결과 처리 서브그래프를 구성합니다.
|
| 47 |
-
|
| 48 |
-
흐름: filter_and_score → summarize_results
|
| 49 |
-
|
| 50 |
-
이 서브그래프는 single_question_subgraph 내부에서 사용되므로
|
| 51 |
-
WorkerState를 사용하여 채널 타입 충돌을 방지합니다.
|
| 52 |
-
|
| 53 |
-
Returns:
|
| 54 |
-
컴파일된 서브그래프
|
| 55 |
-
"""
|
| 56 |
-
# 서브그래프 생성 (WorkerState 사용)
|
| 57 |
-
subgraph = StateGraph(WorkerState)
|
| 58 |
-
|
| 59 |
-
# 노드 추가
|
| 60 |
-
subgraph.add_node("filter_and_score", filter_and_score_node)
|
| 61 |
-
subgraph.add_node("summarize_results", summarize_results_node)
|
| 62 |
-
|
| 63 |
-
# 서브그래프 내부 흐름 정의
|
| 64 |
-
# START → filter_and_score → summarize_results → END
|
| 65 |
-
subgraph.add_edge(START, "filter_and_score")
|
| 66 |
-
subgraph.add_edge("filter_and_score", "summarize_results")
|
| 67 |
-
subgraph.add_edge("summarize_results", END)
|
| 68 |
-
|
| 69 |
-
return subgraph.compile()
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def route_after_analysis_worker(state: WorkerState) -> Literal["generate_with_history", "check_cache"]:
|
| 73 |
-
"""
|
| 74 |
-
질문 분석 결과에 따라 다음 노드를 결정합니다 (WorkerState용).
|
| 75 |
-
|
| 76 |
-
Args:
|
| 77 |
-
state: 현재 워커 상태
|
| 78 |
-
|
| 79 |
-
Returns:
|
| 80 |
-
- "generate_with_history": 후속 질문 → 대화 히스토리 기반 답변
|
| 81 |
-
- "check_cache": 독립 질문 → 캐시 확인
|
| 82 |
-
"""
|
| 83 |
-
raw_qtype = state.question_type or "independent"
|
| 84 |
-
legacy_map = {
|
| 85 |
-
"followup": "clarification",
|
| 86 |
-
"cache_candidate": "independent",
|
| 87 |
-
"new_search": "independent",
|
| 88 |
-
}
|
| 89 |
-
question_type = legacy_map.get(raw_qtype, raw_qtype)
|
| 90 |
-
|
| 91 |
-
if question_type == "clarification":
|
| 92 |
-
return "generate_with_history"
|
| 93 |
-
|
| 94 |
-
return "check_cache"
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def route_after_cache_worker(state: WorkerState) -> Literal["return_cached_answer", "classify_intent"]:
|
| 98 |
-
"""
|
| 99 |
-
캐시 히트 여부에 따라 다음 노드를 결정합니다 (WorkerState용).
|
| 100 |
-
|
| 101 |
-
Args:
|
| 102 |
-
state: 현재 워커 상태
|
| 103 |
-
|
| 104 |
-
Returns:
|
| 105 |
-
- "return_cached_answer": 캐시 히트 시 즉시 답변 반환
|
| 106 |
-
- "classify_intent": 캐시 미스 시 의도 분류
|
| 107 |
-
"""
|
| 108 |
-
if state.cached_result:
|
| 109 |
-
return "return_cached_answer"
|
| 110 |
-
else:
|
| 111 |
-
return "classify_intent"
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def route_after_evaluation_worker(state: WorkerState) -> Literal["refine_search", "search_subgraph"]:
|
| 115 |
-
"""
|
| 116 |
-
검색 결과 평가 후 다음 노드를 결정합니다 (WorkerState용).
|
| 117 |
-
|
| 118 |
-
Args:
|
| 119 |
-
state: 현재 워커 상태
|
| 120 |
-
|
| 121 |
-
Returns:
|
| 122 |
-
- "refine_search": 결과 부족 & 개선 횟수 0회 → 쿼리 개선
|
| 123 |
-
- "search_subgraph": 결과 충분 or 개선 횟수 1회 → 필터링 진행
|
| 124 |
-
"""
|
| 125 |
-
needs_refinement = state.needs_refinement
|
| 126 |
-
refinement_count = state.refinement_count
|
| 127 |
-
|
| 128 |
-
if needs_refinement and refinement_count < 1:
|
| 129 |
-
return "refine_search"
|
| 130 |
-
else:
|
| 131 |
-
return "search_subgraph"
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def initiate_parallel_search_worker(state: WorkerState):
|
| 135 |
-
"""
|
| 136 |
-
Send API를 사용하여 3개의 검색 노드를 병렬로 실행합니다 (WorkerState용).
|
| 137 |
-
|
| 138 |
-
Args:
|
| 139 |
-
state: 현재 워커 상태
|
| 140 |
-
|
| 141 |
-
Returns:
|
| 142 |
-
Send 객체 리스트 (fan-out)
|
| 143 |
-
"""
|
| 144 |
-
return [
|
| 145 |
-
Send("search_stackoverflow", state),
|
| 146 |
-
Send("search_github", state),
|
| 147 |
-
Send("search_official_docs", state),
|
| 148 |
-
]
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
def build_single_question_subgraph() -> StateGraph:
|
| 152 |
-
"""
|
| 153 |
-
단일 질문 처리 서브그래프.
|
| 154 |
-
|
| 155 |
-
🔧 CRITICAL:
|
| 156 |
-
- WorkerState만 사용
|
| 157 |
-
- 부모 AgentState와 완전히 격리
|
| 158 |
-
- 출력: multi_answers 또는 final_answer만
|
| 159 |
-
"""
|
| 160 |
-
# WorkerState 사용 (AgentState와 완전히 독립)
|
| 161 |
-
subgraph = StateGraph(WorkerState)
|
| 162 |
-
|
| 163 |
-
# 노드 추가
|
| 164 |
-
subgraph.add_node("analyze_question", analyze_question_node)
|
| 165 |
-
subgraph.add_node("generate_with_history", generate_with_history_node)
|
| 166 |
-
subgraph.add_node("check_cache", check_cache_node)
|
| 167 |
-
subgraph.add_node("return_cached_answer", return_cached_answer_node)
|
| 168 |
-
subgraph.add_node("classify_intent", classify_intent_node)
|
| 169 |
-
|
| 170 |
-
# 병렬 검색 노드
|
| 171 |
-
subgraph.add_node("search_stackoverflow", search_stackoverflow_node)
|
| 172 |
-
subgraph.add_node("search_github", search_github_node)
|
| 173 |
-
subgraph.add_node("search_official_docs", search_official_docs_node)
|
| 174 |
-
|
| 175 |
-
# 결과 처리 노드
|
| 176 |
-
subgraph.add_node("collect_results", collect_results_node)
|
| 177 |
-
subgraph.add_node("evaluate_results", evaluate_results_node)
|
| 178 |
-
subgraph.add_node("refine_search", refine_search_node)
|
| 179 |
-
|
| 180 |
-
# 최종 답변 생성
|
| 181 |
-
subgraph.add_node("generate_answer", generate_answer_node)
|
| 182 |
-
|
| 183 |
-
# 중첩 서브그래프 (filter + summarize)
|
| 184 |
-
filter_summarize_subgraph = build_search_subgraph()
|
| 185 |
-
subgraph.add_node("search_subgraph", filter_summarize_subgraph)
|
| 186 |
-
|
| 187 |
-
# ===== 엣지 구성 =====
|
| 188 |
-
|
| 189 |
-
# 1. START → analyze_question
|
| 190 |
-
subgraph.add_edge(START, "analyze_question")
|
| 191 |
-
|
| 192 |
-
# 2. analyze_question 결과에 따른 분기
|
| 193 |
-
subgraph.add_conditional_edges(
|
| 194 |
-
"analyze_question",
|
| 195 |
-
route_after_analysis_worker,
|
| 196 |
-
{
|
| 197 |
-
"generate_with_history": "generate_with_history",
|
| 198 |
-
"check_cache": "check_cache",
|
| 199 |
-
}
|
| 200 |
-
)
|
| 201 |
-
|
| 202 |
-
# 3. generate_with_history → END (대화 히스토리 기반 답변)
|
| 203 |
-
subgraph.add_edge("generate_with_history", END)
|
| 204 |
-
|
| 205 |
-
# 4. check_cache 결과에 따른 분기
|
| 206 |
-
subgraph.add_conditional_edges(
|
| 207 |
-
"check_cache",
|
| 208 |
-
route_after_cache_worker,
|
| 209 |
-
{
|
| 210 |
-
"return_cached_answer": "return_cached_answer",
|
| 211 |
-
"classify_intent": "classify_intent",
|
| 212 |
-
}
|
| 213 |
-
)
|
| 214 |
-
|
| 215 |
-
# 5. return_cached_answer → END (캐시 히트)
|
| 216 |
-
subgraph.add_edge("return_cached_answer", END)
|
| 217 |
-
|
| 218 |
-
# 6. classify_intent → 병렬 검색 (Send API)
|
| 219 |
-
subgraph.add_conditional_edges("classify_intent", initiate_parallel_search_worker)
|
| 220 |
-
|
| 221 |
-
# 7. 모든 검색 노드 → collect_results (fan-in)
|
| 222 |
-
subgraph.add_edge("search_stackoverflow", "collect_results")
|
| 223 |
-
subgraph.add_edge("search_github", "collect_results")
|
| 224 |
-
subgraph.add_edge("search_official_docs", "collect_results")
|
| 225 |
-
|
| 226 |
-
# 8. collect_results → evaluate_results
|
| 227 |
-
subgraph.add_edge("collect_results", "evaluate_results")
|
| 228 |
-
|
| 229 |
-
# 9. evaluate_results 결과에 따른 분기
|
| 230 |
-
subgraph.add_conditional_edges(
|
| 231 |
-
"evaluate_results",
|
| 232 |
-
route_after_evaluation_worker,
|
| 233 |
-
{
|
| 234 |
-
"refine_search": "refine_search",
|
| 235 |
-
"search_subgraph": "search_subgraph",
|
| 236 |
-
}
|
| 237 |
-
)
|
| 238 |
-
|
| 239 |
-
# 10. refine_search → classify_intent (쿼리 개선 루프)
|
| 240 |
-
subgraph.add_edge("refine_search", "classify_intent")
|
| 241 |
-
|
| 242 |
-
# 11. search_subgraph → generate_answer
|
| 243 |
-
subgraph.add_edge("search_subgraph", "generate_answer")
|
| 244 |
-
|
| 245 |
-
# 12. generate_answer → END
|
| 246 |
-
subgraph.add_edge("generate_answer", END)
|
| 247 |
-
|
| 248 |
-
return subgraph.compile()
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
def route_after_plan(state: AgentState):
|
| 252 |
-
"""
|
| 253 |
-
create_plan 결과에 따라 다음 노드를 결정합니다.
|
| 254 |
-
|
| 255 |
-
Returns:
|
| 256 |
-
- "handle_too_many_questions": 질문 3개 이상
|
| 257 |
-
- "single_question_subgraph": 단일 주제 (1회 실행)
|
| 258 |
-
- List[Send]: 다중 질문 (N회 병렬 실행)
|
| 259 |
-
"""
|
| 260 |
-
plan = state.plan or {}
|
| 261 |
-
case = plan.get("case", "single_topic")
|
| 262 |
-
|
| 263 |
-
if case == "too_many":
|
| 264 |
-
return "handle_too_many_questions"
|
| 265 |
-
|
| 266 |
-
elif case == "multiple_questions":
|
| 267 |
-
# 다중 질문: Send API로 서브그래프를 여러 번 호출
|
| 268 |
-
sub_questions = plan.get("sub_questions", [])
|
| 269 |
-
messages = state.messages
|
| 270 |
-
|
| 271 |
-
logger.info("다중 질문 처리: %d개 질문을 서브그래프로 병렬 실행", len(sub_questions))
|
| 272 |
-
|
| 273 |
-
sends = []
|
| 274 |
-
for i, sq in enumerate(sub_questions):
|
| 275 |
-
worker_state = WorkerState(
|
| 276 |
-
processing_question=sq,
|
| 277 |
-
messages=messages,
|
| 278 |
-
|
| 279 |
-
# 🔧 [FIX] 이름 변경된 필드로 매핑
|
| 280 |
-
worker_is_multi=True,
|
| 281 |
-
worker_idx=i,
|
| 282 |
-
worker_sub_text=sq,
|
| 283 |
-
)
|
| 284 |
-
sends.append(Send("single_question_subgraph", worker_state))
|
| 285 |
-
|
| 286 |
-
return sends
|
| 287 |
-
|
| 288 |
-
else:
|
| 289 |
-
# 단일 질문
|
| 290 |
-
worker_state = WorkerState(
|
| 291 |
-
processing_question=state.user_question,
|
| 292 |
-
messages=state.messages,
|
| 293 |
-
|
| 294 |
-
# 🔧 [FIX] 기본값 매핑
|
| 295 |
-
worker_is_multi=False,
|
| 296 |
-
worker_idx=0,
|
| 297 |
-
worker_sub_text=None
|
| 298 |
-
)
|
| 299 |
-
return [Send("single_question_subgraph", worker_state)]
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
def route_after_subgraph(state: AgentState) -> Literal["combine_answers", END]:
|
| 303 |
-
"""
|
| 304 |
-
서브그래프 실행 후 다음 노드 결정.
|
| 305 |
-
|
| 306 |
-
- multi_answers가 있으면: 다중 질문 모드 → combine_answers
|
| 307 |
-
- multi_answers가 없으면: 단일 질문 모드 → END
|
| 308 |
-
"""
|
| 309 |
-
# multi_answers에 실제 데이터가 있는지 확인 (reset token 제외)
|
| 310 |
-
has_answers = any(
|
| 311 |
-
isinstance(item, dict) and item.get("__token__") != _MULTI_ANS_RESET_TOKEN
|
| 312 |
-
for item in state.multi_answers
|
| 313 |
-
)
|
| 314 |
-
|
| 315 |
-
if has_answers:
|
| 316 |
-
logger.info("다중 질문 모드: combine_answers로 이동")
|
| 317 |
-
return "combine_answers"
|
| 318 |
-
else:
|
| 319 |
-
logger.info("단일 질문 모드: END로 이동")
|
| 320 |
-
return END
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
def build_agent_graph() -> StateGraph:
|
| 324 |
-
"""
|
| 325 |
-
CodeWeaver 에이전트의 메인 그래프를 구성합니다.
|
| 326 |
-
|
| 327 |
-
전체 흐름 (단순화됨):
|
| 328 |
-
1. START → create_plan (질문 유형 및 개수 판단)
|
| 329 |
-
2. 질문 유형에 따른 분기:
|
| 330 |
-
- single_topic: single_question_subgraph (1회) → END
|
| 331 |
-
- multiple_questions: Send API로 single_question_subgraph (2회 병렬) → combine_answers → END
|
| 332 |
-
- too_many: handle_too_many_questions → END
|
| 333 |
-
|
| 334 |
-
핵심 개선사항:
|
| 335 |
-
- ✅ 단일 질문 파이프라인을 재사용 가능한 서브그래프로 추출
|
| 336 |
-
- ✅ 부모 그래프는 계획/분기/병합만 담당
|
| 337 |
-
- ✅ 복잡한 worker 노드 제거
|
| 338 |
-
- ✅ 코드 중복 제거
|
| 339 |
-
- ✅ 구조 명확화: 부모(orchestration) vs 자식(processing)
|
| 340 |
-
|
| 341 |
-
Returns:
|
| 342 |
-
구성된 StateGraph (컴파일 전)
|
| 343 |
-
"""
|
| 344 |
-
# 메인 그래프 생성
|
| 345 |
-
graph = StateGraph(AgentState)
|
| 346 |
-
|
| 347 |
-
# 노드 추가
|
| 348 |
-
graph.add_node("create_plan", create_plan_node)
|
| 349 |
-
graph.add_node("handle_too_many_questions", handle_too_many_questions_node)
|
| 350 |
-
graph.add_node("combine_answers", combine_answers_node)
|
| 351 |
-
|
| 352 |
-
# 서브그래프를 노드로 등록
|
| 353 |
-
single_question_subgraph = build_single_question_subgraph()
|
| 354 |
-
graph.add_node("single_question_subgraph", single_question_subgraph)
|
| 355 |
-
|
| 356 |
-
# ===== 엣지 구성 =====
|
| 357 |
-
|
| 358 |
-
# 1. START → create_plan
|
| 359 |
-
graph.add_edge(START, "create_plan")
|
| 360 |
-
|
| 361 |
-
# 2. create_plan → 분기
|
| 362 |
-
# - single_topic: "single_question_subgraph" → END
|
| 363 |
-
# - multiple_questions: List[Send("single_question_subgraph", WorkerState)] → combine_answers
|
| 364 |
-
# - too_many: "handle_too_many_questions" → END
|
| 365 |
-
graph.add_conditional_edges("create_plan", route_after_plan)
|
| 366 |
-
|
| 367 |
-
# 3. handle_too_many_questions → END
|
| 368 |
-
graph.add_edge("handle_too_many_questions", END)
|
| 369 |
-
|
| 370 |
-
# 4. 🔧 FIX: single_question_subgraph의 출구를 명확히 분리
|
| 371 |
-
# - 단일 질문 (case=single_topic): 무조건 END
|
| 372 |
-
# - 다중 질문 (case=multiple_questions): Send API가 자동으로 combine_answers로 fan-in
|
| 373 |
-
|
| 374 |
-
# 4-1. 단일 질문 경로: single_question_subgraph → END
|
| 375 |
-
# 4-2. 다중 질문 경로: single_question_subgraph → combine_answers (자동 fan-in)
|
| 376 |
-
|
| 377 |
-
# 🔧 해결책: conditional edges로 분기
|
| 378 |
-
graph.add_conditional_edges(
|
| 379 |
-
"single_question_subgraph",
|
| 380 |
-
route_after_subgraph,
|
| 381 |
-
{
|
| 382 |
-
"combine_answers": "combine_answers",
|
| 383 |
-
END: END,
|
| 384 |
-
}
|
| 385 |
-
)
|
| 386 |
-
|
| 387 |
-
# 5. combine_answers → END
|
| 388 |
-
graph.add_edge("combine_answers", END)
|
| 389 |
-
|
| 390 |
-
return graph
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
def create_agent(enable_checkpointing: bool = True):
|
| 394 |
-
"""
|
| 395 |
-
CodeWeaver 에이전트를 생성하고 컴파일합니다.
|
| 396 |
-
|
| 397 |
-
Args:
|
| 398 |
-
enable_checkpointing: 체크포인트 활성화 여부
|
| 399 |
-
- True: MemorySaver 사용 (개발/테스트용)
|
| 400 |
-
- False: 체크포인트 없이 실행 (상태 저장 불가)
|
| 401 |
-
|
| 402 |
-
Returns:
|
| 403 |
-
컴파일된 실행 가능한 그래프
|
| 404 |
-
|
| 405 |
-
Note:
|
| 406 |
-
프로덕션 환경에서는 MemorySaver 대신
|
| 407 |
-
PostgresSaver, SqliteSaver 등 영구 저장소 사용 권장
|
| 408 |
-
"""
|
| 409 |
-
graph = build_agent_graph()
|
| 410 |
-
|
| 411 |
-
if enable_checkpointing:
|
| 412 |
-
# 메모리 기반 체크포인터 (프로덕션에서는 DB 사용 권장)
|
| 413 |
-
memory = MemorySaver()
|
| 414 |
-
return graph.compile(checkpointer=memory)
|
| 415 |
-
else:
|
| 416 |
-
return graph.compile()
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
# 에이전트 인스턴스 생성 (모듈 임포트 시 자동 생성)
|
| 420 |
-
agent = create_agent(enable_checkpointing=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/src/agent/nodes.py
DELETED
|
@@ -1,1212 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
CodeWeaver LangGraph 노드 구현.
|
| 3 |
-
|
| 4 |
-
각 노드는 AgentState 또는 WorkerState를 받아 처리하고 업데이트된 상태를 반환합니다.
|
| 5 |
-
모든 노드는 LangSmith를 통해 자동으로 추적됩니다.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import asyncio
|
| 9 |
-
import logging
|
| 10 |
-
import os
|
| 11 |
-
from typing import List, Literal, Optional, Union
|
| 12 |
-
|
| 13 |
-
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
| 14 |
-
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 15 |
-
from langgraph.graph import StateGraph, START, END
|
| 16 |
-
from langgraph.types import Send
|
| 17 |
-
|
| 18 |
-
from src.agent.state import AgentState, WorkerState, SearchResult
|
| 19 |
-
from src.agent.state import _MULTI_ANS_RESET_TOKEN
|
| 20 |
-
from src.tools.search_tools import (
|
| 21 |
-
search_github,
|
| 22 |
-
search_official_docs,
|
| 23 |
-
search_stackoverflow,
|
| 24 |
-
)
|
| 25 |
-
from src.utils.tracing import trace_node
|
| 26 |
-
from src.vector_db.qdrant_client import QdrantManager
|
| 27 |
-
|
| 28 |
-
logger = logging.getLogger(__name__)
|
| 29 |
-
|
| 30 |
-
# LLM 초기화 (Gemini 2.5 Flash)
|
| 31 |
-
llm = ChatGoogleGenerativeAI(
|
| 32 |
-
model="gemini-2.5-flash-lite",
|
| 33 |
-
temperature=0.7,
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
# Qdrant 매니저 초기화
|
| 37 |
-
qdrant_manager = QdrantManager()
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
# ==================== 부모 그래프 노드 (AgentState 사용) ====================
|
| 41 |
-
|
| 42 |
-
@trace_node("create_plan")
|
| 43 |
-
def create_plan_node(state: AgentState) -> dict:
|
| 44 |
-
"""
|
| 45 |
-
질문을 분석하여 유형과 개수를 판단합니다.
|
| 46 |
-
|
| 47 |
-
Case:
|
| 48 |
-
- single_topic: 하나의 주제 (서브그래프 1회)
|
| 49 |
-
- multiple_questions: 독립 질문 2개 (Send API로 서브그래프 2회 병렬)
|
| 50 |
-
- too_many: 독립 질문 3개 이상 (에러 메시지)
|
| 51 |
-
"""
|
| 52 |
-
user_question = state.user_question
|
| 53 |
-
logger.info("질문 분석 및 계획 수립 중: %s", user_question[:50])
|
| 54 |
-
|
| 55 |
-
def _extract_question_candidates(text: str) -> List[str]:
|
| 56 |
-
"""입력 문자열에서 '질문 후보'를 최대한 보수적으로 추출합니다(3개 이상 감지용)."""
|
| 57 |
-
import re
|
| 58 |
-
|
| 59 |
-
if not text:
|
| 60 |
-
return []
|
| 61 |
-
|
| 62 |
-
t = text.strip()
|
| 63 |
-
# 1) 물음표 기반 분리
|
| 64 |
-
parts = re.split(r"[??]+", t)
|
| 65 |
-
candidates = [p.strip() for p in parts if p.strip()]
|
| 66 |
-
if len(candidates) >= 2 and re.search(r"[??]", t):
|
| 67 |
-
return candidates
|
| 68 |
-
|
| 69 |
-
# 2) 줄바꿈/번호 매기기 기반
|
| 70 |
-
lines = [ln.strip() for ln in re.split(r"[\r\n]+", t) if ln.strip()]
|
| 71 |
-
numbered = []
|
| 72 |
-
for ln in lines:
|
| 73 |
-
if re.match(r"^\s*(\d+[\.\)]|[-*])\s+", ln):
|
| 74 |
-
numbered.append(re.sub(r"^\s*(\d+[\.\)]|[-*])\s+", "", ln).strip())
|
| 75 |
-
if len(numbered) >= 2:
|
| 76 |
-
return numbered
|
| 77 |
-
|
| 78 |
-
# 3) 구분자 기반(세미콜론)
|
| 79 |
-
semi = [p.strip() for p in t.split(";") if p.strip()]
|
| 80 |
-
if len(semi) >= 2:
|
| 81 |
-
return semi
|
| 82 |
-
|
| 83 |
-
return [t]
|
| 84 |
-
|
| 85 |
-
def _hard_guard_too_many(text: str) -> Optional[dict]:
|
| 86 |
-
"""
|
| 87 |
-
하드 가드: 사용자가 '질문 3개 이상'을 한 번에 던진 것으로 확실한 경우,
|
| 88 |
-
LLM 분류와 무관하게 too_many로 강제합니다.
|
| 89 |
-
"""
|
| 90 |
-
import re
|
| 91 |
-
|
| 92 |
-
if not text:
|
| 93 |
-
return None
|
| 94 |
-
|
| 95 |
-
# 가장 확실한 기준: 물음표가 3개 이상
|
| 96 |
-
qmarks = len(re.findall(r"[??]", text))
|
| 97 |
-
if qmarks >= 3:
|
| 98 |
-
candidates = _extract_question_candidates(text)
|
| 99 |
-
msg = "죄송합니다. 질문은 한 번에 최대 2개까지 가능합니다. 가장 중요한 2개만 골라서 다시 질문해 주세요."
|
| 100 |
-
return {
|
| 101 |
-
"case": "too_many",
|
| 102 |
-
"sub_questions": candidates,
|
| 103 |
-
"reasoning": f"물음표가 {qmarks}개로, 3개 이상의 독립 질문으로 판단했습니다.",
|
| 104 |
-
"error_message": msg,
|
| 105 |
-
"steps_note": f"⚠️ 질문 수 초과 감지(물음표 {qmarks}개) → too_many로 강제",
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
# 번호 매기기/리스트로 3개 이상
|
| 109 |
-
candidates = _extract_question_candidates(text)
|
| 110 |
-
if len(candidates) >= 3:
|
| 111 |
-
msg = "죄송합니다. 질문은 한 번에 최대 2개까지 가능합니다. 가장 중요한 2개만 골라서 다시 질문해 주세요."
|
| 112 |
-
return {
|
| 113 |
-
"case": "too_many",
|
| 114 |
-
"sub_questions": candidates,
|
| 115 |
-
"reasoning": f"질문 후보가 {len(candidates)}개로 감지되어 3개 이상 질문으로 판단했습니다.",
|
| 116 |
-
"error_message": msg,
|
| 117 |
-
"steps_note": f"⚠️ 질문 수 초과 감지(후보 {len(candidates)}개) → too_many로 강제",
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
return None
|
| 121 |
-
|
| 122 |
-
# 하드 가드(결정론적) – LLM이 잘못 분류하더라도 3개 이상이면 무조건 차단
|
| 123 |
-
hard = _hard_guard_too_many(user_question)
|
| 124 |
-
if hard:
|
| 125 |
-
steps_delta = [
|
| 126 |
-
f"📋 계획 타입: {hard['case']}",
|
| 127 |
-
f" 서브질문: {len(hard['sub_questions'])}개",
|
| 128 |
-
f" 이유: {hard['reasoning']}",
|
| 129 |
-
hard["steps_note"],
|
| 130 |
-
]
|
| 131 |
-
logger.info("계획 수립 완료(하드 가드): too_many, %d개 서브질��", len(hard["sub_questions"]))
|
| 132 |
-
return {
|
| 133 |
-
"plan": {
|
| 134 |
-
"case": hard["case"],
|
| 135 |
-
"sub_questions": hard["sub_questions"],
|
| 136 |
-
"reasoning": hard["reasoning"],
|
| 137 |
-
"error_message": hard["error_message"],
|
| 138 |
-
},
|
| 139 |
-
"is_multi_question": False,
|
| 140 |
-
"sub_question_index": 0,
|
| 141 |
-
"sub_question_text": None,
|
| 142 |
-
"original_multi_question": None,
|
| 143 |
-
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
|
| 144 |
-
"intermediate_steps": steps_delta,
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
plan_prompt = f"""질문을 분석하여 유형과 개수를 판단하세요.
|
| 148 |
-
|
| 149 |
-
질문: {user_question}
|
| 150 |
-
|
| 151 |
-
**중요**: sub_questions의 용도는 case에 따라 다릅니다!
|
| 152 |
-
|
| 153 |
-
**Case 1: single_topic** (하나의 주제)
|
| 154 |
-
- 예: "Spring Security JWT 인증 구현"
|
| 155 |
-
→ sub_questions: ["개념", "구현", "예제"]
|
| 156 |
-
→ 용도: 답변 섹션 구조 (검색은 원본 질문으로 1회만)
|
| 157 |
-
→ 검색: "Spring Security JWT 인증 구현"
|
| 158 |
-
|
| 159 |
-
- 예: "React hooks 완벽 가이드"
|
| 160 |
-
→ sub_questions: ["hooks란", "주요 hooks", "실무 패턴"]
|
| 161 |
-
→ 용도: 답변 섹션 구조
|
| 162 |
-
→ 검색: "React hooks 완벽 가이드"
|
| 163 |
-
|
| 164 |
-
**Case 2: multiple_questions** (여러 독립 질문, 최대 2개)
|
| 165 |
-
- 예: "JWT가 뭐야? CORS는?"
|
| 166 |
-
→ sub_questions: ["JWT가 뭐야?", "CORS는?"]
|
| 167 |
-
→ 용도: 각 질문마다 별도 검색
|
| 168 |
-
→ 검색: "JWT가 뭐야?" (1회), "CORS는?" (1회)
|
| 169 |
-
|
| 170 |
-
- 예: "Docker 사용법은? Redis 설치는?"
|
| 171 |
-
→ sub_questions: ["Docker 사용법은?", "Redis 설치는?"]
|
| 172 |
-
→ 용도: 각 질문마다 별도 검색
|
| 173 |
-
|
| 174 |
-
**Case 3: too_many** (3개 이상 질문)
|
| 175 |
-
- 예: "JWT? CORS? Docker?"
|
| 176 |
-
→ 너무 많아서 처리 불가
|
| 177 |
-
→ error_message 제공
|
| 178 |
-
|
| 179 |
-
규칙:
|
| 180 |
-
- single_topic: sub_questions는 짧은 키워드/구절 (1-5개)
|
| 181 |
-
- multiple_questions: sub_questions는 완전한 문장 (정확히 2개만)
|
| 182 |
-
- too_many: 3개 이상이면 이 케이스로 분류
|
| 183 |
-
|
| 184 |
-
다음 JSON 형식으로만 답변하세요:
|
| 185 |
-
{{
|
| 186 |
-
"case": "single_topic|multiple_questions|too_many",
|
| 187 |
-
"sub_questions": [...],
|
| 188 |
-
"reasoning": "이 케이스로 판단한 이유",
|
| 189 |
-
"error_message": "..." (too_many인 경우만, 그 외는 빈 문자열)
|
| 190 |
-
}}
|
| 191 |
-
|
| 192 |
-
JSON 외에 다른 텍스트는 포함하지 마세요."""
|
| 193 |
-
|
| 194 |
-
try:
|
| 195 |
-
import json
|
| 196 |
-
|
| 197 |
-
messages_to_llm = [HumanMessage(content=plan_prompt)]
|
| 198 |
-
response = llm.invoke(messages_to_llm)
|
| 199 |
-
|
| 200 |
-
# JSON 파싱
|
| 201 |
-
response_text = response.content.strip()
|
| 202 |
-
|
| 203 |
-
# JSON 블록 추출
|
| 204 |
-
if "```json" in response_text:
|
| 205 |
-
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 206 |
-
elif "```" in response_text:
|
| 207 |
-
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 208 |
-
|
| 209 |
-
plan_data = json.loads(response_text)
|
| 210 |
-
|
| 211 |
-
case = plan_data.get("case", "single_topic")
|
| 212 |
-
sub_questions = plan_data.get("sub_questions", [user_question])
|
| 213 |
-
reasoning = plan_data.get("reasoning", "")
|
| 214 |
-
error_message = plan_data.get("error_message", "")
|
| 215 |
-
|
| 216 |
-
# LLM 결과를 받은 뒤에도 한 번 더 하드 가드 적용 (안전장치)
|
| 217 |
-
hard2 = _hard_guard_too_many(user_question)
|
| 218 |
-
if hard2:
|
| 219 |
-
case = hard2["case"]
|
| 220 |
-
sub_questions = hard2["sub_questions"]
|
| 221 |
-
reasoning = hard2["reasoning"]
|
| 222 |
-
error_message = hard2["error_message"]
|
| 223 |
-
|
| 224 |
-
# 유효성 검증
|
| 225 |
-
if not sub_questions or len(sub_questions) == 0:
|
| 226 |
-
sub_questions = [user_question]
|
| 227 |
-
case = "single_topic"
|
| 228 |
-
|
| 229 |
-
# multiple_questions일 때 2개 제한 강제
|
| 230 |
-
if case == "multiple_questions" and len(sub_questions) > 2:
|
| 231 |
-
sub_questions = sub_questions[:2]
|
| 232 |
-
reasoning += " (질문 수 제한: 최대 2개)"
|
| 233 |
-
|
| 234 |
-
steps_delta = [
|
| 235 |
-
f"📋 계획 타입: {case}",
|
| 236 |
-
f" 서브질문: {len(sub_questions)}개",
|
| 237 |
-
f" 이유: {reasoning}"
|
| 238 |
-
]
|
| 239 |
-
|
| 240 |
-
logger.info("계획 수립 완료: %s, %d개 서브질문", case, len(sub_questions))
|
| 241 |
-
|
| 242 |
-
return {
|
| 243 |
-
"plan": {
|
| 244 |
-
"case": case,
|
| 245 |
-
"sub_questions": sub_questions,
|
| 246 |
-
"reasoning": reasoning,
|
| 247 |
-
"error_message": error_message
|
| 248 |
-
},
|
| 249 |
-
"is_multi_question": False,
|
| 250 |
-
"sub_question_index": 0,
|
| 251 |
-
"sub_question_text": None,
|
| 252 |
-
"original_multi_question": None,
|
| 253 |
-
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
|
| 254 |
-
"intermediate_steps": steps_delta
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
except Exception as e:
|
| 258 |
-
logger.error("계획 수립 실패: %s", e, exc_info=True)
|
| 259 |
-
|
| 260 |
-
# 기본값: 원본 질문 그대로 사용
|
| 261 |
-
steps_delta = [
|
| 262 |
-
"⚠️ 계획 수립 실패, 기본값 사용: single_topic"
|
| 263 |
-
]
|
| 264 |
-
|
| 265 |
-
return {
|
| 266 |
-
"plan": {
|
| 267 |
-
"case": "single_topic",
|
| 268 |
-
"sub_questions": [user_question],
|
| 269 |
-
"reasoning": "계획 수립 실패, 기본값 사용",
|
| 270 |
-
"error_message": ""
|
| 271 |
-
},
|
| 272 |
-
"is_multi_question": False,
|
| 273 |
-
"sub_question_index": 0,
|
| 274 |
-
"sub_question_text": None,
|
| 275 |
-
"original_multi_question": None,
|
| 276 |
-
"multi_answers": [{"__token__": _MULTI_ANS_RESET_TOKEN}],
|
| 277 |
-
"intermediate_steps": steps_delta
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
@trace_node("handle_too_many_questions")
|
| 282 |
-
def handle_too_many_questions_node(state: AgentState) -> dict:
|
| 283 |
-
"""3개 이상 질문 시 안내 메시지를 반환합니다."""
|
| 284 |
-
plan = state.plan or {}
|
| 285 |
-
error_message = plan.get("error_message", "")
|
| 286 |
-
sub_questions = plan.get("sub_questions", [])
|
| 287 |
-
|
| 288 |
-
logger.info("질문 수 초과: %d개", len(sub_questions))
|
| 289 |
-
|
| 290 |
-
default_message = """죄송합니다. 한 번에 최대 2개의 질문까지만 처리할 수 있습니다.
|
| 291 |
-
|
| 292 |
-
다음 중 하나를 선택해서 다시 질문해 주세요:
|
| 293 |
-
|
| 294 |
-
1. **하나의 주제로 통합해서 질문**
|
| 295 |
-
예: "JWT 인증과 CORS 설정을 함께 구현하는 방법"
|
| 296 |
-
|
| 297 |
-
2. **가장 중요한 2개 질문만 선택**
|
| 298 |
-
예: "JWT가 뭐야? 내 코드에 어떻게 적용해?"
|
| 299 |
-
|
| 300 |
-
3. **질문을 나눠서 순차적으로 질문**
|
| 301 |
-
예: 먼저 "JWT가 뭐야?" 질문 → 답변 확인 → 다음 질문
|
| 302 |
-
|
| 303 |
-
어떻게 도와드릴까요?"""
|
| 304 |
-
|
| 305 |
-
final_message = error_message if error_message else default_message
|
| 306 |
-
|
| 307 |
-
steps_delta = [
|
| 308 |
-
f"⚠️ 질문 수 초과: {len(sub_questions)}개",
|
| 309 |
-
"💬 안내 메시지 제공 (대화 계속 가능)"
|
| 310 |
-
]
|
| 311 |
-
|
| 312 |
-
return {
|
| 313 |
-
"final_answer": final_message,
|
| 314 |
-
"intermediate_steps": steps_delta
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
@trace_node("combine_answers")
|
| 319 |
-
def combine_answers_node(state: AgentState) -> dict:
|
| 320 |
-
"""
|
| 321 |
-
Fan-in: 모든 Send가 완료되면 multi_answers를 조합합니다.
|
| 322 |
-
"""
|
| 323 |
-
answers = state.multi_answers
|
| 324 |
-
original_question = state.original_multi_question or state.user_question
|
| 325 |
-
|
| 326 |
-
if not answers:
|
| 327 |
-
logger.error("다중 답변이 비어있음")
|
| 328 |
-
return {
|
| 329 |
-
"final_answer": "답변 생성에 실패했습니다. 다시 시도해 주세요.",
|
| 330 |
-
"intermediate_steps": ["❌ multi_answers 비어있음"]
|
| 331 |
-
}
|
| 332 |
-
|
| 333 |
-
# 인덱스 순으로 정렬
|
| 334 |
-
answers.sort(key=lambda x: x["index"])
|
| 335 |
-
|
| 336 |
-
# Markdown 형식으로 조합
|
| 337 |
-
combined_parts = []
|
| 338 |
-
for ans in answers:
|
| 339 |
-
section = f"""## {ans['index']+1}. {ans['question']}
|
| 340 |
-
|
| 341 |
-
{ans['answer']}"""
|
| 342 |
-
combined_parts.append(section)
|
| 343 |
-
|
| 344 |
-
combined = "\n\n---\n\n".join(combined_parts)
|
| 345 |
-
|
| 346 |
-
# 헤더 추가
|
| 347 |
-
header = f"# 다중 질문 답변\n\n원본 질문: {original_question}\n\n---\n\n"
|
| 348 |
-
final_combined = header + combined
|
| 349 |
-
|
| 350 |
-
logger.info("다중 답변 조합 완료: %d개", len(answers))
|
| 351 |
-
|
| 352 |
-
return {
|
| 353 |
-
"final_answer": final_combined,
|
| 354 |
-
"intermediate_steps": [f"✅ {len(answers)}개 답변 조합 완료"]
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
# ==================== 서브그래프 노드 (WorkerState 사용) ====================
|
| 359 |
-
|
| 360 |
-
@trace_node("analyze_question")
|
| 361 |
-
async def analyze_question_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 362 |
-
"""
|
| 363 |
-
질문을 분석하여 유형을 분류하고 캐시 적격성을 판단합니다.
|
| 364 |
-
|
| 365 |
-
🔧 FIX: 다중 질문 모드일 때는 messages를 무시하고 독립 질문으로만 분석
|
| 366 |
-
"""
|
| 367 |
-
# 🔧 [FIX] WorkerState일 경우 processing_question 사용
|
| 368 |
-
if isinstance(state, WorkerState):
|
| 369 |
-
user_question = state.processing_question
|
| 370 |
-
# 🔧 [FIX] 이름 변경된 필드 사용
|
| 371 |
-
is_multi = state.worker_is_multi
|
| 372 |
-
else:
|
| 373 |
-
user_question = state.user_question
|
| 374 |
-
is_multi = getattr(state, 'is_multi_question', False)
|
| 375 |
-
|
| 376 |
-
messages = state.messages
|
| 377 |
-
|
| 378 |
-
# 대화 맥락 구성 (다중 질문 모드가 아닐 때만)
|
| 379 |
-
has_history = messages and len(messages) > 1 and not is_multi
|
| 380 |
-
context_info = ""
|
| 381 |
-
|
| 382 |
-
if has_history:
|
| 383 |
-
context_info = "\n이전 대화 맥락:\n"
|
| 384 |
-
for msg in messages[-4:-1]:
|
| 385 |
-
if hasattr(msg, 'type') and hasattr(msg, 'content'):
|
| 386 |
-
role = "사용자" if msg.type == "human" else "AI"
|
| 387 |
-
context_info += f"{role}: {msg.content[:100]}\n"
|
| 388 |
-
|
| 389 |
-
# 🔧 다중 질문 모드 강제 처리
|
| 390 |
-
if is_multi:
|
| 391 |
-
context_info = "\n⚠️ 주의: 이 질문은 다중 질문의 일부입니다. 독립적인 질문으로만 판단하세요.\n"
|
| 392 |
-
|
| 393 |
-
analysis_prompt = f"""질문을 분석하여 유형을 분류하고, 캐시 적격성을 판단하세요.
|
| 394 |
-
|
| 395 |
-
{context_info}
|
| 396 |
-
현재 질문: {user_question}
|
| 397 |
-
|
| 398 |
-
분류 기준:
|
| 399 |
-
|
| 400 |
-
1. **clarification** (보충/형식 변경 요청)
|
| 401 |
-
- 이전 답변/대화 내용을 바탕으로 "설명 방식"을 바꾸거나 보충을 요청
|
| 402 |
-
- 예: "좀 더 쉽게 설명해줘", "예제 코드로 보여줘", "한 줄로 요약해줘"
|
| 403 |
-
- should_cache = false, canonical_question = null
|
| 404 |
-
|
| 405 |
-
2. **new_topic** (대화 중 새 개념 질문)
|
| 406 |
-
- 대화가 이어지는 중이지만, 질문 자체가 독립적으로 성립하는 '새 개념/정의/비교/사용법' 질문
|
| 407 |
-
- 예: "Event Listener는 뭐야?", "CORS가 뭐야?"
|
| 408 |
-
- should_cache = true, canonical_question 생성
|
| 409 |
-
|
| 410 |
-
3. **independent** (완전 독립 질문)
|
| 411 |
-
- 이전 대화 없이도 이해 가능한 일반 질문
|
| 412 |
-
- 예: "Spring Security가 뭐야?", "Docker Compose 사용법은?"
|
| 413 |
-
- should_cache = true, canonical_question 생성
|
| 414 |
-
|
| 415 |
-
다음 JSON 형식으로만 답변하세요:
|
| 416 |
-
{{
|
| 417 |
-
"question_type": "clarification|new_topic|independent",
|
| 418 |
-
"should_cache": true|false,
|
| 419 |
-
"reasoning": "분류 이유 1-2문장",
|
| 420 |
-
"canonical_question": "캐시할 정규화된 질문 (should_cache가 true인 경우에만, 아니면 null)"
|
| 421 |
-
}}
|
| 422 |
-
|
| 423 |
-
JSON 외에 다른 텍스트는 포함하지 마세요."""
|
| 424 |
-
|
| 425 |
-
try:
|
| 426 |
-
messages_to_llm = [HumanMessage(content=analysis_prompt)]
|
| 427 |
-
response = llm.invoke(messages_to_llm)
|
| 428 |
-
|
| 429 |
-
import json
|
| 430 |
-
response_text = response.content.strip()
|
| 431 |
-
|
| 432 |
-
if "```json" in response_text:
|
| 433 |
-
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 434 |
-
elif "```" in response_text:
|
| 435 |
-
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 436 |
-
|
| 437 |
-
analysis = json.loads(response_text)
|
| 438 |
-
|
| 439 |
-
question_type = analysis.get("question_type", "independent")
|
| 440 |
-
should_cache = analysis.get("should_cache", False)
|
| 441 |
-
reasoning = analysis.get("reasoning", "")
|
| 442 |
-
canonical_question = analysis.get("canonical_question", user_question)
|
| 443 |
-
|
| 444 |
-
# 유효성 검증
|
| 445 |
-
if question_type not in ["clarification", "new_topic", "independent"]:
|
| 446 |
-
question_type = "independent"
|
| 447 |
-
|
| 448 |
-
# 🔧 CRITICAL: 다중 질문 모드일 때는 무조건 independent로 강제
|
| 449 |
-
if is_multi and question_type == "clarification":
|
| 450 |
-
logger.warning("다중 질문 모드에서 clarification 감지 → independent로 강제 변경")
|
| 451 |
-
question_type = "independent"
|
| 452 |
-
should_cache = True
|
| 453 |
-
reasoning = "다중 질문 모드: 독립 질문으로 강제 분류"
|
| 454 |
-
|
| 455 |
-
# 정책 보정
|
| 456 |
-
if question_type == "clarification":
|
| 457 |
-
should_cache = False
|
| 458 |
-
canonical_question = None
|
| 459 |
-
else:
|
| 460 |
-
if canonical_question is None or (isinstance(canonical_question, str) and not canonical_question.strip()):
|
| 461 |
-
canonical_question = user_question
|
| 462 |
-
|
| 463 |
-
steps_delta = [
|
| 464 |
-
"__RESET_STEPS__",
|
| 465 |
-
f"🔍 질문 분석: {question_type} (캐시 여부: {should_cache})",
|
| 466 |
-
]
|
| 467 |
-
|
| 468 |
-
return {
|
| 469 |
-
"question_type": question_type,
|
| 470 |
-
"should_cache": should_cache,
|
| 471 |
-
"analysis_reasoning": reasoning,
|
| 472 |
-
"canonical_question": canonical_question if should_cache else None,
|
| 473 |
-
"intermediate_steps": steps_delta
|
| 474 |
-
}
|
| 475 |
-
|
| 476 |
-
except Exception as e:
|
| 477 |
-
logger.error("질문 분석 실패: %s", e, exc_info=True)
|
| 478 |
-
|
| 479 |
-
steps_delta = [
|
| 480 |
-
"__RESET_STEPS__",
|
| 481 |
-
"⚠️ 질문 분석 실패, 기본값 사용: independent",
|
| 482 |
-
]
|
| 483 |
-
|
| 484 |
-
return {
|
| 485 |
-
"question_type": "independent",
|
| 486 |
-
"should_cache": True,
|
| 487 |
-
"analysis_reasoning": "분석 실패, 기본값 사용",
|
| 488 |
-
"canonical_question": user_question,
|
| 489 |
-
"intermediate_steps": steps_delta
|
| 490 |
-
}
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
@trace_node("check_cache")
|
| 494 |
-
async def check_cache_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 495 |
-
"""벡터 DB 캐시에서 유사한 질문을 검색합니다."""
|
| 496 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 497 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 498 |
-
question_for_lookup = state.canonical_question or current_q
|
| 499 |
-
logger.info("캐시 확인 중: %s", question_for_lookup[:50])
|
| 500 |
-
|
| 501 |
-
try:
|
| 502 |
-
cached_result = await qdrant_manager.search_cache(
|
| 503 |
-
question=question_for_lookup,
|
| 504 |
-
threshold=0.85
|
| 505 |
-
)
|
| 506 |
-
|
| 507 |
-
updates = {}
|
| 508 |
-
steps_delta: List[str] = []
|
| 509 |
-
|
| 510 |
-
if cached_result:
|
| 511 |
-
updates["cached_result"] = cached_result
|
| 512 |
-
steps_delta.append(f"✅ 캐시 히트 (답변 길이: {len(cached_result)}자)")
|
| 513 |
-
logger.info("캐시 히트")
|
| 514 |
-
else:
|
| 515 |
-
updates["cached_result"] = None
|
| 516 |
-
steps_delta.append("❌ 캐시 미스: 새로운 검색 필요")
|
| 517 |
-
logger.info("캐시 미스")
|
| 518 |
-
|
| 519 |
-
except Exception as e:
|
| 520 |
-
logger.error("캐시 확인 실패: %s", e, exc_info=True)
|
| 521 |
-
updates["cached_result"] = None
|
| 522 |
-
steps_delta.append(f"⚠️ 캐시 확인 오류: {str(e)}")
|
| 523 |
-
|
| 524 |
-
updates["intermediate_steps"] = steps_delta
|
| 525 |
-
return updates
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
@trace_node("return_cached_answer")
|
| 529 |
-
def return_cached_answer_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 530 |
-
"""캐시 히트 시 저장된 답변을 반환합니다."""
|
| 531 |
-
logger.info("캐시된 답변 반환")
|
| 532 |
-
|
| 533 |
-
cached_answer = state.cached_result
|
| 534 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 535 |
-
|
| 536 |
-
if is_multi:
|
| 537 |
-
return {
|
| 538 |
-
"multi_answers": [{
|
| 539 |
-
"index": state.worker_idx,
|
| 540 |
-
"question": state.worker_sub_text or state.processing_question,
|
| 541 |
-
"answer": cached_answer
|
| 542 |
-
}]
|
| 543 |
-
}
|
| 544 |
-
else:
|
| 545 |
-
# 🔧 [FIX] messages에 AIMessage 추가하여 히스토리 저장 보장
|
| 546 |
-
steps_delta = ["💾 캐시된 답변 반환 (검색 생략)"]
|
| 547 |
-
return {
|
| 548 |
-
"final_answer": cached_answer,
|
| 549 |
-
"messages": [AIMessage(content=cached_answer)], # 👈 핵심 수정
|
| 550 |
-
"intermediate_steps": steps_delta
|
| 551 |
-
}
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
@trace_node("generate_with_history")
|
| 555 |
-
async def generate_with_history_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 556 |
-
"""
|
| 557 |
-
대화 히스토리만 사용하여 후속 질문에 답변합니다.
|
| 558 |
-
|
| 559 |
-
수정 사항:
|
| 560 |
-
1. 문맥 오염 방지: 바로 직전의 대화(질문+답변)만 참조하도록 슬라이싱 적용
|
| 561 |
-
2. 히스토리 저장: AIMessage 반환 추가 (대화 끊김 방지)
|
| 562 |
-
"""
|
| 563 |
-
# 1. 현재 질문 추출
|
| 564 |
-
user_question = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 565 |
-
messages_history = state.messages
|
| 566 |
-
|
| 567 |
-
logger.info("대화 히스토리 기반 답변 생성: %s", user_question[:50])
|
| 568 |
-
|
| 569 |
-
# 2. 대화 맥락 구성 (Context Pollution 방지)
|
| 570 |
-
context_prompt = "이전 대화를 참고하여 후속 질문에 답변하세요.\n\n"
|
| 571 |
-
|
| 572 |
-
# [핵심] 현재 질문을 제외한 과거 기록 중 '가장 최근 2개(직전 질문+답변)'만 참조
|
| 573 |
-
prev_messages = messages_history[:-1] if messages_history else []
|
| 574 |
-
recent_context = prev_messages[-2:] if prev_messages else []
|
| 575 |
-
|
| 576 |
-
if recent_context:
|
| 577 |
-
context_prompt += "직전 대화 내역:\n"
|
| 578 |
-
for msg in recent_context:
|
| 579 |
-
if hasattr(msg, 'type') and hasattr(msg, 'content'):
|
| 580 |
-
role = "사용자" if msg.type == "human" else "AI"
|
| 581 |
-
context_prompt += f"{role}: {msg.content}\n\n"
|
| 582 |
-
|
| 583 |
-
context_prompt += f"현재 질문: {user_question}\n\n"
|
| 584 |
-
context_prompt += "위의 '직전 대화 내역'에만 집중하여 답변하세요. 그 외의 이전 주제나 불필요한 맥락은 언급하지 마세요."
|
| 585 |
-
|
| 586 |
-
updates = {}
|
| 587 |
-
steps_delta: List[str] = []
|
| 588 |
-
|
| 589 |
-
try:
|
| 590 |
-
# 3. LLM 호출
|
| 591 |
-
response = llm.invoke([HumanMessage(content=context_prompt)])
|
| 592 |
-
final_answer = response.content.strip()
|
| 593 |
-
|
| 594 |
-
# 4. 상태 업데이트
|
| 595 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 596 |
-
|
| 597 |
-
if is_multi:
|
| 598 |
-
# 다중 질문 모드 (예외적 상황)
|
| 599 |
-
return {
|
| 600 |
-
"multi_answers": [{
|
| 601 |
-
"index": state.worker_idx,
|
| 602 |
-
"question": state.worker_sub_text or user_question,
|
| 603 |
-
"answer": final_answer
|
| 604 |
-
}]
|
| 605 |
-
}
|
| 606 |
-
else:
|
| 607 |
-
# 단일 질문 모드 (정상 케이스)
|
| 608 |
-
updates["final_answer"] = final_answer
|
| 609 |
-
# [핵심] 대화 히스토리에 AI 답변을 추가하여 다음 턴에서 참조 가능하게 함
|
| 610 |
-
updates["messages"] = [AIMessage(content=final_answer)]
|
| 611 |
-
|
| 612 |
-
steps_delta.append(f"💬 대화 히스토리 기반 답변 생성 (길이: {len(final_answer)}자)")
|
| 613 |
-
steps_delta.append("⚠️ 캐시 저장 생략 (보충 요청)")
|
| 614 |
-
|
| 615 |
-
logger.info("대화 히스토리 기반 답변 생성 완료")
|
| 616 |
-
|
| 617 |
-
except Exception as e:
|
| 618 |
-
logger.error("대화 히스토리 기반 답변 생성 실패: %s", e, exc_info=True)
|
| 619 |
-
|
| 620 |
-
if is_multi:
|
| 621 |
-
return {
|
| 622 |
-
"multi_answers": [{
|
| 623 |
-
"index": state.worker_idx,
|
| 624 |
-
"question": state.worker_sub_text or user_question,
|
| 625 |
-
"answer": "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 626 |
-
}]
|
| 627 |
-
}
|
| 628 |
-
else:
|
| 629 |
-
updates["final_answer"] = "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 630 |
-
steps_delta.append(f"❌ 답변 생성 실패: {str(e)}")
|
| 631 |
-
|
| 632 |
-
updates["intermediate_steps"] = steps_delta
|
| 633 |
-
return updates
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
@trace_node("classify_intent")
|
| 637 |
-
def classify_intent_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 638 |
-
"""
|
| 639 |
-
LLM을 사용하여 사용자 질문의 의도를 분류합니다.
|
| 640 |
-
|
| 641 |
-
🔧 CRITICAL:
|
| 642 |
-
- refined_question이 있으면 그것을 사용, 없으면 user_question 사용
|
| 643 |
-
- WorkerState 필드만 반환 (부모 AgentState와 충돌 방지)
|
| 644 |
-
- ❌ 절대 반환하면 안 되는 것들: user_question, messages
|
| 645 |
-
"""
|
| 646 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 647 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 648 |
-
question_to_classify = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 649 |
-
|
| 650 |
-
logger.info("의도 분류 중: %s", question_to_classify[:50])
|
| 651 |
-
|
| 652 |
-
classification_prompt = f"""질문을 다음 세 가지 의도 중 하나로 분류하세요:
|
| 653 |
-
|
| 654 |
-
1. debugging: 에러 해결, 버그 수정, 문제 해결
|
| 655 |
-
2. learning: 개념 학습, 원리 이해, 튜토리얼
|
| 656 |
-
3. code_review: 코드 개선, 리팩토링, 베스트 프랙티스
|
| 657 |
-
|
| 658 |
-
질문: {question_to_classify}
|
| 659 |
-
|
| 660 |
-
반드시 debugging, learning, code_review 중 하나만 답하세요."""
|
| 661 |
-
|
| 662 |
-
updates = {}
|
| 663 |
-
steps_delta: List[str] = []
|
| 664 |
-
|
| 665 |
-
try:
|
| 666 |
-
messages = [
|
| 667 |
-
SystemMessage(content="당신은 개발자 질문을 분류하는 전문가입니다."),
|
| 668 |
-
HumanMessage(content=classification_prompt)
|
| 669 |
-
]
|
| 670 |
-
|
| 671 |
-
response = llm.invoke(messages)
|
| 672 |
-
intent_raw = response.content.strip().lower()
|
| 673 |
-
|
| 674 |
-
# 유효한 의도로 정규화
|
| 675 |
-
valid_intents = ["debugging", "learning", "code_review"]
|
| 676 |
-
intent = next((i for i in valid_intents if i in intent_raw), "learning")
|
| 677 |
-
|
| 678 |
-
updates["detected_intent"] = intent
|
| 679 |
-
steps_delta.append(f"🎯 의도 분류: {intent}")
|
| 680 |
-
logger.info("의도 분류 완료: %s", intent)
|
| 681 |
-
|
| 682 |
-
except Exception as e:
|
| 683 |
-
logger.error("의도 분류 실패: %s", e, exc_info=True)
|
| 684 |
-
updates["detected_intent"] = "learning"
|
| 685 |
-
steps_delta.append("⚠️ 의도 분류 실패, 기본값 사용: learning")
|
| 686 |
-
|
| 687 |
-
updates["intermediate_steps"] = steps_delta
|
| 688 |
-
|
| 689 |
-
# 🔧 CRITICAL: WorkerState 필드만 반환
|
| 690 |
-
# ✅ OK: detected_intent, intermediate_steps
|
| 691 |
-
# ❌ 절대 반환하면 안 됨: user_question, messages
|
| 692 |
-
return updates
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
@trace_node("search_stackoverflow")
|
| 696 |
-
def search_stackoverflow_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 697 |
-
"""Stack Overflow에서 검색을 수행합니다."""
|
| 698 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 699 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 700 |
-
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 701 |
-
|
| 702 |
-
intent = state.detected_intent or "learning"
|
| 703 |
-
count = 5 if intent == "debugging" else 3
|
| 704 |
-
|
| 705 |
-
logger.info("Stack Overflow 검색 시작: %d개", count)
|
| 706 |
-
|
| 707 |
-
try:
|
| 708 |
-
results = search_stackoverflow(question_to_use, count)
|
| 709 |
-
logger.info("Stack Overflow에서 %d개 결과 수집", len(results))
|
| 710 |
-
|
| 711 |
-
# 🔧 FIX: intermediate_steps 제거
|
| 712 |
-
return {
|
| 713 |
-
"search_results": results,
|
| 714 |
-
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 715 |
-
}
|
| 716 |
-
except Exception as e:
|
| 717 |
-
logger.error("Stack Overflow 검색 실패: %s", e)
|
| 718 |
-
return {}
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
@trace_node("search_github")
|
| 722 |
-
def search_github_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 723 |
-
"""GitHub Issues/Discussions에서 검색을 수행합니다."""
|
| 724 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 725 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 726 |
-
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 727 |
-
|
| 728 |
-
intent = state.detected_intent or "learning"
|
| 729 |
-
count = 5 if intent == "code_review" else 3 if intent == "learning" else 2
|
| 730 |
-
|
| 731 |
-
logger.info("GitHub 검색 시작: %d개", count)
|
| 732 |
-
|
| 733 |
-
try:
|
| 734 |
-
results = search_github(question_to_use, count)
|
| 735 |
-
logger.info("GitHub에서 %d개 결과 수집", len(results))
|
| 736 |
-
|
| 737 |
-
# 🔧 FIX: intermediate_steps 제거
|
| 738 |
-
return {
|
| 739 |
-
"search_results": results,
|
| 740 |
-
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 741 |
-
}
|
| 742 |
-
except Exception as e:
|
| 743 |
-
logger.error("GitHub 검색 실패: %s", e)
|
| 744 |
-
return {}
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
@trace_node("search_official_docs")
|
| 748 |
-
def search_official_docs_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 749 |
-
"""공식 문서/Tavily에서 검색을 수행합니다."""
|
| 750 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 751 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 752 |
-
question_to_use = state.refined_question if hasattr(state, 'refined_question') and state.refined_question else current_q
|
| 753 |
-
|
| 754 |
-
intent = state.detected_intent or "learning"
|
| 755 |
-
count = 5 if intent == "learning" else 2
|
| 756 |
-
|
| 757 |
-
logger.info("공식 문서 검색 시작: %d개", count)
|
| 758 |
-
|
| 759 |
-
try:
|
| 760 |
-
results = search_official_docs(question_to_use, count)
|
| 761 |
-
logger.info("공식 문서에서 %d개 결과 수집", len(results))
|
| 762 |
-
|
| 763 |
-
# 🔧 FIX: intermediate_steps 제거
|
| 764 |
-
return {
|
| 765 |
-
"search_results": results,
|
| 766 |
-
# intermediate_steps 제거! (병렬 충돌 방지)
|
| 767 |
-
}
|
| 768 |
-
except Exception as e:
|
| 769 |
-
logger.error("공식 문서 검색 실패: %s", e)
|
| 770 |
-
return {}
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
@trace_node("collect_results")
|
| 774 |
-
def collect_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 775 |
-
"""병렬 검색 결과를 수집하고 카운트합니다."""
|
| 776 |
-
total_results = len(state.search_results)
|
| 777 |
-
|
| 778 |
-
logger.info("검색 결과 수집 완료: %d개", total_results)
|
| 779 |
-
|
| 780 |
-
# 🔧 FIX: 로그만 찍고, intermediate_steps는 업데이트하지 않음
|
| 781 |
-
# (병렬 노드에서 intermediate_steps 업데이트 시 충돌 발생)
|
| 782 |
-
|
| 783 |
-
return {} # 빈 딕셔너리 반환 (상태 변경 없음)
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
@trace_node("evaluate_results")
|
| 787 |
-
def evaluate_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 788 |
-
"""검색 결과의 개수와 품질을 모두 평가합니다."""
|
| 789 |
-
search_results = state.search_results
|
| 790 |
-
refinement_count = state.refinement_count
|
| 791 |
-
|
| 792 |
-
result_count = len(search_results)
|
| 793 |
-
|
| 794 |
-
logger.info("검색 결과 평가: %d개 (개선 횟수: %d)", result_count, refinement_count)
|
| 795 |
-
|
| 796 |
-
# 안전장치: 이미 1회 개선했으면 더 이상 개선하지 않음
|
| 797 |
-
if refinement_count >= 1:
|
| 798 |
-
steps_delta = [
|
| 799 |
-
f"⚠️ 최대 개선 횟수 도달 ({refinement_count}회), 현재 결과로 진행"
|
| 800 |
-
]
|
| 801 |
-
return {
|
| 802 |
-
"needs_refinement": False,
|
| 803 |
-
"intermediate_steps": steps_delta
|
| 804 |
-
}
|
| 805 |
-
|
| 806 |
-
# 1차 평가: 개수
|
| 807 |
-
if result_count < 2:
|
| 808 |
-
steps_delta = [
|
| 809 |
-
f"⚠️ 검색 결과 부족 ({result_count}개 < 2개), 쿼리 개선 필요"
|
| 810 |
-
]
|
| 811 |
-
return {
|
| 812 |
-
"needs_refinement": True,
|
| 813 |
-
"intermediate_steps": steps_delta
|
| 814 |
-
}
|
| 815 |
-
|
| 816 |
-
# 2차 평가: 품질
|
| 817 |
-
scored_results = [r for r in search_results if r.relevance_score is not None]
|
| 818 |
-
|
| 819 |
-
if scored_results:
|
| 820 |
-
avg_score = sum(r.relevance_score for r in scored_results) / len(scored_results)
|
| 821 |
-
|
| 822 |
-
if avg_score < 0.5:
|
| 823 |
-
steps_delta = [
|
| 824 |
-
f"⚠️ 검색 결과 품질 부족 (평균 점수: {avg_score:.2f} < 0.5), 쿼리 개선 필요"
|
| 825 |
-
]
|
| 826 |
-
return {
|
| 827 |
-
"needs_refinement": True,
|
| 828 |
-
"intermediate_steps": steps_delta
|
| 829 |
-
}
|
| 830 |
-
|
| 831 |
-
steps_delta = [
|
| 832 |
-
f"✅ 검색 결과 충분 ({result_count}개, 평균 점수: {avg_score:.2f}), 필터링 단계로 진행"
|
| 833 |
-
]
|
| 834 |
-
else:
|
| 835 |
-
steps_delta = [
|
| 836 |
-
f"✅ 검색 결과 충분 ({result_count}개), 필터링 단계로 진행"
|
| 837 |
-
]
|
| 838 |
-
|
| 839 |
-
return {
|
| 840 |
-
"needs_refinement": False,
|
| 841 |
-
"intermediate_steps": steps_delta
|
| 842 |
-
}
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
@trace_node("refine_search")
|
| 846 |
-
def refine_search_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 847 |
-
"""
|
| 848 |
-
검색 쿼리를 개선합니다.
|
| 849 |
-
|
| 850 |
-
🔧 CRITICAL:
|
| 851 |
-
- user_question을 직접 업데이트하지 않고, refined_question에 저장
|
| 852 |
-
- 부모 AgentState와 충돌 방지를 위해 WorkerState 필드만 반환
|
| 853 |
-
- ❌ 절대 반환하면 안 되는 것들: user_question, messages, final_answer
|
| 854 |
-
"""
|
| 855 |
-
# 🔧 [FIX] 변수 접근 수정
|
| 856 |
-
user_question = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 857 |
-
original_question = state.original_question or user_question
|
| 858 |
-
result_count = len(state.search_results)
|
| 859 |
-
|
| 860 |
-
logger.info("검색 쿼리 개선 중: %s (%d개 결과)", user_question[:50], result_count)
|
| 861 |
-
|
| 862 |
-
refinement_prompt = f"""검색 결과가 부족합니다. 검색 쿼리를 개선하세요.
|
| 863 |
-
|
| 864 |
-
원본 질문: {user_question}
|
| 865 |
-
현재 결과 수: {result_count}개 (목표: 2개 이상)
|
| 866 |
-
|
| 867 |
-
개선 전략 (하나 선택):
|
| 868 |
-
1. MORE_SPECIFIC: 기술적 세부사항 추가
|
| 869 |
-
2. MORE_GENERAL: 더 넓은 용어 사용
|
| 870 |
-
3. TRANSLATE: 언어 변환
|
| 871 |
-
|
| 872 |
-
다음 JSON 형식으로만 답변하세요:
|
| 873 |
-
{{
|
| 874 |
-
"new_query": "개선된 검색 쿼리",
|
| 875 |
-
"strategy": "MORE_SPECIFIC|MORE_GENERAL|TRANSLATE",
|
| 876 |
-
"reasoning": "이 전략을 선택한 이유 1-2문장"
|
| 877 |
-
}}
|
| 878 |
-
|
| 879 |
-
JSON 외에 다른 텍스트는 포함하지 마세요."""
|
| 880 |
-
|
| 881 |
-
try:
|
| 882 |
-
import json
|
| 883 |
-
|
| 884 |
-
messages_to_llm = [HumanMessage(content=refinement_prompt)]
|
| 885 |
-
response = llm.invoke(messages_to_llm)
|
| 886 |
-
|
| 887 |
-
response_text = response.content.strip()
|
| 888 |
-
if "```json" in response_text:
|
| 889 |
-
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 890 |
-
elif "```" in response_text:
|
| 891 |
-
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 892 |
-
|
| 893 |
-
refinement_data = json.loads(response_text)
|
| 894 |
-
|
| 895 |
-
new_query = refinement_data.get("new_query", user_question)
|
| 896 |
-
strategy = refinement_data.get("strategy", "MORE_GENERAL")
|
| 897 |
-
reasoning = refinement_data.get("reasoning", "")
|
| 898 |
-
|
| 899 |
-
steps_delta = [
|
| 900 |
-
f"🔄 쿼리 개선: {strategy}",
|
| 901 |
-
f" 이전: {user_question[:50]}...",
|
| 902 |
-
f" 이후: {new_query[:50]}...",
|
| 903 |
-
f" 이유: {reasoning}"
|
| 904 |
-
]
|
| 905 |
-
|
| 906 |
-
logger.info("쿼리 개선 완료: %s → %s", user_question[:30], new_query[:30])
|
| 907 |
-
|
| 908 |
-
# 🔧 CRITICAL: WorkerState 필드만 반환 (부모 AgentState와 충돌 방지)
|
| 909 |
-
return {
|
| 910 |
-
"refined_question": new_query, # ✅ WorkerState 필드
|
| 911 |
-
"original_question": original_question, # ✅ WorkerState 필드
|
| 912 |
-
"refinement_count": state.refinement_count + 1, # ✅ WorkerState 필드
|
| 913 |
-
"search_results": [], # ✅ WorkerState 필드 (reducer 있음)
|
| 914 |
-
"intermediate_steps": steps_delta # ✅ WorkerState 필드
|
| 915 |
-
|
| 916 |
-
# ❌ 절대 반환하면 안 되는 것들:
|
| 917 |
-
# "user_question": ..., # 부모 AgentState와 충돌!
|
| 918 |
-
# "messages": ..., # 부모 AgentState와 충돌!
|
| 919 |
-
# "final_answer": ..., # 너무 이른 시점!
|
| 920 |
-
}
|
| 921 |
-
|
| 922 |
-
except Exception as e:
|
| 923 |
-
logger.error("쿼리 개선 실패: %s", e, exc_info=True)
|
| 924 |
-
|
| 925 |
-
fallback_query = user_question + " tutorial example"
|
| 926 |
-
|
| 927 |
-
steps_delta = [
|
| 928 |
-
f"⚠️ 쿼리 개선 실패, 기본 전략 사용",
|
| 929 |
-
f" 이후: {fallback_query}"
|
| 930 |
-
]
|
| 931 |
-
|
| 932 |
-
# 🔧 CRITICAL: WorkerState 필드만 반환
|
| 933 |
-
return {
|
| 934 |
-
"refined_question": fallback_query, # ✅ WorkerState 필드
|
| 935 |
-
"original_question": original_question, # ✅ WorkerState 필드
|
| 936 |
-
"refinement_count": state.refinement_count + 1, # ✅ WorkerState 필드
|
| 937 |
-
"search_results": [], # ✅ WorkerState 필드 (reducer 있음)
|
| 938 |
-
"intermediate_steps": steps_delta # ✅ WorkerState 필드
|
| 939 |
-
}
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
@trace_node("filter_and_score")
|
| 943 |
-
def filter_and_score_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 944 |
-
"""검색 결과를 필터링하고 관련도 점수를 매깁니다."""
|
| 945 |
-
search_results = state.search_results
|
| 946 |
-
logger.info("검색 결과 필터링 중: %d개", len(search_results))
|
| 947 |
-
|
| 948 |
-
# 기본 필터링
|
| 949 |
-
filtered = [
|
| 950 |
-
r for r in search_results
|
| 951 |
-
if r.content and len(r.content) >= 50 and r.url
|
| 952 |
-
]
|
| 953 |
-
|
| 954 |
-
logger.info("기본 필터링 후: %d개 결과", len(filtered))
|
| 955 |
-
|
| 956 |
-
# 상위 5개 결과만 LLM으로 점수 매기기
|
| 957 |
-
# 🔧 [FIX] scoring_prompt 내부에서 질문 참조 시 수정
|
| 958 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 959 |
-
|
| 960 |
-
for result in filtered[:5]:
|
| 961 |
-
if result.relevance_score is None:
|
| 962 |
-
try:
|
| 963 |
-
scoring_prompt = f"""질문: {current_q}
|
| 964 |
-
|
| 965 |
-
검색 결과: {result.content[:500]}
|
| 966 |
-
|
| 967 |
-
이 검색 결과가 질문에 얼마나 관련이 있는지 0.0에서 1.0 사이의 점수로 평가하세요.
|
| 968 |
-
점수만 숫자로 답하세요. (예: 0.8)"""
|
| 969 |
-
|
| 970 |
-
response = llm.invoke([HumanMessage(content=scoring_prompt)])
|
| 971 |
-
score_str = response.content.strip()
|
| 972 |
-
result.relevance_score = float(score_str)
|
| 973 |
-
|
| 974 |
-
except Exception as e:
|
| 975 |
-
logger.warning("점수 매기기 실패: %s", e)
|
| 976 |
-
result.relevance_score = 0.5
|
| 977 |
-
|
| 978 |
-
# 관련도 순으로 정렬
|
| 979 |
-
filtered.sort(key=lambda r: r.relevance_score or 0, reverse=True)
|
| 980 |
-
|
| 981 |
-
# 상위 5개만 유지
|
| 982 |
-
top_results = filtered[:5]
|
| 983 |
-
|
| 984 |
-
subtask_results = dict(state.subtask_results)
|
| 985 |
-
subtask_results["filtered_results"] = [r.model_dump() for r in top_results]
|
| 986 |
-
|
| 987 |
-
steps_delta = [f"✂️ 필터링 완료: {len(top_results)}개 결과 선택"]
|
| 988 |
-
|
| 989 |
-
logger.info("필터링 완료: %d개 결과", len(top_results))
|
| 990 |
-
|
| 991 |
-
return {
|
| 992 |
-
"subtask_results": subtask_results,
|
| 993 |
-
"intermediate_steps": steps_delta
|
| 994 |
-
}
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
@trace_node("summarize_results")
|
| 998 |
-
def summarize_results_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 999 |
-
"""필터링된 각 검색 결과를 초보 개발자가 이해하기 쉽게 요약합니다."""
|
| 1000 |
-
subtask_results = state.subtask_results
|
| 1001 |
-
filtered_results = subtask_results.get("filtered_results", [])
|
| 1002 |
-
logger.info("검색 결과 요약 중: %d개", len(filtered_results))
|
| 1003 |
-
|
| 1004 |
-
summaries = []
|
| 1005 |
-
|
| 1006 |
-
for result_dict in filtered_results:
|
| 1007 |
-
try:
|
| 1008 |
-
summary_prompt = f"""다음 검색 결과를 초보 개발자가 이해하기 쉽게 2-3문장으로 요약하세요:
|
| 1009 |
-
|
| 1010 |
-
출처: {result_dict['source']}
|
| 1011 |
-
내용: {result_dict['content'][:1000]}
|
| 1012 |
-
|
| 1013 |
-
핵심 내용만 간단명료하게 요약하세요."""
|
| 1014 |
-
|
| 1015 |
-
response = llm.invoke([HumanMessage(content=summary_prompt)])
|
| 1016 |
-
|
| 1017 |
-
summaries.append({
|
| 1018 |
-
"source": result_dict['source'],
|
| 1019 |
-
"url": result_dict['url'],
|
| 1020 |
-
"summary": response.content.strip(),
|
| 1021 |
-
"relevance": result_dict.get('relevance_score', 0.5)
|
| 1022 |
-
})
|
| 1023 |
-
|
| 1024 |
-
except Exception as e:
|
| 1025 |
-
logger.error("요약 실패: %s", e)
|
| 1026 |
-
|
| 1027 |
-
updated_subtask_results = dict(subtask_results)
|
| 1028 |
-
updated_subtask_results["summaries"] = summaries
|
| 1029 |
-
|
| 1030 |
-
steps_delta = [f"📝 요약 완료: {len(summaries)}개 결과"]
|
| 1031 |
-
|
| 1032 |
-
logger.info("요약 완료: %d개", len(summaries))
|
| 1033 |
-
|
| 1034 |
-
return {
|
| 1035 |
-
"subtask_results": updated_subtask_results,
|
| 1036 |
-
"intermediate_steps": steps_delta
|
| 1037 |
-
}
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
@trace_node("generate_answer")
|
| 1041 |
-
async def generate_answer_node(state: Union[AgentState, WorkerState]) -> dict:
|
| 1042 |
-
"""
|
| 1043 |
-
요약된 정보를 바탕으로 최종 답변을 생성합니다.
|
| 1044 |
-
|
| 1045 |
-
수정 사항:
|
| 1046 |
-
1. 다중 질문 모드에서도 캐시 저장 로직이 실행되도록 순서 변경
|
| 1047 |
-
2. 단일 질문 모드에서 AIMessage 반환 (히스토리 저장)
|
| 1048 |
-
"""
|
| 1049 |
-
subtask_results = state.subtask_results
|
| 1050 |
-
summaries = subtask_results.get("summaries", [])
|
| 1051 |
-
intent = state.detected_intent or "learning"
|
| 1052 |
-
|
| 1053 |
-
# 변수 접근
|
| 1054 |
-
current_q = state.processing_question if isinstance(state, WorkerState) else state.user_question
|
| 1055 |
-
|
| 1056 |
-
logger.info("최종 답변 생성 중: %s (질문: %s)", intent, current_q[:30])
|
| 1057 |
-
|
| 1058 |
-
# 1. 의도별 프롬프트 템플릿
|
| 1059 |
-
templates = {
|
| 1060 |
-
"debugging": """다음 정보를 바탕으로 디버깅 질문에 답변하세요:
|
| 1061 |
-
|
| 1062 |
-
질문: {question}
|
| 1063 |
-
|
| 1064 |
-
수집된 정보:
|
| 1065 |
-
{summaries}
|
| 1066 |
-
|
| 1067 |
-
답변 구조:
|
| 1068 |
-
1. 문제 정의
|
| 1069 |
-
2. 발생 원인
|
| 1070 |
-
3. 해결 방법 (코드 예제 포함)
|
| 1071 |
-
4. 주의사항
|
| 1072 |
-
5. 참고 자료
|
| 1073 |
-
|
| 1074 |
-
초보 개발자도 이해할 수 있게 Markdown 형식으로 작성하세요.""",
|
| 1075 |
-
|
| 1076 |
-
"learning": """다음 정보를 바탕으로 학습 질문에 답변하세요:
|
| 1077 |
-
|
| 1078 |
-
질문: {question}
|
| 1079 |
-
|
| 1080 |
-
수집된 정보:
|
| 1081 |
-
{summaries}
|
| 1082 |
-
|
| 1083 |
-
답변 구조:
|
| 1084 |
-
1. 개념 설명 (간단명료)
|
| 1085 |
-
2. 동작 원리
|
| 1086 |
-
3. 예제 코드 (주석포함)
|
| 1087 |
-
4. 실무 활용 팁
|
| 1088 |
-
5. 추가 학습 자료
|
| 1089 |
-
|
| 1090 |
-
초보 개발자도 이해할 수 있게 Markdown 형식으로 작성하세요.""",
|
| 1091 |
-
|
| 1092 |
-
"code_review": """다음 정보를 바탕으로 코드 리뷰 질문에 답변하세요:
|
| 1093 |
-
|
| 1094 |
-
질문: {question}
|
| 1095 |
-
|
| 1096 |
-
수집된 정보:
|
| 1097 |
-
{summaries}
|
| 1098 |
-
|
| 1099 |
-
답변 구조:
|
| 1100 |
-
1. 현재 접근 방식 분석
|
| 1101 |
-
2. 개선 포인트
|
| 1102 |
-
3. 리팩토링 예제
|
| 1103 |
-
4. 베스트 프랙티스
|
| 1104 |
-
5. 참고 패턴
|
| 1105 |
-
|
| 1106 |
-
초보 개발자도 이해할 수 있게 Markdown 형식으로 작성하세요."""
|
| 1107 |
-
}
|
| 1108 |
-
|
| 1109 |
-
template = templates.get(intent, templates["learning"])
|
| 1110 |
-
|
| 1111 |
-
# 2. 요약 텍스트 포맷팅
|
| 1112 |
-
summaries_text = "\n\n".join([
|
| 1113 |
-
f"출처: {s['source']} ({s['url']})\n요약: {s['summary']}"
|
| 1114 |
-
for s in summaries
|
| 1115 |
-
])
|
| 1116 |
-
|
| 1117 |
-
# 3. 이전 대화 맥락 추가 (Context Pollution 방지: 최근 1개만 참고용으로)
|
| 1118 |
-
context_prefix = ""
|
| 1119 |
-
messages_history = state.messages
|
| 1120 |
-
if messages_history and len(messages_history) > 1:
|
| 1121 |
-
# 검색 기반 답변이므로 이전 대화는 아주 최소한만 참조 (직전 1개)
|
| 1122 |
-
prev_msg = messages_history[-2] if len(messages_history) >= 2 else None
|
| 1123 |
-
if prev_msg:
|
| 1124 |
-
context_prefix = f"이전 대화 맥락(참고): {prev_msg.content[:200]}...\n---\n"
|
| 1125 |
-
|
| 1126 |
-
final_prompt = (context_prefix + template).format(
|
| 1127 |
-
question=(state.original_question or current_q),
|
| 1128 |
-
summaries=summaries_text
|
| 1129 |
-
)
|
| 1130 |
-
|
| 1131 |
-
updates = {}
|
| 1132 |
-
steps_delta: List[str] = []
|
| 1133 |
-
|
| 1134 |
-
try:
|
| 1135 |
-
# 4. LLM 호출
|
| 1136 |
-
response = llm.invoke([HumanMessage(content=final_prompt)])
|
| 1137 |
-
final_answer = response.content.strip()
|
| 1138 |
-
|
| 1139 |
-
# 5. 캐시 저장 로직 (DRY - 중복 방지 함수)
|
| 1140 |
-
should_cache = state.should_cache if state.should_cache is not None else True
|
| 1141 |
-
canonical_question = state.canonical_question
|
| 1142 |
-
qtype = state.question_type or "independent"
|
| 1143 |
-
question_to_cache = canonical_question or current_q
|
| 1144 |
-
|
| 1145 |
-
async def _try_cache_save():
|
| 1146 |
-
"""조건 충족 시 Qdrant에 캐시 저장"""
|
| 1147 |
-
if should_cache and qtype in ["new_topic", "independent"]:
|
| 1148 |
-
try:
|
| 1149 |
-
await qdrant_manager.save_to_cache(
|
| 1150 |
-
question=question_to_cache,
|
| 1151 |
-
answer=final_answer
|
| 1152 |
-
)
|
| 1153 |
-
logger.info("✅ 캐시 저장 완료: %s", question_to_cache[:30])
|
| 1154 |
-
return True
|
| 1155 |
-
except Exception as cache_err:
|
| 1156 |
-
logger.error("캐시 저장 실패: %s", cache_err)
|
| 1157 |
-
return False
|
| 1158 |
-
return False
|
| 1159 |
-
|
| 1160 |
-
# 6. 결과 반환 및 분기 처리
|
| 1161 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 1162 |
-
|
| 1163 |
-
if is_multi:
|
| 1164 |
-
# [핵심] 다중 질문 모드: Return하기 '전에' 캐시 저장 시도
|
| 1165 |
-
await _try_cache_save()
|
| 1166 |
-
|
| 1167 |
-
logger.info("다중 질문 모드: 답변을 multi_answers에 추가")
|
| 1168 |
-
return {
|
| 1169 |
-
"multi_answers": [{
|
| 1170 |
-
"index": state.worker_idx,
|
| 1171 |
-
"question": state.worker_sub_text or current_q,
|
| 1172 |
-
"answer": final_answer
|
| 1173 |
-
}]
|
| 1174 |
-
}
|
| 1175 |
-
|
| 1176 |
-
else:
|
| 1177 |
-
# 단일 질문 모드
|
| 1178 |
-
updates["final_answer"] = final_answer
|
| 1179 |
-
# [핵심] 대화 히스토리에 AI 답변 추가
|
| 1180 |
-
updates["messages"] = [AIMessage(content=final_answer)]
|
| 1181 |
-
|
| 1182 |
-
# 캐시 저장 시도
|
| 1183 |
-
saved = await _try_cache_save()
|
| 1184 |
-
|
| 1185 |
-
if saved:
|
| 1186 |
-
steps_delta.append(f"✅ 최종 답변 생성 완료 (길이: {len(final_answer)}자)")
|
| 1187 |
-
steps_delta.append(f"💾 캐시 저장 완료 (질문: {question_to_cache[:50]}...)")
|
| 1188 |
-
else:
|
| 1189 |
-
steps_delta.append(f"✅ 최종 답변 생성 완료 (길이: {len(final_answer)}자)")
|
| 1190 |
-
steps_delta.append("⚠️ 캐시 저장 생략 (독립적이지 않거나 일회성 질문)")
|
| 1191 |
-
logger.info("최종 답변 생성 완료 (캐시 저장 생략)")
|
| 1192 |
-
|
| 1193 |
-
updates["intermediate_steps"] = steps_delta
|
| 1194 |
-
return updates
|
| 1195 |
-
|
| 1196 |
-
except Exception as e:
|
| 1197 |
-
logger.error("답변 생성 실패: %s", e, exc_info=True)
|
| 1198 |
-
|
| 1199 |
-
is_multi = isinstance(state, WorkerState) and state.worker_is_multi
|
| 1200 |
-
if is_multi:
|
| 1201 |
-
return {
|
| 1202 |
-
"multi_answers": [{
|
| 1203 |
-
"index": state.worker_idx,
|
| 1204 |
-
"question": state.worker_sub_text or current_q,
|
| 1205 |
-
"answer": "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 1206 |
-
}]
|
| 1207 |
-
}
|
| 1208 |
-
else:
|
| 1209 |
-
updates["final_answer"] = "답변 생성에 실패했습니다. 다시 시도해 주세요."
|
| 1210 |
-
steps_delta.append(f"❌ 답변 생성 실패: {str(e)}")
|
| 1211 |
-
updates["intermediate_steps"] = steps_delta
|
| 1212 |
-
return updates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/src/agent/state.py
DELETED
|
@@ -1,141 +0,0 @@
|
|
| 1 |
-
from typing import Any, Dict, List, Optional, Literal, Tuple, Annotated
|
| 2 |
-
from operator import add
|
| 3 |
-
|
| 4 |
-
from pydantic import BaseModel, Field
|
| 5 |
-
from langchain_core.messages import BaseMessage
|
| 6 |
-
from langgraph.graph import add_messages
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
_STEPS_RESET_TOKEN = "__RESET_STEPS__"
|
| 10 |
-
_MULTI_ANS_RESET_TOKEN = "__RESET_MULTI_ANS__"
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def merge_intermediate_steps(old: List[str], new: List[str]) -> List[str]:
|
| 14 |
-
"""intermediate_steps reducer."""
|
| 15 |
-
if not new:
|
| 16 |
-
return old
|
| 17 |
-
if new[0] == _STEPS_RESET_TOKEN:
|
| 18 |
-
return new[1:]
|
| 19 |
-
return old + new
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def merge_multi_answers(old: List[Dict[str, Any]], new: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 23 |
-
"""multi_answers reducer."""
|
| 24 |
-
if not new:
|
| 25 |
-
return old
|
| 26 |
-
head = new[0]
|
| 27 |
-
if isinstance(head, dict) and head.get("__token__") == _MULTI_ANS_RESET_TOKEN:
|
| 28 |
-
return new[1:]
|
| 29 |
-
return old + new
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def merge_search_results(old: List["SearchResult"], new: List["SearchResult"]) -> List["SearchResult"]:
|
| 33 |
-
"""
|
| 34 |
-
search_results reducer.
|
| 35 |
-
병렬 검색 노드들이 동시에 search_results를 업데이트할 수 있도록 병합 로직 제공.
|
| 36 |
-
"""
|
| 37 |
-
return old + new
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
class SearchResult(BaseModel):
|
| 41 |
-
"""검색 도메인에서 공통으로 사용하는 단일 검색 결과 모델."""
|
| 42 |
-
source: str = Field(..., description="검색 출처")
|
| 43 |
-
content: str = Field(..., description="검색 결과의 핵심 내용")
|
| 44 |
-
url: Optional[str] = Field(default=None, description="원본 출처 URL")
|
| 45 |
-
relevance_score: Optional[float] = Field(default=None, description="관련도 점수")
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
class AgentState(BaseModel):
|
| 49 |
-
"""부모 그래프 전용 상태."""
|
| 50 |
-
|
| 51 |
-
# Core fields
|
| 52 |
-
user_question: str = Field(default="", description="사용자의 원본 질문")
|
| 53 |
-
messages: Annotated[List[BaseMessage], add_messages] = Field(
|
| 54 |
-
default_factory=list,
|
| 55 |
-
description="대화 메시지 히스토리"
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
# Final output
|
| 59 |
-
final_answer: Optional[str] = Field(default=None, description="최종 생성된 답변")
|
| 60 |
-
|
| 61 |
-
# Debugging/tracing
|
| 62 |
-
intermediate_steps: Annotated[List[str], merge_intermediate_steps] = Field(
|
| 63 |
-
default_factory=list,
|
| 64 |
-
description="실행 단계별 로그"
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
# Planning
|
| 68 |
-
plan: Optional[Dict[str, Any]] = Field(
|
| 69 |
-
default=None,
|
| 70 |
-
description="질문 분해 계획"
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
# Multi-question handling
|
| 74 |
-
is_multi_question: bool = Field(default=False)
|
| 75 |
-
sub_question_index: int = Field(default=0)
|
| 76 |
-
sub_question_text: Optional[str] = Field(default=None)
|
| 77 |
-
original_multi_question: Optional[str] = Field(default=None)
|
| 78 |
-
multi_answers: Annotated[List[Dict[str, Any]], merge_multi_answers] = Field(
|
| 79 |
-
default_factory=list,
|
| 80 |
-
description="다중 질문의 각 답변 리스트"
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
class Config:
|
| 84 |
-
arbitrary_types_allowed = True
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
class WorkerState(BaseModel):
|
| 88 |
-
"""
|
| 89 |
-
서브그래프 전용 상태.
|
| 90 |
-
부모 AgentState와 키 이름이 겹치지 않도록 주의해야 합니다.
|
| 91 |
-
"""
|
| 92 |
-
|
| 93 |
-
# === 입력 (부모로부터 받음) ===
|
| 94 |
-
processing_question: str = Field(default="", description="현재 처리 중인 질문")
|
| 95 |
-
messages: List[BaseMessage] = Field(default_factory=list, description="대화 히스토리")
|
| 96 |
-
|
| 97 |
-
# 🔧 [FIX] 부모 상태와 충돌 방지를 위해 이름 변경 (worker_ 접두사)
|
| 98 |
-
worker_is_multi: bool = Field(default=False)
|
| 99 |
-
worker_idx: int = Field(default=0)
|
| 100 |
-
worker_sub_text: Optional[str] = Field(default=None)
|
| 101 |
-
|
| 102 |
-
# === 서브그래프 내부 전용 필드 ===
|
| 103 |
-
# (이 필드들은 서브그래프 내부에서만 사용, 부모에게 전달 안 됨)
|
| 104 |
-
question_type: Optional[Literal["clarification", "new_topic", "independent"]] = None
|
| 105 |
-
should_cache: Optional[bool] = None
|
| 106 |
-
canonical_question: Optional[str] = None
|
| 107 |
-
analysis_reasoning: Optional[str] = None
|
| 108 |
-
cached_result: Optional[str] = None
|
| 109 |
-
detected_intent: Optional[Literal["debugging", "learning", "code_review"]] = None
|
| 110 |
-
|
| 111 |
-
# 검색 결과 (병렬 업데이트 가능하도록 reducer 적용)
|
| 112 |
-
search_results: Annotated[List[SearchResult], merge_search_results] = Field(
|
| 113 |
-
default_factory=list,
|
| 114 |
-
description="병렬 검색 결과 (reducer로 자동 병합)"
|
| 115 |
-
)
|
| 116 |
-
|
| 117 |
-
subtask_results: Dict[str, Any] = Field(default_factory=dict)
|
| 118 |
-
|
| 119 |
-
# 쿼리 개선 (이 필드들은 refine_search_node만 업데이트)
|
| 120 |
-
needs_refinement: bool = False
|
| 121 |
-
refinement_count: int = 0
|
| 122 |
-
original_question: Optional[str] = None
|
| 123 |
-
refined_question: Optional[str] = None # 🔧 개선된 쿼리를 별도 필드로 관리
|
| 124 |
-
|
| 125 |
-
# 🔧 서브그래프 내부 로그 (부모에게 전달 안 됨!)
|
| 126 |
-
intermediate_steps: List[str] = Field(
|
| 127 |
-
default_factory=list,
|
| 128 |
-
description="서브그래프 내부 로그 (부모에 전달하지 않음)"
|
| 129 |
-
)
|
| 130 |
-
|
| 131 |
-
# === 출력 (부모에게 전달될 필드) ===
|
| 132 |
-
# 이 필드들은 부모 AgentState에도 존재하며, Reducer��� 있거나 충돌이 허용되는 필드여야 함
|
| 133 |
-
final_answer: Optional[str] = None
|
| 134 |
-
|
| 135 |
-
multi_answers: Annotated[List[Dict[str, Any]], merge_multi_answers] = Field(
|
| 136 |
-
default_factory=list,
|
| 137 |
-
description="다중 질문 답변용"
|
| 138 |
-
)
|
| 139 |
-
|
| 140 |
-
class Config:
|
| 141 |
-
arbitrary_types_allowed = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/src/tools/__init__.py
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 1 |
-
from .search_tools import (
|
| 2 |
-
search_github,
|
| 3 |
-
search_official_docs,
|
| 4 |
-
search_stackoverflow,
|
| 5 |
-
)
|
| 6 |
-
|
| 7 |
-
__all__ = [
|
| 8 |
-
"search_stackoverflow",
|
| 9 |
-
"search_github",
|
| 10 |
-
"search_official_docs",
|
| 11 |
-
]
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/src/tools/search_tools.py
DELETED
|
@@ -1,217 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import os
|
| 3 |
-
import time
|
| 4 |
-
from typing import List
|
| 5 |
-
|
| 6 |
-
import requests
|
| 7 |
-
from tavily import TavilyClient # type: ignore[import]
|
| 8 |
-
|
| 9 |
-
from src.agent.state import SearchResult
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
logger = logging.getLogger(__name__)
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def search_stackoverflow(query: str, limit: int = 3) -> List[SearchResult]:
|
| 16 |
-
"""Stack Overflow에서 관련 질문을 검색한다.
|
| 17 |
-
|
| 18 |
-
Args:
|
| 19 |
-
query: 검색 쿼리
|
| 20 |
-
limit: 반환할 최대 결과 수
|
| 21 |
-
|
| 22 |
-
Returns:
|
| 23 |
-
SearchResult 리스트 (실패 시 빈 리스트)
|
| 24 |
-
"""
|
| 25 |
-
if not query.strip():
|
| 26 |
-
logger.warning("Stack Overflow 검색: 빈 쿼리")
|
| 27 |
-
return []
|
| 28 |
-
|
| 29 |
-
try:
|
| 30 |
-
url = "https://api.stackexchange.com/2.3/search/advanced"
|
| 31 |
-
params = {
|
| 32 |
-
"q": query,
|
| 33 |
-
"order": "desc",
|
| 34 |
-
"sort": "votes",
|
| 35 |
-
"site": "stackoverflow",
|
| 36 |
-
"pagesize": limit,
|
| 37 |
-
"filter": "withbody",
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
response = requests.get(url, params=params, timeout=10)
|
| 41 |
-
response.raise_for_status()
|
| 42 |
-
|
| 43 |
-
data = response.json()
|
| 44 |
-
items = data.get("items", [])
|
| 45 |
-
|
| 46 |
-
results = []
|
| 47 |
-
max_score = max((item.get("score", 0) for item in items), default=1)
|
| 48 |
-
|
| 49 |
-
for item in items:
|
| 50 |
-
title = item.get("title", "")
|
| 51 |
-
body = item.get("body", "")[:500] # 본문 일부만 포함
|
| 52 |
-
content = f"{title}\n\n{body}"
|
| 53 |
-
|
| 54 |
-
score = item.get("score", 0)
|
| 55 |
-
# 정규화: 0-1 범위로 변환
|
| 56 |
-
relevance = min(score / max(max_score, 1), 1.0) if max_score > 0 else 0.5
|
| 57 |
-
|
| 58 |
-
results.append(
|
| 59 |
-
SearchResult(
|
| 60 |
-
source="Stack Overflow",
|
| 61 |
-
content=content,
|
| 62 |
-
url=item.get("link"),
|
| 63 |
-
relevance_score=relevance,
|
| 64 |
-
)
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
logger.info("Stack Overflow 검색 성공: %d개 결과", len(results))
|
| 68 |
-
|
| 69 |
-
# Rate limit 준수
|
| 70 |
-
time.sleep(1)
|
| 71 |
-
|
| 72 |
-
return results
|
| 73 |
-
|
| 74 |
-
except Exception as e:
|
| 75 |
-
logger.error("Stack Overflow 검색 실패: %s", e, exc_info=True)
|
| 76 |
-
return []
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def search_github(query: str, limit: int = 3) -> List[SearchResult]:
|
| 80 |
-
"""GitHub에서 관련 코드를 검색한다.
|
| 81 |
-
|
| 82 |
-
Args:
|
| 83 |
-
query: 검색 쿼리
|
| 84 |
-
limit: 반환할 최대 결과 수
|
| 85 |
-
|
| 86 |
-
Returns:
|
| 87 |
-
SearchResult 리스트 (실패 시 빈 리스트)
|
| 88 |
-
"""
|
| 89 |
-
if not query.strip():
|
| 90 |
-
logger.warning("GitHub 검색: 빈 쿼리")
|
| 91 |
-
return []
|
| 92 |
-
|
| 93 |
-
try:
|
| 94 |
-
url = "https://api.github.com/search/code"
|
| 95 |
-
|
| 96 |
-
# Python 코드로 제한 (언어 감지 로직은 추후 확장 가능)
|
| 97 |
-
search_query = f"{query} language:python"
|
| 98 |
-
|
| 99 |
-
params = {
|
| 100 |
-
"q": search_query,
|
| 101 |
-
"sort": "indexed",
|
| 102 |
-
"per_page": limit,
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
headers = {
|
| 106 |
-
"Accept": "application/vnd.github.v3+json",
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
# GitHub 토큰이 있으면 Authorization 헤더 추가
|
| 110 |
-
github_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 111 |
-
if github_token:
|
| 112 |
-
headers["Authorization"] = f"token {github_token}"
|
| 113 |
-
logger.debug("GitHub 토큰 사용 (인증된 요청)")
|
| 114 |
-
else:
|
| 115 |
-
logger.warning(
|
| 116 |
-
"GITHUB_TOKEN이 설정되지 않음 - rate limit 제한적 (60 req/hr). "
|
| 117 |
-
"토큰 설정 시 5,000 req/hr로 증가"
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
response = requests.get(url, params=params, headers=headers, timeout=10)
|
| 121 |
-
response.raise_for_status()
|
| 122 |
-
|
| 123 |
-
data = response.json()
|
| 124 |
-
items = data.get("items", [])
|
| 125 |
-
|
| 126 |
-
results = []
|
| 127 |
-
for item in items:
|
| 128 |
-
repo_name = item.get("repository", {}).get("full_name", "unknown")
|
| 129 |
-
path = item.get("path", "")
|
| 130 |
-
content = f"Repository: {repo_name}\nFile: {path}"
|
| 131 |
-
|
| 132 |
-
results.append(
|
| 133 |
-
SearchResult(
|
| 134 |
-
source="GitHub",
|
| 135 |
-
content=content,
|
| 136 |
-
url=item.get("html_url"),
|
| 137 |
-
relevance_score=0.8, # GitHub 결과는 일반적으로 높은 관련도
|
| 138 |
-
)
|
| 139 |
-
)
|
| 140 |
-
|
| 141 |
-
logger.info("GitHub 검색 성공: %d개 결과", len(results))
|
| 142 |
-
|
| 143 |
-
# Rate limit 준수
|
| 144 |
-
time.sleep(1)
|
| 145 |
-
|
| 146 |
-
return results
|
| 147 |
-
|
| 148 |
-
except requests.exceptions.HTTPError as e:
|
| 149 |
-
if e.response.status_code == 403:
|
| 150 |
-
logger.warning("GitHub API rate limit 초과")
|
| 151 |
-
elif e.response.status_code == 401:
|
| 152 |
-
logger.warning("GitHub API 인증 실패 (토큰이 없거나 잘못됨). 토큰 없이 계속 진행합니다.")
|
| 153 |
-
else:
|
| 154 |
-
logger.error("GitHub 검색 HTTP 에러: %s", e, exc_info=True)
|
| 155 |
-
return []
|
| 156 |
-
except Exception as e:
|
| 157 |
-
logger.error("GitHub 검색 실패: %s", e, exc_info=True)
|
| 158 |
-
return []
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def search_official_docs(query: str, limit: int = 3) -> List[SearchResult]:
|
| 162 |
-
"""Tavily API를 사용해 공식 문서를 검색한다.
|
| 163 |
-
|
| 164 |
-
Args:
|
| 165 |
-
query: 검색 쿼리
|
| 166 |
-
limit: 반환할 최대 결과 수
|
| 167 |
-
|
| 168 |
-
Returns:
|
| 169 |
-
SearchResult 리스트 (실패 시 빈 리스트)
|
| 170 |
-
"""
|
| 171 |
-
if not query.strip():
|
| 172 |
-
logger.warning("Official Docs 검색: 빈 쿼리")
|
| 173 |
-
return []
|
| 174 |
-
|
| 175 |
-
api_key = os.getenv("TAVILY_API_KEY", "").strip()
|
| 176 |
-
if not api_key:
|
| 177 |
-
logger.error("TAVILY_API_KEY 환경 변수가 설정되어 있지 않습니다.")
|
| 178 |
-
return []
|
| 179 |
-
|
| 180 |
-
try:
|
| 181 |
-
client = TavilyClient(api_key=api_key)
|
| 182 |
-
|
| 183 |
-
response = client.search(
|
| 184 |
-
query=query,
|
| 185 |
-
search_depth="basic",
|
| 186 |
-
max_results=limit,
|
| 187 |
-
include_domains=[
|
| 188 |
-
"docs.python.org",
|
| 189 |
-
"docs.oracle.com",
|
| 190 |
-
"spring.io/guides",
|
| 191 |
-
"developer.mozilla.org",
|
| 192 |
-
"reactjs.org/docs",
|
| 193 |
-
],
|
| 194 |
-
)
|
| 195 |
-
|
| 196 |
-
results = []
|
| 197 |
-
for item in response.get("results", []):
|
| 198 |
-
content = item.get("content", "")
|
| 199 |
-
url = item.get("url", "")
|
| 200 |
-
score = item.get("score", 0.5) # Tavily가 제공하는 관련도 점수
|
| 201 |
-
|
| 202 |
-
results.append(
|
| 203 |
-
SearchResult(
|
| 204 |
-
source="Official Docs",
|
| 205 |
-
content=content,
|
| 206 |
-
url=url,
|
| 207 |
-
relevance_score=score,
|
| 208 |
-
)
|
| 209 |
-
)
|
| 210 |
-
|
| 211 |
-
logger.info("Tavily 검색 성공: %d개 결과", len(results))
|
| 212 |
-
return results
|
| 213 |
-
|
| 214 |
-
except Exception as e:
|
| 215 |
-
logger.error("Tavily 검색 실패: %s", e, exc_info=True)
|
| 216 |
-
return []
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hf-space2/CodeWeaver/src/utils/__init__.py
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
"""유틸리티 모듈."""
|
| 2 |
-
|
| 3 |
-
from .tracing import ensure_tracing_enabled, trace_node
|
| 4 |
-
|
| 5 |
-
__all__ = ["ensure_tracing_enabled", "trace_node"]
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|