Tag metadata tokens as {\"__meta\"} to separate TTS from progress messages
stream_response() now yields plain str for actual answer tokens and
{\"__meta\": str} dicts for progress/thinking/source metadata.
Consumers (WebUI, Telegram) can filter __meta tokens for TTS/display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+25
-18
@@ -220,30 +220,36 @@ turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 21 — Telegram Bot ★★☆
|
## ✅ Phase 21 — Telegram Bot ★★☆
|
||||||
|
|
||||||
**배경**: Gradio Web UI는 브라우저에서만 사용 가능. 텔레그램으로 이동 중에도 율봇과 대화하고 싶음.
|
**배경**: Gradio Web UI는 브라우저에서만 사용 가능. 텔레그램으로 이동 중에도 율봇과 대화하고 싶음.
|
||||||
|
|
||||||
**구현 방식**: `AgentService`를 직접 임포트 — 별도 API 서버 없이 동일 머신에서 실행.
|
**구현 방식**: youlbot REST API(Phase 22) 호출 — `youlbot-telegram/` 별도 프로젝트로 분리.
|
||||||
|
|
||||||
```
|
```
|
||||||
telegram_bot.py
|
youlbot-telegram/
|
||||||
├── Application (python-telegram-bot >= 20.0, async)
|
├── bot.py ← Application (python-telegram-bot >= 20.0, async)
|
||||||
├── /start, /reset CommandHandler
|
│ ├── /start, /reset CommandHandler
|
||||||
├── MessageHandler → agent.stream_response() → message.edit_text() (타이핑 효과)
|
│ └── MessageHandler → api_client.chat() → edit_message_text() (타이핑 효과)
|
||||||
└── Telegram user_id → youlbot user_id 매핑 (멀티유저 그대로 활용)
|
├── api_client.py ← httpx 기반 REST API 클라이언트 (chat/reset)
|
||||||
|
├── .env ← TELEGRAM_BOT_TOKEN, YOULBOT_API_URL, 유저 ID 매핑
|
||||||
|
└── requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
**구현 내용**:
|
**구현 내용**:
|
||||||
- `python-telegram-bot>=20.0` (asyncio 기반)
|
- `python-telegram-bot>=20.0` (asyncio 기반)
|
||||||
- `telegram_bot.py` — 새 진입점 (`python telegram_bot.py`로 실행)
|
- `youlbot-telegram/bot.py` — 새 진입점 (`python bot.py`로 실행)
|
||||||
- `/start` — 환영 메시지 + 사용법 안내
|
- `/start` — 환영 메시지 + 매핑된 youlbot 사용자 이름 표시
|
||||||
- `/reset` — 대화 이력 초기화 (`agent.reset()`)
|
- `/reset` — `api_client.reset(user_id)` 호출로 대화 이력 초기화
|
||||||
- 일반 메시지 → `agent.stream_response()` → 500자 단위 실시간 편집 (Telegram `edit_message_text`)
|
- 일반 메시지 → `api_client.chat()` SSE 스트리밍 → 0.6초 간격 실시간 편집
|
||||||
- `telegram_user_id`를 `user_id`로 사용 → 기존 멀티유저·메모리·DB 구조 그대로 재사용
|
- Telegram numeric ID → youlbot user_id `.env` 매핑 (`USER_아록_TELEGRAM_ID` 등)
|
||||||
- `.env` `TELEGRAM_BOT_TOKEN` 추가
|
- 미등록 사용자에게 Telegram ID 안내 메시지 표시
|
||||||
|
|
||||||
**제약**: 동일 머신에서만 실행 가능 (원격 실행은 Phase 22 REST API 필요)
|
**실행 방법**:
|
||||||
|
```bash
|
||||||
|
cd youlbot-telegram
|
||||||
|
python bot.py
|
||||||
|
```
|
||||||
|
|
||||||
**난이도**: 중간 | **임팩트**: 높음 (모바일·이동 중 접근)
|
**난이도**: 중간 | **임팩트**: 높음 (모바일·이동 중 접근)
|
||||||
|
|
||||||
@@ -292,7 +298,7 @@ async def ask_youlbot(message: str, user_id: str) -> str:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 23 — WebUI 분리 (youlbot-webui 별도 프로젝트) ★★☆
|
## ✅ Phase 23 — WebUI 분리 (youlbot-webui 별도 프로젝트) ★★☆
|
||||||
|
|
||||||
**배경**: 현재 `app.py`(Gradio)는 `container.py`를 직접 import해 서비스를 사용한다.
|
**배경**: 현재 `app.py`(Gradio)는 `container.py`를 직접 import해 서비스를 사용한다.
|
||||||
REST API(Phase 22)를 완성했으므로, WebUI를 독립 프로젝트로 분리해 API만 호출하도록 변경한다.
|
REST API(Phase 22)를 완성했으므로, WebUI를 독립 프로젝트로 분리해 API만 호출하도록 변경한다.
|
||||||
@@ -401,8 +407,8 @@ docker-compose.yml
|
|||||||
```
|
```
|
||||||
단기 (1~2주) 중기 (1개월) 장기
|
단기 (1~2주) 중기 (1개월) 장기
|
||||||
──────────────────────── ────────────────────── ──────────────────
|
──────────────────────── ────────────────────── ──────────────────
|
||||||
Phase 21 Telegram Bot → Phase 20 RAGAS 평가 → Phase 16 (Docker)
|
Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 16 (Docker)
|
||||||
→ Phase 15 (모델선택) → Phase 17 (멀티모달)
|
→ Phase 17 (멀티모달)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 우선순위 매트릭스
|
### 우선순위 매트릭스
|
||||||
@@ -427,8 +433,9 @@ Phase 21 Telegram Bot → Phase 20 RAGAS 평가 → Phase 16 (Docker)
|
|||||||
| Phase 13-B Reranker | ✅ 완료 | — | — | — |
|
| Phase 13-B Reranker | ✅ 완료 | — | — | — |
|
||||||
| Phase 18 Hybrid Search | ✅ 완료 | — | — | — |
|
| Phase 18 Hybrid Search | ✅ 완료 | — | — | — |
|
||||||
| Phase 19 Query Rewriting | ✅ 완료 | — | — | — |
|
| Phase 19 Query Rewriting | ✅ 완료 | — | — | — |
|
||||||
| Phase 21 Telegram Bot | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 (REST API 활용) |
|
| Phase 21 Telegram Bot | ✅ 완료 | — | — | — |
|
||||||
| Phase 22 REST API | ✅ 완료 | — | — | — |
|
| Phase 22 REST API | ✅ 완료 | — | — | — |
|
||||||
|
| Phase 23 WebUI 분리 | ✅ 완료 | — | — | — |
|
||||||
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 3순위 |
|
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 3순위 |
|
||||||
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
||||||
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
||||||
|
|||||||
@@ -216,8 +216,12 @@ class AgentService:
|
|||||||
def _make_config(self, show_thinking: bool = False) -> dict:
|
def _make_config(self, show_thinking: bool = False) -> dict:
|
||||||
return {"configurable": {"thread_id": self._thread_id, "show_thinking": show_thinking}}
|
return {"configurable": {"thread_id": self._thread_id, "show_thinking": show_thinking}}
|
||||||
|
|
||||||
async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str]:
|
async def stream_response(self, user_input: str, show_thinking: bool | None = None) -> AsyncIterator[str | dict]:
|
||||||
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다."""
|
"""사용자 입력을 받아 응답 토큰을 순서대로 yield한다.
|
||||||
|
|
||||||
|
실제 답변: plain str
|
||||||
|
진행/thinking/출처 메타데이터: {"__meta": str} ← 소비자가 TTS 등에서 필터링 가능
|
||||||
|
"""
|
||||||
_think_verbose = show_thinking if show_thinking is not None else self._think_verbose
|
_think_verbose = show_thinking if show_thinking is not None else self._think_verbose
|
||||||
self._source_buffer.clear()
|
self._source_buffer.clear()
|
||||||
run_id = uuid.uuid4()
|
run_id = uuid.uuid4()
|
||||||
@@ -248,25 +252,25 @@ class AgentService:
|
|||||||
if isinstance(data, dict) and "__query_rewrite" in data:
|
if isinstance(data, dict) and "__query_rewrite" in data:
|
||||||
info = data["__query_rewrite"]
|
info = data["__query_rewrite"]
|
||||||
if lg or self._rag_verbose:
|
if lg or self._rag_verbose:
|
||||||
yield f'\n쿼리 최적화: "{info["original"]}" → "{info["rewritten"]}"\n'
|
yield {"__meta": f'\n쿼리 최적화: "{info["original"]}" → "{info["rewritten"]}"\n'}
|
||||||
continue
|
continue
|
||||||
if isinstance(data, dict) and "__thinking" in data:
|
if isinstance(data, dict) and "__thinking" in data:
|
||||||
# thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신
|
# thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신
|
||||||
if "agent" != prev_node:
|
if "agent" != prev_node:
|
||||||
if thinking_open:
|
if thinking_open:
|
||||||
yield "\n[/사고 과정]\n"
|
yield {"__meta": "\n[/사고 과정]\n"}
|
||||||
thinking_open = False
|
thinking_open = False
|
||||||
content_started = False
|
content_started = False
|
||||||
if lg:
|
if lg:
|
||||||
elapsed = time.perf_counter() - start_time
|
elapsed = time.perf_counter() - start_time
|
||||||
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
|
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
|
||||||
yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"
|
yield {"__meta": f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"}
|
||||||
prev_node = "agent"
|
prev_node = "agent"
|
||||||
if _think_verbose:
|
if _think_verbose:
|
||||||
if not thinking_open:
|
if not thinking_open:
|
||||||
yield "\n[사고 과정]\n"
|
yield {"__meta": "\n[사고 과정]\n"}
|
||||||
thinking_open = True
|
thinking_open = True
|
||||||
yield data["__thinking"]
|
yield {"__meta": data["__thinking"]}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ── messages 이벤트 ──────────────────────────────────────
|
# ── messages 이벤트 ──────────────────────────────────────
|
||||||
@@ -277,44 +281,44 @@ class AgentService:
|
|||||||
# (agent 레이블은 custom 이벤트 핸들러에서 이미 처리될 수 있으므로 중복 방지)
|
# (agent 레이블은 custom 이벤트 핸들러에서 이미 처리될 수 있으므로 중복 방지)
|
||||||
if node != prev_node:
|
if node != prev_node:
|
||||||
if thinking_open:
|
if thinking_open:
|
||||||
yield "\n[/사고 과정]\n"
|
yield {"__meta": "\n[/사고 과정]\n"}
|
||||||
thinking_open = False
|
thinking_open = False
|
||||||
content_started = False
|
content_started = False
|
||||||
if lg:
|
if lg:
|
||||||
elapsed = time.perf_counter() - start_time
|
elapsed = time.perf_counter() - start_time
|
||||||
if node == "agent":
|
if node == "agent":
|
||||||
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
|
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
|
||||||
yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"
|
yield {"__meta": f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"}
|
||||||
elif node == "query_rewrite":
|
elif node == "query_rewrite":
|
||||||
yield f"\n[LangGraph → query_rewrite: 쿼리 최적화 중] ({elapsed:.2f}s)\n"
|
yield {"__meta": f"\n[LangGraph → query_rewrite: 쿼리 최적화 중] ({elapsed:.2f}s)\n"}
|
||||||
elif node == "tools":
|
elif node == "tools":
|
||||||
yield f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n"
|
yield {"__meta": f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n"}
|
||||||
prev_node = node
|
prev_node = node
|
||||||
|
|
||||||
# ── agent 노드 — AIMessageChunk만 처리 (중복 방지) ──────
|
# ── agent 노드 — AIMessageChunk만 처리 (중복 방지) ──────
|
||||||
if node == "agent" and isinstance(chunk, AIMessageChunk):
|
if node == "agent" and isinstance(chunk, AIMessageChunk):
|
||||||
if chunk.tool_calls:
|
if chunk.tool_calls:
|
||||||
if thinking_open:
|
if thinking_open:
|
||||||
yield "\n[/사고 과정]\n"
|
yield {"__meta": "\n[/사고 과정]\n"}
|
||||||
thinking_open = False
|
thinking_open = False
|
||||||
for tc in chunk.tool_calls:
|
for tc in chunk.tool_calls:
|
||||||
pending_tool_calls[tc["id"]] = tc
|
pending_tool_calls[tc["id"]] = tc
|
||||||
if tc.get("name") == "search_documents":
|
if tc.get("name") == "search_documents":
|
||||||
query = tc.get("args", {}).get("query", "")
|
query = tc.get("args", {}).get("query", "")
|
||||||
yield f'\n문서 검색 중... ("{query}")\n' if query else "\n문서 검색 중...\n"
|
yield {"__meta": f'\n문서 검색 중... ("{query}")\n'} if query else {"__meta": "\n문서 검색 중...\n"}
|
||||||
elif tc.get("name") == "web_search":
|
elif tc.get("name") == "web_search":
|
||||||
query = tc.get("args", {}).get("query", "")
|
query = tc.get("args", {}).get("query", "")
|
||||||
yield f'\n웹 검색 중... ("{query}")\n' if query else "\n웹 검색 중...\n"
|
yield {"__meta": f'\n웹 검색 중... ("{query}")\n'} if query else {"__meta": "\n웹 검색 중...\n"}
|
||||||
elif lg:
|
elif lg:
|
||||||
args_str = ", ".join(f'{k}="{v}"' for k, v in tc["args"].items())
|
args_str = ", ".join(f'{k}="{v}"' for k, v in tc["args"].items())
|
||||||
yield f" [tool_call: {tc['name']}({args_str})]\n"
|
yield {"__meta": f" [tool_call: {tc['name']}({args_str})]\n"}
|
||||||
|
|
||||||
elif chunk.content:
|
elif chunk.content:
|
||||||
if thinking_open:
|
if thinking_open:
|
||||||
yield "\n[/사고 과정]\n"
|
yield {"__meta": "\n[/사고 과정]\n"}
|
||||||
thinking_open = False
|
thinking_open = False
|
||||||
if lg and not content_started:
|
if lg and not content_started:
|
||||||
yield "\n[LangGraph → agent: 최종 답변 생성]\n\n"
|
yield {"__meta": "\n[LangGraph → agent: 최종 답변 생성]\n\n"}
|
||||||
content_started = True
|
content_started = True
|
||||||
response_content += chunk.content
|
response_content += chunk.content
|
||||||
yield chunk.content
|
yield chunk.content
|
||||||
@@ -325,12 +329,12 @@ class AgentService:
|
|||||||
if not content_started and not thinking_open:
|
if not content_started and not thinking_open:
|
||||||
thinking = chunk.additional_kwargs.get("thinking", "")
|
thinking = chunk.additional_kwargs.get("thinking", "")
|
||||||
if thinking and _think_verbose:
|
if thinking and _think_verbose:
|
||||||
yield "\n[사고 과정]\n"
|
yield {"__meta": "\n[사고 과정]\n"}
|
||||||
yield thinking
|
yield {"__meta": thinking}
|
||||||
yield "\n[/사고 과정]\n"
|
yield {"__meta": "\n[/사고 과정]\n"}
|
||||||
if chunk.content:
|
if chunk.content:
|
||||||
if lg:
|
if lg:
|
||||||
yield "\n[LangGraph → agent: 최종 답변 생성]\n\n"
|
yield {"__meta": "\n[LangGraph → agent: 최종 답변 생성]\n\n"}
|
||||||
response_content += chunk.content
|
response_content += chunk.content
|
||||||
yield chunk.content
|
yield chunk.content
|
||||||
|
|
||||||
@@ -338,25 +342,25 @@ class AgentService:
|
|||||||
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "search_documents":
|
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "search_documents":
|
||||||
if lg:
|
if lg:
|
||||||
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
|
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
|
||||||
yield f" [결과: {len(result_lines)}개 문서 반환 → agent 복귀]\n"
|
yield {"__meta": f" [결과: {len(result_lines)}개 문서 반환 → agent 복귀]\n"}
|
||||||
|
|
||||||
if self._rag_verbose:
|
if self._rag_verbose:
|
||||||
tc = pending_tool_calls.get(chunk.tool_call_id, {})
|
tc = pending_tool_calls.get(chunk.tool_call_id, {})
|
||||||
query = tc.get("args", {}).get("query", "")
|
query = tc.get("args", {}).get("query", "")
|
||||||
yield f'\n[문서 검색: "{query}"]\n'
|
yield {"__meta": f'\n[문서 검색: "{query}"]\n'}
|
||||||
for block in chunk.content.split("\n\n"):
|
for block in chunk.content.split("\n\n"):
|
||||||
if block.strip():
|
if block.strip():
|
||||||
preview = block.strip().replace("\n", " ")[:80]
|
preview = block.strip().replace("\n", " ")[:80]
|
||||||
yield f" → {preview}\n"
|
yield {"__meta": f" → {preview}\n"}
|
||||||
yield "\n"
|
yield {"__meta": "\n"}
|
||||||
|
|
||||||
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "web_search":
|
elif node == "tools" and hasattr(chunk, "name") and chunk.name == "web_search":
|
||||||
if lg:
|
if lg:
|
||||||
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
|
result_lines = [b for b in chunk.content.split("\n\n") if b.strip()]
|
||||||
yield f" [웹 검색 결과: {len(result_lines)}건 → agent 복귀]\n"
|
yield {"__meta": f" [웹 검색 결과: {len(result_lines)}건 → agent 복귀]\n"}
|
||||||
|
|
||||||
if thinking_open:
|
if thinking_open:
|
||||||
yield "\n[/사고 과정]\n"
|
yield {"__meta": "\n[/사고 과정]\n"}
|
||||||
|
|
||||||
self._last_run_id = str(run_id)
|
self._last_run_id = str(run_id)
|
||||||
|
|
||||||
@@ -369,11 +373,11 @@ class AgentService:
|
|||||||
print(f"[Agent] 대화 저장 실패: {e}")
|
print(f"[Agent] 대화 저장 실패: {e}")
|
||||||
|
|
||||||
if self._rag_show_sources and self._source_buffer:
|
if self._rag_show_sources and self._source_buffer:
|
||||||
yield "\n\n[참고 문서]\n"
|
yield {"__meta": "\n\n[참고 문서]\n"}
|
||||||
for src in self._source_buffer:
|
for src in self._source_buffer:
|
||||||
filename = os.path.basename(src["source"])
|
filename = os.path.basename(src["source"])
|
||||||
page = f" {src['page']}페이지" if "page" in src else ""
|
page = f" {src['page']}페이지" if "page" in src else ""
|
||||||
yield f"- {filename}{page}\n"
|
yield {"__meta": f"- {filename}{page}\n"}
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""새 thread_id로 대화 히스토리를 초기화한다."""
|
"""새 thread_id로 대화 히스토리를 초기화한다."""
|
||||||
|
|||||||
Reference in New Issue
Block a user