SAP-ERP-AI-Agent / src /graph /router.py
daisysooyeon's picture
deploy: SAP ERP AI Agent (HF Spaces docker)
50efdc6
Raw
History Blame Contribute Delete
13.3 kB
"""
src/graph/router.py
Orchestrator: Email intent classification node (ACTION_ONLY / QA_ONLY / BOTH)
Flow:
1. Build ChatPromptTemplate with system prompt (few-shot examples included)
2. Bind Pydantic RouterOutput schema to LLM as structured output
3. Invoke the chain with the user's email text
4. Return {"intent": ..., "error_messages": [...]} to update AgentState
"""
import os
import logging
import time
from typing import Literal
from dotenv import load_dotenv
load_dotenv() # .env ํŒŒ์ผ์˜ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ os.environ์— ๋กœ๋“œ
from pydantic import BaseModel, Field
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama
from langchain_openai import ChatOpenAI
from src.config import get_config
from src.graph.state import AgentState
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Structured-output schema
# ---------------------------------------------------------------------------
class RouterOutput(BaseModel):
"""JSON structured output schema for the router LLM."""
intent: Literal["ACTION_ONLY", "QA_ONLY", "BOTH"] = Field(
description=(
"Classified intent of the email. "
"ACTION_ONLY = ERP modification only; "
"QA_ONLY = policy/regulation question only; "
"BOTH = ERP modification AND policy question present."
)
)
reasoning: str = Field(
description="Step-by-step chain-of-thought reasoning that justifies the chosen intent label."
)
# ---------------------------------------------------------------------------
# Prompt
# ---------------------------------------------------------------------------
ROUTER_SYSTEM_PROMPT = """\
You are an expert at analyzing B2B sales emails in the context of SAP ERP operations.
Your task is to read the customer's email and classify it into **exactly one** of the following intent labels:
- ACTION_ONLY : The email ONLY asks to modify or query a SPECIFIC, NAMED order/item in the ERP
(e.g., change quantity, change delivery date, cancel item, or change the address
of order 4500012345).
- QA_ONLY : The email ONLY asks for knowledge โ€” SAP policies/regulations, system concepts, or
HOW to do something (procedure, steps, transaction code, configuration). No specific
order is being modified.
- BOTH : The email contains BOTH a concrete ERP modification on a specific order AND a
standalone knowledge question.
Classification rules:
1. An ERP ACTION is a request to MODIFY a SPECIFIC, NAMED order/item โ€” it has a concrete target
(e.g., "order 4500012345, item 10"). A request with no concrete target is NOT an action.
2. A KNOWLEDGE question asks about SAP policies/regulations, system concepts, or HOW to perform
something โ€” its procedure, steps, transaction code, or configuration. "How do I cancel / change
/ โ€ฆ?" is a knowledge question even when it names an action verb, as long as no specific order
is being acted on.
3. Combine rules 1-2:
BOTH = at least one concrete action AND at least one standalone knowledge question.
ACTION_ONLY = only concrete action(s), no knowledge question.
QA_ONLY = only knowledge question(s), no concrete action.
4. Asking to confirm/process/execute a CONCRETE action, or for its status / next steps, is PART OF
that action โ€” not a separate knowledge question. This applies only when a specific order is being
acted on; a general "how is this done?" with no target is knowledge (rule 2).
5. Greetings, sign-offs, and pleasantries are ignored.
6. Provide your reasoning step by step BEFORE stating the final label.
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FEW-SHOT EXAMPLES
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[Example 1]
Email: "Please change the quantity of PO-2024031200 to 500 units.
Also, what are the additional charges for express delivery?"
Intent: BOTH
Reasoning: The email requests a quantity update (ERP action) AND asks about express delivery surcharges (policy question). Both categories are present โ†’ BOTH.
[Example 2]
Email: "Kindly update the delivery date for order 4500012345, item 000010 from March 25 to April 1."
Intent: ACTION_ONLY
Reasoning: The email solely requests a delivery date change on a specific sales order item. No policy question is present โ†’ ACTION_ONLY.
[Example 3]
Email: "Could you please explain the penalty clause that applies when we cancel an order after shipment?"
Intent: QA_ONLY
Reasoning: The email only asks about the penalty policy for post-shipment cancellation. No ERP modification is requested โ†’ QA_ONLY.
[Example 4]
Email: "We would like to cancel item 000020 on order 4500099871.
Please also let us know the return policy for defective items."
Intent: BOTH
Reasoning: Cancelling an order item is an ERP action. Asking about the return policy is a policy question. Both are present โ†’ BOTH.
[Example 5]
Email: "What is the lead time policy for rush orders placed after the cutoff time?"
Intent: QA_ONLY
Reasoning: The email is entirely a policy inquiry about rush-order lead times. No ERP change is requested โ†’ QA_ONLY.
[Example 6]
Email: "Please update the requested quantity for order 4500067890, line 000030 to 1,200 units."
Intent: ACTION_ONLY
Reasoning: A single quantity change request with no policy or regulation question โ†’ ACTION_ONLY.
[Example 7]
Email: "I need to reduce the order quantity of PO-2024056789 from 800 to 600.
In addition, can you tell me under what conditions we qualify for a volume discount?"
Intent: BOTH
Reasoning: Quantity reduction is an ERP action; the volume discount inquiry is a policy question. Both categories are present โ†’ BOTH.
[Example 8]
Email: "What is the difference between forward and backward scheduling?"
Intent: QA_ONLY
Reasoning: The email contains only a question about the scheduling concepts. No ERP action is requested โ†’ QA_ONLY.
[Example 9]
Email: "Please change the delivery address for sales order 4500034512 to our new warehouse in Incheon."
Intent: ACTION_ONLY
Reasoning: Updating a delivery address is an ERP modification request. No policy question is present โ†’ ACTION_ONLY.
[Example 10]
Email: "Hi, we want to move the delivery date of order 4500023456 to May 10th.
Also, is there a late delivery penalty we should be aware of?"
Intent: BOTH
Reasoning: The delivery date change is an ERP action; the late delivery penalty question is a policy inquiry. Both are present โ†’ BOTH.
[Example 11]
Email: "Can you clarify how I can define copying rules?"
Intent: QA_ONLY
Reasoning: A pure concept related question about copying rules. No ERP transaction requested โ†’ QA_ONLY.
[Example 12]
Email: "Please cancel item 000010 on order 4500078901.
What is the standard cancellation fee in this case?"
Intent: BOTH
Reasoning: The cancellation request is an ERP action; asking about the cancellation fee is a policy question. Both are present โ†’ BOTH.
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Now classify the following email. Output ONLY valid JSON matching the required schema.
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
"""
ROUTER_HUMAN_TEMPLATE = """\
Email to classify:
{user_input}{preprocess_hint}"""
_PROMPT = ChatPromptTemplate.from_messages(
[
("system", ROUTER_SYSTEM_PROMPT),
("human", ROUTER_HUMAN_TEMPLATE),
]
)
# ---------------------------------------------------------------------------
# LLM factory (swap out get_llm("router") once implemented)
# ---------------------------------------------------------------------------
def _build_llm():
"""
Instantiate the router LLM based on configs.yaml `models.router.provider`.
provider = "ollama" โ†’ ChatOllama (local, no API key needed)
provider = "openrouter" โ†’ ChatOpenAI pointed at OpenRouter endpoint
provider = "openai" โ†’ ChatOpenAI pointed at OpenAI endpoint
"""
cfg = get_config()
router_cfg = cfg.models.router
if router_cfg.provider == "openai":
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise EnvironmentError("OPENAI_API_KEY is not set in .env")
return ChatOpenAI(
model=router_cfg.name,
temperature=router_cfg.temperature,
openai_api_key=api_key,
)
if router_cfg.provider == "openrouter":
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
raise EnvironmentError(
"OPENROUTER_API_KEY is not set. "
"Add it to your .env file: OPENROUTER_API_KEY=sk-or-..."
)
return ChatOpenAI(
model=router_cfg.name,
temperature=router_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",
},
)
# default: Ollama
return ChatOllama(
base_url=cfg.ollama.base_url,
model=router_cfg.name,
temperature=router_cfg.temperature,
)
# Build once at import time and reuse across invocations.
_llm = _build_llm()
_chain = _PROMPT | _llm.with_structured_output(RouterOutput)
# ---------------------------------------------------------------------------
# LangGraph node
# ---------------------------------------------------------------------------
def _build_preprocess_hint(state: AgentState) -> str:
"""์ „์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ๊ฐ€ ์žˆ์œผ๋ฉด ๋ผ์šฐํ„ฐ์— ์ถ”๊ฐ€ ์ปจํ…์ŠคํŠธ๋กœ ์ฃผ์ž…ํ•˜๋Š” ์งง์€ hint ๋ฌธ์ž์—ด์„ ๋งŒ๋“ ๋‹ค.
๋ผ์šฐํ„ฐ์˜ ์ตœ์ข… ํŒ์ •์€ LLM์ด ๋‚ด๋ฆฌ์ง€๋งŒ, request_summary์™€ mentions_* ํ”Œ๋ž˜๊ทธ๊ฐ€
๋ถ„๋ฅ˜ ์ •ํ™•๋„๋ฅผ ์•ˆ์ •ํ™”ํ•˜๋Š” ๋ฐ ๋„์›€์ด ๋œ๋‹ค. ์ „์ฒ˜๋ฆฌ๊ฐ€ ์‹คํŒจํ–ˆ๊ฑฐ๋‚˜ ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด."""
ctx = state.get("email_context")
if not ctx or not ctx.get("preprocess_ok"):
return ""
parts = ["\n\n---\nPreprocessor hints (cross-check, then decide):"]
if ctx.get("request_summary"):
parts.append(f"- Request summary : {ctx['request_summary']}")
parts.append(f"- ERP-action signal : {ctx.get('mentions_action', False)}")
parts.append(f"- Knowledge-question signal: {ctx.get('mentions_question', False)}")
if ctx.get("order_ids"):
parts.append(f"- Order IDs mentioned : {ctx['order_ids']}")
return "\n".join(parts)
def router_node(state: AgentState) -> dict:
"""
LangGraph router node โ€” classifies email intent and updates AgentState.
Reads:
state["user_input"] โ€“ raw email text
state["email_context"] โ€“ (optional) preprocessor result; if present, its
request_summary + mentions_* flags are appended
to the prompt as cross-check hints.
Returns a partial AgentState update:
{
"intent": "ACTION_ONLY" | "QA_ONLY" | "BOTH",
"error_messages": [...], # appended on failure
}
"""
user_input: str = state["user_input"]
preprocess_hint = _build_preprocess_hint(state)
errors: list[str] = list(state.get("error_messages", []))
logger.info("[router_node] Classifying email intent โ€ฆ%s",
" (with preprocess hints)" if preprocess_hint else "")
logger.debug("[router_node] Input: %s", user_input[:200])
# Retry with exponential backoff on 429 Rate Limit errors
max_retries = 5
wait_secs = 5 # 5 โ†’ 10 โ†’ 20 โ†’ 40 โ†’ 80
for attempt in range(1, max_retries + 1):
try:
result: RouterOutput = _chain.invoke({
"user_input": user_input,
"preprocess_hint": preprocess_hint,
})
break # ์„ฑ๊ณต ์‹œ ๋ฃจํ”„ ํƒˆ์ถœ
except Exception as exc:
err_str = str(exc)
# 429 Rate Limit์ธ ๊ฒฝ์šฐ ์žฌ์‹œ๋„
if "429" in err_str and attempt < max_retries:
logger.warning(
"[router_node] Rate limited (429). Attempt %d/%d โ€” waiting %ds โ€ฆ",
attempt, max_retries, wait_secs,
)
time.sleep(wait_secs)
wait_secs *= 2 # ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„
continue
# 429 ์™ธ ์—๋Ÿฌ์ด๊ฑฐ๋‚˜ ์žฌ์‹œ๋„ ์ดˆ๊ณผ ์‹œ ์‹คํŒจ ์ฒ˜๋ฆฌ
logger.error("[router_node] LLM call failed: %s", exc, exc_info=True)
errors.append(f"router_node error: {exc}")
return {
"intent": None,
"error_messages": errors,
}
logger.info("[router_node] intent=%s | reasoning=%s", result.intent, result.reasoning[:120])
return {
"intent": result.intent,
"error_messages": errors,
}