peekabook-api / api /graph_demo.py
lael
fix: update Langfuse import path for v4.x (langfuse.langchain)
d4a7073
Raw
History Blame Contribute Delete
12.2 kB
"""
graph_demo.py β€” μ›Ή 데λͺ¨μš© κ·Έλž˜ν”„ μ–΄λŒ‘ν„°.
backend/app/pipeline/graph_main.pyλ₯Ό importν•΄μ„œ μ‚¬μš©ν•˜λ˜,
μ›Ή 챗봇 ν™˜κ²½μ— 맞게 λ‹€μŒ 두 κ°€μ§€λ₯Ό μ²˜λ¦¬ν•œλ‹€:
1. interrupt_before 우회
- 원본은 process_slot_answer λ“± 3개 λ…Έλ“œ 직전에 λ©ˆμΆ°μ„œ 인간 μž…λ ₯을 κΈ°λ‹€λ¦¬λŠ” ꡬ쑰.
- μ›Ήμ—μ„œλŠ” λ§€ μš”μ²­μ΄ (μœ μ € λ©”μ‹œμ§€) β†’ (AI 응닡) ν•œ 쌍으둜 완결돼야 함.
- λ”°λΌμ„œ stream API + Command(resume) νŒ¨ν„΄ λŒ€μ‹ , λͺ…μ‹œμ μΈ μƒνƒœ inject둜 처리.
2. μ„Έμ…˜ λˆ„μ  (localStorage 기반)
- λ°±μ—”λ“œλŠ” statelessν•˜κ²Œ λ™μž‘.
- ν΄λΌμ΄μ–ΈνŠΈκ°€ 이전 μ„Έμ…˜μ˜ UserProfile + book_experiences + summaryλ₯Ό 보내주면
그것을 initial_state에 injectν•œ λ’€ κ·Έλž˜ν”„ μ‹€ν–‰.
- κ·Έλž˜ν”„ μ’…λ£Œ ν›„ μ—…λ°μ΄νŠΈλœ ν”„λ‘œνŒŒμΌμ„ λ‹€μ‹œ ν΄λΌμ΄μ–ΈνŠΈμ— λ°˜ν™˜.
μ΄λ ‡κ²Œ ν•˜λ©΄ ChromaDB μ˜μ†μ„± 없이도 "μ„Έμ…˜μ„ λ„˜λ‚˜λ“œλŠ” ν”„λ‘œνŒŒμΌ λˆ„μ "의
μ‹œκ°μ  효과λ₯Ό μ •ν™•νžˆ μž¬ν˜„ν•  수 있음.
"""
from __future__ import annotations
import os
import uuid
from typing import Any, Optional
from langchain_core.messages import HumanMessage, AIMessage
from app.pipeline import graph_main
from app.state.state import (
UserProfile,
BookExperience,
Phase,
SLOT_NAMES,
)
# ────────────────────────────────────────────────────────────────────────────
# Langfuse νŠΈλ ˆμ΄μ‹± (ν™˜κ²½λ³€μˆ˜κ°€ μžˆμ„ λ•Œλ§Œ ν™œμ„±ν™”)
# ────────────────────────────────────────────────────────────────────────────
_langfuse_handler = None
try:
if os.environ.get("LANGFUSE_PUBLIC_KEY") and os.environ.get("LANGFUSE_SECRET_KEY"):
from langfuse.langchain import CallbackHandler
_langfuse_handler = CallbackHandler(
public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
secret_key=os.environ["LANGFUSE_SECRET_KEY"],
host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"),
)
print("[graph_demo] Langfuse callback handler initialized")
else:
print("[graph_demo] Langfuse env vars not set β€” tracing disabled")
except Exception as e:
print(f"[graph_demo] Langfuse init failed (continuing without tracing): {e}")
_langfuse_handler = None
# ────────────────────────────────────────────────────────────────────────────
# μœ μ €λ³„ κ·Έλž˜ν”„ μΈμŠ€ν„΄μŠ€ 관리
# ────────────────────────────────────────────────────────────────────────────
# μœ μ € IDλ³„λ‘œ ChromaDB 경둜λ₯Ό 뢄리해 μ„Έμ…˜ 기얡을 κ²©λ¦¬ν•œλ‹€.
# HF Spaces 무료 ν”Œλžœμ€ 슬립 ν›„ /tmpκ°€ μ΄ˆκΈ°ν™”λ˜λ―€λ‘œ μž¬μ‹œμž‘ μ‹œ 데이터가 μ΄ˆκΈ°ν™”λ¨.
_DEMO_CHROMA_BASE = os.environ.get("DEMO_CHROMA_BASE", "/tmp/peekabook_demo_chroma")
_graphs: dict[str, Any] = {}
def _get_graph(user_id: str):
if user_id not in _graphs:
chroma_path = os.path.join(_DEMO_CHROMA_BASE, user_id)
_graphs[user_id] = graph_main.create_app(
chroma_db_path=chroma_path,
use_genre_filter=True,
)
return _graphs[user_id]
# ────────────────────────────────────────────────────────────────────────────
# ν”„λ‘œνŒŒμΌ 직렬화 / 역직렬화 헬퍼
# ────────────────────────────────────────────────────────────────────────────
def serialize_profile_payload(state: dict) -> dict:
"""κ·Έλž˜ν”„ μ‹€ν–‰ ν›„ ν΄λΌμ΄μ–ΈνŠΈμ— λŒλ €μ€„ λˆ„μ  κ°€λŠ₯ν•œ ν”„λ‘œνŒŒμΌ νŽ˜μ΄λ‘œλ“œ.
이 dictκ°€ κ·ΈλŒ€λ‘œ localStorage에 JSON으둜 μ €μž₯되고, λ‹€μŒ μ„Έμ…˜ μ‹œμž‘ μ‹œ
κ·ΈλŒ€λ‘œ λ°›μ•„μ„œ deserialize_profile_payload둜 λ³΅μ›λœλ‹€.
"""
profile: UserProfile = state.get("user_profile") or UserProfile()
book_experiences: list[BookExperience] = state.get("book_experiences") or []
return {
"user_profile": profile.to_display_dict(), # state.py에 이미 μ •μ˜λ¨
"book_experiences": [
{
"book_name": be.book_name,
"impression": be.impression,
"context": be.context,
}
for be in book_experiences
],
"summary": state.get("summary", "") or "",
"reflection": state.get("reflection", "") or "",
}
def deserialize_profile_payload(payload: Optional[dict]) -> dict:
"""ν΄λΌμ΄μ–ΈνŠΈκ°€ 보낸 νŽ˜μ΄λ‘œλ“œλ₯Ό κ·Έλž˜ν”„ initial_state ν˜•νƒœλ‘œ λ³€ν™˜."""
if not payload:
return {
"user_profile": UserProfile(),
"book_experiences": [],
"summary": "",
"reflection": "",
}
profile = UserProfile.from_dict(payload.get("user_profile") or {})
book_experiences = [
BookExperience(
book_name=be.get("book_name", ""),
impression=be.get("impression", ""),
context=be.get("context", ""),
)
for be in (payload.get("book_experiences") or [])
]
return {
"user_profile": profile,
"book_experiences": book_experiences,
"summary": payload.get("summary", "") or "",
"reflection": payload.get("reflection", "") or "",
}
# ────────────────────────────────────────────────────────────────────────────
# 핡심: ν•œ ν„΄ μ‹€ν–‰
# ────────────────────────────────────────────────────────────────────────────
async def run_one_turn(
user_message: str,
session_id: str,
user_id: str = "default",
prior_profile_payload: Optional[dict] = None,
thread_id: Optional[str] = None,
) -> dict:
"""μœ μ € λ©”μ‹œμ§€ ν•œ λ²ˆμ— λŒ€ν•΄ κ·Έλž˜ν”„λ₯Ό ν•œ ν„΄ μ‹€ν–‰ν•˜κ³  κ²°κ³Όλ₯Ό λ°˜ν™˜.
Parameters
----------
user_message : μœ μ €κ°€ 방금 μž…λ ₯ν•œ λ©”μ‹œμ§€.
session_id : ν˜„μž¬ μ„Έμ…˜ ID (ν΄λΌμ΄μ–ΈνŠΈκ°€ λ°œκΈ‰/관리).
prior_profile_payload : 이전 μ„Έμ…˜μ—μ„œ λˆ„μ λœ ν”„λ‘œνŒŒμΌ (μ—†μœΌλ©΄ 빈 μƒνƒœλ‘œ μ‹œμž‘).
thread_id : LangGraph checkpoint thread (같은 λΈŒλΌμš°μ € μ„Έμ…˜ λ‚΄ λ©€ν‹°ν„΄μš©).
ν΄λΌμ΄μ–ΈνŠΈκ°€ 첫 λ©”μ‹œμ§€μ—μ„œ None을 보내면 μƒˆλ‘œ λ§Œλ“€μ–΄ 응닡에 포함.
Returns
-------
{
"ai_response": str, # μ‚¬μš©μžμ—κ²Œ 보여쀄 AI λ‹΅λ³€
"recommendations": list, # μ±… μΆ”μ²œ κ²°κ³Ό (μžˆμ„ λ•Œ)
"phase": str, # ν˜„μž¬ phase (UIμ—μ„œ 단계 ν‘œμ‹œμš©)
"profile_payload": dict, # localStorage μ €μž₯용 λˆ„μ  νŽ˜μ΄λ‘œλ“œ
"thread_id": str, # 같은 μ„Έμ…˜ λ‚΄ λ‹€μŒ ν˜ΈμΆœμ— κ·ΈλŒ€λ‘œ 전달
"session_done": bool, # κ·Έλž˜ν”„κ°€ END에 λ„λ‹¬ν–ˆλŠ”μ§€
}
"""
if thread_id is None:
thread_id = str(uuid.uuid4())
config: dict = {"configurable": {"thread_id": thread_id}}
if _langfuse_handler is not None:
config["callbacks"] = [_langfuse_handler]
# 데λͺ¨ μ‹λ³„μš© 메타데이터 β€” Langfuse λŒ€μ‹œλ³΄λ“œμ—μ„œ 필터링 κ°€λŠ₯
config["metadata"] = {
"user_id": user_id,
"session_id": session_id,
"langfuse_session_id": session_id,
"langfuse_user_id": user_id,
}
compiled_graph = _get_graph(user_id)
# ν˜„μž¬ checkpoint μƒνƒœλ₯Ό 확인 β€” 같은 threadμ—μ„œ μ΄μ–΄μ§€λŠ” ν˜ΈμΆœμΈμ§€ 첫 ν˜ΈμΆœμΈμ§€ νŒλ‹¨
current_snapshot = compiled_graph.get_state(config)
# ν˜„μž¬ checkpoint μƒνƒœλ₯Ό 확인 β€” 같은 threadμ—μ„œ μ΄μ–΄μ§€λŠ” ν˜ΈμΆœμΈμ§€ 첫 ν˜ΈμΆœμΈμ§€ νŒλ‹¨
current_snapshot = compiled_graph.get_state(config)
is_first_turn_in_thread = (
current_snapshot is None or not current_snapshot.values
)
if is_first_turn_in_thread:
# 첫 ν„΄: initial_state에 prior profile inject ν›„ μ‹œμž‘
prior = deserialize_profile_payload(prior_profile_payload)
initial = dict(graph_main.initial_state) # 얕은 볡사
initial["session_id"] = session_id
initial["messages"] = [HumanMessage(content=user_message)]
# μ„Έμ…˜ 2λΆ€ν„°λŠ” 슬둯 빈 μƒνƒœλ‘œ μ‹œμž‘
initial["user_profile"] = UserProfile()
initial["book_experiences"] = []
# 이전 μ„Έμ…˜ μš”μ•½λ§Œ μ»¨ν…μŠ€νŠΈλ‘œ μ£Όμž…
if prior.get("summary"):
initial["summary"] = f"[이전 μ„Έμ…˜ μš”μ•½] {prior['summary']}"
if prior.get("reflection"):
initial["reflection"] = f"[이전 μ„Έμ…˜ μΈμ‚¬μ΄νŠΈ] {prior['reflection']}"
# λˆ„μ  ν”„λ‘œνŒŒμΌμ΄ 있으면 μŠ¬λ‘―ν•„λ§ 일뢀λ₯Ό κ±΄λ„ˆλ›°κ³  λ°”λ‘œ μΆ”μ²œμœΌλ‘œ κ°€λŠ” 것도
# κ°€λŠ₯ν•˜μ§€λ§Œ, 데λͺ¨μ—μ„œλŠ” "κΈ°μ–΅ν•˜κ³  μžˆμŒμ„ λ³΄μ—¬μ£ΌλŠ”" νš¨κ³Όκ°€ 더 μ€‘μš”ν•˜λ―€λ‘œ
# 정상 흐름을 μœ μ§€ν•˜λ˜ generate_slot_question λ…Έλ“œκ°€ 이미 μ±„μ›Œμ§„ μŠ¬λ‘―μ„
# μΈμ§€ν•˜λ„λ‘ λ‘”λ‹€ (κ·Έλž˜ν”„ 둜직이 filled_slots()λ₯Ό 이미 확인함).
await compiled_graph.ainvoke(initial, config=config)
else:
# 같은 thread의 후속 ν„΄: interrupt_before둜 λ©ˆμΆ°μžˆλŠ” μƒνƒœμ— μœ μ € λ©”μ‹œμ§€λ§Œ μΆ”κ°€
compiled_graph.update_state(
config,
{"messages": [HumanMessage(content=user_message)]},
)
await compiled_graph.ainvoke(None, config=config)
# μ‹€ν–‰ ν›„ μƒνƒœ 쑰회
snapshot = compiled_graph.get_state(config)
state_values = snapshot.values
# nextκ°€ λΉ„μ–΄μžˆμœΌλ©΄ κ·Έλž˜ν”„κ°€ END에 λ„λ‹¬ν•œ 것
session_done = not snapshot.next
# μ΅œμ’… 응닡은 messages λ§ˆμ§€λ§‰ ν•­λͺ©μ—μ„œ 읽음 (rag_llm/api_tool_calling이 messages에 기둝)
# ν”„λ‘œνŒŒμΌλ§ 쀑간 턴은 ai_responseλ₯Ό fallback으둜 μ‚¬μš©
messages = state_values.get("messages", [])
last_ai_message = next(
(m.content for m in reversed(messages) if isinstance(m, AIMessage)),
state_values.get("ai_response", ""),
)
return {
"ai_response": last_ai_message,
"recommendations": state_values.get("recommendations", []),
"phase": _phase_to_str(state_values.get("phase")),
"profile_payload": serialize_profile_payload(state_values),
"thread_id": thread_id,
"session_done": session_done,
}
def _phase_to_str(phase) -> str:
"""Phase enum λ˜λŠ” λ¬Έμžμ—΄μ„ μ•ˆμ „ν•˜κ²Œ λ¬Έμžμ—΄λ‘œ λ³€ν™˜."""
if phase is None:
return ""
if isinstance(phase, Phase):
return phase.value
return str(phase)
# ────────────────────────────────────────────────────────────────────────────
# ν—¬μŠ€μ²΄ν¬ β€” HF Spaces 슬립 λ°©μ§€μš©
# ────────────────────────────────────────────────────────────────────────────
def healthcheck() -> dict:
"""κ·Έλž˜ν”„κ°€ 정상 μ»΄νŒŒμΌλλŠ”μ§€λ§Œ κ°€λ³κ²Œ 확인."""
return {
"graph_compiled": True,
"slot_names": SLOT_NAMES,
}