SAP-ERP-AI-Agent / src /graph /worker_a.py
daisysooyeon's picture
deploy: SAP ERP AI Agent (HF Spaces docker)
50efdc6
Raw
History Blame Contribute Delete
25.4 kB
"""
src/graph/worker_a.py
Worker A: ERP Transaction Processing Node
Flow:
β‘  extract_erp_action() β€” LLM structured output β†’ ERPActionRequest (Pydantic)
β‘‘ run_validation_query() β€” Text-to-SQL β†’ SQLite execution β†’ erp_validation_result
β‘’ check_business_rules() β€” Stock / delivery-status checks β†’ BLOCKED_* or pass
β‘£ call_sap_odata_patch() β€” SAP Sandbox OData v4 PATCH (async β†’ asyncio.run wrapper)
β‘€ set PENDING_APPROVAL β€” Signals human_loop that approval is required
β‘₯ (human_loop handles) β€” worker_a is responsible only up to β‘€
"""
from __future__ import annotations
import asyncio
import logging
import os
from dotenv import load_dotenv
load_dotenv()
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import ValidationError
from src.api.schemas import ERPActionRequest
from src.config import get_config
from src.graph.state import AgentState
from src.tools.text2sql import run_validation_query
from src.tools.sap_odata_tools import call_sap_odata_patch
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# ERP Action Extraction β€” Prompt + LLM
# ---------------------------------------------------------------------------
_EXTRACTION_SYSTEM = """\
You are an expert at parsing B2B customer emails for SAP ERP order modification requests.
Extract the ERP action parameters from the email below.
CRITICAL β€” reading numbers correctly:
A single email often contains SEVERAL numbers: the order number, the item/line
number, and a quantity. Map each to the correct field and copy EVERY digit
exactly as written β€” do NOT add, drop, reorder, pad, or invent any digit.
Do NOT zero-pad β€” the system pads automatically. Just return the raw digits.
Ignore unrelated how-to / configuration / inquiry sentences; they never change
the order or item number.
Field rules:
- order_id : SAP sales order number (VBELN) β€” introduced by "order", "order #",
"sales order", or "PO". Copy its digits EXACTLY as written.
Strip separators like "-" only. It is NOT the item number and NOT a
quantity. No padding.
- item_no : Item / line number (POSNR) β€” introduced by "item", "line", or "position".
Copy its digits exactly as written. No padding.
Never confuse it with the order number or a quantity.
- action_type: One of EXACTLY FIVE values β€” read the definitions carefully:
"CHANGE_QTY" β€” customer wants to INCREASE or DECREASE the ordered quantity of an item.
"CHANGE_DATE" β€” customer wants to RESCHEDULE or UPDATE the delivery/requested date of an item.
"CANCEL_ITEM" β€” customer wants to CANCEL or REMOVE an order line item entirely.
"CHANGE_ADDR" β€” customer wants to UPDATE or CHANGE the shipping address / ship-to location.
Use this whenever the request mentions: shipping address, delivery address,
ship-to party, destination address, or similar address-related changes.
"OTHER" β€” any other ERP action that does NOT fit the four above. Use "OTHER" when the
request involves any of the following (even if an order/item number is mentioned):
β€’ Unblocking a delivery or removing a delivery block
β€’ Assigning or changing a batch number
β€’ Changing the shipping method, carrier, or route (NOT address)
β€’ Updating payment terms or billing conditions
β€’ Any clarification, inquiry, or system question
β€’ Any action not directly mapping to qty / date / cancel / address
- Quantity (only for CHANGE_QTY) β€” distinguish ABSOLUTE vs RELATIVE, fill exactly ONE:
β€’ new_quantity : the ABSOLUTE target quantity, when the customer states the final
value β€” e.g. "set quantity to 264", "change quantity to 100",
"make it 80 units". Integer > 0.
β€’ quantity_change : the RELATIVE change, when the customer states an adjustment rather
than a final value β€” e.g. "reduce by 50" β†’ -50, "decrease by 20" β†’ -20,
"increase by 30" β†’ +30, "add 10 more units" β†’ +10.
Use a NEGATIVE number for reductions, POSITIVE for increases.
Never put a relative adjustment into new_quantity. "reduce by 50" is quantity_change=-50,
NOT new_quantity=50. Leave the other field null.
- new_date : ISO date string "YYYY-MM-DD" (only for CHANGE_DATE). Omit or null for other actions.
- new_address : Free-text string with the new shipping address (only for CHANGE_ADDR).
If the email does not specify a new address, use "[Address to be provided by customer]".
Omit or null for other actions.
Output ONLY valid JSON matching the required schema. No explanation, no markdown.
"""
_EXTRACTION_HUMAN = """\
Customer email:
{user_input}{preprocess_hint}"""
_EXTRACTION_PROMPT = ChatPromptTemplate.from_messages([
("system", _EXTRACTION_SYSTEM),
("human", _EXTRACTION_HUMAN),
])
def _build_extraction_llm() -> ChatOpenAI:
"""Build the worker_a LLM (OpenRouter) for structured extraction."""
cfg = get_config()
api_key = os.getenv("OPENROUTER_API_KEY", "")
if not api_key:
raise EnvironmentError("OPENROUTER_API_KEY is not set. Check your .env file.")
wa_cfg = cfg.models.worker_a
return ChatOpenAI(
model=wa_cfg.name,
temperature=wa_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 A",
},
)
# Build once at import time
_extraction_llm = _build_extraction_llm()
_extraction_chain = _EXTRACTION_PROMPT | _extraction_llm.with_structured_output(ERPActionRequest)
# ---------------------------------------------------------------------------
# β‘  Parameter Extraction
# ---------------------------------------------------------------------------
def _build_preprocess_hint(email_context: dict | None) -> str:
"""μ „μ²˜λ¦¬ κ²°κ³Όμ—μ„œ worker_a μΆ”μΆœμ— 도움이 λ˜λŠ” hintλ₯Ό λ§Œλ“ λ‹€.
LLM은 μ—¬μ „νžˆ 자기 νŒλ‹¨μœΌλ‘œ μ΅œμ’…κ°’μ„ μ •ν•˜μ§€λ§Œ(=order_ids 쀑 μ–΄λŠ 게 μ§„μ§œ order_id인지,
item_nos 쀑 μ–΄λŠ 게 μ§„μ§œ item_noμΈμ§€λŠ” λ¬Έλ§₯에 의쑴), 후보 풀을 미리 μ’ν˜€μ£Όλ©΄ λ‹€λ₯Έ
숫자(μˆ˜λŸ‰, λ‚ μ§œμ˜ 일뢀)와 ν—·κ°ˆλ¦¬λŠ” μ‹€μˆ˜λ₯Ό 쀄일 수 μžˆλ‹€."""
if not email_context or not email_context.get("preprocess_ok"):
return ""
parts = ["\n\n---\nPreprocessor hints (use as a cross-check, not authoritative):"]
if email_context.get("request_summary"):
parts.append(f"- Request summary: {email_context['request_summary']}")
if email_context.get("order_ids"):
parts.append(f"- Candidate order IDs detected in email: {email_context['order_ids']}")
if email_context.get("item_nos"):
parts.append(f"- Candidate item numbers detected in email: {email_context['item_nos']}")
return "\n".join(parts)
def extract_erp_action(user_input: str, email_context: dict | None = None) -> ERPActionRequest | None:
"""
Extract ERPActionRequest from the email using LLM structured output.
Args:
user_input: raw email text. Used as the extraction input only when the
preprocessor produced no usable cleaned_body.
email_context: optional preprocessor result. When preprocess_ok and a
cleaned_body is present, the cleaned_body (greetings/signatures
stripped, but order/item/quantity digits preserved) becomes the
extraction input; its summary/entity fields are still passed as
soft cross-check hints. Mirrors worker_b's pattern.
Returns None if extraction fails or validation errors occur.
"""
# μ „μ²˜λ¦¬κ°€ λ§Œλ“  cleaned_bodyκ°€ 있으면 μΆ”μΆœ μž…λ ₯으둜 μ‚¬μš©ν•œλ‹€(worker_b와 동일 νŒ¨ν„΄).
# μΈμ‚¬Β·μ„œλͺ… λ…Έμ΄μ¦ˆκ°€ 제거돼 더 μ•ˆμ •μ μ΄λ©°, cleaned_bodyλŠ” μ£Όλ¬Έ/μ•„μ΄ν…œ/μˆ˜λŸ‰ 숫자λ₯Ό
# 원문 κ·ΈλŒ€λ‘œ λ³΄μ‘΄ν•œλ‹€. μ—†κ±°λ‚˜ μ „μ²˜λ¦¬ μ‹€νŒ¨ μ‹œ 원본 user_input으둜 폴백.
extraction_input = user_input
used_cleaned = bool(
email_context
and email_context.get("preprocess_ok")
and email_context.get("cleaned_body")
)
if used_cleaned:
extraction_input = email_context["cleaned_body"]
logger.info("[worker_a] β‘  Extracting ERP action parameters … (input=%s%s)",
"cleaned_body" if used_cleaned else "raw",
", with hints" if email_context else "")
try:
result: ERPActionRequest = _extraction_chain.invoke({
"user_input": extraction_input,
"preprocess_hint": _build_preprocess_hint(email_context),
})
# νŒ¨λ”©μ€ μ—¬κΈ°(결정둠적 μ½”λ“œ)μ„œ λ‹΄λ‹Ήν•œλ‹€. LLM은 원본 숫자만 μΆ”μΆœν•˜λ―€λ‘œ
# 끝자리λ₯Ό ν˜λ¦¬λŠ” μ˜€νŒ¨λ”©μ΄ λ°œμƒν•˜μ§€ μ•ŠλŠ”λ‹€.
# order_id β†’ 10자리, item_no β†’ 6자리 쒌츑 μ œλ‘œνŒ¨λ”©.
raw_id = "".join(c for c in result.order_id if c.isdigit())
result.order_id = str(int(raw_id)).zfill(10) if raw_id else result.order_id
raw_item = "".join(c for c in result.item_no if c.isdigit())
result.item_no = str(int(raw_item)).zfill(6) if raw_item else result.item_no
logger.info(
"[worker_a] Extracted: order_id=%s item_no=%s action_type=%s",
result.order_id, result.item_no, result.action_type,
)
return result
except ValidationError as e:
logger.error("[worker_a] ERPActionRequest validation failed: %s", e)
return None
except Exception as e:
logger.error("[worker_a] Extraction LLM call failed: %s", e)
return None
# ---------------------------------------------------------------------------
# β‘’ Business Rule Checks
# ---------------------------------------------------------------------------
# Delivery status codes that block modification
_BLOCKED_STATUSES = {"C"} # C = Fully processed / shipped
def check_business_rules(action: ERPActionRequest, validation_result: dict | None) -> str | None:
"""
Validate ERP business rules against the DB snapshot.
Returns:
None β€” all checks passed, proceed
"BLOCKED_NO_STOCK" β€” additional quantity (delta) exceeds available stock
"BLOCKED_SHIPPED" β€” item already fully shipped (WBSTA = C)
"BLOCKED_PARTIALLY_PROCESSED" β€” cancellation of an item whose goods movement
already started (WBSTA = B)
"BLOCKED_NO_DATA" β€” order/item not found in DB
"""
logger.info("[worker_a] β‘’ Checking business rules …")
if validation_result is None:
logger.warning("[worker_a] No DB record found for order_id=%s item_no=%s",
action.order_id, action.item_no)
return "BLOCKED_NO_DATA"
delivery_status = validation_result.get("delivery_status", "")
available_stock = validation_result.get("available_stock") or 0.0
# Rule 1: Block if item is already fully shipped
if delivery_status in _BLOCKED_STATUSES:
logger.warning(
"[worker_a] BLOCKED_SHIPPED: delivery_status=%s for order=%s item=%s",
delivery_status, action.order_id, action.item_no,
)
return "BLOCKED_SHIPPED"
# Rule 1b: An item cannot be CANCELLED once goods movement has started.
# Full shipment (WBSTA=C) is already caught by Rule 1 (BLOCKED_SHIPPED); here we
# additionally block partial processing (WBSTA=B) β€” but only for cancellations.
# Other actions (qty/date) on a partially processed item remain allowed.
if action.action_type == "CANCEL_ITEM" and delivery_status == "B":
logger.warning(
"[worker_a] BLOCKED_PARTIALLY_PROCESSED: cannot cancel partially processed "
"item (WBSTA=B) for order=%s item=%s",
action.order_id, action.item_no,
)
return "BLOCKED_PARTIALLY_PROCESSED"
# Rule 2: For a quantity change, only the ADDITIONAL units (delta) must be covered
# by available stock β€” not the whole new quantity. A decrease frees stock, so its
# delta is negative and never blocks. new_quantity is already absolute here
# (resolve_quantity normalized any relative "reduce by N"/"increase by N" upstream),
# so the delta is computed identically regardless of how the email phrased it.
if action.action_type == "CHANGE_QTY" and action.new_quantity is not None:
current_qty = validation_result.get("quantity")
if current_qty is None:
# Current quantity unknown β†’ can't compute the delta. Fall back to the
# conservative absolute check so we never under-reserve stock.
additional_needed = float(action.new_quantity)
else:
additional_needed = action.new_quantity - float(current_qty)
if additional_needed > available_stock:
logger.warning(
"[worker_a] BLOCKED_NO_STOCK: new_qty=%d current=%s additional_needed=%.2f "
"available=%.2f for order=%s item=%s",
action.new_quantity, current_qty, additional_needed, available_stock,
action.order_id, action.item_no,
)
return "BLOCKED_NO_STOCK"
# Rule 3: CHANGE_ADDR β€” always passes business rules (address validity is human's responsibility)
if action.action_type == "CHANGE_ADDR":
logger.info("[worker_a] CHANGE_ADDR: forwarding to human approval.")
return None
logger.info("[worker_a] Business rules passed.")
return None
# ---------------------------------------------------------------------------
# β‘’-b Relative quantity resolution
# ---------------------------------------------------------------------------
def resolve_quantity(action: ERPActionRequest, validation_result: dict | None) -> str | None:
"""
For CHANGE_QTY with a RELATIVE quantity_change (e.g. "reduce by 50" β†’ -50),
resolve it into an absolute new_quantity using the current DB quantity (KWMENG),
then write the result back into action.new_quantity.
Must run AFTER run_validation_query (needs the current quantity) and BEFORE
check_business_rules (which validates the absolute new_quantity against stock).
Returns:
None β€” nothing to resolve, or resolved successfully
"BLOCKED_NO_DATA" β€” relative change requested but current quantity unknown
"BLOCKED_INVALID_QTY" β€” resolved quantity would be <= 0
"""
if action.action_type != "CHANGE_QTY" or action.quantity_change is None:
return None # absolute path or not a qty change β€” nothing to do
current = validation_result.get("quantity") if validation_result else None
if current is None:
logger.warning("[worker_a] Cannot resolve relative quantity β€” current quantity unknown.")
return "BLOCKED_NO_DATA"
resolved = int(current) + action.quantity_change # quantity_change is signed
logger.info(
"[worker_a] Relative qty resolved: current=%s change=%+d β†’ new_quantity=%d",
current, action.quantity_change, resolved,
)
if resolved <= 0:
logger.warning(
"[worker_a] BLOCKED_INVALID_QTY: current=%s change=%+d β†’ %d (must be > 0)",
current, action.quantity_change, resolved,
)
return "BLOCKED_INVALID_QTY"
action.new_quantity = resolved
action.quantity_change = None # consumed β€” downstream uses the absolute value
return None
# ---------------------------------------------------------------------------
# β‘‘-context β€” Text-to-SQL μš”μ²­ μ»¨ν…μŠ€νŠΈ
# ---------------------------------------------------------------------------
def _build_request_context(action: ERPActionRequest) -> str:
"""text2sql에 λ„˜κΈΈ μš”μ²­ μ»¨ν…μŠ€νŠΈ λ¬Έμžμ—΄μ„ λ§Œλ“ λ‹€.
LLM이 이 μ•‘μ…˜μ„ κ²€μ¦ν•˜λ €λ©΄ (ν•„μˆ˜ 5개 컬럼 외에) μ–΄λ–€ μΆ”κ°€ 컬럼이 ν•„μš”ν•œμ§€ νŒλ‹¨ν• 
수 μžˆλ„λ‘ μ•‘μ…˜ μ˜λ„λ₯Ό μ„œμˆ ν•œλ‹€. ν•„μˆ˜ 5개 μ»¬λŸΌμ€ μ»¨ν…μŠ€νŠΈμ™€ λ¬΄κ΄€ν•˜κ²Œ 항상 λ°˜ν™˜λœλ‹€.
주의: ν˜„μž¬ check_business_rulesλŠ” 결정둠을 μœ„ν•΄ κ³ μ • 5개 ν•„λ“œλ§Œ μ†ŒλΉ„ν•˜λ―€λ‘œ, μΆ”κ°€λ‘œ
쑰회된 μ»¬λŸΌμ€ 아직 λ‹€μš΄μŠ€νŠΈλ¦Όμ—μ„œ μ‚¬μš©λ˜μ§€ μ•ŠλŠ”λ‹€(ν–₯ν›„ κ°€λ³€ 검증 ν™•μž₯ λŒ€λΉ„).
"""
at = action.action_type
if at == "CHANGE_QTY":
if action.new_quantity is not None:
tgt = f"to an absolute quantity of {action.new_quantity}"
elif action.quantity_change is not None:
verb = "increase" if action.quantity_change > 0 else "decrease"
tgt = f"by a relative {verb} of {abs(action.quantity_change)}"
else:
tgt = "(quantity change)"
return (f"- Action: CHANGE_QTY {tgt}. To judge stock feasibility, the current "
f"ordered quantity and the available stock at the order's own plant matter.")
if at == "CHANGE_DATE":
return (f"- Action: CHANGE_DATE to {action.new_date or '(unspecified)'}. "
f"The current delivery/requested schedule date is relevant for validation.")
if at == "CANCEL_ITEM":
return ("- Action: CANCEL_ITEM (cancel this order line). Goods-movement status and "
"any already-processed/delivered quantity are relevant to whether cancellation is allowed.")
if at == "CHANGE_ADDR":
return "- Action: CHANGE_ADDR (change ship-to address). No extra stock/schedule context is needed."
return f"- Action: {at} (general status validation)."
# ---------------------------------------------------------------------------
# β‘£ OData PATCH (async β†’ sync wrapper)
# ---------------------------------------------------------------------------
def _call_odata_sync(action: ERPActionRequest) -> dict:
"""Run the async OData PATCH call synchronously."""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Inside an already-running event loop (e.g. Jupyter / FastAPI)
import nest_asyncio # type: ignore
nest_asyncio.apply()
return loop.run_until_complete(call_sap_odata_patch(action))
else:
return loop.run_until_complete(call_sap_odata_patch(action))
except RuntimeError:
# No event loop exists yet β€” create a fresh one
return asyncio.run(call_sap_odata_patch(action))
# ---------------------------------------------------------------------------
# LangGraph Node
# ---------------------------------------------------------------------------
def worker_a_node(state: AgentState) -> dict:
"""
LangGraph Worker A node β€” ERP transaction processing.
Reads:
state["user_input"] β€” raw email text
state["error_messages"] β€” existing error list (appended on failure)
Returns a partial AgentState update:
{
"erp_action": ERPActionRequest dict | None,
"erp_validation_result": dict | None,
"erp_action_status": "PENDING_APPROVAL" | "BLOCKED_*",
"odata_response": dict | None,
"requires_human_approval": bool,
"error_messages": [...],
}
"""
user_input: str = state["user_input"]
email_context: dict | None = state.get("email_context")
errors: list[str] = list(state.get("error_messages", []))
logger.info("[worker_a] ═══════════════ Worker A START ═══════════════")
# ── β‘  Extract ERP action parameters ────────────────────────────────────
action = extract_erp_action(user_input, email_context=email_context)
if action is None:
msg = "worker_a: Failed to extract ERP action parameters from email."
logger.error("[worker_a] %s", msg)
errors.append(msg)
return {
"erp_action": None,
"erp_validation_result": None,
"erp_action_status": "BLOCKED_EXTRACTION_FAILED",
"odata_response": None,
"requires_human_approval": False,
"error_messages": errors,
}
# ── OTHER μ•‘μ…˜μ€ μ‹œμŠ€ν…œμ΄ μžλ™ μ²˜λ¦¬ν•  수 μ—†λ‹€ β†’ μˆ˜λ™ 처리둜 λΆ„κΈ° ────────────────
# 지원 μ•‘μ…˜(CHANGE_QTY/CHANGE_DATE/CANCEL_ITEM/CHANGE_ADDR)이 μ•„λ‹ˆλ―€λ‘œ 검증·ODataΒ·
# 승인 큐λ₯Ό νƒœμš°μ§€ μ•Šκ³  MANUAL_REQUIRED둜 μ’…λ£Œν•œλ‹€. SUCCESS/PENDING_APPROVAL이 λ˜μ–΄
# "μ™„λ£Œν–ˆλ‹€"κ³  λ‹΅ν•˜λ©΄ μ•ˆ 되며(golden도 'μˆ˜λ™ 처리 ν•„μš”'둜 λ Œλ”), λΆˆν•„μš”ν•œ OData ν˜ΈμΆœλ„ λ§‰λŠ”λ‹€.
if action.action_type == "OTHER":
logger.info("[worker_a] action_type=OTHER β€” μžλ™ 처리 λΆˆκ°€, MANUAL_REQUIRED둜 λΆ„κΈ°.")
logger.info("[worker_a] ═══════════════ Worker A END ═══════════════")
return {
"erp_action": action.model_dump(),
"erp_validation_result": None,
"erp_action_status": "MANUAL_REQUIRED",
"odata_response": None,
"requires_human_approval": False,
"error_messages": errors,
}
# ── β‘‘ Text-to-SQL validation query ─────────────────────────────────────
logger.info("[worker_a] β‘‘ Running Text-to-SQL validation query …")
validation_result = run_validation_query(
action.order_id, action.item_no,
request_context=_build_request_context(action),
)
logger.info("[worker_a] Validation result: %s", validation_result)
# ── β‘’ Business rule checks ──────────────────────────────────────────────
# Resolve relative quantity ("reduce by 50") β†’ absolute, then validate.
block_reason = resolve_quantity(action, validation_result) \
or check_business_rules(action, validation_result)
if block_reason is not None:
logger.warning("[worker_a] Blocked: %s", block_reason)
return {
"erp_action": action.model_dump(),
"erp_validation_result": validation_result,
"erp_action_status": block_reason,
"odata_response": None,
"requires_human_approval": False,
"error_messages": errors,
}
# ── β‘£ SAP OData PATCH call ─────────────────────────────────────────────
logger.info("[worker_a] β‘£ Calling SAP OData PATCH …")
odata_response: dict | None = None
try:
odata_response = _call_odata_sync(action)
status_code = odata_response.get("status_code", 0)
logger.info("[worker_a] OData response: status_code=%s", status_code)
if status_code in (200, 204):
logger.info("[worker_a] SAP OData PATCH succeeded.")
elif status_code == 405:
# SAP Business Accelerator Hub sandbox is read-only (GET only).
# 405 confirms the endpoint is reachable; write ops require a live S/4HANA system.
logger.info("[worker_a] SAP sandbox reached (405 expected β€” write ops not supported in sandbox).")
else:
msg = f"worker_a: SAP OData PATCH returned unexpected status {status_code}."
logger.warning("[worker_a] %s", msg)
errors.append(msg)
except Exception as e:
msg = f"worker_a: OData PATCH call failed: {e}"
logger.error("[worker_a] %s", msg)
errors.append(msg)
# Non-fatal: proceed to approval queue so a human can retry manually.
# ── β‘€ Queue for human approval ─────────────────────────────────────────
logger.info("[worker_a] β‘€ Setting PENDING_APPROVAL β€” routing to human_loop …")
logger.info("[worker_a] ═══════════════ Worker A END ═══════════════")
return {
"erp_action": action.model_dump(),
"erp_validation_result": validation_result,
"erp_action_status": "PENDING_APPROVAL",
"odata_response": odata_response,
"requires_human_approval": True,
"error_messages": errors,
}