01. GreetingExecutor 제거¶
이 문서는 당시 변경 과정을 보존한 기록입니다. 이후 종료 policy와 fallback 구조가 다시 변경되었습니다. 현재 계약은 Graph Builder와 SeniMate를 참고하세요.
배경¶
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_turns 는 dst_conditions.py 가 조건 평가 컨텍스트에 이미 노출하고 있던 값이다. 세션 시작 시 1 이고, 노드에 머물 때마다 apply_node_transition() 이 증가시킨다. 따라서 첫 턴에만 조건이 참이 된다.
무응답 / 이해 불가 → FallbackPolicy¶
거절 처리는 왜 되살리지 않았나¶
GreetingExecutor 는 "싫어", "바빠" 같은 발화를 거절로 보고 한 번 더 권유한 뒤 두 번째에 세션을 종료했다.
되살리지 않은 이유:
- 한 마디의 거절로 세션을 끊는 것은 지금까지 문제로 지적된 조급한 종료와 같은 성질이다.
- 명시적인 종료 의도는 이미 두 경로가 처리한다.
EndIntentPolicy(_END_CONVERSATION_KEYWORDS에 "대화하기 싫", "말하기 싫" 등 포함)와 시나리오의global_transitions(nlu.intent == user_wants_to_end→conversation_closure). - 단순한
deny는 LLM 이 자연스럽게 받아주는 편이 낫다. 부담스러워하는 사용자를 코드가 끊는 것보다 대화로 푸는 쪽이 제품 목표에 맞는다.
종료 조건을 다시 강하게 걸어야 한다면, executor 가 아니라 시나리오의 global_transitions 에 조건을 추가하는 방식으로 표현한다.
동반 변경: 주제 이탈 가드 범위 축소¶
gate 예외가 사라지면 인사 노드가 TopicGuardPolicy 와 transition_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")
근거: 노드를 붙잡는 것은 모을 정보가 있을 때만 의미가 있다. 수집 대상이 없는 노드를 주제 이탈로 붙잡으면 재질문 외에 할 수 있는 일이 없다.
영향받은 테스트¶
| 테스트 | 처리 |
|---|---|
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 판정 기준 명확화로 이어졌다.