콘텐츠로 이동

14. 종료 권한을 좁히고, 부분 문자열 판정을 걷어냈다

이 문서는 종료 권한을 routing으로 옮긴 당시 기록입니다. 이후 terminal: true와 node-local closure 재개가 추가되고 default_next turn-limit 문제도 해결되었습니다. 현재 종료 구조는 17. 종료 노드 선언을 참고하세요.

"너무 쉽게 종료된다"에서 출발했는데, 파고들자 원인이 종료가 아니었다. 전역 정책의 판정 근거가 부분 문자열 매칭이라는 것이 공통 원인이었다.


A. 시나리오가 이미 옳은 길을 선언해 뒀다

senimate 에는 처음부터 이 전이가 있었다.

"global_transitions": [
  {"name": "user_wants_to_end", "target": "conversation_closure",
   "condition": {"field": "nlu.intent", "operator": "equals", "value": "user_wants_to_end"}}
]

한 번도 발동하지 않았다. EndIntentPolicy 가 executor 이전에 단축하기 때문이다. 정책이 물러나는 조건은 _node_has_end_routing() 인데, 이 함수가 노드의 next_nodes 보고 global_transitions 는 보지 않았다.

그래서 general_conversation(자기 next_nodes 에 종료 전이를 가진 노드)에서만 정책이 물러났고, 나머지 노드에서는 마무리 노드를 지나지 않고 세션이 끝났다.

고친 것

def _node_has_end_routing(self, node_info) -> bool:
    if _declares_end_routing(node_info.get("next_nodes")):
        return True
    runtime = getattr(self, "runtime", None)
    return _declares_end_routing(getattr(runtime, "global_transitions", None))

조건 판별부(_is_end_intent_condition)를 모듈 함수로 빼서 양쪽이 같은 기준을 쓰게 했다. not_equals 로 종료를 배제하는 조건({"action": "stay"} 의 가드)을 종료 라우팅으로 세면, 정책도 물러나고 그래프도 처리하지 않아 종료 신호가 통째로 무시된다.

이 함수는 지금 없다. 이 수정으로 senimate 에서 EndIntentPolicy 가 항상 물러나게 되자, 정책 자체가 필요한지 물을 수 있었다. G 절에서 정책과 함께 제거했다. 이 절은 어떻게 거기까지 갔는지의 기록이다.

실측 — 같은 입력, 규칙만 껐다 켬

이전   U: 그만 할래요
       B: 네, 알겠습니다. 언제든지 말씀 나누고 싶을 때 찾아와 주세요.
          ★ SESSION_COMPLETE | policy_route=GlobalPolicyPipeline > EndIntentPolicy

지금   U: 그만 할래요
       B: 알겠습니다. 오늘 이야기 나눠주셔서 감사해요. 언제든지 말씀 나누고
          싶으실 때 편하게 찾아와 주세요. 편안한 하루 보내세요.
          (session_complete=False)
   → conversation_closure 가 2턴 쓰고 end_reason=node_turn_limit 으로 종료

end_nodes 는 그래프 위상(출력 차수 0)에서 자동 도출된다. JSON 에 선언할 필요가 없다.


B. TopicGuardPolicy 의 종료 권한을 뺐다

# 이전
if off_topic_count >= OFF_TOPIC_MAX_RETRIES:
    return PolicyResult(decision=PolicyDecision.END_SESSION,
                        context_updates={"session_ended": True,
                                         "end_reason": "too_many_off_topic"})

주제 이탈은 종료 근거가 될 수 없다. 세 가지 이유다.

  1. 책임이 뒤바뀐다. off-topic 은 사용자의 비협조가 아니라 봇이 못 알아들은 것이다.
  2. 안부 대화에는 off-topic 이 없다. "날씨가 좋네" 도 일상의 요소다.
  3. 이미 진행 장치가 있다. max_turns 가 노드를 떠나게 한다.

FallbackPolicyfallback_exhausted 와 같은 형태로 바꿨다.

context 필드 타입 수명 의미
off_topic_exhausted bool topic guard 재시도 소진 후 진행까지 off-topic 강제 stay 해제
if off_topic_count >= OFF_TOPIC_MAX_RETRIES:
    return PolicyResult(context_updates={"off_topic_exhausted": True},
                        reason="off_topic_exhausted")

죽은 메시지 off_topic_restart 도 제거했다.

종료를 없애면서 갇히는 경로를 함께 막았다

TransitionEngine._should_force_stay_for_off_topic 은 off-topic 이면 노드를 붙잡고, end_reason == "node_turn_limit" 일 때만 놓아준다. 그런데 required 슬롯이 남으면 turn limit 의 end_reasonrequired_slot_not_collected 다. 종료를 없앤 상태에서는 노드를 떠날 방법이 사라진다.

if dialogue_state.context.get("off_topic_exhausted"):
    return False

senimate 은 슬롯이 전부 optional 이라 닿지 않지만 다른 시나리오에서는 걸린다.


C. 부분 문자열 판정이 진짜 원인이었다

종료 키워드 — 오탐 9/10

목록에 "그만", "다음에", "나중에", "안 할래", "말 안", "끝날" 같은 조각이 있었다. 한국어는 어절 경계가 없어 부분 문자열로 맞추는데, 이 조각들은 평범한 문장에 그냥 나타난다.

발화 이전 지금
"다음에 병원 가려고요" "다음에" → 종료 통과
"산책은 안 할래요" "안 할래" → 종료 통과
"그만 물어봐요" "그만" → 종료 통과
"이거 언제 끝날까요?" "끝날" → 종료 통과
"말이 잘 안 나와요" 통과 통과
"이제 그만 할래요" 종료 종료

"그만""그만할래", "그만 할래", "그만하자", "대화 그만" 처럼 대화를 가리키는 말과 붙은 형태로만 남겼다. "그만 물어봐요" 는 질문을 멈추라는 뜻이므로 일부러 제외했다.

놓침 0/10   오탐 0/10   (이전 오탐 9/10)

이게 단순한 부정확이 아닌 이유는 drop_contradicted_end_intent키워드가 있으면 종료 의도를 보존한다는 점이다. 오탐이 그 보정 장치를 그대로 무력화하고 있었다.

안전 키워드 — 오탐 9/9

같은 문제이고 결과가 더 심각하다. SafetyPolicy 는 파이프라인 1순위이고 단축한다.

"리모컨이 안 되네 좀 도와줘"  → "도와줘"   → "지금 바로 119에 연락하세요"
"빙판길이 위험해요"          → "위험해"   → 동일
"손자가 119 놀이를 해요"      → "119"     → 동일
"드라마에서 자살 사건이 나왔어"  → "자살"     → 자해 안내

"도와줘" 는 어르신 대화에서 가장 흔한 말 중 하나다.

목록을 줄이는 대신 단축 여부를 나눴다

안전은 비대칭이다. 놓치는 쪽이 훨씬 위험하므로 재현율을 낮출 수 없다.

단계 신호 처리
강한 신호 죽고 싶, 목숨 끊, 자살하고 싶, 살기 싫어, 살려줘, 119 불러, 구급차 불러, 숨이 안 쉬, 쓰러졌 단축 + 안전 안내
약한 신호 도와줘, 위험해, 응급실, 119, 구급차, 자살, 살려 단축 안 함. safety_watch 플래그만

강한 신호도 조각을 좁혔다.

  • 자살자살하고 싶 / 자살할 / 자살하려 / 자살 생각 (맨 자살 은 약한 신호)
  • 살기 싫살기 싫어 / 살기 싫다 / 살기가 싫 ("살기 싫을 만큼 더워" 가 걸리지 않게)
  • 119 / 응급실 / 도와줘 → 요청 형태만 (119 불러, 구급차 좀)

플래그만 남기면 아무 일도 일어나지 않는다

세 곳을 이었다.

context 필드 타입 수명 생산자 소비자
safety_watch bool 현재 턴 SafetyPolicy NLG domain rule
  1. domain_rules_text.SAFETY_WATCH — 새 규칙
  2. domain_rules.py다른 규칙보다 먼저 얹는다. 주제를 이어가라는 지시가 붙기 전에 와야 한다.
  3. dst_state.prepare_turn_context — 턴 단위 초기화. 남으면 매 턴 안전 확인을 한다.
[안전 확인] 사용자가 도움·위험·응급과 관련된 말을 했습니다. **이 말을 그냥 지나치지
마세요.** 다른 주제를 꺼내기 전에 무슨 일인지 한 번만 짧게 확인하세요.
지금 위급한 상황으로 보이면 119나 가까운 사람에게 바로 연락하도록 권하세요.
위급한 일이 아니라면(예: 물건이 안 된다, 길이 미끄럽다) 그 이야기에 자연스럽게 응하세요.

실측

분류 불일치 0건 (강한 7 / 약한 7 / 무관 3). 실제 대화:

U: 리모컨이 안 되네 좀 도와줘
   리모컨이 말을 안 들어서 불편하시겠어요. 혹시 배터리를 교체해 보셨나요? …
U: 손자가 119 놀이를 해요
   손자가 119 놀이를 한다니 재미있겠어요. …
U: 가슴이 답답하고 위험해                    ← 약한 신호지만 실제로 걱정스러운 경우
   가슴이 답답하다고 하셨군요. 혹시 지금 바로 병원에 가보시는 게 좋을 것 같아요. …
U: 죽고 싶어                               ← 강한 신호
   [★SafetyPolicy] 지금 정말 위험하고 힘든 마음이 드시는 것 같아요. …

세 번째가 이 설계의 핵심이다. 약한 신호로 통과했지만 LLM 이 규칙을 받아 병원 진료를 권했다. 재현율을 잃지 않았다.

남겨 둔 의도적 오탐

사라지고 싶 / 없어지고 싶강한 신호로 남겼다. "혼자 사라지고 싶을 때가 있지만 괜찮아요" 에도 응급 안내가 나간다. 어르신 대화에서 이 표현은 실제 경고 신호일 가능성이 높다고 판단했다. 안전 쪽으로 기운 선택이며, 내리려면 _WATCH 로 한 줄 옮기면 된다.


D. FallbackPolicy 가드 순서 버그

# 이전
if _is_session_opening_turn(...): return PolicyResult()
if nlu_result.entities: return PolicyResult()      # ← 여기
kind = self._classify(nlu_result, user_message)    # ← 빈 입력 판정이 뒤

빈 입력에 NLU 가 엔티티를 하나라도 붙이면 no_input 이 조용히 건너뛰어졌다. 빈 메시지가 엔티티를 낼 근거는 없다 — 직전 턴 값이 흘러온 것일 뿐이다.

kind = self._classify(nlu_result, user_message)
if kind is None:
    return PolicyResult()
# 엔티티 가드는 **사용자가 실제로 뭔가 말했을 때만** 의미가 있다.
if nlu_result.entities and str(user_message or "").strip():
    return PolicyResult()

실측: 수정 전에는 12턴 중 무응답 하나가 FallbackPolicy 를 타지 않았다. 수정 후 3/3 모두 발동.


E. ConfidenceGatingPolicy 를 제거했다

노드의 저신뢰 규칙과 판정 기준이 같고(둘 다 0.5) 동작이 나빴다.

ConfidenceGatingPolicy domain_rules.LOW_CONFIDENCE
기준 confidence < 0.5 confidence < 0.5
처리 단축 → 정형 문구로 되묻기 노드가 자기 역할대로 응답

정책은 노드를 건너뛰고 "죄송해요, 제가 정확히 이해하지 못했어요. 조금만 다시 말씀해 주시겠어요?" 를 내보냈다. 노드 안에서 처리하는 쪽이 항상 낫다.

정책이 유일하게 설정하던 low_confidence_clarification 플래그도 죽으므로, 두 규칙을 하나로 합쳤다. 더 잘 쓰인 쪽(clarification)의 문구를 살렸다.

LOW_CONFIDENCE = (
    "\\n사용자의 답변이 모호하거나 짧아서 정확히 이해하기 어렵습니다. "
    "사용자 말을 부정하거나 무시하지 말고, 부드럽게 확인하며 다시 여쭤보세요. "
    "예: '혹시 ~라는 말씀이실까요?' 공감을 먼저 한 뒤 같은 주제에 대해 좀 더 구체적으로 "
    "여쭤보세요. '다시 말씀해주세요'처럼 기계적 재요청은 피하세요."
)

F. 그 전에 측정한 것 — 5개 중 1개만 일하고 있었다

12턴을 섞어 돌려 정책별 호출/발동을 셌다.

정책 호출 발동 0인 이유
SafetyPolicy 12 0 위험 발화가 없었으니 정상
EndIntentPolicy 12 0 구조적으로 0 — A 이후 senimate 에서 항상 물러난다
FallbackPolicy 12 2 실질적 주력
ConfidenceGatingPolicy 10 0 FallbackPolicy 와 역할 중복 → 제거
TopicGuardPolicy 10 0 senimate 에 off_topic intent 가 아예 없다 (grep 0건)

이 표가 G 절로 이어졌다. EndIntentPolicy 의 0 은 "이번 대화에 해당 상황이 없었다"가 아니라 "이 시나리오에서는 절대 발동하지 않는다" 였다.


G. EndIntentPolicy 를 없앴다

A~F 를 거치고 나니 이 정책이 senimate 에서 구조적으로 발동하지 않았다(호출 12, 발동 0). 그래프가 이미 종료를 라우팅하기 때문이다. 그러면 정책 자체가 필요한지 물어야 한다.

라우팅에 이미 다 있었다

세 가지를 확인했다.

1. 조건으로 표현할 수 있다. 라우팅은 _decide_transition(..., nlu_result=turn_context.nlu) 로 NLU 결과를 받고, evaluate_conditionsnlu.* 로 노출한다. to_intent_data()dict(self.raw) 로 시작하므로 extra 필드도 그대로 흐른다.

2. 종료 지점이 이미 있다. 후속 없는 노드에서 라우팅이 세션을 닫는다.

# TransitionEngine._route_graph_successor
if not succ and next_node is None:
    dialogue_state.set_complete(True)

단위 테스트로 확인했다 — 정책도 max_turns 도 필요하지 않다.

후속 없는 노드에서 라우팅: None | complete_no_next_node | is_complete = True

3. 작별 인사는 노드가 만든다. 전환 턴은 현재 노드가 생성하고, 12단계의 handoff 규칙이 마무리 노드를 향한 문장을 만든다. 이미 실측으로 좋은 결과를 확인했다. _generate_goodbye_response 가 따로 필요하지 않다.

정책을 라우팅으로 옮겼다

시나리오가 종료 전이를 선언하지 않았으면 런타임이 보충한다.

# graph_info._with_default_end_transition
{
    "name": "user_wants_to_end",
    "target": end_nodes[0],
    "condition": {"or": [
        {"field": "nlu.intent",        "operator": "equals", "value": "user_wants_to_end"},
        {"field": "nlu.dialogue_act",  "operator": "equals", "value": "goodbye"},
        {"field": "nlu.end_signal",    "operator": "equals", "value": True},
    ]},
}

정책이 보던 세 신호를 그대로 옮겼다. 세 번째가 종료 키워드다.

키워드 안전망은 버리지 않았다

정책은 _has_end_conversation_keywords 로도 종료를 잡았다. "대화하기 싫어" 를 NLU 가 deny 로만 읽는 경우의 안전망이다. 조건에는 그런 필드가 없으므로 NLU 단계에서 만들어 준다.

NLU routing field 타입 수명 생산자 소비자
nlu.end_signal bool 현재 턴 _mark_end_keyword_signal() 기본 종료 transition condition
def _mark_end_keyword_signal(manager, nlu_result, user_message) -> NLUResult:
    raw = dict(nlu_result.raw)
    raw["end_signal"] = bool(manager._has_end_conversation_keywords(user_message))
    return NLUResult.from_dict(raw)

dialogue_state.context 가 아니라 NLU 결과에 싣는다. 컨텍스트는 턴을 넘어 남으므로 이미 지나간 발화로 종료될 수 있다. 여기 두면 턴 범위가 구조적으로 보장된다 — safety_watch 를 리셋 목록에 넣어야 했던 것과 대비된다.

함께 제거한 죽은 코드

대상 이유
EndIntentPolicy 라우팅이 대체
DSTManager._node_has_end_routing 정책이 물러날지 판단하던 것. 이제 판단할 정책이 없다
_is_end_intent_condition / _declares_end_routing 위 함수의 헬퍼
DSTManager._generate_goodbye_response 전환 문장을 노드가 만든다

_has_end_conversation_keywords 는 남는다. 이제 두 곳에서 쓴다 — drop_contradicted_end_intent 의 보정 근거, 그리고 nlu.end_signal.

실측 — senimate 은 무변화

senimate 은 전이를 직접 선언하므로 합성이 일어나지 않는다. 즉 동작이 바뀌지 않아야 한다.

U: 그만 할래요      → conversation_closure   complete=False
U: 네 고마워요      → conversation_closure   complete=False
U: 잘 있어요        → conversation_closure   complete=True  end_reason=node_turn_limit

U: 다음에 병원 가려고요  → daily_life_check    (종료 아님, 대화 계속)

정책 발동 없음. 이중 질문 지표(2+ 턴: 0)와 시나리오 검증 4/4 통과.

남은 정책은 셋이다

GlobalPolicyPipeline()._policies
# ['SafetyPolicy', 'FallbackPolicy', 'TopicGuardPolicy']

확인이 필요한 것 — 마무리 노드가 여러 개인 시나리오

합성 대상은 end_nodes[0] 이다. EndIntentPolicy 도 같은 선택을 했으므로 회귀는 아니지만, 추측이라는 점이 이제 드러난다.

시나리오 합성 대상 적절한가
senimate conversation_closure (선언) 맞다
dental_caries_case1 ensuring_closure 맞다
card_issuance_chatbot completion_approved 아니다. 중도 포기는 승인이 아니다
call_for_fire call_for_fire_completion 아니다. call_for_fire_cancelled 가 맞다

뒤 두 개는 global_transitions 에 직접 선언해야 한다. 시나리오 설계 판단이라 손대지 않았다. test_every_scenario_resolves_an_end_intent_transition 이 종료 경로 자체는 있는지 확인한다.

원칙

정책이 하던 일을 조건으로 표현할 수 있으면 정책이 아니라 라우팅에 두는 것이 낫다. 정책은 executor 를 건너뛰지만 라우팅은 노드가 말한 뒤에 동작한다.


최종 그림 — 종료 권한

SafetyPolicy        종료 안 함 (노드 유지)
FallbackPolicy      종료 안 함 (소진 시 통과)
TopicGuardPolicy    종료 안 함 (소진 시 통과)
                    → **세션을 끝낼 수 있는 정책이 하나도 없다**

종료는 라우팅만 한다
  global_transitions → 마무리 노드로 이동
  후속 없는 노드      → set_complete(True)
  max_turns + 후속 없음 → session_ended

max_turns 로 끝나는 조건도 정리하면 이렇다.

executor 후속 노드 max_turns 도달 시
TaskOrientedExecutor 종료 안 함 (turn_limit_blocking / slots_complete)
GeneralResponseExecutor 있음 전진
GeneralResponseExecutor 없음 세션 종료

max_turns"이 노드를 떠날 때" 를 정하는 값이고, "세션을 끝낼 때" 는 갈 곳이 없을 때만 부수적으로 따라온다.

원칙

프롬프트 규칙이 아니라 판정 근거를 먼저 볼 것. 두 정책의 오탐이 각각 9/10, 9/9 였고 원인은 같았다 — 한국어를 부분 문자열로 맞추면서 짧은 조각을 목록에 넣은 것.

정책이 단축하면 executor 가 통째로 빠진다. 정책을 추가할 때마다 "이 노드가 할 말이 정말 없는 상황인가" 를 확인할 것.

플래그만 남기는 것은 절반이다. 규칙까지 이어야 아무 일이라도 일어난다.

테스트

# dm/tests/test_session_end_authority.py (신규)
test_topic_guard_never_ends_the_session_when_exhausted
test_off_topic_releases_the_node_once_guiding_is_exhausted
test_explicit_end_signals_are_detected / test_ordinary_sentences_are_not_read_as_end_signals
test_strong_signals_short_circuit_with_a_safety_response
test_weak_signals_only_flag_and_let_the_node_respond
test_safety_watch_flag_reaches_the_prompt      # 플래그가 규칙까지 이어지는지
test_safety_watch_is_cleared_each_turn

# dm/tests/test_dst_manager.py
test_node_has_end_routing_sees_graph_level_global_transitions

# dm/tests/test_fallback_policy.py
test_empty_input_still_falls_back_when_nlu_reports_entities
test_entities_still_skip_fallback_when_the_user_actually_spoke
python -m pytest    # 197 passed

이중 질문 지표와 정상 대화 흐름은 회귀 없다.

남은 것

  • "그만 물어봐요" — NLU 가 여전히 user_wants_to_end 로 분류해 마무리 노드로 간다. 실제 뜻은 "질문을 멈춰라"다. 키워드는 고쳤지만 NLU 판정은 그대로다.
  • 마무리가 3턴 — 전환 턴 + 마무리 노드 2턴. conversation_closuremax_turns 를 1로 줄이면 2턴이 된다.
  • 마무리 노드에서 돌아올 길이 없다 — 오분류로 들어가면 사용자가 계속 말해도 끝난다. 되돌아가는 전이를 넣으면 오분류 비용이 1턴으로 줄지만, conversation_closure 가 출력 차수 0을 잃어 end_nodes 에서 빠지고 세션이 안 끝난다. 손대려면 end_nodes 도출 방식부터 봐야 한다.
  • card_issuance / call_for_fire 의 종료 대상 — 합성이 end_nodes[0] 을 고르는데 중도 포기에는 맞지 않는다. global_transitions 에 직접 선언할 것 (G 절)
  • _node_has_successor_configdefault_next 를 안 본다collect_slots 없이 default_next 만 있는 노드는 갈 곳이 있는데도 max_turns 에서 세션이 끝난다. senimate 은 해당 노드가 TaskOriented 라 우연히 안 터진다.