Spaces:
Running
Running
| """ | |
| 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, | |
| } | |