Spaces:
Running
Running
| from __future__ import annotations | |
| import json | |
| import os | |
| from dotenv import load_dotenv | |
| from langchain_core.messages import AIMessage | |
| from langchain_core.prompts import ChatPromptTemplate | |
| from langchain_openai import ChatOpenAI | |
| from langgraph.graph import StateGraph, START, END | |
| from qdrant_client.models import Filter, FieldCondition, MatchAny | |
| from app.config import QDRANT_COLLECTION_NAME | |
| from app.db.qdrant import QdrantDB | |
| from app.embedding.embedder import LocalEmbedder | |
| from app.reranking.reranker import LocalReranker | |
| from app.state.state import GraphState, Phase, UserProfile | |
| load_dotenv() | |
| # โโ ๋ชจ๋ ๋ ๋ฒจ ์ธ์คํด์ค (์ฒซ import ์ ์ด๊ธฐํ) โโ | |
| embedder = LocalEmbedder("BAAI/bge-m3") | |
| db = QdrantDB(vector_size=1024) | |
| llm = ChatOpenAI(model="gpt-4o-mini") | |
| reranker = LocalReranker("BAAI/bge-reranker-v2-m3") | |
| CATEGORY_LIST = [ | |
| "์์ค", "๋ํ๊ต์ฌ/์ ๋ฌธ์์ ", "์ด๋ฆฐ์ด", "์ํ์/์๊ฒฉ์ฆ", "์/์์ธ์ด", "์ข ๊ต", "์ ์", "๋งํ", | |
| "์ฌํ/์ ์น", "๊ฒฝ์ /๊ฒฝ์", "์ธ๋ฌธ", "์์ /๋์ค๋ฌธํ", "๊ตญ์ด/์ธ๊ตญ์ด", "๊ณ ๋ฑํ๊ต ์ฐธ๊ณ ์", | |
| "์๊ธฐ๊ณ๋ฐ", "์ด๋ฑํ๊ต ์ฐธ๊ณ ์", "๊ฑด๊ฐ/์ทจ๋ฏธ", "์ปดํจํฐ/IT", "์ญ์ฌ", "์์ฐ/๊ณผํ", | |
| "์ฒญ์๋ ", "์คํ๊ต ์ฐธ๊ณ ์", "๊ฐ์ /์๋ฆฌ", "์ฌํ", "์ก์ง", "์ ์ง", "์ธ๊ตญ๋์" | |
| ] | |
| genre_prompt = ChatPromptTemplate.from_template(""" | |
| ์ฌ์ฉ์ ํ๋กํ์ผ์ ๋ณด๊ณ ์๋ ์นดํ ๊ณ ๋ฆฌ ๋ชฉ๋ก์์ ์ ํฉํ ๊ฒ์ ์ต๋ 3๊ฐ ์ ํํ์ธ์. | |
| ๋ชฉ๋ก์ ์๋ ๊ฐ์ ์ ๋ ๋ฐํํ์ง ๋ง์ธ์. | |
| ์นดํ ๊ณ ๋ฆฌ ๋ชฉ๋ก: {category_list} | |
| ์ฌ์ฉ์ ํ๋กํ์ผ: {summary} | |
| JSON์ผ๋ก๋ง ๋ฐํ: {{"categories": ["์์ค"]}} | |
| """) | |
| def extract_genre_node(state: GraphState) -> dict: | |
| summary = state.get("summary", "") | |
| chain = genre_prompt | llm | |
| response = chain.invoke({"category_list": CATEGORY_LIST, "summary": summary}) | |
| try: | |
| categories = json.loads(response.content)["categories"] | |
| except (json.JSONDecodeError, KeyError): | |
| categories = [] | |
| print(f"์ถ์ถ๋ ์ฅ๋ฅด: {categories}") | |
| return {"genre_filter": categories} | |
| # โโ Query Transformation โโ | |
| step_back_prompt = ChatPromptTemplate.from_template(""" | |
| ๋น์ ์ ๋์ ์ถ์ฒ ์์คํ ์ AI ์ด์์คํดํธ์ ๋๋ค. | |
| ์๋ ์ฌ์ฉ์ ํ๋กํ์ผ์์ ํ ๋จ๊ณ ๋ฌผ๋ฌ๋, | |
| ๋ ๋์ ๋ฒ์์ ๋์๋ฅผ ๊ฒ์ํ ์ ์๋ ์ผ๋ฐ์ ์ธ ์ฟผ๋ฆฌ๋ฅผ ์์ฑํ์ธ์. | |
| ํน์ ์ฅ๋ฅด๋ ์กฐ๊ฑด์ ๊ตญํ๋์ง ์๊ณ ๋ ์ ๊ฒฝํ๊ณผ ๋ชฉ์ ์ค์ฌ์ผ๋ก ์์ฑํ์ธ์. | |
| 2๋ฌธ์ฅ ์ด๋ด๋ก ์์ฑํ์ธ์. | |
| ์ฌ์ฉ์ ํ๋กํ์ผ: {summary} | |
| ์ถ๋ ฅ: | |
| """) | |
| rewrite_prompt = ChatPromptTemplate.from_template(""" | |
| ๋น์ ์ ๋์ ์ถ์ฒ ์์คํ ์ AI ์ด์์คํดํธ์ ๋๋ค. | |
| ์๋ ๋ ์ ๋ชฉ์ ์ ๋์ ๊ฒ์์ ๋ ์ ํฉํ๊ณ ๊ตฌ์ฒด์ ์ธ ๊ฒ์์ด๋ก ์ฌ์์ฑํ์ธ์. | |
| ์ฅ๋ฅด, ๋ ์ ์์ค, ๋ถ์๊ธฐ ๋ฑ ๊ฒ์ ์ ํ๋๋ฅผ ๋์ผ ์ ์๋ ํํ์ ํฌํจํ์ธ์. | |
| ๋ ์ ๋ชฉ์ : {step_back} | |
| ์ฌ์์ฑ๋ ๊ฒ์ ์ฟผ๋ฆฌ (๋ ๋ฌธ์ฅ ์ด๋ด๋ก): | |
| """) | |
| decompose_prompt = ChatPromptTemplate.from_template(""" | |
| ๋น์ ์ ๋์ ์ถ์ฒ ์์คํ ์ ๊ฒ์ ์ฟผ๋ฆฌ ์ ๋ฌธ๊ฐ์ ๋๋ค. | |
| ์ฌ์ฉ์์ ๋ ์ ์ทจํฅ๊ณผ ์ํฉ์ ๊น์ด ์ดํดํ์ฌ, ๋ฒกํฐ ์๋ฒ ๋ฉ ๊ฒ์์ ์ต์ ํ๋ ์๋ธ์ฟผ๋ฆฌ๋ฅผ ์์ฑํฉ๋๋ค. | |
| ์๋ ๊ฒ์ ์ฟผ๋ฆฌ๋ฅผ 2~4๊ฐ์ ์๋ธ์ฟผ๋ฆฌ๋ก ๋ถํดํ์ธ์. | |
| ๊ฐ ์๋ธ์ฟผ๋ฆฌ๋ ๋ ๋ฆฝ์ ์ธ ๋ฌธ์ฅ์ผ๋ก ์์ฑํ๋, ์ ์ฒด์ ์ผ๋ก ์์ฐ์ค๋ฝ๊ฒ ๋งฅ๋ฝ์ด ์ด์ด์ง๋๋ก ํ์ธ์. | |
| ์๋ ์ฟผ๋ฆฌ์ ์กฐ๊ฑด์ ์ถฉ์คํ ๋ฐ์ํ๋ฉด์, ์ฌ์ฉ์๊ฐ ๋ฏธ์ฒ ์๊ฐํ์ง ๋ชปํ์ ๊ด๋ จ ๊ด์ ์ ํ ๊ฐ ํฌํจํ์ธ์. | |
| [์ฃผ์] | |
| - "์ด ์ค์์", "๊ทธ ์ค์์" ๊ฐ์ ์ฐธ์กฐ ํํ์ ์ฌ์ฉํ์ง ๋ง์ธ์. | |
| - ๋ฆฌ๋ทฐ, ํ์ , ์ฌ์ฉ์ ์๊ฒฌ ๋ฑ ๋์ ๋ฉํ๋ฐ์ดํฐ ์ธ์ ์ ๋ณด๋ฅผ ์์ฒญํ์ง ๋ง์ธ์. | |
| - "์ถ์ฒ ๋์", "์ถ์ฒํด์ฃผ์ธ์", "ํฌํจํด์ฃผ์ธ์" ๊ฐ์ ํํ์ผ๋ก ๋๋ด์ง ๋ง์ธ์. | |
| ๊ฒ์ ์ฟผ๋ฆฌ: {rewritten} | |
| ์ถ๋ ฅ ํ์ (๋ฒํธ์ ํ ์คํธ๋ง, ๋ค๋ฅธ ํ ์คํธ ์์ด): | |
| 1. [์๋ธ์ฟผ๋ฆฌ 1] | |
| 2. [์๋ธ์ฟผ๋ฆฌ 2] | |
| 3. [์๋ธ์ฟผ๋ฆฌ 3] | |
| """) | |
| explain_prompt = ChatPromptTemplate.from_template(""" | |
| ๋น์ ์ ๋์ ์ถ์ฒ ์์คํ ์ AI ์ด์์คํดํธ์ ๋๋ค. | |
| ์๋ ์ฌ์ฉ์ ํ๋กํ์ผ๊ณผ ์ฑ ์๊ฐ๋ฅผ ์ฝ๊ณ , | |
| ์ด ์ฑ ์ด ์ด ์ฌ์ฉ์์๊ฒ ์ ์ ํฉํ์ง ๋๋ ์ ํฉํ์ง ์์์ง 2๋ฌธ์ฅ์ผ๋ก ๋ถ์ํ์ธ์. | |
| [์ฃผ์] | |
| - ์ฑ ์๊ฐ์ ์๋ ๋ด์ฉ์ ์ ๋ ์ง์ด๋ด์ง ๋ง์ธ์. | |
| - ์ฌ์ฉ์ ํ๋กํ์ผ์ ๋ ์ ๋ชฉ์ , ์ ํธ ์ฅ๋ฅด, ๋ ์ ์คํ์ผ๊ณผ ์ฐ๊ฒฐํด์ ์์ฑํ์ธ์. | |
| [์ฌ์ฉ์ ํ๋กํ์ผ] | |
| {summary} | |
| [์ฑ ์๊ฐ] | |
| {book_intro} | |
| ๋ถ์: | |
| """) | |
| explain_chain = explain_prompt | llm | |
| rag_prompt = ChatPromptTemplate.from_template(""" | |
| ๋น์ ์ ๋์๊ด ํ๋ ์ดํฐ AI์ ๋๋ค. | |
| [๊ท์น] | |
| - ๋ฐ๋์ [๊ฒ์๋ ๋์ ๋ชฉ๋ก]์ ์๋ ์ฑ ๋ง ์ถ์ฒํ์ธ์. | |
| - ๋ฐ๋์ JSON ํ์์ผ๋ก๋ง ๋ตํ์ธ์. ๋ค๋ฅธ ํ ์คํธ๋ ์ ๋ ํฌํจํ์ง ๋ง์ธ์. | |
| - ์ฌ์ฉ์ ํ๋กํ์ผ์ ์ฐธ๊ณ ํด์ ๊ฐ์ฅ ์ ํฉํ ๋์ 3๊ถ์ ์ถ์ฒํ์ธ์. | |
| - ์ฅ๋ฅด๋ ๋ฐ๋์ [๊ฒ์๋ ๋์ ๋ชฉ๋ก]์ ์ฅ๋ฅด ๊ฐ์ ๊ทธ๋๋ก ์ฌ์ฉํ์ธ์. | |
| [์ถ์ฒ ์ด์ ์์ฑ ๊ท์น] | |
| - ๋ฐ๋์ [์ฌ์ ๋ถ์]๊ณผ [์๊ฐ]์ ๋์จ ๊ตฌ์ฒด์ ์ธ ๋ด์ฉ์ ๊ทผ๊ฑฐ๋ก ์์ฑํ์ธ์. | |
| - ์ฌ์ฉ์ ํ๋กํ์ผ์ ์ด๋ค ๋ถ๋ถ(๋ ์ ๋ชฉ์ , ์ ํธ ์ฅ๋ฅด, ๋ ์ ์คํ์ผ)๊ณผ ์ฐ๊ฒฐ๋๋์ง ๋ช ์ํ์ธ์. | |
| - [์๊ฐ]์ [์ฌ์ ๋ถ์]์ ์๋ ๋ด์ฉ์ ์์๋ก ์ง์ด๋ด์ง ๋ง์ธ์. | |
| - 2~3๋ฌธ์ฅ์ผ๋ก ์์ฑํ์ธ์. | |
| [์ฌ์ฉ์ ํ๋กํ์ผ] | |
| {summary} | |
| [๊ฒ์๋ ๋์ ๋ชฉ๋ก] | |
| {context} | |
| [์ถ๋ ฅ ํ์] | |
| [ | |
| {{"title": "์ฑ ์ ๋ชฉ", "author": "์ ์", "isbn": "ISBN๋ฒํธ", "book_intro": "์ฑ ์๊ฐ", "cate_depth1": "์ฅ๋ฅด", "reason": "์ถ์ฒ ์ด์ 2~3๋ฌธ์ฅ"}}, | |
| {{"title": "์ฑ ์ ๋ชฉ", "author": "์ ์", "isbn": "ISBN๋ฒํธ", "book_intro": "์ฑ ์๊ฐ", "cate_depth1": "์ฅ๋ฅด", "reason": "์ถ์ฒ ์ด์ 2~3๋ฌธ์ฅ"}}, | |
| {{"title": "์ฑ ์ ๋ชฉ", "author": "์ ์", "isbn": "ISBN๋ฒํธ", "book_intro": "์ฑ ์๊ฐ", "cate_depth1": "์ฅ๋ฅด", "reason": "์ถ์ฒ ์ด์ 2~3๋ฌธ์ฅ"}} | |
| ] | |
| """) | |
| def step_back_query(summary: str) -> str: | |
| chain = step_back_prompt | llm | |
| return chain.invoke({"summary": summary}).content.strip() | |
| def rewrite_query(step_back: str) -> str: | |
| chain = rewrite_prompt | llm | |
| return chain.invoke({"step_back": step_back}).content.strip() | |
| def decompose_query(rewritten: str) -> list: | |
| chain = decompose_prompt | llm | |
| response = chain.invoke({"rewritten": rewritten}).content | |
| sub_queries = [ | |
| q.strip().lstrip("1234567890. ") | |
| for q in response.split("\n") | |
| if q.strip() and q.strip()[0].isdigit() | |
| ] | |
| return sub_queries | |
| def get_chained_queries(summary: str) -> dict: | |
| step_back = step_back_query(summary) | |
| print(f" [Step-back] : {step_back}") | |
| rewritten = rewrite_query(summary) | |
| print(f" [Rewritten] : {rewritten}") | |
| sub_queries = decompose_query(rewritten) | |
| print(f" [Sub-queries]: {sub_queries}") | |
| return { | |
| "step_back": step_back, | |
| "rewritten": rewritten, | |
| "sub_queries": sub_queries, | |
| "all": [step_back, rewritten] + sub_queries, | |
| } | |
| def reciprocal_rank_fusion(results_list: list, k: int = 60) -> list: | |
| scores, payloads = {}, {} | |
| for results in results_list: | |
| for rank, r in enumerate(results): | |
| isbn = r.payload.get("isbn", "") | |
| if isbn: | |
| scores[isbn] = scores.get(isbn, 0) + 1 / (k + rank + 1) | |
| payloads[isbn] = r.payload | |
| return [payloads[isbn] for isbn, _ in sorted(scores.items(), key=lambda x: x[1], reverse=True)] | |
| def query_transform_rag_node(state: GraphState) -> dict: | |
| summary = state.get("summary", "") | |
| reflection = state.get("reflection", "") | |
| categories = state.get("genre_filter", []) | |
| user_profile_query = " ".join(filter(None, [summary, reflection])) | |
| print("\n[Query Transformations]") | |
| queries = get_chained_queries(user_profile_query) | |
| all_queries = queries["all"] | |
| query_filter = None | |
| if categories: | |
| query_filter = Filter( | |
| must=[FieldCondition(key="cate_depth1", match=MatchAny(any=categories))] | |
| ) | |
| all_results = [] | |
| for query in all_queries: | |
| query_vector = embedder.embed(query) | |
| if query_filter: | |
| results = db.search_with_filter( | |
| QDRANT_COLLECTION_NAME, query_vector, | |
| query_filter=query_filter, limit=5, threshold=0.5, | |
| ) | |
| else: | |
| results = db.search(QDRANT_COLLECTION_NAME, query_vector, limit=5, threshold=0.5) | |
| all_results.append(results) | |
| merged_payloads = reciprocal_rank_fusion(all_results) | |
| reranked_payloads = reranker.rerank(query=user_profile_query, books=merged_payloads) | |
| retrieved_books = [ | |
| { | |
| "isbn": p.get("isbn"), | |
| "title": p.get("title"), | |
| "author": p.get("author"), | |
| "book_intro": p.get("book_intro"), | |
| "cate_depth1": p.get("cate_depth1"), | |
| } | |
| for p in reranked_payloads[:5] | |
| ] | |
| return {"retrieved_books": retrieved_books} | |
| def explain_node(state: GraphState) -> dict: | |
| summary = state.get("summary", "") | |
| reflection = state.get("reflection", "") | |
| books = state["retrieved_books"] | |
| user_profile_query = " ".join(filter(None, [summary, reflection])) | |
| for book in books: | |
| analysis = explain_chain.invoke({ | |
| "summary": user_profile_query, | |
| "book_intro": book.get("book_intro", ""), | |
| }).content | |
| book["analysis"] = analysis | |
| print(f" [๋ถ์] {book.get('title')} โ {analysis[:60]}...") | |
| return {"retrieved_books": books} | |
| def rag_llm_node(state: GraphState) -> dict: | |
| summary = state.get("summary", "") | |
| reflection = state.get("reflection", "") | |
| books = state["retrieved_books"] | |
| user_profile_query = " ".join(filter(None, [summary, reflection])) | |
| context = "\n\n".join([ | |
| f"ISBN: {b['isbn']}\n" | |
| f"์ ๋ชฉ: {b['title']}\n" | |
| f"์ ์: {b['author']}\n" | |
| f"์ฅ๋ฅด: {b['cate_depth1']}\n" | |
| f"์๊ฐ: {b['book_intro'][:300]}\n" | |
| f"์ฌ์ ๋ถ์: {b.get('analysis', '')}" | |
| for b in books | |
| ]) | |
| chain = rag_prompt | llm | |
| response = chain.invoke({"context": context, "summary": user_profile_query}) | |
| try: | |
| recommendations = json.loads(response.content) | |
| except json.JSONDecodeError: | |
| recommendations = response.content | |
| return { | |
| "messages": [AIMessage(content=response.content)], | |
| "recommendations": recommendations, | |
| } | |