본문으로 건너뛰기Hwanseok Sim

작성일

Gem3Park 개발기 3 — guided 모드와 방문 목적 판정

모델이 재질문을 정하던 구조를 슬롯 기반 guided 상태 기계로 옮기고, 방문 목적 판정을 공통 resolver로 모은 3~5월의 작업. 정규식 오탐, 단일 사이트 회귀, 재질문 무한 루프 수정까지 다뤘다.

Gem3Park 개발기 시리즈 3/8편. 전체 목록은 gem3park 태그에서 볼 수 있다.

모델이 묻게 두면 생기는 일

2편까지의 대화는 모델이 질문하고 판단하는 방식이었다. 운영해 보니 같은 질문을 반복하거나, 이미 받은 정보를 다시 묻는 통화가 나왔다. 코드가 알고 싶은 것은 두 가지뿐이다. 동·호수와 방문 목적이 채워졌는가, 안 채워졌다면 무엇이 비었는가.

3월 10일에 GuidedOrchestrator를 새로 만들었다(533줄). 상태 기계, 플래너, 코드 게이트로 이뤄져 있다. 한 번에 갈아엎지 않고 Phase 0~3으로 나눠서 넣었다. 단계마다 넣은 것은 이렇다.

  • logTurn: 매 턴의 인식 결과와 질문을 기록한다.
  • shadow 플래너: 구조화 출력(JSON schema)으로 다음 계획을 만들되, 통화에는 반영하지 않고 비교만 한다.
  • nextQuestion: 비어 있는 슬롯을 기준으로 다음 질문을 오케스트레이터가 정한다.
  • 입차 게이트: 슬롯이 채워지지 않았으면 코드가 출입 명령을 막는다.

기존 함수 4개는 @deprecated로 표시만 하고 지우지 않았다. 환경 변수도 GUIDED_PLANNER_SHADOW, GUIDED_ORCH_ENTRY_GATE처럼 단계별로 켜고 끌 수 있게 했다. 먼저 관측만 하다가 나중에 개입시키는 이 순서는 이후 5·6·7편의 기능에도 그대로 썼다.

같은 날 마지막 커밋에서 재질문 한도를 넣었다. 재질문이 GUIDED_REQUESTION_LIMIT(2회)에 닿으면 apt_agent_redirect를 내보내고 흐름을 끝낸다. 2편의 3-strike를 모델 지시에서 상태 기계의 카운터로 옮긴 것이다.

정규식이 노인회관을 거절로 읽었다

같은 날 버그도 하나 고쳤다. 부정 응답을 잡는 정규식에 /노/가 있었다. "노인회관 가요"가 "노"로 오탐돼 거절로 처리됐다. 패턴을 /^노$/로 좁히고 공용시설 패턴을 추가했다.

src/asterisk/text-analysis.ts
const FACILITY_COMMUNITY_PATTERN =
  /노인회관|노인정|경로당|주민센터|커뮤니티센터|커뮤니티|헬스장|독서실/;

if (FACILITY_COMMUNITY_PATTERN.test(normalizedText)) {
  return "공용시설";
}

키워드 규칙은 이런 오탐과 빠짐을 계속 만들었다. 그래서 4월 10일에 방문 목적 판정 경로를 공통 resolver로 모았다. 그때까지는 CallFlowController, GeminiLiveBridge, GuidedOrchestrator가 같은 방문 목적을 각자 정규화해서, 어떤 흐름에서는 매칭되고 다른 흐름에서는 재질문이나 상담원 전환으로 빠졌다. 세부 사유를 뽑는 로직은 이미 있었다. 모자란 것은 키워드 사전에 없는 표현을 정해 둔 대분류 중 하나로라도 확정하는 힘이었다. 그래서 키워드 규칙이 놓친 발화만 모델이 의미로 한 번 더 판단하게 하고, 근거가 약하면 억지로 맞추지 않고 알 수 없음으로 두게 했다.

멀티사이트 분기를 만들었다가 접은 일

4월 9일에는 사이트별로 상담 로직을 나누는 구조를 넣었다. 전화가 걸려 오는 서비스 위치마다 프롬프트와 명령, 방문증 발급 여부를 다르게 하는 계획이었다. 설정 JSON과 프롬프트 파일만 추가하면 새 사이트가 늘도록 했다.

열흘 만인 4월 19일에 접었다. 운영 대상은 한 곳뿐인데 사이트별 분기를 먼저 만든 것이었다. 130줄짜리 sites.json을 지우고 44줄짜리 site.json으로 바꿨고, 같은 날 AI REST API와 SIP 레지스트리 서비스를 넣으면서 설정을 한 사이트 기준으로 정리했다.

현장 소음과 재질문 루프

5월 초에는 소음이 많은 현장에서 인식이 흔들렸다. 잡음성 STT 결과와 너무 짧은 발화를 거르고 후보 순위를 다시 매겼고, 상담원으로 넘어간 통화를 모아 놓친 목적 키워드를 보강했다(5월 1~3일). 그래도 못 잡은 통화를 위해 전환 직전에 방문 목적을 한 번 더 판정하는 단계를 넣었고(5월 4일), 이 단계는 4편에서 녹음 기반으로 커진다.

5월 9일에는 운영 중 발견한 재질문 루프를 잡았다. 첫 질문 한 번만 "모든 슬롯이 비어 있던 초기 질문"으로 세야 하는데, 반복되는 불명확·빈 발화도 초기 질문으로 취급돼서 한도에 닿지 않았다. 고친 코드는 한 조건이다.

src/asterisk/guided-orchestrator.ts
if (this.guidedState.turnCount !== 1) {
    return false;
}

같은 커밋에서 동·호수와 방문 목적이 다 모이면 LLM 응답을 기다리지 않고 바로 출입 명령을 만들게 했다. 반복된 슬롯 누락, 빈 전사, 즉시 명령 생성은 테스트로 묶어 두었다.

다음 글

Gem3Park 개발기 4 — SIP 등록 장애, watchdog, 그리고 최후검증

이전 글: Gem3Park 개발기 2 — 상담원 인계 안전장치와 1,400줄 클로저 해체