ㅅㅎㅇ commited on
Commit
f627d36
·
1 Parent(s): 3e5c5ab

refactor: split nodes into modules, add core/prompts, switch to sync mode

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +0 -34
  2. CodeWeaver/README.md +145 -114
  3. CodeWeaver/pyproject.toml +8 -2
  4. CodeWeaver/requirements.txt +16 -21
  5. CodeWeaver/src/agent/__init__.py +7 -12
  6. CodeWeaver/src/agent/graph.py +76 -317
  7. CodeWeaver/src/agent/nodes.py +0 -1212
  8. {hf-space2/CodeWeaver/src/agent → CodeWeaver/src/agent/nodes}/__init__.py +45 -30
  9. CodeWeaver/src/agent/nodes/analysis.py +187 -0
  10. CodeWeaver/src/agent/nodes/answer.py +381 -0
  11. CodeWeaver/src/agent/nodes/common.py +44 -0
  12. CodeWeaver/src/agent/nodes/planning.py +171 -0
  13. CodeWeaver/src/agent/nodes/search.py +345 -0
  14. CodeWeaver/src/agent/routes.py +126 -0
  15. CodeWeaver/src/agent/state.py +37 -8
  16. CodeWeaver/src/core/__init__.py +15 -0
  17. CodeWeaver/src/core/config.py +47 -0
  18. CodeWeaver/src/core/llm.py +41 -0
  19. CodeWeaver/src/core/resources.py +86 -0
  20. CodeWeaver/src/prompts/__init__.py +6 -0
  21. CodeWeaver/src/prompts/loader.py +144 -0
  22. CodeWeaver/src/prompts/templates/analysis.yaml +45 -0
  23. CodeWeaver/src/prompts/templates/answer.yaml +65 -0
  24. CodeWeaver/src/prompts/templates/planning.yaml +66 -0
  25. CodeWeaver/src/prompts/templates/search.yaml +25 -0
  26. CodeWeaver/src/scripts/init_db.py +47 -0
  27. CodeWeaver/src/scripts/init_qdrant.py +73 -0
  28. CodeWeaver/src/tools/__init__.py +1 -2
  29. CodeWeaver/src/tools/{search_tools.py → search.py} +67 -95
  30. CodeWeaver/src/vector_db/local_embeddings.py +95 -16
  31. CodeWeaver/src/vector_db/qdrant_client.py +24 -31
  32. CodeWeaver/ui/app.py +120 -149
  33. CodeWeaver/uv.lock +0 -0
  34. hf-space2/CodeWeaver/.env.example +0 -9
  35. hf-space2/CodeWeaver/.gitignore +0 -23
  36. hf-space2/CodeWeaver/.python-version +0 -1
  37. hf-space2/CodeWeaver/IMPLEMENTATION_REPORT.md +0 -175
  38. hf-space2/CodeWeaver/PHASE3_CHANGES.md +0 -142
  39. hf-space2/CodeWeaver/PHASE5_SUBGRAPH_REFACTORING.md +0 -320
  40. hf-space2/CodeWeaver/README.md +0 -118
  41. hf-space2/CodeWeaver/main.py +0 -6
  42. hf-space2/CodeWeaver/pyproject.toml +0 -27
  43. hf-space2/CodeWeaver/requirements.txt +0 -24
  44. hf-space2/CodeWeaver/src/__init__.py +0 -0
  45. hf-space2/CodeWeaver/src/agent/graph.py +0 -420
  46. hf-space2/CodeWeaver/src/agent/nodes.py +0 -1212
  47. hf-space2/CodeWeaver/src/agent/state.py +0 -141
  48. hf-space2/CodeWeaver/src/tools/__init__.py +0 -12
  49. hf-space2/CodeWeaver/src/tools/search_tools.py +0 -217
  50. 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
- 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`를 참고하세요.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 🕸️ CodeWeaver: LangGraph 기반 지능형 개발 어시스턴트
2
+ CodeWeaver는 초보 개발자를 위해 설계된 LangGraph 기반의 자율 AI 에이전트입니다.
3
+ 단순한 LLM 래퍼가 아닙니다. 사용자의 질문 의도를 파악하고(Planning), 필요한 경우에만 외부 지식을 검색하며(Retrieval), 검색 결과를 재평가(Reranking)하여 최적의 답변을 생성하는 Agentic Workflow를 구현했습니다.
4
+ ![alt text](https://img.shields.io/badge/Python-3.10%2B-blue)
5
+
6
+ ![alt text](https://img.shields.io/badge/LangGraph-StateGraph-orange)
7
+
8
+ ![alt text](https://img.shields.io/badge/UI-Gradio-purple)
9
+
10
+ ![alt text](https://img.shields.io/badge/Package_Manager-uv-astral)
11
+
12
+ ![alt text](https://img.shields.io/badge/License-MIT-green)
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
- "sentence-transformers>=3.0.0",
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
- # 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
-
 
 
 
 
 
 
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
- from .graph import agent, build_agent_graph, create_agent
 
 
 
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
- "agent",
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
- 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,
@@ -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
- 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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- LangGraph 기반 개발자 질문 답변 에이전트를 제공합니다.
5
-
6
- 주요 컴포넌트:
7
- - State: 에이전트 상태 관리
8
- - Nodes: 개별 처리 노드
9
- - Graph: LangGraph 워크플로우
10
- """
11
 
12
- from .state import AgentState, SearchResult
13
- from .graph import agent, build_agent_graph, create_agent
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,
 
 
 
 
 
 
 
 
 
 
26
  )
27
 
28
  __all__ = [
29
- # State
30
- "AgentState",
31
- "SearchResult",
32
-
33
- # Graph
34
- "agent",
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
 
 
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[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
 
@@ -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[Dict[str, Any]] = Field(
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[Dict[str, Any]], merge_multi_answers] = Field(
79
  default_factory=list,
80
  description="다중 질문의 각 답변 리스트"
81
  )
@@ -101,12 +127,11 @@ class WorkerState(BaseModel):
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(
@@ -114,7 +139,11 @@ class WorkerState(BaseModel):
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
@@ -132,7 +161,7 @@ class WorkerState(BaseModel):
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
  )
 
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 .search_tools import (
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 os
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
- 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: 검색 쿼리
@@ -90,76 +86,59 @@ def search_github(query: str, limit: int = 3) -> List[SearchResult]:
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: 검색 쿼리
@@ -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("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
 
 
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
- BAAI/bge-m3 모델을 사용해 로컬에서 임베딩을 생성한다.
 
5
  """
6
 
7
  import logging
 
8
  from typing import List
9
 
10
- from sentence_transformers import SentenceTransformer
 
 
11
 
12
  logger = logging.getLogger(__name__)
13
 
14
 
15
  class LocalEmbeddingManager:
16
- """BAAI/bge-m3 로컬 임베딩 생성기."""
 
 
 
 
17
 
18
- def __init__(self, model_name: str = "BAAI/bge-m3") -> None:
19
- logger.info("로컬 임베딩 모델 로딩 중: %s", model_name)
20
- self.model = SentenceTransformer(model_name)
21
- dim = self.model.get_sentence_embedding_dimension()
22
- logger.info("로컬 임베딩 모델 로딩 완료 (차원: %d)", dim)
23
 
24
- def get_embedding(self, text: str) -> List[float]:
25
- """단일 텍스트를 임베딩."""
26
- embedding = self.model.encode(text, convert_to_numpy=True)
27
- return embedding.tolist()
 
 
 
 
28
 
29
- def get_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
30
- """배치 텍스트 임베딩."""
31
- embeddings = self.model.encode(texts, convert_to_numpy=True)
32
- return embeddings.tolist()
 
 
 
 
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 os
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
- qdrant_url = os.getenv("QDRANT_URL", "").strip()
27
- qdrant_api_key = os.getenv("QDRANT_API_KEY", "").strip()
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=1024, # bge-m3 임베딩 차원
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
- async def get_embedding(self, text: str) -> List[float]:
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
- async def search_cache(
86
  self,
87
  question: str,
88
- threshold: float = 0.85,
89
  ) -> Optional[str]:
90
  """질문에 대한 캐시된 답변을 Qdrant에서 검색한다.
91
 
92
  threshold보다 높은 score를 가진 결과가 있을 때만 answer를 반환한다.
93
  """
94
  try:
95
- embedding = await self.get_embedding(question)
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, question=%s, answer_length=%d",
142
  score,
143
  question,
 
144
  len(str(answer)),
145
  )
146
  return str(answer)
147
 
148
- async def save_to_cache(self, question: str, answer: str) -> None:
149
  """질문-답변 쌍을 Qdrant 캐시에 저장한다.
150
 
151
  동일한 질문에 대해서는 deterministic ID를 사용하여,
152
  upsert 시 기존 엔트리를 덮어쓰게 함으로써 중복을 방지한다.
153
  """
154
  try:
155
- embedding = await self.get_embedding(question)
156
  except Exception:
157
  # 임베딩 실패 시 캐시에 저장하지 않는다.
158
  logger.warning("임베딩 실패로 인해 캐시에 저장하지 않음. question=%s", question)
159
  return
160
 
161
- # UUID 대신 질문 해시 기반 deterministic ID 사용
162
- # → 동일 질문 = 동일 ID → upsert가 덮어쓰기로 동작 → 중복 방지
163
- #
164
- # 주의: Qdrant point id는 "unsigned int" 또는 "UUID"만 허용한다.
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 캐시에 저장 완료 (hash ID로 중복 방지): point_id=%s, question_length=%d, answer_length=%d",
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
- async def get_cache_stats(self) -> Dict[str, int]:
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 uuid
6
  from pathlib import Path
 
7
 
8
  import gradio as gr
9
  from dotenv import load_dotenv
10
 
11
- # 환경 변수 로드 (에이전트/트레이싱 import 이전에 실행)
12
  load_dotenv()
13
 
14
  # 프로젝트 루트를 경로에 추가
15
  sys.path.insert(0, str(Path(__file__).parent.parent))
16
 
17
- from src.agent.graph import agent
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
- # 외부 라이브러리 로그는 WARNING만
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
- async def chat(
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
- # 초기 상태 생성 (Pydantic BaseModel 사용)
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
- # 세션별 thread_id를 config에 전달 (MemorySaver가 대화 맥락 유지)
69
- config = {"configurable": {"thread_id": thread_id}}
70
 
71
- # 에이전트 실행
72
- result = await agent.ainvoke(initial_state, config=config)
 
 
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
- # 🤖 CodeWeaver
115
- ### AI 기반 개발 질문 답변 시스템
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
- # 세션별 고유 ID (브라우저 세션마다 독립적으로 생성)
135
- session_id = gr.State(value=lambda: str(uuid.uuid4()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
- # 채팅 인터페이스
138
  chatbot_interface = gr.ChatInterface(
139
  fn=chat,
140
- examples=None, # examples는 아래 Accordion에서 수동 처리
141
- chatbot=gr.Chatbot(height=500),
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=[session_id], # thread_id 전달
151
  )
152
 
153
- # Clear 버튼 클릭 시 새 세션 ID 생성 (새 대화 시작)
154
  def reset_session():
155
- new_id = str(uuid.uuid4())
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
- session_id,
164
  queue=False
165
  )
166
 
167
- # 빠른 질문 버튼들 (Accordion 밖으로 분리)
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
- for question in example_questions:
179
- btn = gr.Button(
180
- question,
181
- variant="secondary",
182
- size="sm",
183
- scale=1,
184
- )
185
- # 버튼 클릭 입력창에 자동 입력
186
- btn.click(
187
- fn=lambda q=question: q,
188
- outputs=[chatbot_interface.textbox],
189
- )
190
-
191
- # 정보 섹션
192
- with gr.Accordion("📊 시스템 정보", open=False):
193
- gr.Markdown("""
194
- ### 사용된 기술
195
- - **LLM**: Gemini 2.5 Flash Lite
196
- - **임베딩**: BAAI/bge-m3 (로컬)
197
- - **벡터 DB**: Qdrant Cloud
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
- ### 1. 구체적으로 질문하기
228
- - "파이썬 에러"
229
- - "ImportError: No module named 'requests' 해결 방법"
230
-
231
- ### 2. 질문 유형별 예시
232
- - **디버깅**: "이 에러 메시지는 무엇을 의미하나요?"
233
- - **학습**: "JPA N+1 문제는 발생하나요?"
234
- - **코드 리뷰**: "이 코드를 더 효율적으로 개선하려면?"
235
-
236
- ### 3. 다중 질문 사용법
237
- - **2개까지 가능**: "JWT가 뭐야? CORS는?"
238
- - **3개 이상 불가**: "JWT? CORS? Docker?" 안내 메시지 표시
239
- - 💡 **팁**: 관련 질문은 하나로 통합하거나, 순차적으로 질문하세요
240
-
241
- ### 4. 대화 맥락 활용
242
- - **후속 질문**: "좀 쉽게 설명해줘", "예제 코드로 보여줘"
243
- - **새 개념 질문**: 대화 중에도 "Event Listener는 뭐야?" 같은 독립 질문 가능
244
- - 💡 **팁**: 이전 대화를 참고한 답변이 필요하면 자연스럽게 질문하세요
245
-
246
- ### 5. 응답 시간
247
- - **첫 질문**: 20~30초 소요 (검색 + 답변 생성)
248
- - **유사 질문**: 즉시 답변 (캐시 활용, 임계값 0.85 이상)
249
- - **다중 질문**: 질문별 병렬 처리로 효율적
250
-
251
- ### 6. 나은 답변을 위한
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, # True로 하면 공개 URL 생성
271
- show_api=False, # Gradio 4.44.x 버그 우회용
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
-