Spaces:
Running
Running
| """ | |
| src/preprocess/email_preprocessor.py | |
| LangGraph ์ง์ ์ ์ด๋ฉ์ผ ์ ์ฒ๋ฆฌ โ ์์ฐ์ด ์ด๋ฉ์ผ โ ๊ตฌ์กฐํ๋ EmailContext. | |
| ๋ชฉ์ : | |
| ํ์ฌ router / worker_a / worker_b / synthesizer 4๊ฐ ๋ ธ๋๊ฐ ๊ฐ์ LLM ํธ์ถ๋ก | |
| ๊ฐ์ ์ด๋ฉ์ผ์ ๋ค์ ํ์ฑํ๋ค(๋๊ฐ ๋ณด๋๋์ง, ๋ฌด์์ ์์ฒญํ๋์ง, ์ด๋ค ์ซ์๊ฐ ์๋์งโฆ). | |
| ์ด ์ ์ฒ๋ฆฌ๋ฅผ ํ ๋ฒ ์ํํด์ EmailContext์ ๋ด์๋๋ฉด: | |
| 1) ๋ค์ด์คํธ๋ฆผ ๋ ธ๋์ LLM ํธ์ถ์ด ์งง์ hint๋ฅผ ๋ฐ์ ์ ํ๋/์ผ๊ด์ฑ ํฅ์ | |
| 2) ๊ฐ์ ์ถ๋ก ์ 4๋ฒ ๋ฐ๋ณตํ๋ ๋ญ๋น ๊ฐ์ | |
| 3) ๋๋ฒ๊น ๊ฐ์์ฑ ํฅ์ (์ด๋ฉ์ผ ํ ํต์ด ์ด๋ป๊ฒ ํ์ฑ๋๋์ง ํ๊ณณ์์ ํ์ธ) | |
| ์ค๊ณ: | |
| - LangGraph ์ธ๋ถ์์ ๋์ (graph_builder / api/server / eval ์ง์ ์ ์์ ํธ์ถ). | |
| - ๊ฒฐ๊ณผ๋ AgentState["email_context"]์ dict๋ก ์ ์ฌ. ๋ ธ๋๋ค์ ์์ผ๋ฉด ํ์ฉ, ์์ผ๋ฉด | |
| ๊ธฐ์กด ๋์(์๋ณธ user_input ํ์ฑ)์ผ๋ก ์๋ fallback โ ํ์ํธํ. | |
| - ์คํจํด๋ ๊ทธ๋ํ ํ๋ฆ์ ์ค๋จํ์ง ๋ง ๊ฒ. EmailContext.preprocess_ok=False๋ก ํ์๋ง. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import re | |
| from typing import Optional | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from langchain_core.prompts import ChatPromptTemplate | |
| from langchain_openai import ChatOpenAI | |
| from pydantic import BaseModel, Field | |
| from src.config import get_config | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # ๊ตฌ์กฐํ๋ ๊ฒฐ๊ณผ ์คํค๋ง | |
| # --------------------------------------------------------------------------- | |
| class EmailContext(BaseModel): | |
| """์ ์ฒ๋ฆฌ ๊ฒฐ๊ณผ โ LangGraph ๋ ธ๋๋ค์ด user_input๊ณผ ํจ๊ป ์ฐธ๊ณ ํ๋ ๊ตฌ์กฐํ ์ ๋ณด.""" | |
| # ์๋ฌธ (๊ทธ๋๋ก ๋ณด์กด โ ๋ค์ด์คํธ๋ฆผ ๋ ธ๋๊ฐ ํ์ ์ ์ฐธ๊ณ ) | |
| raw_text: str = Field(..., description="์๋ณธ ์ด๋ฉ์ผ ํ ์คํธ (์์ ์์)") | |
| # ๋ฉํ๋ฐ์ดํฐ (LLM์ด ์ถ์ถ, ์์ผ๋ฉด ๋น ๋ฌธ์์ด) | |
| sender_name: str = Field("", description="๋ฐ์ ์ ์ด๋ฆ (์๋ช /From ๋ผ์ธ ๋ฑ์์ ์ถ์ถ). ๋ชจ๋ฅด๋ฉด ๋น ๋ฌธ์์ด.") | |
| sender_email: str = Field("", description="๋ฐ์ ์ ์ด๋ฉ์ผ ์ฃผ์. ๋ชจ๋ฅด๋ฉด ๋น ๋ฌธ์์ด.") | |
| sender_company: str = Field("", description="๋ฐ์ ์ ์์ ํ์ฌ๋ช . ๋ชจ๋ฅด๋ฉด ๋น ๋ฌธ์์ด.") | |
| recipient: str = Field("", description="์์ ์ (๋ด๋น์/ํ๋ช ). ๋ชจ๋ฅด๋ฉด ๋น ๋ฌธ์์ด.") | |
| subject: str = Field("", description="์ ๋ชฉ. ๋ณธ๋ฌธ์์ ์ถ์ ํด๋ ๋จ.") | |
| language: str = Field("en", description="์ด๋ฉ์ผ ์ธ์ด (en/ko/ja/zh/...). ๊ธฐ๋ณธ en.") | |
| # ์ ์ ๋ ๋ณธ๋ฌธ โ ์ธ์ฌ๋ง/์๋ช /Disclaimer ์ ์ธ, ๋ณธ๋ฌธ ํต์ฌ๋ง | |
| cleaned_body: str = Field(..., description="์ธ์ฌยท์๋ช ยท๋ฉด์ฑ ์กฐํญ์ ์ ์ธํ ๋ณธ๋ฌธ ํต์ฌ") | |
| # ์์ฒญ ์์ฝ โ ํ๋ ๋ฌธ์ฅ | |
| request_summary: str = Field( | |
| ..., | |
| description="์ด๋ฉ์ผ์ด ๋ฌด์์ ์์ฒญํ๋์ง ํ๋ ๋ฌธ์ฅ ์์ฝ (์์ด๋ก). " | |
| "์: 'Change quantity of item 10 on order 4500023456 to 100 units.'", | |
| ) | |
| # ์ง์ ์ง๋ฌธ๋ง ๋ถ๋ฆฌยท์ ๊ทํ โ RAG(worker_b) ๊ฒ์ ์ฟผ๋ฆฌ ์ ๋ ฅ์ผ๋ก ์ฌ์ฉ | |
| question_summary: str = Field( | |
| "", | |
| description="์ด๋ฉ์ผ์ SAP ์ง์/๋ฐฉ๋ฒ(how-to/์ ์ฑ /๊ฐ๋ ) ์ง๋ฌธ์ด ์์ผ๋ฉด, ERP ์ก์ ๋งฅ๋ฝ์ " | |
| "๋ชจ๋ ์ ๊ฑฐํ๊ณ ํ๋์ ๋ช ํํ๊ณ ์๊ธฐ์๊ฒฐ์ ์ธ ์์ด ์ง๋ฌธ์ผ๋ก ์ฌ์์ฑ. ํน์ ์ฃผ๋ฌธ/์์ดํ " | |
| "๋ฒํธ๋ ์๋ ๊ฐ์ ํธ๋์ญ์ ๋ํ ์ผ์ ๋นผ๊ณ ์ผ๋ฐํํ๋ค. ์ง์ ์ง๋ฌธ์ด ์์ผ๋ฉด ๋น ๋ฌธ์์ด. " | |
| "์: 'How do I view the incompletion log for a sales order before creating an outbound delivery?'", | |
| ) | |
| # โโ ๋น ๋ฅธ ๋ผ์ฐํ ํํธ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| mentions_action: bool = Field( | |
| False, | |
| description="ERP ์์ ์์ฒญ(์๋/๋ ์ง/์ทจ์/์ฃผ์ ๋ณ๊ฒฝ ๋ฑ)์ด ๋ช ์๋์ด ์๋๊ฐ", | |
| ) | |
| mentions_question: bool = Field( | |
| False, | |
| description="SAP/์ ์ฑ /๋งค๋ด์ผ์ ๋ํ ์ง์/๋ฌธ์ ์ง๋ฌธ์ด ๋ช ์๋์ด ์๋๊ฐ", | |
| ) | |
| # โโ ์ฌ์ ์ถ์ถ๋ ํต์ฌ ์ํฐํฐ (๋ค์ด์คํธ๋ฆผ์ด hint๋ก ํ์ฉ) โโโโโโโโโโโโโโโโโโโโ | |
| order_ids: list[str] = Field( | |
| default_factory=list, | |
| description="์ด๋ฉ์ผ์ ๋ช ์๋ ๋ชจ๋ ์์ ์ค๋ ๋ฒํธ(VBELN) ํ๋ณด โ ์๋ณธ ์ซ์ ๊ทธ๋๋ก, ํจ๋ฉ ์์ด", | |
| ) | |
| item_nos: list[str] = Field( | |
| default_factory=list, | |
| description="์ด๋ฉ์ผ์ ๋ช ์๋ ๋ชจ๋ ์์ดํ ๋ฒํธ(POSNR) ํ๋ณด โ ์๋ณธ ์ซ์ ๊ทธ๋๋ก", | |
| ) | |
| # โโ ๋ฉํ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| preprocess_ok: bool = Field(True, description="์ ์ฒ๋ฆฌ ์ฑ๊ณต ์ฌ๋ถ (LLM ์คํจ ์ False)") | |
| error: str = Field("", description="์คํจ ์ฌ์ (preprocess_ok=False์ผ ๋๋ง)") | |
| # --------------------------------------------------------------------------- | |
| # Prompt | |
| # --------------------------------------------------------------------------- | |
| _SYSTEM_PROMPT = """\ | |
| You are a B2B email preprocessor for an SAP ERP support pipeline. Your job is to | |
| read a single customer email and produce a structured summary that downstream | |
| agents (router, ERP action extractor, RAG question extractor, reply composer) | |
| will consume. | |
| Extract the following fields from the email and output ONLY valid JSON matching | |
| the required schema. | |
| Field guidance: | |
| โข sender_name / sender_email / sender_company: | |
| Look in the signature block ("Best regards, John Smith / john@acme.com / | |
| Acme Corp"), or in "From:" headers if present. If a field is not | |
| recoverable from the email, return an empty string. Do NOT invent. | |
| โข recipient: | |
| The addressed party ("Hi Support Team", "Dear SAP Desk"). Empty if unclear. | |
| โข subject: | |
| The Subject: line if present, otherwise a short topic phrase you infer | |
| from the body (under 80 chars). | |
| โข language: | |
| Two-letter code: "en", "ko", "ja", "zh", "de", "es", "fr". Default "en". | |
| โข cleaned_body: | |
| The substantive body โ strip greetings ("Dear ..."), sign-offs ("Best | |
| regards, ..."), legal disclaimers, and "Sent from my iPhone"-style | |
| footers. Keep the actual request and any context the customer provides. | |
| Preserve order numbers, item numbers, quantities, dates exactly as | |
| written. | |
| โข request_summary: | |
| ONE or TWO sentences in ENGLISH that describe what the customer wants. | |
| Be specific โ include order number, item number, action, and quantities | |
| or dates if mentioned. Examples: | |
| "Change quantity of item 10 on order 4500023456 to 100 units." | |
| "Reduce quantity of item 20 on order 6105 by 50, and ask about the | |
| late-delivery penalty policy." | |
| โข question_summary: | |
| If (and only if) the email contains an SAP knowledge / how-to / policy / | |
| concept question, restate THAT question as ONE clear, self-contained | |
| question in ENGLISH โ optimized as a documentation search query. | |
| Remove ONLY the transaction instance data (order/item numbers, quantities, | |
| dates) and the action request itself. Otherwise stay close to the | |
| customer's own wording: PRESERVE, verbatim, every SAP term, feature / | |
| screen / field name, and concept keyword they used โ these are the search | |
| keys the retriever matches on. Do NOT paraphrase a specific SAP term into a | |
| generic one, and do NOT swap in a related but different concept for the one | |
| asked about. If the question has two distinct parts, keep both. If there is | |
| no knowledge question, return an empty string. | |
| โข mentions_action: | |
| true if the email asks to MODIFY anything in the ERP โ change quantity, | |
| change delivery date, cancel an item, change shipping address, unblock a | |
| delivery, change carrier/batch/payment terms. false if it is a pure | |
| knowledge question. | |
| โข mentions_question: | |
| true if the email asks about HOW SAP works / what a policy says / how to | |
| configure something / definitions โ i.e. knowledge-base questions answered | |
| from documentation. false if it is purely a transaction request. | |
| mentions_action and mentions_question are NOT mutually exclusive โ an | |
| email can have both. | |
| โข order_ids: | |
| EVERY distinct order number (VBELN) mentioned. Copy digits EXACTLY as | |
| written โ do NOT zero-pad, do NOT add or drop digits. Numbers may | |
| appear after "order", "order #", "sales order", "PO". Return as strings, | |
| no commas/dashes. Empty list if none. | |
| โข item_nos: | |
| EVERY distinct item / line / position number (POSNR) mentioned. Copy | |
| digits EXACTLY. Numbers may appear after "item", "line", "position". | |
| Empty list if none. | |
| CRITICAL โ number handling: | |
| A single email often contains the order number, the item number, AND a | |
| quantity. They are DIFFERENT things. Map each to the correct field. Never | |
| put a quantity ("100 units", "50 pcs") into order_ids or item_nos. | |
| Output ONLY valid JSON matching the schema. Set raw_text to the EXACT input | |
| email (unchanged). Set preprocess_ok=true and error="" โ these are only set to | |
| false by the calling code on failure. | |
| """ | |
| _HUMAN_TEMPLATE = "Email to preprocess:\n\n{email}" | |
| _PROMPT = ChatPromptTemplate.from_messages([ | |
| ("system", _SYSTEM_PROMPT), | |
| ("human", _HUMAN_TEMPLATE), | |
| ]) | |
| # --------------------------------------------------------------------------- | |
| # LLM factory (OpenRouter) | |
| # --------------------------------------------------------------------------- | |
| def _build_llm() -> ChatOpenAI: | |
| """์ ์ฒ๋ฆฌ LLM. configs.yaml์ models.preprocessor๋ฅผ ์ฐ์ ์ฌ์ฉํ๊ณ , | |
| ์ค์ ์ด ์์ผ๋ฉด worker_a(์ด๋ฏธ ๊ตฌ์กฐํ ์ถ์ถ์ ์ ๋์)์ ๋์ผํ ๋ชจ๋ธ๋ก ํด๋ฐฑ.""" | |
| cfg = get_config() | |
| # ๋์ lookup โ ์ ์ค์ ํค๊ฐ ์์ด๋ ๋์ํ๋๋ก | |
| preproc_cfg = getattr(cfg.models, "preprocessor", None) or cfg.models.worker_a | |
| api_key = os.getenv("OPENROUTER_API_KEY", "") | |
| if not api_key: | |
| raise EnvironmentError("OPENROUTER_API_KEY is not set. Check your .env file.") | |
| return ChatOpenAI( | |
| model=preproc_cfg.name, | |
| temperature=preproc_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 - Preprocessor", | |
| }, | |
| ) | |
| _llm: Optional[ChatOpenAI] = None | |
| _chain = None | |
| def _get_chain(): | |
| """LLM ์ฒด์ธ์ lazyํ๊ฒ ์์ฑ โ import ์์ ์ API ํค ๊ฒ์ฆ์ ๋ฏธ๋ฃจ๊ธฐ ์ํจ.""" | |
| global _llm, _chain | |
| if _chain is None: | |
| _llm = _build_llm() | |
| _chain = _PROMPT | _llm.with_structured_output(EmailContext) | |
| return _chain | |
| # --------------------------------------------------------------------------- | |
| # Regex ๊ธฐ๋ฐ backstop entity ์ถ์ถ (LLM ์คํจ/๋๋ฝ ๋ณด์กฐ) | |
| # --------------------------------------------------------------------------- | |
| _ORDER_RE = re.compile( | |
| r"(?:order|order\s*#|sales\s+order|PO)\s*[:#]?\s*(\d{3,10})", | |
| re.IGNORECASE, | |
| ) | |
| _ITEM_RE = re.compile( | |
| r"(?:item|line|position)\s*[:#]?\s*(\d{1,6})", | |
| re.IGNORECASE, | |
| ) | |
| def _regex_entities(text: str) -> tuple[list[str], list[str]]: | |
| """LLM์ด ์คํจํ๊ฑฐ๋ ๋น ๋จ๋ฆด ๋๋ฅผ ๋๋นํ ์ ๊ท์ backstop. ์ค๋ณต ์ ๊ฑฐ.""" | |
| order_ids = list(dict.fromkeys(_ORDER_RE.findall(text))) | |
| item_nos = list(dict.fromkeys(_ITEM_RE.findall(text))) | |
| return order_ids, item_nos | |
| # --------------------------------------------------------------------------- | |
| # Public API | |
| # --------------------------------------------------------------------------- | |
| def preprocess_email(raw_email: str) -> EmailContext: | |
| """์ด๋ฉ์ผ์ LLM ํ ๋ฒ ํธ์ถ๋ก ๊ตฌ์กฐํ๋ EmailContext๋ก ๋ณํ. | |
| LLM์ด ์คํจํด๋ EmailContext.preprocess_ok=False๋ก ํ์ํ๊ณ ์ ๊ท์ backstop์ผ๋ก | |
| ์ต์ํ์ ์ ๋ณด๋ฅผ ์ฑ์ ๋ฐํํ๋ค. ํธ์ถ์(graph entry point)๋ ๊ทธ๋ฅ ํต๊ณผ์ํค๋ฉด | |
| ๋๊ณ , ๋ค์ด์คํธ๋ฆผ ๋ ธ๋๋ ๋น ํ๋๋ฅผ ๋ณด๋ฉด ๊ธฐ์กด user_input ํ์ฑ์ผ๋ก ํด๋ฐฑํ๋ค. | |
| """ | |
| raw_email = raw_email or "" | |
| logger.info("[preprocess] Preprocessing email (%d chars)โฆ", len(raw_email)) | |
| try: | |
| chain = _get_chain() | |
| result: EmailContext = chain.invoke({"email": raw_email}) | |
| # LLM์ด raw_text๋ฅผ ๋น์ฐ๊ฑฐ๋ ๋ณํํ์ ์ ์์ผ๋ฏ๋ก ๊ฒฐ์ ๋ก ์ ์ผ๋ก ๋ฎ์ด์ด๋ค | |
| result.raw_text = raw_email | |
| # LLM์ด ์ํฐํฐ๋ฅผ ๋น ๋จ๋ ธ์ผ๋ฉด ์ ๊ท์์ผ๋ก ๋ณด๊ฐ (๋ฎ์ด์ฐ์ง ์๊ณ union) | |
| rx_orders, rx_items = _regex_entities(raw_email) | |
| if not result.order_ids and rx_orders: | |
| result.order_ids = rx_orders | |
| if not result.item_nos and rx_items: | |
| result.item_nos = rx_items | |
| logger.info( | |
| "[preprocess] OK | sender=%r action=%s question=%s " | |
| "orders=%s items=%s lang=%s", | |
| result.sender_name, result.mentions_action, | |
| result.mentions_question, result.order_ids, result.item_nos, | |
| result.language, | |
| ) | |
| return result | |
| except Exception as e: | |
| # ์คํจํด๋ ํ๋ฆ์ ๋์ง ์๋๋ค โ backstop์ผ๋ก ์ต์ ์ ๋ณด ์ฑ์ ๋ฐํ | |
| logger.error("[preprocess] FAILED: %s โ falling back to regex backstop only", e) | |
| rx_orders, rx_items = _regex_entities(raw_email) | |
| return EmailContext( | |
| raw_text=raw_email, | |
| cleaned_body=raw_email, # ์๋ณธ์ ๊ทธ๋๋ก ์ ์ง | |
| request_summary="", | |
| order_ids=rx_orders, | |
| item_nos=rx_items, | |
| preprocess_ok=False, | |
| error=f"{type(e).__name__}: {e}", | |
| ) | |