RAG2026-5일29차시 · Conditional Edge 실습7 / 19← 이전목차다음 →MetaContents

29차시. Conditional Edge 실습

4. 실습 ③ — 유형별 처리 노드

출처: 원본 PDF 29차시_ConditionalEdge실습.pdf 9쪽 · 쪽 그림 9쪽

📝 src/nodes/intents.py

Python
import re
import config

def greeting_node(state) -> dict:
    return {
        "answer": ("안녕하세요! 사내 문서에 관한 질문을 도와드립니다.\n"
                   "예: 환불 규정, 휴가 신청 방법, 고객센터 운영 시간 등"),
        "grade": "pass",
        "log": ["greeting 응답"],
    }

def calc_node(state) -> dict:
    expr = state["question"].strip().rstrip("=?").strip()

    # 안전 검사: 숫자와 연산자만 허용 (eval 사용 시 필수)
    if not re.fullmatch(r"[\d\s\+\-\*/\(\)\.]+", expr):
        return {"answer": "계산할 수 없는 식입니다.",
                "grade": "pass", "log": ["calc: 형식 오류"]}
    try:
        result = eval(expr, {"__builtins__": {}}, {})
    except Exception:
        return {"answer": "계산할 수 없는 식입니다.",
                "grade": "pass", "log": ["calc: 계산 실패"]}

    return {"answer": f"{expr} = {result}",
            "grade": "pass", "log": [f"calc: {result}"]}

def scope_node(state) -> dict:
    return {
        "answer": ("죄송합니다. 저는 사내 문서에 관한 질문에만 "
                   "답변할 수 있습니다.\n"
                   "문서와 관련된 내용을 물어봐 주세요."),
        "grade": "pass",
        "log": ["scope: 범위 밖 안내"],
    }

⚠️ eval을 쓸 때는 반드시 검사부터
eval은 문자열을 파이썬 코드로 실행합니다.
사용자 입력을 그대로 넣으면 심각한 보안 취약점입니다.
· 정규식으로 숫자·연산자만 허용
· __builtins__를 비워 내장 함수 접근 차단
운영 환경이라면 eval 대신
ast.literal_eval이나 전용 계산 라이브러리를 쓰세요.

실습 코드 · 29/intents.py

실습 코드: 실습/src/29/intents.py · 위의 원본 PDF 29차시 9쪽 「4️⃣ 실습 ③ — 유형별 처리 노드」과 이어집니다.

Python
# ==============================================================
# 29차시. PPT 29차시_ConditionalEdge실습. 코드 폴더는 src/29
# PPT: 29차시 질문 분류
# 역할: intent가 greeting, calc, scope일 때 바로 답을 만드는 세 노드. 문서를 찾지 않습니다.
# 읽는 키: question (calc_node만 계산식에 사용)
# 쓰는 키: answer, grade, reason, log
# 그래프:
#   greeting_node --> 안내 문장, grade=pass
#   calc_node -----> 식 계산, 성공하면 grade=pass, 실패하면 grade=giveup
#   scope_node ----> 문서 밖 안내, grade=pass
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
# return에 적지 않은 키는 이전 값이 남습니다.
# log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
# Retriever: 검색 담당. 질문과 비슷한 문서 조각만 고릅니다. 답 문장은 만들지 않습니다.
# Generator: 고른 문서를 근거로 답 문장을 만듭니다. 문서가 없으면 안내 문장만 돌려줍니다.
# Verifier: 답이 근거와 맞는지 검사하고 grade를 적습니다.
#   grade=pass     답을 확정하고 그래프를 끝냅니다.
#   grade=retry    같은 문서로 답 문장만 다시 씁니다. retries가 1 늘어납니다.
#   grade=research 문서가 부족합니다. 검색어를 바꾼 뒤 다시 찾습니다. rewrites가 늘어납니다.
#   grade=giveup   더 진행하지 않고 fallback으로 안내 문장만 돌려줍니다.
# fallback: 실패했을 때 답을 지어내지 않고 안내 문장을 반환하는 노드입니다.
# intent: 질문 종류. greeting 인사, calc 계산, scope 문서 밖, document 문서 검색.
# rewrite: 사용자가 친 question은 유지하고, 검색에 쓰는 query만 바꿉니다.
# safe_node: 노드를 try로 감쌉니다. 오류가 나도 그래프는 멈추지 않고 node_error에 적습니다.
# 세 노드 모두 Retriever로 가지 않고 그래프가 END로 끝납니다.
# calc_node는 숫자와 +-*/(). 쉼표만 허용한 뒤 eval로 계산합니다. 내장 함수는 비워 둡니다.
# scope_node는 질문 문장과 상관없이 같은 안내를 반환합니다. 분류는 classifier가 이미 끝낸 상태입니다.
# ==============================================================

# ============================================================
# Intent Nodes
# ============================================================
# 질문 유형에 따라 간단한 답변을 반환하는 노드 모음입니다.
# 인사말, 계산식, 문서 범위 밖 질문을 각각 처리합니다.
# ============================================================

import re

# ============================================================
# 1. 인사말 처리 노드
# ============================================================

def greeting_node(state):
    """
    사용자가 인사했을 때 안내 메시지를 반환합니다.
    """

    # 사내 문서 질문을 도와줄 수 있다는 안내문입니다.
    answer = (
        "안녕하세요! 사내 문서에 관한 질문을 도와드립니다.\n"
        "예: 환불 규정, 휴가 신청 방법, 고객센터 운영 시간 등"
    )

    # 답변과 처리 결과를 State에 저장합니다.
    return {
        "answer": answer,
        "grade": "pass",
        "reason": "인사말에 대한 안내 완료",
        "log": ["인사말 응답"],
    }

# ============================================================
# 2. 계산 처리 노드
# ============================================================

def calc_node(state):
    """
    사용자가 입력한 간단한 계산식을 계산합니다.
    """

    # State에서 사용자의 질문을 가져옵니다.
    question = state.get("question", "")

    # 질문 앞뒤의 공백을 제거합니다.
    expression = question.strip()

    # 마지막에 붙은 '=' 또는 '?'를 제거합니다.
    expression = expression.rstrip("=?").strip()

    # 숫자와 계산 기호만 허용합니다.
    allowed_pattern = r"[\d\s+\-*/().,]+"

    # 허용되지 않은 문자가 있으면 계산하지 않습니다.
    if re.fullmatch(allowed_pattern, expression) is None:
        return {
            "answer": "계산식을 처리하지 못했습니다.",
            "grade": "giveup",
            "reason": "허용되지 않은 문자가 있습니다.",
            "log": ["계산 실패"],
        }

    try:
        # 천 단위 구분 쉼표를 제거합니다.
        clean_expression = expression.replace(",", "")

        # 검사를 통과한 계산식을 계산합니다.
        result = eval(
            clean_expression,
            {
                "__builtins__": {}
            },
            {},
        )

        # 계산 결과를 반환합니다.
        return {
            "answer": f"{expression} = {result}",
            "grade": "pass",
            "reason": "계산 완료",
            "log": [
                f"계산 완료: {expression} = {result}"
            ],
        }

    except Exception as error:
        # 계산 중 오류가 발생하면 안내 메시지를 반환합니다.
        return {
            "answer": "계산식을 처리하지 못했습니다.",
            "grade": "giveup",
            "reason": f"계산 중 오류 발생: {type(error).__name__}",
            "log": ["계산 실패"],
        }

# ============================================================
# 3. 문서 범위 밖 질문 처리 노드
# ============================================================

def scope_node(state):
    """
    사내 문서와 관련 없는 질문에 안내 메시지를 반환합니다.
    """

    # 문서 질문만 처리한다는 안내문입니다.
    answer = (
        "죄송합니다.\n"
        "저는 사내 문서에 관한 질문에만 답변할 수 있습니다.\n"
        "문서와 관련된 내용을 물어봐 주세요."
    )

    # 범위 밖 질문에 대한 결과를 반환합니다.
    return {
        "answer": answer,
        "grade": "pass",
        "reason": "문서 범위 밖 질문에 대한 안내 완료",
        "log": ["문서 범위 밖 질문"],
    }

# ============================================================
# 단독 실행 테스트
# ============================================================

if __name__ == "__main__":

    # 각 노드를 테스트할 질문입니다.
    test_cases = [
        ("greeting", "안녕하세요"),
        ("calc", "1,000 + 2,000 * 3"),
        ("scope", "오늘 날씨 어때요?"),
    ]

    # 질문을 하나씩 테스트합니다.
    for intent, question in test_cases:

        # 노드에 전달할 State를 만듭니다.
        state = {
            "question": question
        }

        # 의도에 따라 실행할 노드를 선택합니다.
        if intent == "greeting":
            result = greeting_node(state)

        elif intent == "calc":
            result = calc_node(state)

        else:
            result = scope_node(state)

        # 실행 결과를 출력합니다.
        print(f"\n질문: {question}")
        print(f"답변: {result['answer']}")
        print(f"판정: {result['grade']}")
        print(f"로그: {result['log']}")

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
실제 실행 (API 호출 없음)
이 파일 __main__ 을 실행한 출력입니다.

질문: 안녕하세요
답변: 안녕하세요! 사내 문서에 관한 질문을 도와드립니다.
예: 환불 규정, 휴가 신청 방법, 고객센터 운영 시간 등
판정: pass
로그: ['인사말 응답']

질문: 1,000 + 2,000 * 3
답변: 1,000 + 2,000 * 3 = 7000
판정: pass
로그: ['계산 완료: 1,000 + 2,000 * 3 = 7000']

질문: 오늘 날씨 어때요?
답변: 죄송합니다.
저는 사내 문서에 관한 질문에만 답변할 수 있습니다.
문서와 관련된 내용을 물어봐 주세요.
판정: pass
로그: ['문서 범위 밖 질문']

같은 calc_node에 기록된 질문 '10 + 20'을 넣어 확인한 반환:
answer='10 + 20 = 30' grade=pass log=['계산 완료: 10 + 20 = 30']
그래프 로그: 10 + 20 intent=calc grade=pass elapsed=0.0
그래프 로그: 안녕하세요 intent=greeting grade=pass elapsed=0.01
그래프 로그: 오늘 날씨 intent=scope grade=pass

코드 해설

intent가 greeting, calc, scope일 때 바로 답을 만드는 세 노드. 문서를 찾지 않습니다.

코드 앞에서 고정해 두는 말입니다.

  • 읽는 키: question (calc_node만 계산식에 사용)
  • 쓰는 키: answer, grade, reason, log
  • 그래프:
  • greeting_node --> 안내 문장, grade=pass
  • calc_node -----> 식 계산, 성공하면 grade=pass, 실패하면 grade=giveup
  • scope_node ----> 문서 밖 안내, grade=pass
  • 파이썬만 아는 분을 위한 말
  • State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.

함수와 클래스로 나누어 읽습니다.

  • greeting_node: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
  • calc_node: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
  • scope_node: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

← 이전 절29차시 · 3. 실습 ② — 분류 노드 (2차 LLM)다음 절 →29차시 · 5. 실습 ④ — 다중 분기 구현