File size: 10,624 Bytes
50efdc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
"""
src/graph/human_loop.py
Human-in-the-Loop node

How it fits in the LangGraph flow:
  1. worker_a_node sets erp_action_status="PENDING_APPROVAL" and
     requires_human_approval=True, then the graph pauses at this node
     via interrupt_before=["human_loop_node"].
  2. The FastAPI /api/approve endpoint receives the Slack button callback,
     updates the checkpoint with human_approved=True/False, and resumes
     the graph.
  3. This node is then executed with the updated state:
       - human_approved == True  β†’ apply SQLite UPDATE β†’ SUCCESS
       - human_approved == False β†’ mark REJECTED, no DB write

SQLite UPDATE mapping:
  action_type  | table | column   | value
  ─────────────┼───────┼──────────┼────────────────────────────
  CHANGE_QTY   | VBAP  | KWMENG   | new_quantity (REAL)
  CHANGE_DATE  | VBEP  | EDATU    | new_date (YYYYMMDD text)
  CANCEL_ITEM  | VBAP  | ABGRU    | "ZZ" (rejection reason code)
"""

from __future__ import annotations

import logging
import re
import sqlite3

from langgraph.types import interrupt

from src.config import get_config
from src.graph.state import AgentState

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# SQLite UPDATE helpers
# ---------------------------------------------------------------------------

def _get_db_path() -> str:
    return get_config().paths.erp_db


def _execute_update(sql: str, params: tuple) -> int:
    """Execute a single UPDATE and return rowcount."""
    db_path = _get_db_path()
    conn = sqlite3.connect(db_path)
    try:
        cursor = conn.execute(sql, params)
        conn.commit()
        rowcount = cursor.rowcount
        logger.info("[human_loop] SQL executed β€” rows affected: %d", rowcount)
        logger.debug("[human_loop] SQL: %s | params: %s", sql.strip(), params)
        return rowcount
    except sqlite3.Error as e:
        conn.rollback()
        logger.error("[human_loop] SQLite error: %s", e)
        raise
    finally:
        conn.close()


def _date_to_sap(date_str: str | None) -> str | None:
    """
    Convert ISO date "YYYY-MM-DD" to SAP EDATU format "YYYYMMDD".
    Returns None if conversion fails.
    """
    if not date_str:
        return None
    # Already YYYYMMDD
    if re.fullmatch(r"\d{8}", date_str):
        return date_str
    # ISO format
    m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", date_str)
    if m:
        return m.group(1) + m.group(2) + m.group(3)
    logger.warning("[human_loop] Unrecognised date format: %s", date_str)
    return None


def apply_erp_update(erp_action: dict) -> dict:
    """
    Apply the approved ERP action to the SQLite DB.

    Parameters
    ----------
    erp_action : dict  (from ERPActionRequest.model_dump())
        Keys: order_id, item_no, action_type, new_quantity, new_date

    Returns
    -------
    dict with keys:
        success  : bool
        rowcount : int
        message  : str
    """
    order_id    = erp_action.get("order_id", "")
    item_no_raw = erp_action.get("item_no", "")
    action_type = erp_action.get("action_type", "")

    # NOTE on VBELN matching:
    #   VBAP.VBELN is stored inconsistently in the dataset β€” some orders are
    #   zero-padded to 10 chars ("0000015353"), others are not ("6105").
    #   The incoming order_id may likewise be padded or not. To match
    #   regardless of padding, every WHERE clause below compares the numeric
    #   value via CAST(VBELN AS INTEGER) = CAST(? AS INTEGER) instead of an
    #   exact string match. (3 of 67,429 VBAP rows are non-numeric junk and
    #   are intentionally unreachable this way.)

    # item_no may arrive as a zero-padded string ("000010") or int (10)
    try:
        item_no = int(str(item_no_raw).lstrip("0") or "0")
    except ValueError:
        item_no = 0

    logger.info(
        "[human_loop] Applying update: order=%s item=%s action=%s",
        order_id, item_no, action_type,
    )

    try:
        if action_type == "CHANGE_QTY":
            new_qty = erp_action.get("new_quantity")
            if new_qty is None:
                return {"success": False, "rowcount": 0,
                        "message": "CHANGE_QTY requires new_quantity"}
            rowcount = _execute_update(
                "UPDATE VBAP SET KWMENG = ? "
                "WHERE CAST(VBELN AS INTEGER) = CAST(? AS INTEGER) AND POSNR = ?",
                (float(new_qty), order_id, item_no),
            )

        elif action_type == "CHANGE_DATE":
            new_date_raw = erp_action.get("new_date")
            new_date_sap = _date_to_sap(new_date_raw)
            if new_date_sap is None:
                return {"success": False, "rowcount": 0,
                        "message": f"CHANGE_DATE: invalid date '{new_date_raw}'"}
            rowcount = _execute_update(
                "UPDATE VBEP SET EDATU = ? "
                "WHERE CAST(VBELN AS INTEGER) = CAST(? AS INTEGER) AND POSNR = ?",
                (new_date_sap, order_id, item_no),
            )

        elif action_type == "CANCEL_ITEM":
            # Set rejection reason code "ZZ" in VBAP
            rowcount = _execute_update(
                "UPDATE VBAP SET ABGRU = ? "
                "WHERE CAST(VBELN AS INTEGER) = CAST(? AS INTEGER) AND POSNR = ?",
                ("ZZ", order_id, item_no),
            )

        elif action_type == "CHANGE_ADDR":
            # For shipping address changes, we only simulate the update
            # because there is no ADRC/VBKD table in the local SQLite mock DB
            logger.info("[human_loop] Simulated CHANGE_ADDR update for order=%s", order_id)
            return {
                "success": True,
                "rowcount": 1,
                "message": "CHANGE_ADDR applied successfully (simulated).",
            }

        else:
            return {
                "success": False,
                "rowcount": 0,
                "message": f"Unknown action_type: '{action_type}'",
            }

    except sqlite3.Error as e:
        return {"success": False, "rowcount": 0, "message": str(e)}

    if rowcount == 0:
        return {
            "success": False,
            "rowcount": 0,
            "message": (
                f"No rows updated for order={order_id} item={item_no}. "
                "Record may not exist in DB."
            ),
        }

    return {
        "success": True,
        "rowcount": rowcount,
        "message": f"{action_type} applied successfully ({rowcount} row(s) updated).",
    }


# ---------------------------------------------------------------------------
# LangGraph node
# ---------------------------------------------------------------------------

def human_loop_node(state: AgentState) -> dict:
    """
    LangGraph Human-in-the-Loop node.

    LangGraph 1.x interrupt() 방식:
      - λ…Έλ“œ μ‹€ν–‰ 쀑 interrupt()λ₯Ό ν˜ΈμΆœν•΄ μΌμ‹œ 쀑단
      - μ™ΈλΆ€μ—μ„œ Command(resume=True/False)둜 μž¬κ°œν•˜λ©΄ interrupt() λ°˜ν™˜κ°’μœΌλ‘œ κ²°κ³Ό μˆ˜μ‹ 
      - True(승인) β†’ DB UPDATE μ‹€ν–‰ β†’ SUCCESS
      - False(거절) β†’ DB λ³€κ²½ μ—†μŒ β†’ REJECTED

    Reads:
        state["erp_action"] β€” ERPActionRequest dict from worker_a

    Returns a partial AgentState update:
        {
            "erp_action_status": "SUCCESS" | "REJECTED" | "FAILED",
            "human_approved":    bool,
            "error_messages":    [...],
        }
    """
    erp_action: dict | None = state.get("erp_action")
    errors: list[str]       = list(state.get("error_messages", []))

    logger.info("[human_loop] ═══════════════ Human Loop START ═══════════════")

    # ── Human decision via interrupt() ──────────────────────────────────────
    # resume 값은 두 ν˜•νƒœλ‘œ λ“€μ–΄μ˜¬ 수 μžˆλ‹€:
    #   β€’ bool                          β€” λΈŒλΌμš°μ € GET /api/approve (λ ˆκ±°μ‹œ)
    #   β€’ {"approved": bool,            β€” Slack μΈν„°λž™ν‹°λΈŒ ν”Œλ‘œμš°
    #      "reason": str | None}          (거절 μ‚¬μœ  포함 κ°€λŠ₯)
    resume_value = interrupt({
        "erp_action": erp_action,
        "message":    "Approve or reject this ERP action? (True=Approve, False=Reject)",
    })

    if isinstance(resume_value, dict):
        human_approved   = bool(resume_value.get("approved", False))
        rejection_reason = resume_value.get("reason") or None
    else:
        human_approved   = bool(resume_value)
        rejection_reason = None

    logger.info(
        "[human_loop] human_approved=%s rejection_reason=%s",
        human_approved, rejection_reason,
    )

    # ── Case 1: Rejected ────────────────────────────────────────────────────
    if not human_approved:
        logger.info("[human_loop] Action REJECTED by human reviewer.")
        return {
            "erp_action_status": "REJECTED",
            "human_approved":    False,
            "rejection_reason":  rejection_reason,
            "error_messages":    errors,
        }

    # ── Case 2: Approved β€” apply DB update ──────────────────────────────────
    if erp_action is None:
        msg = "human_loop: human_approved=True but erp_action is None β€” cannot update DB."
        logger.error("[human_loop] %s", msg)
        errors.append(msg)
        return {
            "erp_action_status": "FAILED",
            "error_messages":    errors,
        }

    result = apply_erp_update(erp_action)

    if result["success"]:
        logger.info("[human_loop] DB update SUCCESS: %s", result["message"])
        logger.info("[human_loop] ═══════════════ Human Loop END ═══════════════")
        return {
            "erp_action_status": "SUCCESS",
            "human_approved":    True,
            "error_messages":    errors,
        }
    else:
        msg = f"human_loop: DB update FAILED β€” {result['message']}"
        logger.error("[human_loop] %s", msg)
        errors.append(msg)
        logger.info("[human_loop] ═══════════════ Human Loop END ═══════════════")
        return {
            "erp_action_status": "FAILED",
            "human_approved":    True,
            "error_messages":    errors,
        }