Spaces:
Sleeping
Sleeping
ㅅㅎㅇ commited on
Commit ·
ea80cdc
0
Parent(s):
Initial commit for Hugging Face Spaces
Browse files- .gitignore +34 -0
- ARCHITECTURE.md +231 -0
- CodeWeaver +1 -0
- DYNAMIC_PARALLEL_SEARCH.md +553 -0
- README.md +40 -0
- app.py +41 -0
- requirements.txt +7 -0
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CodeWeaver 아키텍처 (실제 코드 기준)
|
| 2 |
+
|
| 3 |
+
이 문서는 현재 저장소의 CodeWeaver가 **어떤 순서로 동작하는지**, 그리고 그 원리가 무엇인지(상태/라우팅/병렬화/캐시)를 **코드와 1:1로 정합**되게 설명합니다.
|
| 4 |
+
|
| 5 |
+
## 전체 구성 요소
|
| 6 |
+
|
| 7 |
+
- **UI**: Gradio 채팅 UI (`CodeWeaver/ui/app.py`)
|
| 8 |
+
- 사용자 입력을 `AgentState`로 포장한 뒤 `agent.ainvoke(..., config={"configurable": {"thread_id": ...}})`로 실행합니다.
|
| 9 |
+
- **오케스트레이션(그래프)**: LangGraph `StateGraph` (`CodeWeaver/src/agent/graph.py`)
|
| 10 |
+
- `START → create_plan`로 진입 후, 질문 유형/개수에 따라 분기합니다.
|
| 11 |
+
- 체크포인팅: `MemorySaver` 사용(스레드/세션 단위 상태 유지).
|
| 12 |
+
- **노드 구현**: (`CodeWeaver/src/agent/nodes.py`)
|
| 13 |
+
- 질문 분석, 캐시 조회, 의도 분류, 3소스 병렬 검색, 결과 평가/리파인, 필터링/요약, 답변 생성, 다중 질문 결합 등을 담당합니다.
|
| 14 |
+
- **상태 모델(Reducer 포함)**: (`CodeWeaver/src/agent/state.py`)
|
| 15 |
+
- `search_results`는 `Annotated[List[SearchResult], add]`로 **병렬 검색 결과가 자동 병합**됩니다.
|
| 16 |
+
- `intermediate_steps`, `multi_answers`는 **리셋 토큰을 지원하는 커스텀 reducer**로, 체크포인팅/스레드 유지 시 이전 턴의 누적을 방지합니다.
|
| 17 |
+
- **캐시(Vector DB)**: Qdrant Cloud (`CodeWeaver/src/vector_db/qdrant_client.py`)
|
| 18 |
+
- 임베딩은 로컬 `BAAI/bge-m3`(`sentence-transformers`)로 생성, Qdrant에 저장/검색합니다.
|
| 19 |
+
- **검색 소스**: (`CodeWeaver/src/tools/search_tools.py`)
|
| 20 |
+
- Stack Overflow(공식 StackExchange API), GitHub Code Search API, Tavily(공식문서 도메인 제한) 사용.
|
| 21 |
+
|
| 22 |
+
## 사용자 제공 그래프와의 정합성
|
| 23 |
+
|
| 24 |
+
사용자께서 제공한 Mermaid 그래프는 이 프로젝트의 의도와 **대부분 일치**합니다.
|
| 25 |
+
|
| 26 |
+
### 일치하는 부분(핵심 파이프라인)
|
| 27 |
+
|
| 28 |
+
- `create_plan`에서 **single_topic / multiple_questions(2개) / too_many(3+)** 분기
|
| 29 |
+
- 단일 질문(혹은 단일 주제)에서:
|
| 30 |
+
- `analyze_question → check_cache → (hit면 return_cached_answer) / (miss면 classify_intent)`
|
| 31 |
+
- `classify_intent` 이후 3소스 검색을 Send API로 병렬 실행(fan-out)하고 `collect_results`에서 fan-in
|
| 32 |
+
- `evaluate_results → (필요 시 refine_search 1회) → filter_and_score → summarize_results → generate_answer`
|
| 33 |
+
- `evaluate_results`가 부족하면 `refine_search → classify_intent`로 **최대 1회 루프**
|
| 34 |
+
|
| 35 |
+
### 실제 코드에서 추가/변형된 부분(중요)
|
| 36 |
+
|
| 37 |
+
1) **clarification(보충 요청) 전용 경로가 존재**
|
| 38 |
+
|
| 39 |
+
- `analyze_question` 결과가 `clarification`이면
|
| 40 |
+
- **캐시/검색을 수행하지 않고**
|
| 41 |
+
- `generate_with_history`로 바로 답변하고 종료합니다.
|
| 42 |
+
|
| 43 |
+
2) **multiple_questions fan-out은 `analyze_question`로 직접 들어가지 않음**
|
| 44 |
+
|
| 45 |
+
사용자 그래프는 “dynamic에서 Send로 analyze_question을 2번 호출” 형태에 가깝지만, 실제 구현은 다릅니다.
|
| 46 |
+
|
| 47 |
+
- 실제 구현은 `fanout_multi_questions`가 `Send("run_single_question_worker", child_state)`를 생성합니다.
|
| 48 |
+
- 이유: outer graph에서 질문 2개를 동시에 동일 파이프라인(analyze/cache/intent/…)으로 돌리면
|
| 49 |
+
- `question_type`, `cached_result` 같은 **scalar 채널(state 필드)**이 병렬 업데이트 충돌을 일으킬 수 있습니다.
|
| 50 |
+
- 따라서 **worker 내부에서 별도의 ‘단일 질문 그래프’를 실행**하고,
|
| 51 |
+
- outer graph에는 reducer 채널인 `multi_answers`만 업데이트하여 충돌을 제거합니다.
|
| 52 |
+
|
| 53 |
+
## 실제 실행 흐름(코드 기준)
|
| 54 |
+
|
| 55 |
+
### 1) UI → Agent 실행(엔트리)
|
| 56 |
+
|
| 57 |
+
`CodeWeaver/ui/app.py`에서:
|
| 58 |
+
|
| 59 |
+
- 입력 문자열 `message`를 `AgentState(user_question=..., messages=[HumanMessage(...)], ...)`로 만들고
|
| 60 |
+
- `thread_id`를 `config={"configurable":{"thread_id": thread_id}}`로 전달하여 `agent.ainvoke()` 실행
|
| 61 |
+
- `MemorySaver`가 `thread_id` 단위로 상태를 보존합니다.
|
| 62 |
+
|
| 63 |
+
### 2) 메인 그래프(Top-level) 흐름
|
| 64 |
+
|
| 65 |
+
`CodeWeaver/src/agent/graph.py` 기준 메인 흐름은 아래와 같습니다.
|
| 66 |
+
|
| 67 |
+
```mermaid
|
| 68 |
+
graph TD
|
| 69 |
+
startNode[START] --> createPlan[create_plan]
|
| 70 |
+
|
| 71 |
+
createPlan -->|single_topic| analyzeQuestion[analyze_question]
|
| 72 |
+
createPlan -->|multiple_questions_2| initiateDynamic[initiate_dynamic_search]
|
| 73 |
+
createPlan -->|too_many_3plus| tooMany[handle_too_many_questions]
|
| 74 |
+
|
| 75 |
+
tooMany --> endNode[END]
|
| 76 |
+
|
| 77 |
+
analyzeQuestion -->|clarification| withHistory[generate_with_history]
|
| 78 |
+
withHistory --> endNode
|
| 79 |
+
|
| 80 |
+
analyzeQuestion -->|new_topic_or_independent| checkCache[check_cache]
|
| 81 |
+
checkCache -->|hit| returnCached[return_cached_answer]
|
| 82 |
+
returnCached --> endNode
|
| 83 |
+
|
| 84 |
+
checkCache -->|miss| classifyIntent[classify_intent]
|
| 85 |
+
|
| 86 |
+
classifyIntent --> searchSO[search_stackoverflow]
|
| 87 |
+
classifyIntent --> searchGH[search_github]
|
| 88 |
+
classifyIntent --> searchDocs[search_official_docs]
|
| 89 |
+
|
| 90 |
+
searchSO --> collect[collect_results]
|
| 91 |
+
searchGH --> collect
|
| 92 |
+
searchDocs --> collect
|
| 93 |
+
|
| 94 |
+
collect --> evalNode[evaluate_results]
|
| 95 |
+
evalNode -->|needs_refinement_and_lt1| refine[refine_search]
|
| 96 |
+
refine --> classifyIntent
|
| 97 |
+
|
| 98 |
+
evalNode -->|sufficient_or_ge1| searchSubgraph[search_subgraph]
|
| 99 |
+
searchSubgraph --> generateAnswer[generate_answer]
|
| 100 |
+
generateAnswer --> routeAfterGen[route_after_generate]
|
| 101 |
+
routeAfterGen -->|single| endNode
|
| 102 |
+
routeAfterGen -->|multi| combine[combine_answers]
|
| 103 |
+
combine --> endNode
|
| 104 |
+
|
| 105 |
+
initiateDynamic --> fanout[fanout_multi_questions]
|
| 106 |
+
fanout --> worker[run_single_question_worker]
|
| 107 |
+
worker --> combine
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### 3) `create_plan`: 질문 개수/형태 판별 + “3개 이상” 하드 가드
|
| 111 |
+
|
| 112 |
+
`create_plan_node`는 입력을 아래 3가지로 분류합니다.
|
| 113 |
+
|
| 114 |
+
- **single_topic**: 하나의 주제를 다양한 관점으로 묻는 형태
|
| 115 |
+
- **multiple_questions**: 독립 질문 2개
|
| 116 |
+
- **too_many**: 독립 질문 3개 이상
|
| 117 |
+
|
| 118 |
+
추가로, LLM 분류와 무관하게 다음 조건이면 **결정론적으로 too_many**로 강제합니다.
|
| 119 |
+
|
| 120 |
+
- 물음표가 3개 이상
|
| 121 |
+
- 또는 “질문 후보”가 3개 이상(줄바꿈/번호/구분자 등으로 추정)
|
| 122 |
+
|
| 123 |
+
또한 체크포인팅 상태 누적을 막기 위해, 매 실행 시작 시 `multi_answers`를 리셋 토큰으로 초기화합니다.
|
| 124 |
+
|
| 125 |
+
### 4) `analyze_question`: 질문 타입(clarification/new_topic/independent) + 캐시 적격성 판단
|
| 126 |
+
|
| 127 |
+
`analyze_question_node`가 LLM으로 아래 값을 생성합니다.
|
| 128 |
+
|
| 129 |
+
- `question_type`: `clarification | new_topic | independent`
|
| 130 |
+
- `should_cache`: 캐시 저장 여부
|
| 131 |
+
- `canonical_question`: 캐시용 정규화 질문(should_cache=true일 때)
|
| 132 |
+
|
| 133 |
+
라우팅은 `graph.py`의 `route_after_analysis`에서:
|
| 134 |
+
|
| 135 |
+
- `clarification` → `generate_with_history` (검색/캐시 생략)
|
| 136 |
+
- 나머지 → `check_cache`
|
| 137 |
+
|
| 138 |
+
### 5) 캐시(`check_cache` / `return_cached_answer`)
|
| 139 |
+
|
| 140 |
+
`check_cache_node`는 Qdrant에서 유사 질문을 검색합니다.
|
| 141 |
+
|
| 142 |
+
- 임베딩: 로컬 `BAAI/bge-m3` (1024차원)
|
| 143 |
+
- 임계값: cosine score **0.85 이상**이면 hit로 간주
|
| 144 |
+
|
| 145 |
+
hit면 `return_cached_answer_node`가 저장된 답변을 즉시 반환합니다.
|
| 146 |
+
|
| 147 |
+
### 6) 의도 분류(`classify_intent`)
|
| 148 |
+
|
| 149 |
+
`classify_intent_node`가 질문을 `debugging | learning | code_review`로 분류합니다.
|
| 150 |
+
|
| 151 |
+
이 값은 검색 개수 등 일부 정책에 반영됩니다(예: StackOverflow는 debugging이면 더 많이 가져옴).
|
| 152 |
+
|
| 153 |
+
### 7) 병렬 검색(fan-out) → 수집(fan-in)
|
| 154 |
+
|
| 155 |
+
`classify_intent` 이후 conditional edge 함수가 `Send(...)` 3개를 반환하여 병렬로 실행됩니다.
|
| 156 |
+
|
| 157 |
+
- `search_stackoverflow_node`
|
| 158 |
+
- `search_github_node`
|
| 159 |
+
- `search_official_docs_node`
|
| 160 |
+
|
| 161 |
+
각 노드는 `{"search_results": [..]}`를 반환하고, `AgentState.search_results`의 reducer(`add`)가 이를 자동 병합합니다.
|
| 162 |
+
|
| 163 |
+
`collect_results_node`는 병합된 총 결과 개수만 집계합니다.
|
| 164 |
+
|
| 165 |
+
### 8) 결과 평가(`evaluate_results`)와 쿼리 리파인(`refine_search`)
|
| 166 |
+
|
| 167 |
+
`evaluate_results_node`는 다음 기준으로 “개선 필요”를 판단합니다.
|
| 168 |
+
|
| 169 |
+
- 결과 개수 < 2 → 개선 필요
|
| 170 |
+
- (relevance_score가 있다면) 평균 점수 < 0.5 → 개선 필요
|
| 171 |
+
|
| 172 |
+
`refine_search_node`는 LLM이 `MORE_SPECIFIC | MORE_GENERAL | TRANSLATE` 전략을 선택해 쿼리를 개선합니다.
|
| 173 |
+
|
| 174 |
+
- 무한 루프 방지: `refinement_count < 1`일 때만 1회 허용
|
| 175 |
+
- 재검색을 위해 `search_results`를 빈 리스트로 초기화하고 `classify_intent`로 되돌아갑니다.
|
| 176 |
+
|
| 177 |
+
### 9) `search_subgraph`: 필터링 + 요약
|
| 178 |
+
|
| 179 |
+
메인 그래프에는 `search_subgraph`가 “하나의 노드”처럼 붙어 있습니다.
|
| 180 |
+
|
| 181 |
+
- `filter_and_score`: 최소 길이/URL 조건으로 필터 후, 상위 일부에 대해 관련도 점수 부여
|
| 182 |
+
- `summarize_results`: 각 결과를 2~3문장으로 요약
|
| 183 |
+
|
| 184 |
+
### 10) `generate_answer`: 답변 생성 + (조건부) 캐시 저장
|
| 185 |
+
|
| 186 |
+
`generate_answer_node`는 의도에 따라 템플릿을 바꿔 최종 답변을 생성합니다.
|
| 187 |
+
|
| 188 |
+
캐시 저장 정책:
|
| 189 |
+
|
| 190 |
+
- `question_type`가 `new_topic` 또는 `independent`이고 `should_cache`가 true이면 저장
|
| 191 |
+
- `clarification`은 저장하지 않음(라우팅상 보통 여기로 오지 않지만 방어적으로 체크)
|
| 192 |
+
|
| 193 |
+
### 11) 다중 질문(multiple_questions) 처리 원리
|
| 194 |
+
|
| 195 |
+
다중 질문의 핵심은 “outer graph는 충돌 없이 orchestration만, 실제 파이프라인은 worker 내부에서 실행”입니다.
|
| 196 |
+
|
| 197 |
+
#### 흐름
|
| 198 |
+
|
| 199 |
+
- `create_plan(case=multiple_questions)` → `initiate_dynamic_search` (준비)
|
| 200 |
+
- `fanout_multi_questions`(conditional edge)이 질문 2개를 각각 `run_single_question_worker`로 Send
|
| 201 |
+
- `run_single_question_worker_node` 내부에서 **단일 질문용 그래프를 별도 compile/실행**
|
| 202 |
+
- worker 결과는 `multi_answers`에 append(reducer로 병합)
|
| 203 |
+
- 모든 worker가 끝나면 `combine_answers_node`가 Markdown으로 결합
|
| 204 |
+
|
| 205 |
+
#### 왜 worker가 필요한가?
|
| 206 |
+
|
| 207 |
+
outer graph에서 동일한 state를 복제해 `analyze_question`부터 동시에 돌리면,
|
| 208 |
+
scalar 채널(`question_type`, `cached_result` 등)이 서로 덮어쓰일 수 있습니다.
|
| 209 |
+
|
| 210 |
+
그래서 실제 구현은:
|
| 211 |
+
|
| 212 |
+
- worker 내부에서 단일 질문 그래프를 돌리고
|
| 213 |
+
- outer state에는 **reducer 채널인 `multi_answers`만** 업데이트
|
| 214 |
+
|
| 215 |
+
이 방식으로 병렬 실행 안정성을 확보합니다.
|
| 216 |
+
|
| 217 |
+
## 환경 변수(실행에 필요한 실제 값)
|
| 218 |
+
|
| 219 |
+
필수:
|
| 220 |
+
|
| 221 |
+
- `GOOGLE_API_KEY`: Gemini 호출(`langchain-google-genai`)
|
| 222 |
+
- `QDRANT_URL`, `QDRANT_API_KEY`: Qdrant Cloud 캐시
|
| 223 |
+
- `TAVILY_API_KEY`: 공식 문서 검색(Tavily)
|
| 224 |
+
|
| 225 |
+
선택:
|
| 226 |
+
|
| 227 |
+
- `GITHUB_TOKEN`: GitHub API rate limit 완화(없으면 60 req/hr 수준)
|
| 228 |
+
- `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`: LangSmith 트레이싱(선택)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
|
CodeWeaver
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit fc4c811e94059981ae4ef7924c9aed6ccc9cbc44
|
DYNAMIC_PARALLEL_SEARCH.md
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dynamic Parallel Search for Multiple Independent Questions
|
| 2 |
+
|
| 3 |
+
## 개요
|
| 4 |
+
|
| 5 |
+
CodeWeaver Phase 4는 **다중 독립 질문**을 Send API로 동적 병렬 처리하여, 각 질문마다 독립적인 검색 파이프라인을 실행합니다.
|
| 6 |
+
|
| 7 |
+
### 핵심 철학
|
| 8 |
+
|
| 9 |
+
> "기존 그래프를 100% 재사용하되, 질문 개수만큼 복제해서 병렬 실행한다"
|
| 10 |
+
|
| 11 |
+
- **기존 코드 재사용률**: ~95%
|
| 12 |
+
- **새로운 노드**: 5개 추가
|
| 13 |
+
- **새로운 edge 함수**: 1개 추가 (fanout_multi_questions)
|
| 14 |
+
- **수정된 노드**: 2개 수정 (create_plan, generate_answer)
|
| 15 |
+
|
| 16 |
+
## 주요 기능
|
| 17 |
+
|
| 18 |
+
### 1. 자동 질문 유형 감지
|
| 19 |
+
|
| 20 |
+
**create_plan_node**가 질문을 분석하여 3가지 케이스로 분류:
|
| 21 |
+
|
| 22 |
+
#### Case 1: single_topic
|
| 23 |
+
- **정의**: 하나의 주제를 다각도로 묻는 경우
|
| 24 |
+
- **예시**: "Spring Security JWT 인증 구현 방법"
|
| 25 |
+
- **서브질문**: ["개념", "구현", "예제"] (답변 섹션 구조용)
|
| 26 |
+
- **실행**: 기존 그래프 1회 (검색은 원본 질문으로)
|
| 27 |
+
|
| 28 |
+
#### Case 2: multiple_questions
|
| 29 |
+
- **정의**: 서로 무관한 독립 질문 (최대 2개)
|
| 30 |
+
- **예시**: "JWT가 뭐야? CORS는?"
|
| 31 |
+
- **서브질문**: ["JWT가 뭐야?", "CORS는?"] (각각 별도 검색)
|
| 32 |
+
- **실행**: Send API로 기존 그래프 2회 병렬 실행
|
| 33 |
+
|
| 34 |
+
#### Case 3: too_many
|
| 35 |
+
- **정의**: 질문 3개 이상
|
| 36 |
+
- **예시**: "JWT? CORS? Docker?"
|
| 37 |
+
- **실행**: 친절한 에러 메시지 표시, 대화 계속 가능
|
| 38 |
+
- **하드 가드**: LLM 분류와 무관하게 물음표 개수(3개 이상) 또는 질문 후보 개수(3개 이상)로 결정론적 차단
|
| 39 |
+
|
| 40 |
+
### 2. 질문 개수 제한
|
| 41 |
+
|
| 42 |
+
비용 및 품질 관리를 위해 **최대 2개 질문**으로 제한:
|
| 43 |
+
|
| 44 |
+
```
|
| 45 |
+
입력: "JWT? CORS? Docker? Redis?"
|
| 46 |
+
처리: too_many 케이스 → 에러 메시지
|
| 47 |
+
안내: "하나의 주제로 통합" 또는 "2개만 선택" 권장
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 3. Send API 동적 복제
|
| 51 |
+
|
| 52 |
+
**중요**: LangGraph에서 `List[Send]`는 노드 반환값이 아니라 **conditional edge 함수 반환값**으로만 사용됩니다.
|
| 53 |
+
|
| 54 |
+
```python
|
| 55 |
+
# initiate_dynamic_search_node: state 준비만 (dict 반환)
|
| 56 |
+
def initiate_dynamic_search_node(state: AgentState) -> dict:
|
| 57 |
+
return {"intermediate_steps": [...]} # Send 반환 안 함!
|
| 58 |
+
|
| 59 |
+
# fanout_multi_questions: conditional edge 함수 (List[Send] 반환)
|
| 60 |
+
def fanout_multi_questions(state: AgentState) -> List[Send]:
|
| 61 |
+
sends = []
|
| 62 |
+
for i, question in enumerate(["JWT가 뭐야?", "CORS는?"]):
|
| 63 |
+
child_state = state.model_copy(deep=True)
|
| 64 |
+
child_state.user_question = question
|
| 65 |
+
child_state.is_multi_question = True
|
| 66 |
+
# ... 메타데이터 설정 ...
|
| 67 |
+
sends.append(Send("run_single_question_worker", child_state))
|
| 68 |
+
return sends
|
| 69 |
+
|
| 70 |
+
# run_single_question_worker: 내부 서브그래프 실행
|
| 71 |
+
# 각 Send는 독립적으로 내부 그래프를 실행:
|
| 72 |
+
# analyze → cache → classify → search(×3) → collect → eval → subgraph → generate
|
| 73 |
+
# → multi_answers에 결과 추가
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### 4. Reducer 자동 Fan-in (Reset 기능 포함)
|
| 77 |
+
|
| 78 |
+
```python
|
| 79 |
+
# State 정의 (커스텀 reducer 사용)
|
| 80 |
+
multi_answers: Annotated[List[Dict[str, Any]], merge_multi_answers] = []
|
| 81 |
+
|
| 82 |
+
# merge_multi_answers reducer:
|
| 83 |
+
# - 기본 동작: old + new (병렬 worker에서 답변을 동시에 append)
|
| 84 |
+
# - 리셋 동작: new의 첫 원소가 {"__token__": "__RESET_MULTI_ANS__"}이면
|
| 85 |
+
# old를 버리고 new[1:]로 교체 (이전 턴 누적 방지)
|
| 86 |
+
|
| 87 |
+
# run_single_question_worker 1이 리턴:
|
| 88 |
+
{"multi_answers": [{"index": 0, "question": "JWT가 뭐야?", "answer": "..."}]}
|
| 89 |
+
|
| 90 |
+
# run_single_question_worker 2가 리턴:
|
| 91 |
+
{"multi_answers": [{"index": 1, "question": "CORS는?", "answer": "..."}]}
|
| 92 |
+
|
| 93 |
+
# LangGraph Reducer가 자동 병합:
|
| 94 |
+
state.multi_answers = [
|
| 95 |
+
{"index": 0, ...},
|
| 96 |
+
{"index": 1, ...}
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
# combine_answers_node가 이를 통합 Markdown으로 변환
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## 그래프 흐름
|
| 103 |
+
|
| 104 |
+
```mermaid
|
| 105 |
+
graph TD
|
| 106 |
+
START[START] --> plan[create_plan]
|
| 107 |
+
|
| 108 |
+
plan -->|single_topic| analyze[analyze_question]
|
| 109 |
+
plan -->|multiple_questions 2개| dynamic[initiate_dynamic_search]
|
| 110 |
+
plan -->|too_many 3+| tooMany[handle_too_many_questions]
|
| 111 |
+
|
| 112 |
+
tooMany --> END
|
| 113 |
+
|
| 114 |
+
analyze --> cache[check_cache]
|
| 115 |
+
cache -->|hit| returnCache[return_cached_answer]
|
| 116 |
+
cache -->|miss| classify[classify_intent]
|
| 117 |
+
|
| 118 |
+
returnCache --> END
|
| 119 |
+
|
| 120 |
+
classify --> searchSO[search_stackoverflow]
|
| 121 |
+
classify --> searchGH[search_github]
|
| 122 |
+
classify --> searchDocs[search_official_docs]
|
| 123 |
+
|
| 124 |
+
searchSO --> collect[collect_results]
|
| 125 |
+
searchGH --> collect
|
| 126 |
+
searchDocs --> collect
|
| 127 |
+
|
| 128 |
+
collect --> eval[evaluate_results]
|
| 129 |
+
|
| 130 |
+
eval -->|needs_refinement| refine[refine_search]
|
| 131 |
+
eval -->|sufficient| filterNode[filter_and_score]
|
| 132 |
+
|
| 133 |
+
refine --> classify
|
| 134 |
+
|
| 135 |
+
filterNode --> summarize[summarize_results]
|
| 136 |
+
summarize --> generate[generate_answer]
|
| 137 |
+
|
| 138 |
+
generate -->|is_multi_question| combine[combine_answers]
|
| 139 |
+
generate -->|single_topic| END
|
| 140 |
+
|
| 141 |
+
combine --> END
|
| 142 |
+
|
| 143 |
+
dynamic --> fanout[fanout_multi_questions<br/>conditional edge]
|
| 144 |
+
fanout -.Send Q1.-> worker1[run_single_question_worker<br/>내부 서브그래프]
|
| 145 |
+
fanout -.Send Q2.-> worker2[run_single_question_worker<br/>내부 서브그래프]
|
| 146 |
+
worker1 --> combine
|
| 147 |
+
worker2 --> combine
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### 흐름 설명
|
| 151 |
+
|
| 152 |
+
#### Single Topic (기존 동작 유지)
|
| 153 |
+
```
|
| 154 |
+
START → create_plan (case: single_topic)
|
| 155 |
+
→ analyze → cache → classify → search(×3) → collect → eval → subgraph → generate → END
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
#### Multiple Questions (신규)
|
| 159 |
+
```
|
| 160 |
+
START → create_plan (case: multiple_questions)
|
| 161 |
+
→ initiate_dynamic_search (state 준비)
|
| 162 |
+
→ fanout_multi_questions (conditional edge)
|
| 163 |
+
├─ Send("run_single_question_worker", Q1) → [내부 서브그래프 전체 파이프라인] → multi_answers[0]
|
| 164 |
+
└─ Send("run_single_question_worker", Q2) → [내부 서브그래프 전체 파이프라인] → multi_answers[1]
|
| 165 |
+
→ combine_answers (자동 fan-in) → END
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
#### Too Many (신규)
|
| 169 |
+
```
|
| 170 |
+
START → create_plan (case: too_many)
|
| 171 |
+
→ handle_too_many_questions → END
|
| 172 |
+
(사용자는 즉시 다시 질문 가능)
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
## 구현 상세
|
| 176 |
+
|
| 177 |
+
### State 확장
|
| 178 |
+
|
| 179 |
+
```python
|
| 180 |
+
# src/agent/state.py
|
| 181 |
+
|
| 182 |
+
class AgentState(BaseModel):
|
| 183 |
+
# ... 기존 필드 ...
|
| 184 |
+
|
| 185 |
+
# Phase 4: Dynamic Parallel Search
|
| 186 |
+
is_multi_question: bool = False
|
| 187 |
+
sub_question_index: int = 0
|
| 188 |
+
sub_question_text: Optional[str] = None
|
| 189 |
+
original_multi_question: Optional[str] = None
|
| 190 |
+
multi_answers: Annotated[List[Dict[str, Any]], merge_multi_answers] = []
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
### 새로운 노드 (5개)
|
| 194 |
+
|
| 195 |
+
#### 1. create_plan_node (수정)
|
| 196 |
+
- **위치**: `src/agent/nodes.py` 라인 206
|
| 197 |
+
- **역할**: 질문 유형 및 개수 판단
|
| 198 |
+
- **변경**:
|
| 199 |
+
- `case` 필드 추가 (single_topic/multiple_questions/too_many)
|
| 200 |
+
- **하드 가드 추가**: `_hard_guard_too_many` 함수로 3개 이상 질문 결정론적 차단
|
| 201 |
+
- 물음표 개수(3개 이상) 또는 질문 후보 개수(3개 이상) 감지
|
| 202 |
+
- LLM 분류와 무관하게 `too_many`로 강제
|
| 203 |
+
|
| 204 |
+
#### 2. handle_too_many_questions_node (신규)
|
| 205 |
+
- **위치**: `src/agent/nodes.py` 라인 1068
|
| 206 |
+
- **역할**: 3개 이상 질문 시 안내 메시지
|
| 207 |
+
- **특징**: 대화 종료하지 않음 (즉시 재질문 가능)
|
| 208 |
+
|
| 209 |
+
#### 3. initiate_dynamic_search_node (신규)
|
| 210 |
+
- **위치**: `src/agent/nodes.py` 라인 1092
|
| 211 |
+
- **역할**: 다중 질문 처리 진입점, state 준비
|
| 212 |
+
- **핵심**: dict만 반환 (Send는 반환하지 않음)
|
| 213 |
+
|
| 214 |
+
#### 4. fanout_multi_questions (신규 - Edge 함수)
|
| 215 |
+
- **위치**: `src/agent/nodes.py` 라인 1110
|
| 216 |
+
- **역할**: conditional edge 함수로 `List[Send]` 반환
|
| 217 |
+
- **핵심**: 각 서브 질문을 `run_single_question_worker`로 Send
|
| 218 |
+
|
| 219 |
+
#### 5. run_single_question_worker_node (신규)
|
| 220 |
+
- **위치**: `src/agent/nodes.py` 라인 1306
|
| 221 |
+
- **역할**: 내부 서브그래프를 실행하여 state 충돌 방지
|
| 222 |
+
- **핵심**:
|
| 223 |
+
- 독립된 단일 질문 그래프를 내부에서 실행
|
| 224 |
+
- outer graph의 scalar state 채널 충돌 방지
|
| 225 |
+
- 결과를 `multi_answers` reducer에만 추가
|
| 226 |
+
|
| 227 |
+
#### 6. combine_answers_node (신규)
|
| 228 |
+
- **위치**: `src/agent/nodes.py` 라인 1168
|
| 229 |
+
- **역할**: multi_answers를 통합 Markdown 포맷으로 변환
|
| 230 |
+
- **특징**: 자동 fan-in (모든 Send 완료 대기)
|
| 231 |
+
|
| 232 |
+
### 수정된 노드 (1개)
|
| 233 |
+
|
| 234 |
+
#### generate_answer_node (5줄 추가)
|
| 235 |
+
- **위치**: `src/agent/nodes.py` 라인 726
|
| 236 |
+
- **추가 내용**:
|
| 237 |
+
```python
|
| 238 |
+
# 기존 로직 마지막에 추가
|
| 239 |
+
if state.is_multi_question:
|
| 240 |
+
updates["multi_answers"] = [{
|
| 241 |
+
"index": state.sub_question_index,
|
| 242 |
+
"question": state.sub_question_text,
|
| 243 |
+
"answer": final_answer
|
| 244 |
+
}]
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
### 그래프 재구성
|
| 248 |
+
|
| 249 |
+
```python
|
| 250 |
+
# src/agent/graph.py
|
| 251 |
+
|
| 252 |
+
# 1. START 진입점 변경
|
| 253 |
+
graph.add_edge(START, "create_plan") # 기존: analyze_question
|
| 254 |
+
|
| 255 |
+
# 2. create_plan 후 분기 추가
|
| 256 |
+
graph.add_conditional_edges(
|
| 257 |
+
"create_plan",
|
| 258 |
+
route_after_plan,
|
| 259 |
+
{
|
| 260 |
+
"analyze_question": "analyze_question",
|
| 261 |
+
"initiate_dynamic_search": "initiate_dynamic_search",
|
| 262 |
+
"handle_too_many_questions": "handle_too_many_questions"
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# 3. initiate_dynamic_search 후 fan-out
|
| 267 |
+
graph.add_conditional_edges(
|
| 268 |
+
"initiate_dynamic_search",
|
| 269 |
+
fanout_multi_questions, # List[Send] 반환
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# 4. run_single_question_worker 후 fan-in
|
| 273 |
+
graph.add_edge("run_single_question_worker", "combine_answers")
|
| 274 |
+
|
| 275 |
+
# 5. generate_answer 후 분기 추가
|
| 276 |
+
graph.add_conditional_edges(
|
| 277 |
+
"generate_answer",
|
| 278 |
+
route_after_generate,
|
| 279 |
+
{
|
| 280 |
+
"combine_answers": "combine_answers",
|
| 281 |
+
END: END
|
| 282 |
+
}
|
| 283 |
+
)
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
## 사용 예시
|
| 287 |
+
|
| 288 |
+
### 예시 1: 단일 주제 (기존 동작)
|
| 289 |
+
|
| 290 |
+
```python
|
| 291 |
+
from CodeWeaver.src.agent.graph import create_agent
|
| 292 |
+
from langchain_core.messages import HumanMessage
|
| 293 |
+
|
| 294 |
+
agent = create_agent()
|
| 295 |
+
|
| 296 |
+
result = await agent.ainvoke({
|
| 297 |
+
"user_question": "React hooks 완벽 가이드",
|
| 298 |
+
"messages": [HumanMessage(content="React hooks 완벽 가이드")]
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
# 결과
|
| 302 |
+
# plan.case: "single_topic"
|
| 303 |
+
# plan.sub_questions: ["hooks란", "주요 hooks", "실무 패턴"]
|
| 304 |
+
# 흐름: 기존 그래프 1회 실행
|
| 305 |
+
# 출력: 일반 답변 형식
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
### 예시 2: 다중 독립 질문 (신규)
|
| 309 |
+
|
| 310 |
+
```python
|
| 311 |
+
result = await agent.ainvoke({
|
| 312 |
+
"user_question": "JWT가 뭐야? CORS 에러는 어떻게 해결해?",
|
| 313 |
+
"messages": [HumanMessage(content="JWT가 뭐야? CORS 에러는 어떻게 해결해?")]
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
# 결과
|
| 317 |
+
# plan.case: "multiple_questions"
|
| 318 |
+
# plan.sub_questions: ["JWT가 뭐야?", "CORS 에러는 어떻게 해결해?"]
|
| 319 |
+
# 흐름: Send API로 그래프 2회 병렬 실행
|
| 320 |
+
# 출력:
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
**출력 예시**:
|
| 324 |
+
```markdown
|
| 325 |
+
# 다중 질문 답변
|
| 326 |
+
|
| 327 |
+
원본 질문: JWT가 뭐야? CORS 에러는 어떻게 해결해?
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## 1. JWT가 뭐야?
|
| 332 |
+
|
| 333 |
+
JWT(JSON Web Token)는 인증 정보를 안전하게 전송하기 위한...
|
| 334 |
+
|
| 335 |
+
[상세 답변...]
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## 2. CORS 에러는 어떻게 해결해?
|
| 340 |
+
|
| 341 |
+
CORS(Cross-Origin Resource Sharing) 에러는...
|
| 342 |
+
|
| 343 |
+
[상세 답변...]
|
| 344 |
+
```
|
| 345 |
+
|
| 346 |
+
### 예시 3: 질문 3개 이상
|
| 347 |
+
|
| 348 |
+
```python
|
| 349 |
+
result = await agent.ainvoke({
|
| 350 |
+
"user_question": "JWT? CORS? Docker?",
|
| 351 |
+
"messages": [HumanMessage(content="JWT? CORS? Docker?")]
|
| 352 |
+
})
|
| 353 |
+
|
| 354 |
+
# 결과
|
| 355 |
+
# plan.case: "too_many"
|
| 356 |
+
# 출력:
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
**출력 예시**:
|
| 360 |
+
```
|
| 361 |
+
죄송합니다. 한 번에 최대 2개의 질문까지만 처리할 수 있습니다.
|
| 362 |
+
|
| 363 |
+
다음 중 하나를 선택해서 다시 질문해 주세요:
|
| 364 |
+
|
| 365 |
+
1. **하나의 주제로 통합해서 질문**
|
| 366 |
+
예: "JWT 인증과 CORS 설정을 함께 구현하는 방법"
|
| 367 |
+
|
| 368 |
+
2. **가장 중요한 2개 질문만 선택**
|
| 369 |
+
예: "JWT가 뭐야? 내 코드에 어떻게 적용해?"
|
| 370 |
+
|
| 371 |
+
3. **질문을 나눠서 순차적으로 질문**
|
| 372 |
+
예: 먼저 "JWT가 뭐야?" 질문 → 답변 확인 → 다음 질문
|
| 373 |
+
|
| 374 |
+
어떻게 도와드릴까요?
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
## 테스트
|
| 378 |
+
|
| 379 |
+
테스트 파일은 프로젝트 루트에 있습니다. (삭제됨 - 필요시 재생성)
|
| 380 |
+
|
| 381 |
+
### 테스트 시나리오
|
| 382 |
+
|
| 383 |
+
1. ✅ **단일 주제**: "Spring Security JWT 인증 구현 방법"
|
| 384 |
+
- 기존 그래프 1회 실행
|
| 385 |
+
- multi_answers 비어있음
|
| 386 |
+
- 일반 답변 형식
|
| 387 |
+
|
| 388 |
+
2. ✅ **다중 질문 2개**: "JWT가 뭐야? CORS는?"
|
| 389 |
+
- Send API로 그래프 2회 병렬 실행
|
| 390 |
+
- multi_answers에 2개 항목
|
| 391 |
+
- 섹션 구분된 통합 답변
|
| 392 |
+
|
| 393 |
+
3. ✅ **질문 3개 이상**: "JWT? CORS? Docker?"
|
| 394 |
+
- handle_too_many_questions로 분기
|
| 395 |
+
- 친절한 에러 메시지
|
| 396 |
+
- 대화 계속 가능
|
| 397 |
+
|
| 398 |
+
4. ✅ **엣지 케이스**: "JWT? CORS? Docker? Redis?"
|
| 399 |
+
- **하드 가드로 무조건 too_many 차단** (물음표 4개 감지)
|
| 400 |
+
- LLM 분류와 무관하게 차단 보장
|
| 401 |
+
|
| 402 |
+
## 성능 고려사항
|
| 403 |
+
|
| 404 |
+
### 병렬 실행
|
| 405 |
+
- **단일 주제**: 3개 검색 노드 병렬 (기존)
|
| 406 |
+
- **다중 질문 (2개)**: 2×3=6개 검색 노드 병렬
|
| 407 |
+
- LangGraph Send API가 자동 병렬화 관리
|
| 408 |
+
|
| 409 |
+
### 비용 관리
|
| 410 |
+
- 질문 개수 제한: 최대 2개
|
| 411 |
+
- 검색 결과 개수: 소스당 3-5개
|
| 412 |
+
- 다중 질문 시 의도 분류 생략 (기본값 "learning" 사용)
|
| 413 |
+
|
| 414 |
+
### 캐싱
|
| 415 |
+
- **단일 주제**: 전체 답변 캐시 ✅
|
| 416 |
+
- **다중 질문**: 각 서브 질문 답변 개별 캐시 ✅
|
| 417 |
+
- Q1 답변 → Q1 질문으로 캐시
|
| 418 |
+
- Q2 답변 → Q2 질문으로 캐시
|
| 419 |
+
- 다음번 동일 질문 시 개별 캐시 히트 가능
|
| 420 |
+
|
| 421 |
+
## 기술적 핵심
|
| 422 |
+
|
| 423 |
+
### 1. Send API 패턴 (Conditional Edge 함수 사용)
|
| 424 |
+
|
| 425 |
+
```python
|
| 426 |
+
# ❌ 잘못된 방법: 노드에서 Send 반환
|
| 427 |
+
def initiate_dynamic_search_node(state):
|
| 428 |
+
return [Send(...), Send(...)] # 에러 발생!
|
| 429 |
+
|
| 430 |
+
# ✅ 올바른 방법: conditional edge 함수에서 Send 반환
|
| 431 |
+
def fanout_multi_questions(state: AgentState) -> List[Send]:
|
| 432 |
+
sends = []
|
| 433 |
+
for i, question in enumerate(sub_questions):
|
| 434 |
+
child_state = state.model_copy(deep=True)
|
| 435 |
+
child_state.user_question = question
|
| 436 |
+
sends.append(Send("run_single_question_worker", child_state))
|
| 437 |
+
return sends
|
| 438 |
+
|
| 439 |
+
# 그래프 설정
|
| 440 |
+
graph.add_conditional_edges(
|
| 441 |
+
"initiate_dynamic_search",
|
| 442 |
+
fanout_multi_questions, # List[Send] 반환
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
# LangGraph가 자동으로:
|
| 446 |
+
# 1. 두 Send를 병렬 실행
|
| 447 |
+
# 2. 각 Send의 모든 노드 실행 대기
|
| 448 |
+
# 3. 다음 공통 노드로 이동 (combine_answers)
|
| 449 |
+
```
|
| 450 |
+
|
| 451 |
+
### 2. Reducer 자동 병합 (Reset 기능 포함)
|
| 452 |
+
|
| 453 |
+
```python
|
| 454 |
+
# State 정의 (커스텀 reducer)
|
| 455 |
+
multi_answers: Annotated[List[Dict[str, Any]], merge_multi_answers] = []
|
| 456 |
+
|
| 457 |
+
# merge_multi_answers reducer:
|
| 458 |
+
def merge_multi_answers(old: List[Dict], new: List[Dict]) -> List[Dict]:
|
| 459 |
+
if not new:
|
| 460 |
+
return old
|
| 461 |
+
# Reset 토큰 체크
|
| 462 |
+
if new[0].get("__token__") == "__RESET_MULTI_ANS__":
|
| 463 |
+
return new[1:] # 이전 턴 누적 방지
|
| 464 |
+
return old + new # 기본 병합
|
| 465 |
+
|
| 466 |
+
# create_plan_node에서 매 실행 시작 시 리셋:
|
| 467 |
+
updates["multi_answers"] = [{"__token__": "__RESET_MULTI_ANS__"}]
|
| 468 |
+
|
| 469 |
+
# 병렬 실행 시:
|
| 470 |
+
# [Q1_answer] + [Q2_answer] = [Q1_answer, Q2_answer]
|
| 471 |
+
```
|
| 472 |
+
|
| 473 |
+
### 3. Fan-in 보장
|
| 474 |
+
|
| 475 |
+
```python
|
| 476 |
+
# 모든 검색 노드가 collect_results로 연결
|
| 477 |
+
graph.add_edge("search_stackoverflow", "collect_results")
|
| 478 |
+
graph.add_edge("search_github", "collect_results")
|
| 479 |
+
graph.add_edge("search_official_docs", "collect_results")
|
| 480 |
+
|
| 481 |
+
# LangGraph가 자동으로:
|
| 482 |
+
# 1. 3개 검색 모두 완료 대기
|
| 483 |
+
# 2. collect_results 1회만 실행
|
| 484 |
+
```
|
| 485 |
+
|
| 486 |
+
## 코드 변경 요약
|
| 487 |
+
|
| 488 |
+
### 파일별 변경사항
|
| 489 |
+
|
| 490 |
+
| 파일 | 추가 | 수정 | 삭제 |
|
| 491 |
+
|------|------|------|------|
|
| 492 |
+
| `state.py` | 5 필드, 1 reducer 함수 | - | - |
|
| 493 |
+
| `nodes.py` | 5 노드 + 1 edge 함수 (~300줄) | 2 노드 (create_plan 하드 가드 추가, generate_answer 5줄) | - |
|
| 494 |
+
| `graph.py` | 3 routing 함수, 엣지 재구성 | build_agent_graph | - |
|
| 495 |
+
|
| 496 |
+
**총 변경량**: ~350줄 추가, ~100줄 수정
|
| 497 |
+
|
| 498 |
+
### 재사용률
|
| 499 |
+
|
| 500 |
+
- **기존 노드 재사용**: 12/16 (75%)
|
| 501 |
+
- **기존 로직 재사용**: ~95% (검색, 평가, 필터링, 요약 등)
|
| 502 |
+
- **새로운 개념**: Send API + Reducer만
|
| 503 |
+
|
| 504 |
+
## LangGraph 공식 가이드라인 준수
|
| 505 |
+
|
| 506 |
+
### ✅ Graph API
|
| 507 |
+
- StateGraph 사용
|
| 508 |
+
- Pydantic BaseModel state
|
| 509 |
+
- START/END 명시
|
| 510 |
+
|
| 511 |
+
### ✅ Workflows + Agents
|
| 512 |
+
- Send API로 동적 병렬화
|
| 513 |
+
- Conditional edges로 라우팅
|
| 514 |
+
- Fan-out/Fan-in 패턴
|
| 515 |
+
|
| 516 |
+
### ✅ Thinking in LangGraph
|
| 517 |
+
- 노드는 순수 함수 (한 가지 일만)
|
| 518 |
+
- State는 불변 업데이트
|
| 519 |
+
- Reducer로 병합 자동화
|
| 520 |
+
|
| 521 |
+
## 한계 및 향후 개선
|
| 522 |
+
|
| 523 |
+
### 현재 한계
|
| 524 |
+
|
| 525 |
+
1. **질문 개수 제한**: 최대 2개
|
| 526 |
+
- 비용 vs 품질 트레이드오프
|
| 527 |
+
- 향후 3-4개로 확장 가능
|
| 528 |
+
|
| 529 |
+
2. **캐싱 전략**: 통합 답변은 캐시 안 됨
|
| 530 |
+
- 각 서브 질문은 개별 캐시됨
|
| 531 |
+
- 동일한 다중 질문 재입력 시 개별 캐시 히트
|
| 532 |
+
|
| 533 |
+
3. **Refinement 루프**: 다중 질문에서도 각각 독립적으로 작동
|
| 534 |
+
- 한 질문 refine 시 다른 질문에 영향 없음
|
| 535 |
+
|
| 536 |
+
### 향후 개선 방향
|
| 537 |
+
|
| 538 |
+
1. **더 많은 질문 지원**: 3-4개까지 확장
|
| 539 |
+
2. **혼합 질문 감지**: "JWT가 뭐야? 그걸 Spring에 적용하려면?" (순차 의존)
|
| 540 |
+
3. **스트리밍 답변**: 각 서브 질문 완료 즉시 스트리밍
|
| 541 |
+
4. **우선순위**: 중요도에 따라 질문 순서 조정
|
| 542 |
+
|
| 543 |
+
## 참고 자료
|
| 544 |
+
|
| 545 |
+
- [LangGraph Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api)
|
| 546 |
+
- [LangGraph Workflows + Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents)
|
| 547 |
+
- [LangGraph Thinking Guide](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph)
|
| 548 |
+
- CodeWeaver Phase 3: Open Deep Research
|
| 549 |
+
|
| 550 |
+
## 문의
|
| 551 |
+
|
| 552 |
+
구현 관련 질문이나 버그 리포트는 이슈로 등록해주세요.
|
| 553 |
+
|
README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: codeweaver-ai
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "4.44.1"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# CodeWeaver AI (Gradio Space)
|
| 14 |
+
|
| 15 |
+
CodeWeaver를 Hugging Face Spaces에서 실행하기 위한 Gradio 데모입니다.
|
| 16 |
+
|
| 17 |
+
## 실행 방식
|
| 18 |
+
|
| 19 |
+
- Space 엔트리: `app.py` (repo root)
|
| 20 |
+
- 실제 Gradio UI: `CodeWeaver/ui/app.py`
|
| 21 |
+
|
| 22 |
+
## 필수 Secrets (Settings → Variables and secrets)
|
| 23 |
+
|
| 24 |
+
- `GOOGLE_API_KEY`
|
| 25 |
+
- `TAVILY_API_KEY`
|
| 26 |
+
- `QDRANT_URL`
|
| 27 |
+
- `QDRANT_API_KEY`
|
| 28 |
+
|
| 29 |
+
선택:
|
| 30 |
+
|
| 31 |
+
- `GITHUB_TOKEN`
|
| 32 |
+
- `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGCHAIN_PROJECT`
|
| 33 |
+
|
| 34 |
+
## 문서
|
| 35 |
+
|
| 36 |
+
- `ARCHITECTURE.md`
|
| 37 |
+
- `DYNAMIC_PARALLEL_SEARCH.md`
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
app.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Spaces entrypoint.
|
| 3 |
+
|
| 4 |
+
This file is intentionally minimal:
|
| 5 |
+
- It imports the existing Gradio Blocks app from `CodeWeaver/ui/app.py`
|
| 6 |
+
- It launches it with HF-friendly defaults.
|
| 7 |
+
|
| 8 |
+
Local dev remains unchanged:
|
| 9 |
+
- You can still run `python CodeWeaver/ui/app.py` as before.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _bootstrap_import_path() -> None:
|
| 20 |
+
# Make `CodeWeaver/` importable as a top-level path so we can `import ui.app`.
|
| 21 |
+
repo_root = Path(__file__).resolve().parent
|
| 22 |
+
codeweaver_root = repo_root / "CodeWeaver"
|
| 23 |
+
sys.path.insert(0, str(codeweaver_root))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
_bootstrap_import_path()
|
| 28 |
+
|
| 29 |
+
# Import AFTER sys.path tweak
|
| 30 |
+
from ui.app import app as demo # type: ignore
|
| 31 |
+
|
| 32 |
+
# HF Spaces commonly provides PORT; fall back to 7860 for local.
|
| 33 |
+
port = int(os.getenv("PORT", "7860"))
|
| 34 |
+
demo.launch(server_name="0.0.0.0", server_port=port, show_api=False)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
main()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces installs dependencies from the repository root.
|
| 2 |
+
# Reuse the project's existing dependency list.
|
| 3 |
+
|
| 4 |
+
-r CodeWeaver/requirements.txt
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
|