peekabook-api / app /profiling /profiler.py
lael
feat: initial deploy
3960e2b
Raw
History Blame Contribute Delete
38.4 kB
from __future__ import annotations
import json
import os
import re
from typing import Any, Optional
import chromadb
from dotenv import load_dotenv
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from chromadb.utils import embedding_functions
from app.state.state import (
BookExperience,
GraphState,
MemoryLink,
Phase,
ProfileSlot,
SessionMemory,
SlotStatus,
SLOT_DESCRIPTIONS,
SLOT_NAMES,
UserProfile,
)
load_dotenv()
# ──────────────────────────────────────────────
# Prompts
# ──────────────────────────────────────────────
SLOT_QUESTION_PROMPT = """\
당신은 μΉœμ ˆν•œ λ„μ„œ νλ ˆμ΄ν„°μž…λ‹ˆλ‹€.
μ‚¬μš©μžμ˜ λ„μ„œ μ„ ν˜Έ ν”„λ‘œνŒŒμΌμ„ νŒŒμ•…ν•˜κΈ° μœ„ν•΄ μžμ—°μŠ€λŸ¬μš΄ μ§ˆλ¬Έμ„ 생성해야 ν•©λ‹ˆλ‹€.
ν˜„μž¬κΉŒμ§€ μˆ˜μ§‘λœ ν”„λ‘œν•„ 정보:
{filled_profile}
μ΄λ²ˆμ— νŒŒμ•…ν•΄μ•Ό ν•  ν•­λͺ©: {slot_name}
ν•­λͺ© μ„€λͺ…: {slot_description}
이전 λŒ€ν™” λ§₯락:
{conversation_context}
{retry_instruction}
μš”κ΅¬μ‚¬ν•­:
- ν•œκ΅­μ–΄λ‘œ μž‘μ„±
- 이미 μˆ˜μ§‘λœ 정보λ₯Ό μžμ—°μŠ€λŸ½κ²Œ μ°Έμ‘°ν•˜λ©° λŒ€ν™”λ₯Ό μ΄μ–΄κ°€μ„Έμš”
- 직접적이지 μ•Šκ³  λŒ€ν™”μ²΄λ‘œ λ¬Όμ–΄λ³΄μ„Έμš”
- 1~3λ¬Έμž₯ 이내
질문:"""
RETRY_INSTRUCTION_TEMPLATE = """\
주의: 이전에 이 ν•­λͺ©μ— λŒ€ν•΄ μ§ˆλ¬Έν–ˆμœΌλ‚˜ μ‚¬μš©μžκ°€ λͺ…ν™•ν•œ 닡변을 ν•˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. (μž¬μ‹œλ„ {retry_count}/{max_retries}회)
λ‹€λ₯Έ κ°λ„μ—μ„œ, 더 ꡬ체적인 μ˜ˆμ‹œλ₯Ό λ“€μ–΄ μ§ˆλ¬Έμ„ μž¬κ΅¬μ„±ν•˜μ„Έμš”."""
EXTRACT_SLOT_PROMPT = """\
당신은 λ„μ„œ νλ ˆμ΄μ…˜μ„ μœ„ν•œ 정보 μΆ”μΆœκΈ°μž…λ‹ˆλ‹€.
μ‚¬μš©μžμ˜ μ‘λ‹΅μ—μ„œ μ•„λž˜ ν•­λͺ©λ“€μ— λŒ€ν•œ 정보λ₯Ό μΆ”μΆœν•˜μ„Έμš”.
[주의]
- λ°˜λ“œμ‹œ {{"value": "..."}} ν˜•νƒœλ₯Ό μœ μ§€ν•˜μ„Έμš”.
- <slot_name>의 값은 λ°˜λ“œμ‹œ 객체 {{"value": "λ¬Έμžμ—΄"}} 이어야 ν•©λ‹ˆλ‹€. λ¬Έμžμ—΄μ„ 직접 κ°’μœΌλ‘œ μ“°μ§€ λ§ˆμ„Έμš”.
- valueλŠ” λ°˜λ“œμ‹œ λ¬Έμžμ—΄. κ°μ²΄λ‚˜ λ°°μ—΄ κΈˆμ§€. 정보 μ—†μœΌλ©΄ null
μΆ”μΆœ λŒ€μƒ ν•­λͺ©:
{target_slots}
μ‚¬μš©μž 응닡: "{user_message}"
λŒ€ν™” λ§₯락:
{conversation_context}
μ•„λž˜ JSON λ°˜λ“œμ‹œ ν˜•μ‹μœΌλ‘œ μ‘λ‹΅ν•˜μ„Έμš”. 정보가 μ—†λŠ” ν•­λͺ©μ€ null둜 ν‘œμ‹œν•©λ‹ˆλ‹€:
{{
"extracted": {{
"<slot_name>": {{
"value": "μΆ”μΆœλœ λ¬Έμžμ—΄"
}}
}}
}}
JSON 응닡:"""
SIMILAR_PROFILE_PRESENT_PROMPT = """\
이전에 λΉ„μŠ·ν•œ λ§₯λ½μ—μ„œ λ„μ„œλ₯Ό μ°ΎμœΌμ…¨λ˜ 기둝이 μžˆμŠ΅λ‹ˆλ‹€.
이전 ν”„λ‘œνŒŒμΌ μš”μ•½:
{profile_summary}
이 정보λ₯Ό μ‚¬μš©μžμ—κ²Œ μžμ—°μŠ€λŸ½κ²Œ μ„€λͺ…ν•˜κ³ , μ΄λ²ˆμ—λ„ λΉ„μŠ·ν•œ λ„μ„œλ₯Ό μ°Ύκ³  μžˆλŠ”μ§€ λ¬Όμ–΄λ³΄μ„Έμš”.
μš”κ΅¬μ‚¬ν•­:
- ν•œκ΅­μ–΄λ‘œ μž‘μ„±
- 이전 기둝을 κ°„κ²°ν•˜κ²Œ 정리
- μ΄λ²ˆμ—λ„ 같은 μ’…λ₯˜μ˜ 책을 μ°ΎλŠ”μ§€ yes/no둜 λ‹΅ν•  수 있게 질문
- 2~4λ¬Έμž₯ 이내
- 인삿말 κΈˆμ§€
응닡:"""
# ──────────────────────────────────────────────
# 4-1. λ§€μΉ­ ν›„ ν˜„μž¬ 상황 질문
# ──────────────────────────────────────────────
POST_MATCH_CONTEXT_PROMPT = """\
당신은 μΉœμ ˆν•œ λ„μ„œ νλ ˆμ΄ν„°μž…λ‹ˆλ‹€.
μ‚¬μš©μžκ°€ 이전에 λΉ„μŠ·ν•œ λ§₯λ½μ—μ„œ λ„μ„œλ₯Ό μ°Ύμ•˜λ˜ 기둝이 μžˆμ–΄ ν•΄λ‹Ή ν”„λ‘œν•„μ„ λΆˆλŸ¬μ™”μŠ΅λ‹ˆλ‹€.
이전 ν”„λ‘œν•„μ˜ μ„ ν˜Έμ—μ„œ 달라진 점이 μžˆλŠ”μ§€λ₯Ό μžμ—°μŠ€λŸ½κ²Œ λ¬Όμ–΄λ³΄μ„Έμš”.
뢈러온 이전 ν”„λ‘œν•„:
{matched_profile}
이전 λŒ€ν™” λ§₯락:
{conversation_context}
μ§ˆλ¬Έμ— 포함할 관점:
- 이전과 λΉ„κ΅ν•˜μ—¬ λ…μ„œ μŠ€νƒ€μΌ, μž₯λ₯΄,λ‚œμ΄λ„μ—μ„œ 달라진 점이 μžˆλŠ”μ§€
- ν˜„μž¬ μ–΄λ–€ μƒν™©μ΄λ‚˜ κ°μ •μ—μ„œ 책을 읽으렀 ν•˜λŠ”μ§€
μš”κ΅¬μ‚¬ν•­:
- ν•œκ΅­μ–΄λ‘œ μž‘μ„±
- μ„ ν˜Έμ˜ λ³€ν™”λ₯Ό λ¬Όμ–΄λ³΄μ„Έμš”
- λΆ€λ‹΄ 없이 λŒ€λ‹΅ν•  수 μžˆλ„λ‘ λŒ€ν™”μ²΄λ‘œ μž‘μ„±
- 1~3λ¬Έμž₯ 이내
- 인삿말 κΈˆμ§€
질문:"""
MATCH_CONFIRM_PROMPT = """\
μ‚¬μš©μžμ˜ 응닡이 이전 ν”„λ‘œνŒŒμΌκ³Ό 같은 μ’…λ₯˜μ˜ 책을 μ°Ύκ³  μžˆλ‹€λŠ” 긍정적 닡변인지 νŒλ‹¨ν•˜μ„Έμš”.
μ‚¬μš©μž 응닡: "{user_message}"
μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œ μ‘λ‹΅ν•˜μ„Έμš”:
{{
"is_match": true λ˜λŠ” false,
"reason": "νŒλ‹¨ κ·Όκ±°"
}}
JSON 응닡:"""
BOOK_EXPERIENCE_PROMPT = """\
당신은 μΉœμ ˆν•œ λ„μ„œ νλ ˆμ΄ν„°μž…λ‹ˆλ‹€.
μ‚¬μš©μžμ˜ ν”„λ‘œν•„ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ, λΉ„μŠ·ν•œ λ§₯λ½μ—μ„œ 이전에 μ½μ—ˆλ˜ 책이 μžˆλŠ”μ§€ λ¬Όμ–΄λ³΄μ„Έμš”.
ν˜„μž¬ μ‚¬μš©μž ν”„λ‘œν•„:
{profile_summary}
기쑴에 μˆ˜μ§‘λœ λ…μ„œ κ²½ν—˜:
{existing_experiences}
μš”κ΅¬μ‚¬ν•­:
- ν•œκ΅­μ–΄λ‘œ μž‘μ„±
- ν”„λ‘œν•„ 정보λ₯Ό 기반으둜 κ°„λž΅ν•œ μš”μ•½ μ œμ‹œν›„, 질문
- ν”„λ‘œν•„ λ§₯락에 λ§žλŠ” μžμ—°μŠ€λŸ¬μš΄ 질문
- μ˜ˆμ‹œλ₯Ό λ“€μ§€ 말 것
- μ±… 제λͺ©κ³Ό κ°„λ‹¨ν•œ μ†Œκ°μ„ ν•¨κ»˜ λ¬Όμ–΄λ³΄μ„Έμš”
- 2~3λ¬Έμž₯ 이내
질문:"""
EXTRACT_BOOK_EXPERIENCE_PROMPT = """\
μ‚¬μš©μžμ˜ μ‘λ‹΅μ—μ„œ 이전 λ…μ„œ κ²½ν—˜μ„ μΆ”μΆœν•˜μ„Έμš”.
μ‚¬μš©μž 응닡: "{user_message}"
μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œ μ‘λ‹΅ν•˜μ„Έμš”. μ±… κ²½ν—˜μ΄ μ—†μœΌλ©΄ 빈 리슀트λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€:
{{
"experiences": [
{{
"book_name": "μ±… 이름",
"impression": "μ†Œκ°/감상",
"context": "μ–΄λ–€ λ§₯λ½μ—μ„œ μ½μ—ˆλŠ”μ§€"
}}
],
"has_more": true λ˜λŠ” false
}}
JSON 응닡:"""
SUMMARY_PROMPT = """\
μ•„λž˜ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ, μ‚¬μš©μžκ°€ ν˜„μž¬ μ–΄λ–€ 책을 μ›ν•˜κ³  μžˆλŠ”μ§€λ₯Ό κ°„λž΅ν•˜κ²Œ μš”μ•½ν•˜μ„Έμš”.
μ‚¬μš©μž ν”„λ‘œν•„:
{profile}
이전 λ…μ„œ κ²½ν—˜:
{book_experiences}
μš”κ΅¬μ‚¬ν•­:
- ν•œκ΅­μ–΄λ‘œ 3~5λ¬Έμž₯의 κ°„κ²°ν•œ μš”μ•½λ¬Έ
- μ‚¬μš©μžμ˜ λ…μ„œ λͺ©μ , μ„ ν˜Έ μž₯λ₯΄, μŠ€νƒ€μΌ, λ‚œμ΄λ„, ν˜„μž¬ 상황을 μžμ—°μŠ€λŸ½κ²Œ 톡합
- ꡬ체적인 λ„μ„œ μΆ”μ²œμ„ μœ„ν•œ κ·Όκ±°κ°€ 될 수 μžˆλ„λ‘ μž‘μ„±
- λ°˜λ“œμ‹œ 포함해야 ν•  μš”μ†Œ:
(1) 책을 찾게 된 상황적·감정적 λ§₯락 (μ™œ μ§€κΈˆ 이 책이 ν•„μš”ν•œκ°€)
(2) μ›ν•˜λŠ” μ±…μ˜ ꡬ체적 νŠΉμ„± (μž₯λ₯΄, ν…Œλ§ˆ, λΆ„μœ„κΈ°, 문체, λ‚œμ΄λ„)
(3) 이전 λ…μ„œ κ²½ν—˜μ΄ μžˆλ‹€λ©΄ κ·Έκ²ƒκ³Όμ˜ 관계
- 이 μš”μ•½λ¬Έμ΄ ν–₯ν›„ μœ μ‚¬ν•œ λ…μ„œ μš”κ΅¬λ₯Ό κ°€μ§„ λ‹€λ₯Έ μ„Έμ…˜μ„ κ²€μƒ‰ν•˜λŠ” 데 μ‚¬μš©λ˜λ―€λ‘œ, 감정적 λ§₯락과 λ…μ„œ λͺ©μ μ„ λͺ…ν™•ν•˜κ²Œ μ„œμˆ ν•˜μ„Έμš”
μš”μ•½λ¬Έ:"""
REFLECTION_PROMPT = """\
μ•„λž˜μ˜ ν˜„μž¬ μ„Έμ…˜ 정보와 μ—°κ²°λœ 이전 λ©”λͺ¨λ¦¬λ“€μ„ λΆ„μ„ν•˜μ—¬, **ν˜„μž¬ 이 μ‚¬μš©μžκ°€ μ›ν•˜λŠ” μ±…**을 더 깊이 μ΄ν•΄ν•˜κΈ° μœ„ν•œ 3~7κ°€μ§€ μΈμ‚¬μ΄νŠΈλ₯Ό λ„μΆœν•˜μ„Έμš”.
μ€‘μš”: μΈμ‚¬μ΄νŠΈλŠ” λ°˜λ“œμ‹œ ν˜„μž¬ μ„Έμ…˜μ˜ λ…μ„œ μš”κ΅¬λ₯Ό μ€‘μ‹¬μœΌλ‘œ μž‘μ„±ν•˜μ„Έμš”. μ—°κ²°λœ 이전 λ©”λͺ¨λ¦¬λŠ” ν˜„μž¬ μš”κ΅¬μ˜ λ§₯락을 깊이 μ΄ν•΄ν•˜κΈ° μœ„ν•œ μ°Έκ³  자료둜 ν™œμš©ν•˜λ˜, λ²”μš©μ μΈ κ³Όκ±° νŒ¨ν„΄ μš”μ•½μ΄ μ•„λ‹Œ ν˜„μž¬ μ‹œμ μ—μ„œ μ‚¬μš©μžκ°€ μ–΄λ–€ 책을 μ›ν•˜λŠ”μ§€λ₯Ό κ΅¬μ²΄ν™”ν•˜λŠ” 데 μ§‘μ€‘ν•˜μ„Έμš”.
ν˜„μž¬ μ„Έμ…˜ λ©”λͺ¨λ¦¬:
- ν”„λ‘œν•„: {current_profile}
- μš”μ•½: {current_summary}
- λ…μ„œ κ²½ν—˜: {current_experiences}
μ—°κ²°λœ 이전 λ©”λͺ¨λ¦¬λ“€ (1-hop):
{linked_memories}
μ ˆλŒ€ κ·œμΉ™
- λ°˜λ“œμ‹œ 제곡된 정보λ₯Ό 기반으둜 λΆ„μ„ν•˜μ„Έμš”
- ν•©λ¦¬μ μœΌλ‘œ μΆ”λ‘  κ°€λŠ₯ν•œ μΈμ‚¬μ΄νŠΈλ₯Ό μΆ”μΆœν•˜μ„Έμš”
λ‹€μŒ 4κ°€μ§€ κ΄€μ μ—μ„œ ν˜„μž¬ μš”κ΅¬λ₯Ό 심화 λΆ„μ„ν•˜μ„Έμš”:
1. ν˜„μž¬ μš•κ΅¬μ˜ 본질: μ‚¬μš©μžκ°€ ν‘œλ©΄μ μœΌλ‘œ λ§ν•œ 것 λ„ˆλ¨Έμ—, 이전 λ©”λͺ¨λ¦¬μ˜ λ§₯락을 κ³ λ €ν–ˆμ„ λ•Œ μ‹€μ œλ‘œ μ›ν•˜λŠ” λ…μ„œ κ²½ν—˜μ€ 무엇인가?
2. ν˜„μž¬ μ„ ν˜Έμ˜ ꡬ체화: 이전 λ©”λͺ¨λ¦¬μ—μ„œμ˜ λ…μ„œ κ²½ν—˜μ„ μ°Έκ³ ν•˜μ—¬, ν˜„μž¬ μ›ν•˜λŠ” μ±…μ˜ ν…Œλ§ˆ, λΆ„μœ„κΈ°, 문체, μ„œμ‚¬ ꡬ쑰 등을 더 ꡬ체적으둜 μΆ”λ‘ ν•  수 μžˆλŠ”κ°€?
3. 잠재적 μ„ ν˜Έ: μ‚¬μš©μžκ°€ λͺ…μ‹œμ μœΌλ‘œ λ§ν•˜μ§€ μ•Šμ•˜μ§€λ§Œ, ν˜„μž¬ ν”„λ‘œν•„κ³Ό 이전 κ²½ν—˜μ˜ λ§₯λ½μ—μ„œ μΆ”λ‘  κ°€λŠ₯ν•œ ν˜„μž¬ μ‹œμ μ˜ μˆ¨κ²¨μ§„ μ„ ν˜Έκ°€ μžˆλŠ”κ°€?
4. 이전 κ²½ν—˜κ³Όμ˜ 차별점: κ³Όκ±° λΉ„μŠ·ν•œ λ§₯λ½μ—μ„œ μ½μ—ˆλ˜ μ±…κ³Ό λΉ„κ΅ν•˜μ—¬, μ΄λ²ˆμ—λŠ” μ–΄λ–€ μ μ—μ„œ κ°™κ±°λ‚˜ λ‹€λ₯Έ 책을 μ›ν•˜λŠ”κ°€?
μΈμ‚¬μ΄νŠΈ μž‘μ„± κΈ°μ€€:
- 각 μΈμ‚¬μ΄νŠΈλŠ” λ„μ„œ 메타데이터(μž₯λ₯΄, ν…Œλ§ˆ, λΆ„μœ„κΈ°, 문체, λ‚œμ΄λ„ λ“±)와 μœ μ‚¬λ„ 검색 μ‹œ 맀칭될 수 μžˆλ„λ‘ ꡬ체적인 λ…μ„œ νŠΉμ„±μ„ ν¬ν•¨ν•˜μ„Έμš”.
- "μ‚¬μš©μžκ°€ 'λ°λ―Έμ•ˆ'을 μ’‹μ•„ν•œλ‹€"처럼 νŠΉμ • 책에 κ΅­ν•œλ˜μ§€ μ•Šμ•„μ•Ό ν•©λ‹ˆλ‹€.
- "μ‚¬μš©μžκ°€ μ†Œμ„€μ„ μ’‹μ•„ν•œλ‹€"처럼 μ§€λ‚˜μΉ˜κ²Œ μΌλ°˜μ μ΄μ–΄μ„œλ„ μ•ˆ λ©λ‹ˆλ‹€.
- "μ‚¬μš©μžλŠ” λ•Œλ‘œλŠ” A, λ•Œλ‘œλŠ” Bλ₯Ό μ½λŠ”λ‹€"와 같은 λ²”μš©μ  νŒ¨ν„΄μ΄ μ•„λ‹Œ, ν˜„μž¬ 이 μˆœκ°„ μ–΄λ–€ 책이 ν•„μš”ν•œμ§€λ₯Ό μ„œμˆ ν•˜μ„Έμš”.
- μ—°κ²°λœ 이전 λ©”λͺ¨λ¦¬κ°€ μ—†λŠ” 경우, ν˜„μž¬ μ„Έμ…˜ μ •λ³΄λ§ŒμœΌλ‘œ μΈμ‚¬μ΄νŠΈλ₯Ό λ„μΆœν•˜μ„Έμš”.
μ˜ˆμ‹œ (ν˜„μž¬ μ„Έμ…˜: 직μž₯ 슀트레슀 β†’ νŒνƒ€μ§€ μ†Œμ„€, μ—°κ²°λœ λ©”λͺ¨λ¦¬: 슀트레슀 β†’ μžκΈ°κ³„λ°œμ„œ κ²½ν—˜):
[
"ν˜„μ‹€μ˜ 직μž₯ μŠ€νŠΈλ ˆμŠ€μ—μ„œ μ™„μ „νžˆ λ²—μ–΄λ‚˜ λͺ°μž…ν•  수 μžˆλŠ” 세계관이 ν’λΆ€ν•œ νŒνƒ€μ§€λ₯Ό 원함",
"이전에 μžκΈ°κ³„λ°œμ„œλ‘œ λŠ₯동적 λŒ€μ²˜λ₯Ό μ‹œλ„ν•œ κ²½ν—˜μ΄ μžˆμœΌλ‚˜, ν˜„μž¬λŠ” 뢄석보닀 감정적 도피와 이야기 속 λͺ°μž…을 ν†΅ν•œ ν•΄μ†Œλ₯Ό 원함",
"무거운 μ£Όμ œλ³΄λ‹€λŠ” κΈ΄μž₯감과 λͺ¨ν—˜μ΄ μžˆμœΌλ©΄μ„œλ„ 결말이 희망적인 μ„œμ‚¬ ꡬ쑰λ₯Ό μ„ ν˜Έν•  κ°€λŠ₯성이 λ†’μŒ",
"λΉ λ₯Έ μ „κ°œμ™€ νŽ˜μ΄μ§€ ν„°λ„ˆ μŠ€νƒ€μΌμ˜ 가독성 높은 문체가 ν˜„μž¬ μƒνƒœμ— 적합함",
"직μž₯ λ‚΄ 인간관계 μŠ€νŠΈλ ˆμŠ€κ°€ λ°°κ²½μ΄λ―€λ‘œ, 주인곡이 역경을 κ·Ήλ³΅ν•˜κ³  μ„±μž₯ν•˜λŠ” μ„œμ‚¬μ— 감정적 곡감을 λŠλ‚„ κ°€λŠ₯성이 있음"
]
μœ„ ν˜•μ‹μ— λ§žμΆ”μ–΄ Python 리슀트 ν˜•νƒœμ˜ JSON λ°°μ—΄λ‘œλ§Œ μ‘λ‹΅ν•˜μ„Έμš”. λ‹€λ₯Έ ν…μŠ€νŠΈλŠ” ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”..
JSON 응닡:"""
MEMORY_LINK_PROMPT = """\
당신은 λ„μ„œ νλ ˆμ΄μ…˜ μ‹œμŠ€ν…œμ˜ λ©”λͺ¨λ¦¬ link 생성 μ—μ΄μ „νŠΈμž…λ‹ˆλ‹€.
μƒˆλ‘œμš΄ μ„Έμ…˜ λ©”λͺ¨λ¦¬μ˜ μš”μ•½κ³Ό μœ μ‚¬ν•œ 이웃 λ©”λͺ¨λ¦¬λ“€μ˜ μš”μ•½μ„ λΉ„κ΅ν•˜μ—¬, μ–΄λ–€ 이웃 λ©”λͺ¨λ¦¬μ™€ μ—°κ²°(link)을 생성해야 ν•˜λŠ”μ§€ νŒλ‹¨ν•˜μ„Έμš”.
ν˜„μž¬ μ„Έμ…˜ μš”μ•½:
{current_summary}
μœ μ‚¬ν•œ 이웃 λ©”λͺ¨λ¦¬λ“€:
{nearest_neighbors}
μ ˆλŒ€ κ·œμΉ™:
- λ°˜λ“œμ‹œ μ„Έμ…˜ λ©”λͺ¨λ¦¬μ˜ μš”μ•½κ³Ό μœ μ‚¬ν•œ 이웃 λ©”λͺ¨λ¦¬λ“€μ˜ μš”μ•½λ§Œμ„ 근거둜 νŒλ‹¨ν•œλ‹€.
- μš”μ•½μ— λͺ…μ‹œμ μœΌλ‘œ μ“°μ—¬ μžˆμ§€ μ•Šμ€ λ™κΈ°λ‚˜ 감정을 μΆ”λ‘ ν•˜μ—¬ μ—°κ²° 근거둜 μ‚¬μš©ν•˜μ§€ λ§ˆμ„Έμš”.
- λ‹€μŒμ€ link의 κ·Όκ±°κ°€ 될 수 μ—†λ‹€:
Β· λ‚œμ΄λ„, λ…μ„œ μŠ€νƒ€μΌ, 문체 μ„ ν˜Έ λ“± ν‘œλ©΄μ  μ†μ„±μ˜ μœ μ‚¬μ„±
Β· 주제λ₯Ό "~적 관점", "~적 μ ‘κ·Ό"으둜 μž¬ν‘œν˜„ν•˜μ—¬ 곡톡점을 λ§Œλ“œλŠ” 것
- 기본값은 should_link: false μž…λ‹ˆλ‹€. λͺ…ν™•ν•œ κ·Όκ±°κ°€ μžˆμ„ λ•Œλ§Œ true둜 νŒλ‹¨ν•˜μ„Έμš”.
νŒλ‹¨ κΈ°μ€€ β€” λ‹€μŒμ„ λͺ¨λ‘ μΆ©μ‘±ν•΄μ•Ό 연결을 μƒμ„±ν•˜μ„Έμš”:
1. ν•„μˆ˜ 쑰건: 책을 찾게 된 동기, λͺ©μ  λ˜λŠ” 상황적 배경이 κ²ΉμΉ˜λŠ”κ°€?
2. μΆ”κ°€ 쑰건: μœ„ 쑰건을 μΆ©μ‘±ν•œ μƒνƒœμ—μ„œ, 이웃 λ©”λͺ¨λ¦¬λ₯Ό μ°Έκ³ ν•˜λ©΄ ν˜„μž¬ μ‚¬μš©μžκ°€ μ›ν•˜λŠ” μ±…μ˜ λ³Έμ§ˆμ„ 더 깊이 이해할 수 μžˆλŠ”κ°€?
- μœ μ‚¬ν•œ 감정 μƒν™©μ—μ„œ λ‹€λ₯Έ μž₯λ₯΄, λΆ„μ•Ό, λ‚œμ΄λ„λ₯Ό μ„ νƒν•œ κ²½ν—˜μ΄ μžˆμ–΄ λŒ€λΉ„κ°€ κ°€λŠ₯ν•œ 경우
- μœ μ‚¬ν•œ 감정 μƒν™©μ—μ„œμ˜ 이전 λ…μ„œ κ²½ν—˜μ΄ ν˜„μž¬ μ›ν•˜λŠ” μ±…μ˜ νŠΉμ„±μ„ κ΅¬μ²΄ν™”ν•˜λŠ” 경우
λΉ„μ—°κ²° νŒλ‹¨ μ˜ˆμ‹œ (should_link: false):
- "경제 곡뢀 ν›„ μœ„λ‘œλ°›μ„ 에세이" vs "직μž₯ μ†Œν†΅μ„ μ‹¬λ¦¬ν•™μœΌλ‘œ 이해" β†’ λ…μ„œ 동기가 닀름 (μœ„λ‘œ vs 지적 탐ꡬ)
- "μ˜μ‚¬κ²°μ • 편ν–₯ 이해" vs "직μž₯ 관계 심리학 이해" β†’ 같은 μ‹¬λ¦¬ν•™μ΄μ§€λ§Œ 동기와 λ§₯락이 μ™„μ „νžˆ 닀름 (인지 편ν–₯ vs λŒ€μΈκ΄€κ³„)
μ—°κ²° νŒλ‹¨ μ˜ˆμ‹œ (should_link: true):
- "직μž₯ λ²ˆμ•„μ›ƒ β†’ νŒνƒ€μ§€λ‘œ 도피" vs "직μž₯ κ°ˆλ“± β†’ μžκΈ°κ³„λ°œμ„œλ‘œ λŒ€μ²˜" β†’ 직μž₯ 슀트레슀 ν•΄μ†ŒλΌλŠ” ꡬ체적 감정 동기 곡유, λ‹€λ₯Έ μ „λž΅ 비ꡐ κ°€λŠ₯
- "이별 ν›„ μœ„λ‘œλ°›μ„ μ†Œμ„€" vs "μΉœκ΅¬μ™€ μ†Œμ›ν•΄μ§„ ν›„ 관계 λ¬Έν•™" β†’ 관계 μƒμ‹€μ΄λΌλŠ” 감정적 λ§₯락 곡유
- "이직 μ€€λΉ„ 쀑 μž¬μ • 점검" vs "이직 ν›„ μ†ŒλΉ„ νŒ¨ν„΄ 정리" β†’ 컀리어 μ „ν™˜μ΄λΌλŠ” 상황적 λ§₯락 곡유, 동일 λͺ©μ μ˜ μ „ν›„ 비ꡐ κ°€λŠ₯
μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œ μ‘λ‹΅ν•˜μ„Έμš”:
{{
"evolution_decisions": [
{{
"neighbor_session_id": "이웃 μ„Έμ…˜ ID",
"should_link": false,
"link_reason": "ν˜„μž¬ μ„Έμ…˜μ˜ 이해에 μ–΄λ–»κ²Œ 도움이 λ˜λŠ”μ§€ (1~2λ¬Έμž₯)",
"link_strength": 0.0~1.0
}}
]
}}
JSON 응닡:"""
# ──────────────────────────────────────────────
# LLM utilities
# ──────────────────────────────────────────────
async def llm_call(llm: BaseChatModel, prompt: str) -> str:
messages = [HumanMessage(content=prompt)]
response = await llm.ainvoke(messages)
return response.content.strip()
def parse_json_response(text: str) -> dict[str, Any]:
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
if match:
text = match.group(1)
match = re.search(r"(\{.*\}|\[.*\])", text, re.DOTALL)
if match:
text = match.group(1)
try:
return json.loads(text)
except json.JSONDecodeError:
return {}
def format_conversation_context(messages: list, last_n: int = 10) -> str:
recent = messages[-last_n:] if len(messages) > last_n else messages
lines = []
for msg in recent:
role = "μ‚¬μš©μž" if (isinstance(msg, HumanMessage) or getattr(msg, "type", None) == "human") else "νλ ˆμ΄ν„°"
lines.append(f"{role}: {msg.content}")
return "\n".join(lines)
# ──────────────────────────────────────────────
# MemoryStore
# ──────────────────────────────────────────────
class MemoryStore:
def __init__(self, persist_directory: str = "./chroma_db"):
self.client = chromadb.PersistentClient(path=persist_directory)
# ν•œκ΅­μ–΄ νŠΉν™” μž„λ² λ”© λͺ¨λΈ (HuggingFace sentence-transformers)
self._embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="dragonkue/bge-m3-ko"
)
self.sessions = self.client.get_or_create_collection(
name="session_memories",
metadata={"hnsw:space": "cosine"},
embedding_function=self._embedding_fn,
)
self.links = self.client.get_or_create_collection(
name="memory_links",
metadata={"hnsw:space": "cosine"},
embedding_function=self._embedding_fn,
)
def save_session(self, memory: SessionMemory) -> None:
embedding_text = self._build_embedding_text(memory)
metadata = {
"session_id": memory.session_id,
"timestamp": memory.timestamp,
"summary": memory.summary,
"reflection": memory.reflection,
"profile_json": memory.profile.model_dump_json(),
"experiences_json": json.dumps(
[e.model_dump() for e in memory.book_experiences], ensure_ascii=False
),
"linked_ids": json.dumps(memory.linked_session_ids),
}
self.sessions.upsert(
ids=[memory.session_id],
documents=[embedding_text],
metadatas=[metadata],
)
def search_similar_profiles(
self,
profile: UserProfile,
k: int = 3,
exclude_session_id: Optional[str] = None,
) -> list[dict[str, Any]]:
query_text = profile.to_embedding_text()
if not query_text.strip():
return []
results = self.sessions.query(query_texts=[query_text], n_results=k)
similar = []
if results and results["ids"] and results["ids"][0]:
for i, sid in enumerate(results["ids"][0]):
if exclude_session_id and sid == exclude_session_id:
continue
meta = results["metadatas"][0][i]
distance = results["distances"][0][i] if results["distances"] else None
similar.append({
"session_id": sid,
"summary": meta.get("summary", ""),
"reflection": meta.get("reflection", ""),
"profile": json.loads(meta.get("profile_json", "{}")),
"experiences": json.loads(meta.get("experiences_json", "[]")),
"distance": distance,
"timestamp": meta.get("timestamp", ""),
})
return similar
def search_by_summary(
self,
summary: str,
k: int = 5,
exclude_session_id: Optional[str] = None,
) -> list[dict[str, Any]]:
if not summary.strip():
return []
results = self.sessions.query(query_texts=[summary], n_results=k)
similar = []
if results and results["ids"] and results["ids"][0]:
for i, sid in enumerate(results["ids"][0]):
if exclude_session_id and sid == exclude_session_id:
continue
meta = results["metadatas"][0][i]
distance = results["distances"][0][i] if results["distances"] else None
similar.append({
"session_id": sid,
"summary": meta.get("summary", ""),
"reflection": meta.get("reflection", ""),
"profile": json.loads(meta.get("profile_json", "{}")),
"experiences": json.loads(meta.get("experiences_json", "[]")),
"distance": distance,
"timestamp": meta.get("timestamp", ""),
})
return similar
def save_link(self, link: MemoryLink) -> None:
link_id = f"{link.source_session_id}___{link.target_session_id}"
self.links.upsert(
ids=[link_id],
documents=[link.link_reason],
metadatas=[{
"source_id": link.source_session_id,
"target_id": link.target_session_id,
"strength": link.strength,
"reason": link.link_reason,
}],
)
def get_session(self, session_id: str) -> Optional[dict[str, Any]]:
try:
result = self.sessions.get(ids=[session_id])
if result and result["metadatas"] and result["metadatas"][0]:
meta = result["metadatas"][0]
return {
"session_id": session_id,
"summary": meta.get("summary", ""),
"reflection": meta.get("reflection", ""),
"profile": json.loads(meta.get("profile_json", "{}")),
"experiences": json.loads(meta.get("experiences_json", "[]")),
"timestamp": meta.get("timestamp", ""),
}
except Exception:
pass
return None
def _build_embedding_text(self, memory: SessionMemory) -> str:
parts = [f"μš”μ•½: {memory.summary}"]
if memory.reflection:
parts.append(f"μΈμ‚¬μ΄νŠΈ: {memory.reflection}")
#parts = [memory.profile.to_embedding_text()]
#if memory.summary:
# parts.append(f"μš”μ•½: {memory.summary}")
#if memory.reflection:
# parts.append(f"μΈμ‚¬μ΄νŠΈ: {memory.reflection}")
#for exp in memory.book_experiences:
# parts.append(f"λ…μ„œκ²½ν—˜: {exp.book_name} - {exp.impression} {exp.context}")
return " | ".join(parts)
# ──────────────────────────────────────────────
# Node factory
# ──────────────────────────────────────────────
def create_nodes(llm: BaseChatModel, memory_store: MemoryStore):
"""LLMκ³Ό MemoryStoreλ₯Ό ν΄λ‘œμ €λ‘œ μΊ‘μ²˜ν•œ λ…Έλ“œ ν•¨μˆ˜λ“€μ„ λ°˜ν™˜ν•œλ‹€."""
async def generate_slot_question(state: GraphState) -> dict[str, Any]:
profile = state["user_profile"]
current_slot = state["current_slot"]
# λ°©μ–΄ μ½”λ“œ: λΌμš°νŒ…μ—μ„œ κ±ΈλŸ¬μ§€λ―€λ‘œ μ •μƒμ μœΌλ‘œλŠ” λ„λ‹¬ν•˜μ§€ μ•ŠμŒ
if current_slot is None:
current_slot = _get_next_empty_slot(profile)
if current_slot is None:
# μ§„μ§œ λͺ¨λ“  슬둯이 μ™„λ£Œλ¨ β€” 빈 응닡 λ°˜ν™˜ (λΌμš°νŒ…μ΄ 처리)
return {"current_slot": None}
slot = profile.get_slot(current_slot)
# λ§€μΉ­ ν›„ current_context 질문인 경우 β†’ μ „μš© ν”„λ‘¬ν”„νŠΈ μ‚¬μš©
if (
current_slot == "current_context"
and state.get("matched_profile_id") is not None
):
# 이전 ν”„λ‘œν•„ 정보λ₯Ό similar_profilesμ—μ„œ κ°€μ Έμ˜΄
matched = state["similar_profiles"][0]
matched_profile_text = _format_profile_from_dict(matched.get("profile", {}))
prompt = POST_MATCH_CONTEXT_PROMPT.format(
matched_profile=matched_profile_text,
conversation_context=format_conversation_context(state["messages"], last_n=100),
)
else:
# μž¬μ‹œλ„ μ•ˆλ‚΄ ꡬ성
retry_instruction = ""
if slot.retry_count > 0:
retry_instruction = RETRY_INSTRUCTION_TEMPLATE.format(
retry_count=slot.retry_count,
max_retries=slot.MAX_RETRIES,
)
# 이미 μ±„μ›Œμ§„ ν”„λ‘œν•„ 정보 ν¬λ§€νŒ…
filled_info = _format_filled_profile(profile)
prompt = SLOT_QUESTION_PROMPT.format(
filled_profile=filled_info if filled_info else "아직 μˆ˜μ§‘λœ 정보 μ—†μŒ",
slot_name=current_slot,
slot_description=SLOT_DESCRIPTIONS[current_slot],
conversation_context=format_conversation_context(state["messages"], last_n=100),
retry_instruction=retry_instruction,
)
question = await llm_call(llm, prompt)
return {
"messages": [AIMessage(content=question)],
"ai_response": question,
"current_slot": current_slot,
}
async def process_slot_answer(state: GraphState) -> dict[str, Any]:
user_msg = _get_last_human_message(state)
profile = state["user_profile"]
current_slot = state["current_slot"]
profile = await _extract_slots_from_message(llm, user_msg, profile, state["messages"])
if current_slot and profile.get_slot(current_slot).status == SlotStatus.EMPTY:
slot = profile.get_slot(current_slot)
slot.retry_count += 1
if slot.retry_count >= slot.MAX_RETRIES:
slot.status = SlotStatus.UNCLEAR
profile.set_slot(current_slot, slot)
# λ§€μΉ­ ν›„ 응닡 처리: μ‚¬μš©μžκ°€ μ–ΈκΈ‰ν•˜μ§€ μ•Šμ€ 빈 μŠ¬λ‘―μ€ 이전 ν”„λ‘œν•„λ‘œ 채움
if state.get("matched_profile_id") and state.get("similar_profiles"):
matched_data = state["similar_profiles"][0].get("profile", {})
for slot_name in SLOT_NAMES:
if profile.get_slot(slot_name).status == SlotStatus.EMPTY:
matched_slot = matched_data.get(slot_name, {})
if isinstance(matched_slot, dict) and matched_slot.get("value"):
slot = ProfileSlot(
value=matched_slot["value"],
status=SlotStatus.FILLED,
)
profile.set_slot(slot_name, slot)
next_slot = _get_next_empty_slot(profile)
return {"user_profile": profile, "current_slot": next_slot}
async def search_similar_profiles(state: GraphState) -> dict[str, Any]:
profile = state["user_profile"]
similar = memory_store.search_similar_profiles(
profile=profile, k=3, exclude_session_id=state["session_id"]
)
filtered = [s for s in similar if s.get("distance", 1.0) < 0.5]
if not filtered:
return {"similar_profiles": [], "phase": Phase.SLOT_FILLING}
best = filtered[0]
prompt = SIMILAR_PROFILE_PRESENT_PROMPT.format(
profile_summary=best.get("summary", "정보 μ—†μŒ")
)
question = await llm_call(llm, prompt)
return {
"similar_profiles": filtered,
"messages": [AIMessage(content=question)],
"ai_response": question,
"phase": Phase.MATCH_CONFIRM,
}
async def process_match_confirm(state: GraphState) -> dict[str, Any]:
user_msg = _get_last_human_message(state)
prompt = MATCH_CONFIRM_PROMPT.format(user_message=user_msg)
result = parse_json_response(await llm_call(llm, prompt))
is_match = result.get("is_match", False)
if is_match and state["similar_profiles"]:
best = state["similar_profiles"][0]
prev_experiences = [BookExperience(**e) for e in best.get("experiences", [])]
transition_msg = "이전에 λΉ„μŠ·ν•œ λ§₯락으둜 λ„μ„œλ₯Ό μ°ΎμœΌμ…¨λ˜ 기둝이 μžˆμ–΄μ„œ ν•΄λ‹Ή ν”„λ‘œν•„μ„ λΆˆλŸ¬μ™”μŠ΅λ‹ˆλ‹€!"
return {
"matched_profile_id": best["session_id"],
"book_experiences": prev_experiences,
"current_slot": "current_context",
"messages": [AIMessage(content=transition_msg)],
"ai_response": transition_msg,
"phase": Phase.SLOT_FILLING,
}
else:
next_slot = _get_next_empty_slot(state["user_profile"])
return {
"similar_profiles": [],
"current_slot": next_slot,
"phase": Phase.SLOT_FILLING,
}
async def ask_book_experience(state: GraphState) -> dict[str, Any]:
profile = state["user_profile"]
existing = state.get("book_experiences", [])
prompt = BOOK_EXPERIENCE_PROMPT.format(
profile_summary=profile.to_embedding_text(),
existing_experiences=(
"\n".join(f"- {e.book_name}: {e.impression} {e.context}" for e in existing)
if existing else "μ—†μŒ"
),
)
question = await llm_call(llm, prompt)
return {
"messages": [AIMessage(content=question)],
"ai_response": question,
"asked_book_experience": True,
}
async def process_book_experience(state: GraphState) -> dict[str, Any]:
user_msg = _get_last_human_message(state)
prompt = EXTRACT_BOOK_EXPERIENCE_PROMPT.format(user_message=user_msg)
result = parse_json_response(await llm_call(llm, prompt))
experiences = list(state.get("book_experiences", []))
for exp_data in result.get("experiences", []):
experiences.append(BookExperience(**exp_data))
has_more = result.get("has_more", False)
return {
"book_experiences": experiences,
"phase": Phase.BOOK_EXPERIENCE if has_more else Phase.SUMMARY,
}
async def generate_summary(state: GraphState) -> dict[str, Any]:
profile = state["user_profile"]
experiences = state.get("book_experiences", [])
prompt = SUMMARY_PROMPT.format(
profile=profile.to_embedding_text(),
book_experiences=(
"\n".join(f"- {e.book_name}: {e.impression} {e.context}" for e in experiences)
if experiences else "μ—†μŒ"
),
)
summary = await llm_call(llm, prompt)
return {"summary": summary, "phase": Phase.REFLECTION}
async def perform_reflection(state: GraphState) -> dict[str, Any]:
profile = state["user_profile"]
summary = state["summary"]
experiences = state.get("book_experiences", [])
session_id = state["session_id"]
# 1) summary 기반으둜 μœ μ‚¬ λ©”λͺ¨λ¦¬ 검색
similar = memory_store.search_by_summary(
summary=summary,
k=5,
exclude_session_id=session_id,
)
# ν˜„μž¬ μ„Έμ…˜ λ…μ„œ κ²½ν—˜ ν…μŠ€νŠΈ ꡬ성
current_experiences_text = (
"\n".join(f"- {e.book_name}: {e.impression}" for e in experiences)
if experiences
else "μ—†μŒ"
)
# 2) 이웃 λ©”λͺ¨λ¦¬ μš”μ•½ ν¬λ§€νŒ… + 단일 호좜둜 링크 νŒλ‹¨
links: list[MemoryLink] = []
linked_session_ids: list[str] = []
if similar:
neighbor_texts = []
for idx, cand in enumerate(similar):
neighbor_texts.append(
f"[이웃 {idx + 1}] μ„Έμ…˜ ID: {cand['session_id']}\n"
f" μš”μ•½: {cand.get('summary', '')}"
)
evolution_prompt = MEMORY_LINK_PROMPT.format(
current_summary=summary,
nearest_neighbors="\n---\n".join(neighbor_texts),
)
evolution_result = parse_json_response(await llm_call(llm, evolution_prompt))
decisions = evolution_result.get("evolution_decisions", [])
for decision in decisions:
if decision.get("should_link", False):
target_id = decision.get("neighbor_session_id", "")
valid_ids = {c["session_id"] for c in similar}
if target_id in valid_ids:
link = MemoryLink(
source_session_id=session_id,
target_session_id=target_id,
link_reason=decision.get("link_reason", ""),
strength=decision.get("link_strength", 0.0),
)
links.append(link)
linked_session_ids.append(target_id)
memory_store.save_link(link)
# 3) 1-hop μ—°κ²° λ©”λͺ¨λ¦¬ μˆ˜μ§‘ + link reason λ§€ν•‘
link_reasons = {
link.target_session_id: link.link_reason for link in links
}
linked_memories = []
for lid in linked_session_ids:
sess = memory_store.get_session(lid)
if sess:
sess["link_reason"] = link_reasons.get(lid, "")
linked_memories.append(sess)
# 4) Reflection μˆ˜ν–‰ β€” 5κ°€μ§€ μΈμ‚¬μ΄νŠΈ 리슀트 μΆ”μΆœ
# linked_memories ν¬λ§€νŒ… (μ—°κ²° 이유 + ν”„λ‘œν•„ + μš”μ•½ + λ…μ„œκ²½ν—˜ + 이전 reflection)
def _format_linked_memory(m: dict) -> str:
parts = [f"μ„Έμ…˜ {m['session_id']}:"]
# μ—°κ²° 이유
if m.get("link_reason"):
parts.append(f"μ—°κ²° 이유: {m['link_reason']}")
# ν”„λ‘œν•„ 정보
m_profile = m.get("profile", {})
profile_items = []
for k, v in m_profile.items():
if isinstance(v, dict) and v.get("value"):
desc = SLOT_DESCRIPTIONS.get(k, k)
profile_items.append(f"{desc}: {v['value']}")
if profile_items:
parts.append(f"ν”„λ‘œν•„: {' | '.join(profile_items)}")
# μš”μ•½
if m.get("summary"):
parts.append(f"μš”μ•½: {m['summary']}")
# λ…μ„œ κ²½ν—˜
m_exps = m.get("experiences", [])
if m_exps:
exp_lines = [f" - {e.get('book_name', '')}: {e.get('impression', '')}" for e in m_exps]
parts.append(f"λ…μ„œ κ²½ν—˜:\n" + "\n".join(exp_lines))
# 이전 reflection
if m.get("reflection"):
parts.append(f"이전 μΈμ‚¬μ΄νŠΈ: {m['reflection']}")
return "\n".join(parts)
prompt = REFLECTION_PROMPT.format(
current_profile=profile.to_embedding_text(),
current_summary=summary,
current_experiences=current_experiences_text,
linked_memories=(
"\n---\n".join(
_format_linked_memory(m) for m in linked_memories
)
if linked_memories
else "μ—°κ²°λœ 이전 λ©”λͺ¨λ¦¬ μ—†μŒ"
),
)
reflection_raw = await llm_call(llm, prompt)
# 리슀트 ν˜•νƒœ νŒŒμ‹±
reflection_parsed = parse_json_response(reflection_raw)
if isinstance(reflection_parsed, list):
reflection_list = reflection_parsed
else:
# fallback: νŒŒμ‹± μ‹€νŒ¨ μ‹œ 원문을 단일 ν•­λͺ© 리슀트둜
reflection_list = [reflection_raw.strip()]
reflection_text = " ".join(reflection_list)
# 5) ChromaDB에 μ €μž₯
session_memory = SessionMemory(
session_id=session_id,
profile=profile,
book_experiences=experiences,
summary=summary,
reflection=reflection_text,
linked_session_ids=linked_session_ids,
)
memory_store.save_session(session_memory)
# 6) μ™„λ£Œ λ©”μ‹œμ§€
insights_display = "\n".join(f" β€’ {item}" for item in reflection_list)
done_msg = (
f"ν”„λ‘œν•„ 뢄석이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\n\n"
f"πŸ“‹ μš”μ•½: {summary}\n\n"
f"πŸ’‘ μΈμ‚¬μ΄νŠΈ:\n{insights_display}"
)
return {
"reflection": reflection_text,
"links": links,
"messages": [AIMessage(content=done_msg)],
"ai_response": done_msg,
"phase": Phase.DONE,
}
async def _extract_slots_from_message(
llm_inst: BaseChatModel,
user_msg: str,
profile: UserProfile,
messages: list,
) -> UserProfile:
target_slots = profile.empty_slots()
if not target_slots:
return profile
slot_desc = "\n".join(f"- {s}: {SLOT_DESCRIPTIONS[s]}" for s in target_slots)
prompt = EXTRACT_SLOT_PROMPT.format(
target_slots=slot_desc,
user_message=user_msg,
conversation_context=format_conversation_context(messages),
)
result = parse_json_response(await llm_call(llm_inst, prompt))
extracted = result.get("extracted", {})
for slot_name, info in extracted.items():
if slot_name not in SLOT_NAMES or info is None:
continue
value = info.get("value")
if value and value != "null" and value.strip():
slot = profile.get_slot(slot_name)
slot.value = value
slot.status = SlotStatus.FILLED
profile.set_slot(slot_name, slot)
return profile
return {
"generate_slot_question": generate_slot_question,
"process_slot_answer": process_slot_answer,
"search_similar_profiles": search_similar_profiles,
"process_match_confirm": process_match_confirm,
"ask_book_experience": ask_book_experience,
"process_book_experience": process_book_experience,
"generate_summary": generate_summary,
"perform_reflection": perform_reflection,
}
# ──────────────────────────────────────────────
# Edge routing functions
# ──────────────────────────────────────────────
def route_after_slot_processing(state: GraphState) -> str:
profile = state["user_profile"]
similar_profiles = state.get("similar_profiles")
phase = state.get("phase")
if profile.all_filled_or_unclear():
return "ask_book_experience"
if (
profile.reading_goal_filled()
and similar_profiles is None
and phase != Phase.MATCH_CONFIRM
):
return "search_similar_profiles"
return "generate_slot_question"
def route_after_similar_search(state: GraphState) -> str:
phase = state.get("phase")
if phase == Phase.MATCH_CONFIRM:
return "process_match_confirm"
return "generate_slot_question"
def route_after_match_confirm(state: GraphState) -> str:
phase = state.get("phase")
if phase == Phase.BOOK_EXPERIENCE:
return "ask_book_experience"
return "generate_slot_question"
def route_after_book_experience(state: GraphState) -> str:
phase = state.get("phase")
if phase == Phase.SUMMARY:
return "generate_summary"
return "ask_book_experience"
# ──────────────────────────────────────────────
# Internal helpers
# ──────────────────────────────────────────────
def _get_last_human_message(state: GraphState) -> str:
for msg in reversed(state["messages"]):
if isinstance(msg, HumanMessage) or getattr(msg, "type", None) == "human":
return msg.content
return ""
def _get_next_empty_slot(profile: UserProfile) -> str | None:
remaining = profile.empty_slots()
return remaining[0] if remaining else None
def _format_filled_profile(profile: UserProfile) -> str:
lines = []
for name in SLOT_NAMES:
slot = profile.get_slot(name)
if slot.status == SlotStatus.FILLED:
lines.append(f"- {SLOT_DESCRIPTIONS[name]}: {slot.value}")
return "\n".join(lines) if lines else ""
def _format_profile_from_dict(profile_dict: dict) -> str:
"""ChromaDBμ—μ„œ κ°€μ Έμ˜¨ dict ν˜•νƒœμ˜ ν”„λ‘œν•„μ„ λ¬Έμžμ—΄λ‘œ ν¬λ§€νŒ…."""
lines = []
for name in SLOT_NAMES:
if name in profile_dict:
slot_data = profile_dict[name]
if isinstance(slot_data, dict) and slot_data.get("value"):
lines.append(f"- {SLOT_DESCRIPTIONS[name]}: {slot_data['value']}")
return "\n".join(lines) if lines else ""