콘텐츠로 이동

01. GreetingExecutor 제거

이 문서는 당시 변경 과정을 보존한 기록입니다. 이후 종료 policy와 fallback 구조가 다시 변경되었습니다. 현재 계약은 Graph BuilderSeniMate를 참고하세요.

배경

GreetingExecutor 는 entry 노드의 인사 턴을 전담하는 executor 였다. 인사말 생성 외에 greeting gate 라는 개념을 함께 들고 있었다. 사용자가 대화를 시작할 의사가 있는지 판정하고, 승낙이면 다음 노드로 보내고 거절이면 세션을 끝내는 장치다.

무엇이 문제였나

1. 다음 노드의 질문을 미리 한다

if gate_action == "proceed":
    return {
        "response": "시간 내주셔서 감사해요. 그럼 요즘 일상은 어떻게 보내고 계세요?",
        "next_node": default_next,
        ...
    }
if gate_action == "social_greeting":
    return {
        "response": "안녕하세요, 어르신. 반가워요. 그럼 요즘 일상은 어떻게 보내고 계세요?",
        "next_node": default_next,
        ...
    }

"요즘 일상은 어떻게 보내고 계세요?"는 daily_life_check 노드가 해야 할 질문이다. 인사 노드가 이걸 미리 하고 곧바로 daily_life_check 로 넘기므로, 도착하자마자 같은 질문을 다시 하게 된다.

2. 도메인 문장이 Python 에 있다

응답 4개, 종료 문구 1개가 모두 코드 리터럴이었다. 시나리오를 바꿔도 인사말이 바뀌지 않는다. 저장소의 레이어 원칙(docs/graph_builder/architecture.md)은 "scenario JSON: 도메인별 질문, 톤, 흐름"이라고 정하고 있는데 이를 위반한다.

3. 한국어 키워드 하드코딩

_GATE_REJECT_MESSAGES = {"아니", "아니요", "아뇨", "싫어", ...}
_GATE_SOCIAL_GREETINGS = {"안녕", "안녕하세요", "반가워", "하이", "hi", ...}
_GATE_RELUCTANT_ACCEPT_KEYWORDS = {"빨리", "잠깐", "짧게", ...}

_GATE_SOCIAL_GREETINGS 는 NLU 가 인사말을 제대로 분류하지 못하는 문제를 코드에서 덮는 장치였다. 근본 해결 지점은 NLU 프롬프트다.

4. gate 상태가 여러 모듈에 퍼져 있다

greeting_gate_complete / greeting_gate_outcome / greeting_reject_count 플래그를 네 곳이 함께 읽고 썼다.

builtin_executors.GreetingExecutor        플래그 생성
executors/resolver._is_greeting_context   플래그로 executor 선택
global_policies._is_entry_greeting_gate_active  플래그로 정책 우회
transition_engine._action_completes_greeting_gate  플래그로 라우팅 우회

제거한 것

위치 내용
dm/core/executors/builtin_executors.py GreetingExecutor 클래스, _classify_greeting_gate(), _GATE_* 상수 3개, _handle_turn_limit_simple(), _node_params(), _node_default_next()
dm/core/executors/factory.py "greeting" 레지스트리 항목
dm/core/executors/resolver.py _is_greeting_context(), is_entry_node 파라미터
dm/core/dialog/dst/dst_execution.py resolve_executor(..., is_entry_node=...) 인자
dm/core/dialog/policy/global_policies.py _is_entry_greeting_gate_active()
dm/core/dialog/transition_engine.py _action_completes_greeting_gate()
dm/core/constants.py DEFAULT_GREETING_MAX_TURNS
dm/core/executors/messages.py greeting_farewell

합계 약 187줄.

Executor 선택은 이렇게 단순해졌다

def resolve_executor(node_config, nlu_result, dialogue_state, executor_factory):
    if _has_slots(node_config):
        return executor_factory.get("task_oriented")
    return executor_factory.get("general_response")

인사는 "슬롯을 모으지 않는 노드"의 한 종류일 뿐이다. 노드 종류마다 executor 를 만들지 않는다.

gate 가 하던 일은 어디로 갔나

첫 턴에 노드 유지 → 그래프로 이동

GreetingExecutor 는 첫(빈 입력) 턴에 next_node: "STAY_CURRENT" 를 반환해 인사만 하고 사용자의 답을 같은 노드에서 받았다. 이걸 없애면 인사와 동시에 default_next 로 넘어가 버린다.

시나리오에서 선언적으로 표현한다.

{
  "next_nodes": [
    {
      "action": "stay",
      "condition": { "field": "system.node_turns", "operator": "equals", "value": 1 }
    }
  ],
  "default_next": "daily_life_check"
}

system.node_turnsdst_conditions.py 가 조건 평가 컨텍스트에 이미 노출하고 있던 값이다. 세션 시작 시 1 이고, 노드에 머물 때마다 apply_node_transition() 이 증가시킨다. 따라서 첫 턴에만 조건이 참이 된다.

무응답 / 이해 불가 → FallbackPolicy

02. FallbackPolicy 참고.

거절 처리는 왜 되살리지 않았나

GreetingExecutor 는 "싫어", "바빠" 같은 발화를 거절로 보고 한 번 더 권유한 뒤 두 번째에 세션을 종료했다.

되살리지 않은 이유:

  • 한 마디의 거절로 세션을 끊는 것은 지금까지 문제로 지적된 조급한 종료와 같은 성질이다.
  • 명시적인 종료 의도는 이미 두 경로가 처리한다. EndIntentPolicy(_END_CONVERSATION_KEYWORDS 에 "대화하기 싫", "말하기 싫" 등 포함)와 시나리오의 global_transitions(nlu.intent == user_wants_to_endconversation_closure).
  • 단순한 deny 는 LLM 이 자연스럽게 받아주는 편이 낫다. 부담스러워하는 사용자를 코드가 끊는 것보다 대화로 푸는 쪽이 제품 목표에 맞는다.

종료 조건을 다시 강하게 걸어야 한다면, executor 가 아니라 시나리오의 global_transitions 에 조건을 추가하는 방식으로 표현한다.

동반 변경: 주제 이탈 가드 범위 축소

gate 예외가 사라지면 인사 노드가 TopicGuardPolicytransition_engine 의 off-topic 강제 유지에 걸린다. 인사 노드는 수집할 슬롯이 없으므로, 붙잡히면 같은 인사말만 반복하게 된다.

두 곳에서 판정 기준을 collect_slots 유무로 바꿨다.

# transition_engine.py
if (
    self._node_collects_slots(node_info)
    and self._should_force_stay_for_off_topic(dialogue_state, nlu_result)
):
    return TransitionDecision(next_node=current_node, reason="off_topic_guard")
# global_policies.py TopicGuardPolicy
if not _node_collects_slots(node_info):
    return PolicyResult()

근거: 노드를 붙잡는 것은 모을 정보가 있을 때만 의미가 있다. 수집 대상이 없는 노드를 주제 이탈로 붙잡으면 재질문 외에 할 수 있는 일이 없다.

영향받은 테스트

테스트 처리
test_resolve_executor_uses_greeting_executor_for_entry_first_turn 삭제 → test_resolve_executor_uses_general_response_for_entry_greeting_node
test_resolve_executor_keeps_greeting_executor_until_gate_complete 삭제
test_resolve_executor_leaves_greeting_executor_after_gate_complete 삭제
test_greeting_executor_retries_on_first_reject 삭제
test_greeting_executor_ends_on_repeated_reject 삭제
test_greeting_executor_proceeds_on_affirm_response 삭제
test_global_policies_do_not_block_active_entry_greeting_gate 재작성 → test_global_policies_let_informative_greeting_reply_through
test_process_turn_advances_after_social_greeting_even_when_nlu_is_off_topic 재작성 → test_process_turn_advances_through_greeting_node_without_a_gate
신규 test_executor_factory_no_longer_registers_greeting
신규 test_global_policies_do_not_fallback_on_session_opening_turn

test_process_turn_advances_... 는 stub 이 "안녕"을 dialogue_act: "null" 로 반환하고 있었다. 실제 NLU 라면 inform 이므로 stub 을 고쳤다. 이 지점이 04. 프롬프트 변경null 판정 기준 명확화로 이어졌다.