콘텐츠로 이동

17. 종료를 위상에서 추론하지 않고 노드가 선언한다

15단계에서 마무리 노드에 되돌아가는 길을 달려다가 막혔다. 엣지를 하나라도 달면 종료가 깨졌기 때문global_transitions 로 우회했다. 그 제약의 뿌리를 걷어냈다.


제약이 어디서 나왔나

end_nodes 는 그래프 위상에서 도출됐다.

# dm/graph/toposort.py
ends = [n for n, d in out_deg.items() if d == 0 and g.in_degree(n) > 0]

그래서 마무리 노드에 next_nodes 를 하나 달면:

closure 후속: ['general_conversation']
end_nodes: []                          ← 마무리 노드가 사라진다

end_nodes 가 비면 기본 종료 전이의 대상도, 세션을 닫는 장치도 함께 사라진다. 15단계는 이 제약을 우회했다 — global_transitions 는 엣지를 만들지 않으니까. 그 대가가 두 가지였다.

  1. 전역 전이는 모든 노드에서 평가되므로, 마무리 노드에서만 걸리도록 system.current_node 가드를 손으로 달아야 했다.
  2. 노드에 속한 규칙이 노드 밖에 있었다.

추론에는 함정이 하나 더 있었다. 결과가 노드 선언 순서를 따르므로, JSON 에서 노드를 옮기면 end_nodes[0] 을 쓰는 곳이 조용히 바뀐다.


노드가 선언한다

노드 필드 타입 기본값 의미
id string 필수 노드 고유 ID
terminal boolean false 조건부 outgoing edge가 있어도 종료 노드로 선언
next_nodes array[object] [] terminal 판정보다 먼저 평가할 조건부 복귀 경로
{"id": "conversation_closure", "terminal": true, "next_nodes": [...]}
def _resolve_end_nodes(nodes_info, derived) -> List[str]:
    """세션을 닫는 노드. **선언한 것과 추론한 것을 모두 모은다.**"""
    declared = [
        node_id for node_id, node in (nodes_info or {}).items()
        if isinstance(node, dict) and node.get("terminal") is True
    ]
    return declared + [node_id for node_id in derived if node_id not in declared]

둘을 합친다. 처음에는 "선언이 있으면 선언이 이긴다"로 만들었는데, 그러면 일부만 선언한 시나리오에서 나머지 노드가 갈 곳도 마무리 자격도 없어진다. 반대로 추론만 보면 복귀 엣지 하나에 마무리 노드가 사라진다. 어느 쪽이든 구멍이 나므로 합집합이 맞다.

선언이 앞에 온다 — end_nodes[0] 을 쓰는 곳(기본 종료 전이의 대상)이 추론 순서에 흔들리면 안 되기 때문이다. 나머지 세 시나리오는 손대지 않아도 동작이 같다.

senimate                 end_nodes=['conversation_closure']              (선언)
card_issuance_chatbot    end_nodes=['completion_approved', 'completion_rejected']  (추론)
dental_caries_case1      end_nodes=['ensuring_closure']                  (추론)
call_for_fire            end_nodes=['call_for_fire_completion', ...]     (추론)

종료 판정을 옮겼다

핵심은 여기다. 종료는 "후속이 없다"가 아니라 "마무리 노드다" 로 판정한다.

# 명시적 전이가 모두 빗나간 뒤, 그래프 후속을 보기 전에
if self._is_terminal(manager, current_node):
    dialogue_state.set_complete(True)
    return TransitionDecision(next_node=None, reason="terminal_node")

순서가 중요하다. 조건부 next_nodesdefault_next먼저 보므로, 복귀 조건이 맞으면 그리로 가고 아니면 닫힌다. 마무리 노드가 엣지를 가져도 매 턴 그 엣지로 새어 나가지 않는다.

그리고 종료를 판정하는 곳은 여기 하나뿐이다. 예전에는 _route_graph_successor 안에도 set_complete(True) 가 있었다. end_nodes 가 합집합이 되면서 후속 없는 노드도 이 검사에 걸리므로, 그 코드는 도달할 수 없게 되어 제거했다.

test_termination_is_decided_in_one_place
    # _route_graph_successor 소스에 set_complete 가 없는지 — 갈라지면 실패한다

결과 — 우회가 사라졌다

closure 후속: ['daily_life_check', 'meal_check', 'health_check', 'general_conversation']
end_nodes: ['conversation_closure']      ← 엣지 4개가 있는데도 유지

복귀 전이가 전역에서 노드로 돌아왔다.

15·16단계 지금
복귀 전이 위치 global_transitions × 4 conversation_closure.next_nodes × 4
노드 범위 가드 system.current_node 를 손으로 필요 없음 (노드에 달렸다)
전역 전이 개수 6 2 (user_wants_to_end, silent_close)

전역 전이가 매 턴 6번 평가되던 것도 2번으로 줄었다.

max_turns 경로도 terminal 을 본다

def _node_has_successor_config(node_config) -> bool:
    if _node_field(node_config, "terminal") is True:
        return False
    return bool(
        _node_field(node_config, "next_nodes", []) or _node_field(node_config, "default_next")
    )

두 가지를 함께 고쳤다.

  • terminal 이 먼저다. 복귀 엣지를 "갈 곳이 있다"로 읽으면 턴 상한에서 세션이 닫히지 않고 작별 대신 전환 문장이 나온다.
  • default_next 도 본다. 예전에는 next_nodes 만 봐서, 선형 경로만 선언한 노드가 갈 곳이 있는데도 턴 상한에서 세션을 끝냈다. senimate 에서는 해당 노드들이 TaskOriented 라 우연히 안 터졌을 뿐이다.
                       terminal  next  default_next            갈 곳 있음
greeting_introduction     -        1   daily_life_check           True
daily_life_check          -        0   meal_check                 True   ← 이전 False (버그)
meal_check                -        0   health_check               True   ← 이전 False (버그)
health_check              -        1   general_conversation       True
general_conversation      -        3   -                          True
conversation_closure     True      4   -                          False  ← 엣지가 있어도 마무리

실측 — 동작 동일

미안 일이 있어서 다시 이야기 해  → daily_life_check   (복귀, 작별 표현 없음)
그런데 오늘 날씨 어때?         → daily_life_check
아 잠깐만요                  → daily_life_check
응                         → 종료
(무응답)                    → 종료

정상 완주 9턴              → conversation_closure 에서 종료

validator 도 같은 기준을 본다

기준이 갈리면 복귀 엣지를 단 마무리 노드가 있는 그래프에 헛경고가 나간다.

declared_ends = _find_declared_terminals(g)
end_nodes = declared_ends + [n for n in topos.end_nodes if n not in declared_ends]

고립 판정에서도 선언된 마무리 노드를 빼야 한다 — 나가는 길이 없는 것이 정상이다. 그리고 종료 노드가 없을 때의 경고가 이제 선언 방법을 알려준다.

이전: 종료 노드(out_degree=0)가 없습니다.
지금: 종료 노드가 없습니다. 노드에 "terminal": true 를 선언하거나 출력 엣지가 없는 노드를 두세요.

테스트

test_closing_node_declares_terminal_instead_of_relying_on_topology
    # 복귀 엣지가 있는데도 end_nodes 에 남는지 — 이 구조의 존재 이유
test_end_nodes_combine_declaration_and_topology
    # 합집합인지, 선언이 앞에 오는지, 중복이 없는지
test_termination_is_decided_in_one_place
test_resume_transitions_use_the_runtime_predicate
    # 노드 next_nodes 에서 읽도록 갱신
test_routing_ends_the_session_at_a_successorless_node
# dm/tests/graph/test_validator_terminal.py (신규)
test_declared_terminal_counts_even_with_outgoing_edges
test_topology_still_used_without_declaration
test_declaration_comes_first_in_the_list
test_missing_end_node_warning_mentions_the_declaration
test_declared_terminal_without_edges_is_not_isolated
python -m pytest    # 228 passed

원칙

성질은 선언하게 하고, 추론은 폴백으로만 둔다. 위상에서 우연히 나오는 값에 동작을 걸면, 그 위상을 바꿔야 하는 순간 우회로가 생긴다. 15단계의 global_transitions 우회와 system.current_node 가드가 그 우회로였다.

남은 것

  • 다른 시나리오는 여전히 추론에 의존한다 — 마무리 노드가 무언가를 해야 할 때 terminal 을 선언하면 된다. 지금은 선언하지 않아도 동작이 같다
  • 다른 시나리오의 기본 종료 전이 대상 — 선언이 없으면 end_nodes[0] 을 고르는데, card_issuancecompletion_approved, call_for_firecall_for_fire_completion 이다. 중도 포기에는 맞지 않는다(call_for_fire_cancelled 가 맞다). terminal 선언 순서를 바꾸거나 global_transitions 를 직접 선언하면 된다 — 시나리오 설계 판단이라 손대지 않았다