Spaces:
Running
Running
| """ | |
| src/graph/worker_b.py | |
| Worker B: RAG ๊ฒ์ + ๋ต๋ณ ์์ฑ ๋ ธ๋ | |
| ์ฒ๋ฆฌ ํ๋ฆ: | |
| โ ์ฟผ๋ฆฌ ์ถ์ถ (LLM) โ ์ด๋ฉ์ผ์์ ํต์ฌ SAP ์ง๋ฌธ์ 2๊ฐ ํํ์ผ๋ก ํ์ฅ (Query Expansion) | |
| โก Hybrid Retrieval (Dense 0.5 + BM25 0.5) โ ์ฟผ๋ฆฌ๋ณ top-k ๊ฒ์ ํ ์ค๋ณต ์ ๊ฑฐ ๋ณํฉ | |
| โข Reranking (bge-reranker-v2-m3) โ top-5 ์ฌ์์ํ | |
| โฃ ๋ต๋ณ ์์ฑ (LLM) โ top-5 ์ปจํ ์คํธ ๊ธฐ๋ฐ RAG ๋ต๋ณ | |
| ์ค์ ๊ฐ์ ๋ชจ๋ configs.yaml์์ ์ฝ์: | |
| models.worker_b.name = google/gemini-2.0-flash-lite-001 | |
| rag.dense_weight = 0.5 | |
| rag.sparse_weight = 0.5 | |
| rag.top_k_retrieval = 15 | |
| rag.top_k_rerank = 5 | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from dotenv import load_dotenv | |
| from langchain_core.prompts import ChatPromptTemplate | |
| from langchain_openai import ChatOpenAI | |
| from src.config import get_config | |
| from src.graph.state import AgentState | |
| from src.rag.retriever import build_hybrid_retriever | |
| from src.rag.reranker import rerank | |
| load_dotenv() | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # LLM builder (์ฑ๊ธํด) | |
| # --------------------------------------------------------------------------- | |
| _llm: ChatOpenAI | None = None | |
| def _get_llm() -> ChatOpenAI: | |
| global _llm | |
| if _llm is not None: | |
| return _llm | |
| cfg = get_config() | |
| api_key = os.getenv("OPENROUTER_API_KEY", "") | |
| if not api_key: | |
| raise EnvironmentError("OPENROUTER_API_KEY is not set.") | |
| wb_cfg = cfg.models.worker_b | |
| _llm = ChatOpenAI( | |
| model=wb_cfg.name, | |
| temperature=wb_cfg.temperature, | |
| openai_api_key=api_key, | |
| openai_api_base=cfg.openrouter.base_url, | |
| default_headers={ | |
| "HTTP-Referer": "https://github.com/daisysooyeon/SAP-ERP-AI-Agent", | |
| "X-Title": "SAP-ERP-AI-Agent Worker-B", | |
| }, | |
| ) | |
| return _llm | |
| # --------------------------------------------------------------------------- | |
| # โ ์ฟผ๋ฆฌ ์ถ์ถ + Query Expansion ํ๋กฌํํธ | |
| # --------------------------------------------------------------------------- | |
| _QUERY_EXTRACTION_SYSTEM = """\ | |
| You are an expert at extracting and expanding search queries from B2B SAP ERP customer emails. | |
| The email may contain an ERP transaction request (e.g., changing an order) AND a knowledge question about SAP. | |
| Extract ONLY the knowledge/inquiry part, then generate multiple search query variants. | |
| Rules: | |
| 1. Analyze the email and identify the core SAP knowledge question (ignore any ERP | |
| action requests, order/item numbers, quantities, and dates). | |
| 2. If a knowledge question exists, set 'has_knowledge_question' to true and generate 2 search query variants: | |
| - 'query_primary': the most direct restatement of the question. PRESERVE, | |
| verbatim, every SAP term and feature / screen / field name the customer used | |
| โ do NOT generalize them into generic phrases and do NOT substitute a related | |
| but different concept. If the question has two distinct parts, keep both. | |
| - 'query_variant_1': the SAME intent, but MAXIMIZE keyword diversity for hybrid | |
| (keyword + semantic) retrieval โ use alternative SAP terminology, synonyms, or | |
| the formal SAP feature name for the same concept, rather than only rephrasing | |
| the grammar. | |
| 3. If the email contains NO knowledge question, set 'has_knowledge_question' to false and leave all queries empty (""). | |
| Output MUST be a valid JSON object: | |
| {{ | |
| "has_knowledge_question": true or false, | |
| "query_primary": "...", | |
| "query_variant_1": "..." | |
| }} | |
| """ | |
| _QUERY_EXTRACTION_PROMPT = ChatPromptTemplate.from_messages([ | |
| ("system", _QUERY_EXTRACTION_SYSTEM), | |
| ("human", "Email:\n\n{user_input}"), | |
| ]) | |
| def _extract_rag_queries(user_input: str) -> list[str]: | |
| """์ด๋ฉ์ผ์์ RAG ๊ฒ์ ์ฟผ๋ฆฌ๋ฅผ ์ต๋ 2๊ฐ ์ถ์ถ (Query Expansion). ์ง๋ฌธ์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ ๋ฐํ. | |
| ํ์ฅ ๊ฐ์ ์คํ(reports/_expansion_result) ๊ฒฐ๊ณผ: 2๊ฐ๊ฐ 3๊ฐ๋ณด๋ค hit_rate๊ฐ ๊ฐ๊ฑฐ๋ ๋๊ณ | |
| (ํ๋ณดํ 22%โ๋ก ๋ฆฌ๋ญํฌ ๋ถํ๋ ๊ฐ์), 1๊ฐ๋ ๋ฉํฐ์ฟผ๋ฆฌ ์ด์ ์ ์์ด ๋ ๋ฎ์๋ค โ 2๊ฐ๋ก ๊ณ ์ . | |
| """ | |
| from langchain_core.output_parsers import JsonOutputParser | |
| try: | |
| llm = _get_llm() | |
| chain = _QUERY_EXTRACTION_PROMPT | llm | JsonOutputParser() | |
| result = chain.invoke({"user_input": user_input}) | |
| if not result.get("has_knowledge_question"): | |
| return [] | |
| queries = [] | |
| for key in ("query_primary", "query_variant_1"): | |
| q = (result.get(key) or "").strip() | |
| if q: | |
| queries.append(q) | |
| logger.info("[worker_b] โ Extracted %d queries: %s", len(queries), queries) | |
| return queries | |
| except Exception as e: | |
| logger.error("[worker_b] Query extraction failed: %s", e) | |
| return [] | |
| # --------------------------------------------------------------------------- | |
| # โฃ ๋ต๋ณ ์์ฑ ํ๋กฌํํธ | |
| # --------------------------------------------------------------------------- | |
| _ANSWER_SYSTEM = """\ | |
| You are an SAP ERP expert assistant. Answer the user's question using ONLY the provided context from SAP documentation. | |
| Guidelines: | |
| - Use ANY relevant information in the context to answer as fully as you can. If the context covers the question only partially, answer the parts that ARE supported and briefly state what the documentation does not specify (e.g., "The documentation explains X but does not give the exact transaction code."). | |
| - Only reply EXACTLY "I could not find sufficient information in the SAP documentation to answer this question." when the context contains NOTHING relevant to the question. Do not give up just because the answer is incomplete. | |
| - Do NOT invent facts, figures, transaction codes, or steps that are not present in the context. | |
| - Reproduce every identifier EXACTLY as it appears in the context โ SAP Note numbers, business function / feature names (e.g. ALL_CAPS_WITH_UNDERSCORES like Q2C_MULTIPLE_BP_ADDRESSES), transaction codes, table/field/app names. Never abbreviate, truncate, reformat, or guess them; if you are not certain of the exact string, omit it rather than alter it. | |
| - Be concise and accurate, and cite the source document when possible (e.g., "According to TS460..."). | |
| """ | |
| _ANSWER_PROMPT = ChatPromptTemplate.from_messages([ | |
| ("system", _ANSWER_SYSTEM), | |
| ("human", "Question: {query}\n\nContext:\n{context}"), | |
| ]) | |
| def _generate_answer(query: str, docs: list) -> str: | |
| """top-k ์ปจํ ์คํธ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก LLM ๋ต๋ณ ์์ฑ.""" | |
| if not docs: | |
| return "No relevant SAP documentation found for this query." | |
| # ์ปจํ ์คํธ ์กฐํฉ (source ์ ๋ณด ํฌํจ) | |
| context_parts = [] | |
| for i, doc in enumerate(docs, 1): | |
| source = doc.metadata.get("source", "unknown") | |
| context_parts.append(f"[{i}] (Source: {source})\n{doc.page_content}") | |
| context = "\n\n".join(context_parts) | |
| try: | |
| llm = _get_llm() | |
| chain = _ANSWER_PROMPT | llm | |
| result = chain.invoke({"query": query, "context": context}) | |
| answer = result.content.strip() | |
| logger.info("[worker_b] โฃ Answer generated (%d chars)", len(answer)) | |
| return answer | |
| except Exception as e: | |
| logger.error("[worker_b] Answer generation failed: %s", e) | |
| return f"Answer generation failed: {e}" | |
| def _serialize_docs(docs: list) -> list[dict]: | |
| """LangChain Document ๋ฆฌ์คํธ๋ฅผ JSON ์ง๋ ฌํ ๊ฐ๋ฅํ dict ๋ฆฌ์คํธ๋ก ๋ณํ""" | |
| return [ | |
| { | |
| "content": doc.page_content, | |
| "source": doc.metadata.get("source", ""), | |
| "chunk_id": doc.metadata.get("chunk_id", -1), | |
| "page": doc.metadata.get("page", -1), | |
| } | |
| for doc in docs | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # LangGraph ๋ ธ๋ | |
| # --------------------------------------------------------------------------- | |
| def worker_b_node(state: AgentState) -> dict: | |
| """ | |
| LangGraph Worker B ๋ ธ๋. | |
| Reads: | |
| state["user_input"] โ ์๋ณธ ์ด๋ฉ์ผ | |
| state["rag_query"] โ ์์ผ๋ฉด ์ฟผ๋ฆฌ ์ถ์ถ ์คํต | |
| Returns partial AgentState update: | |
| { | |
| "rag_query": str, | |
| "retrieved_docs": list[dict], # [{content, source, chunk_id}] | |
| "rag_answer": str, | |
| "error_messages": list[str], | |
| } | |
| """ | |
| logger.info("[worker_b] โโโโโโโโโโโโโโ Worker B START โโโโโโโโโโโโโโ") | |
| user_input: str = state.get("user_input", "") | |
| email_context: dict | None = state.get("email_context") | |
| errors: list[str] = list(state.get("error_messages", [])) | |
| # ์ ์ฒ๋ฆฌ ๊ฒฐ๊ณผ๊ฐ "์ง์ ์ง๋ฌธ ์์"์ด๋ผ๊ณ ๋ช ํํ ์ ํธํ๋ฉด, ๋น์ผ LLM ์ฟผ๋ฆฌ ์ถ์ถ ๋จ๊ณ๋ฅผ | |
| # ๊ฑด๋๋ด๋ค. preprocess_ok=False์ด๊ฑฐ๋ context ์์ฒด๊ฐ ์์ผ๋ฉด ์์ ํ๊ฒ ๊ธฐ์กด ๊ฒฝ๋ก๋ก ์งํ. | |
| if (email_context | |
| and email_context.get("preprocess_ok") | |
| and email_context.get("mentions_question") is False): | |
| logger.info("[worker_b] Preprocessor: mentions_question=False โ skipping RAG stage") | |
| return { | |
| "rag_query": "", | |
| "retrieved_docs": [], | |
| "rag_answer": "", | |
| "error_messages": errors, | |
| } | |
| # ์ฟผ๋ฆฌ ์ถ์ถ ์ ๋ ฅ ์ ํ (์ฐ์ ์์): | |
| # โ question_summary โ ์ ์ฒ๋ฆฌ๊ฐ ERP ์ก์ ๋ ธ์ด์ฆ๋ฅผ ์ ๊ฑฐํ๊ณ ์ง์ ์ง๋ฌธ๋ง ์ ๊ทํํ ๊ฒ. | |
| # BOTH ์ด๋ฉ์ผ์์ ์ก์ ๊ณผ ์์ธ ์ง๋ฌธ์ ๊น๋ํ๊ฒ ๋ถ๋ฆฌํด ๊ฒ์ ์ ํ๋๋ฅผ ๋์ธ๋ค(๊ฒ์ฆ: 7๊ฑด ์ค 3๊ฑด ํ๋ณต). | |
| # โก cleaned_body โ ์ธ์ฌยท์๋ช ๋ง ์ ๊ฑฐํ ๋ณธ๋ฌธ (question_summary๊ฐ ์์ ๋). | |
| # โข user_input โ ์ ์ฒ๋ฆฌ ์คํจ ์ ์๋ณธ. | |
| query_extraction_input = user_input | |
| if email_context and email_context.get("preprocess_ok"): | |
| query_extraction_input = ( | |
| email_context.get("question_summary") | |
| or email_context.get("cleaned_body") | |
| or user_input | |
| ) | |
| # โ ์ฟผ๋ฆฌ ์ถ์ถ (Query Expansion: ์ต๋ 2๊ฐ) | |
| # ์ฌ์ง์ (HITL resumeยท์ฌ์๋) ์ ์ด์ ์คํ์ด ์ ์ฅํ ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฌ์ฉํด LLM ์ถ์ถ์ ์๋ตํ๋ค. | |
| # ์ด๋ ๋ฉํฐ์ฟผ๋ฆฌ๋ฅผ ์์ง ์๋๋ก rag_queries(๋ฆฌ์คํธ)๋ฅผ ์ฐ์ ๋ณต์ํ๊ณ , ์์ผ๋ฉด ๋จ์ผ rag_query๋ก ํด๋ฐฑ. | |
| cached_queries: list[str] = state.get("rag_queries") or [] | |
| cached_query: str = state.get("rag_query", "") | |
| if cached_queries: | |
| rag_queries = cached_queries | |
| elif cached_query: | |
| rag_queries = [cached_query] | |
| else: | |
| rag_queries = _extract_rag_queries(query_extraction_input) | |
| if not rag_queries: | |
| logger.warning("[worker_b] No RAG query extracted โ skipping retrieval.") | |
| return { | |
| "rag_query": "", | |
| "retrieved_docs": [], | |
| "rag_answer": "", | |
| "error_messages": errors, | |
| } | |
| rag_query = rag_queries[0] # ๋ํ ์ฟผ๋ฆฌ (๋ต๋ณ ์์ฑ + reranking ๊ธฐ์ค) | |
| # โก Hybrid Retrieval โ ์ฟผ๋ฆฌ๋ณ ๊ฒ์ ํ ์ค๋ณต ์ ๊ฑฐ ๋ณํฉ | |
| logger.info("[worker_b] โก Hybrid retrieval (%d queries)", len(rag_queries)) | |
| try: | |
| retriever = build_hybrid_retriever() | |
| seen_contents: set[str] = set() | |
| candidate_docs = [] | |
| for q in rag_queries: | |
| results = retriever.invoke(q) | |
| for doc in results: | |
| if doc.page_content not in seen_contents: | |
| seen_contents.add(doc.page_content) | |
| candidate_docs.append(doc) | |
| logger.info("[worker_b] โก Retrieved %d unique candidate docs", len(candidate_docs)) | |
| except Exception as e: | |
| msg = f"worker_b: retrieval failed โ {e}" | |
| logger.error("[worker_b] %s", msg) | |
| errors.append(msg) | |
| return { | |
| "rag_query": rag_query, | |
| "retrieved_docs": [], | |
| "rag_answer": "", | |
| "error_messages": errors, | |
| } | |
| # โข Reranking (๋ํ ์ฟผ๋ฆฌ ๊ธฐ์ค) | |
| logger.info("[worker_b] โข Reranking %d docs โฆ", len(candidate_docs)) | |
| try: | |
| top_docs = rerank(rag_query, candidate_docs, queries=rag_queries) | |
| logger.info("[worker_b] โข Reranked โ top %d docs", len(top_docs)) | |
| except Exception as e: | |
| logger.warning("[worker_b] Reranking failed: %s. Using top-k directly.", e) | |
| cfg = get_config() | |
| top_docs = candidate_docs[: cfg.rag.top_k_rerank] | |
| # state์ ์ ์ฅํ ๋ฌธ์ ๋ฉํ ์ง๋ ฌํ | |
| retrieved_docs_serialized = _serialize_docs(top_docs) | |
| # โฃ ๋ต๋ณ ์์ฑ | |
| logger.info("[worker_b] โฃ Generating answer โฆ") | |
| rag_answer = _generate_answer(rag_query, top_docs) | |
| logger.info("[worker_b] โโโโโโโโโโโโโโ Worker B END โโโโโโโโโโโโโโ") | |
| return { | |
| "rag_query": rag_query, | |
| "rag_queries": rag_queries, | |
| "retrieved_docs": retrieved_docs_serialized, | |
| "rag_answer": rag_answer, | |
| "error_messages": errors, | |
| } | |