Skip to content

FUTURE_CAR SPEC OC

yechankim91 edited this page Aug 23, 2026 · 2 revisions

[OC 구현 스펙] 미래차 연동 — 세션 모드 · passthrough

  • 작성: 미래차 팀, 2026-08-23 · 대상 코드: orchestration-server 64065c7
  • 미래차가 OC 에 요청하는 변경은 3 절(세션 모드) 하나다. 2 절은 OC 작업이 없고 "동의"만 필요하다. 1 절은 이미 동작 중인 것(참고).
  • 계약 정본: 위키 MQTT_SCHEMA-FUTURE_CAR §3-2-1

1. 지금 이미 되고 있는 것 (OC 변경 없음)

방향 토픽 OC 코드 비고
OC → 미래차 future-car/request type=plan (retained) mqttx/future_vehicle.go PublishFutureVehiclePlan stops[] = {id, kind, place, contexts}. 미래차는 stops 를 장소 카탈로그로만 쓴다
OC → 미래차 dashboard (retained) mqttx/location_tasks.go 차 장소의 tasks[] 를 여정 태스크로 쓴다. status 로 완료 여부 판단
OC → 미래차 me (retained) mqttx/create_persona.go PersonaDisplayPayload.ai_companion.prompt 스텝 생성 시스템 프롬프트 앞에 붙인다
OC → 미래차 ai/response mqttx/ai_chat.go transcript = 관람객 발화. 미래차가 구독해 스텝 생성 재료로 쓴다
미래차 → 태블릿 ai/response {ok, context_id, message} · ai/suggestions (OC 코드 없음) 미래차가 직접 발행한다(2장). OC 는 구독하지 않는 토픽이라 영향 없음
미래차 → OC ai/mode (retained) 신규 — 3장 {"mode":"car","ttl_sec":1800} / {"mode":"chat"} 를 이미 발행 중(OC 가 구독하기 전까지는 retained 로 남을 뿐)
미래차 → OC task/complete {task_id} mqttx/task_complete.go 태블릿과 동일 페이로드. OC 가 dashboard·point 재발행

2. A′ — 미래차 문장은 미래차가 ai/response 에 직접 쓴다 (OC 작업 없음, 동의만)

미래차가 태블릿 채팅에 띄우는 문장("출발 할까요?", 각 step 의 질문)은 LLM 답변이 아니라 정해진 문장이다. OC 를 거치면 LLM 이 문장을 바꾸고, 지시문이 대화 기록에 남고, 세션당 1건 락에 우리 요청이 조용히 드롭됐다. 그래서 미래차가 orchestration/{sid}/ai/response 에 직접 발행한다(브로커 ACL 상 future-car 계정은 orchestration/# 쓰기 가능).

미래차가 발행 페이로드 태블릿 동작
ai/response { "ok": true, "context_id": "<미래차 생성>", "message": "출발 할까요?" } chat-store.receiveAiResponse 가 모르는 context_id 의 message 를 AI 말풍선으로 추가 — 이미 그렇게 동작
ai/suggestions { "context_id": "<같은 값>", "suggestions": ["네, 출발해요!", "잠깐만요"] } 답 후보 칩 표시 — 이미 그렇게 동작. 탭하면 ai/request type=text 로 나가 transcript 가 된다

OC 가 할 일은 없다. 이 두 토픽은 OC 가 구독하지 않는다. 다만 계약상 OC 가 발행자인 토픽에 미래차가 같이 쓰는 것이므로 동의가 필요하다(AsyncAPI 문서·모니터링 관점). 거절하면 대안으로 ai/request 에 passthrough: true (LLM·히스토리·suggestions 생략하고 text 를 그대로 message 로)를 구현해 달라고 요청한다 — 미래차는 env 한 줄 (OC_CHAT_PUBLISH=request)로 그 경로로 돌아간다.

미래차가 직접 쓰는 메시지는 OC 의 ai_messages 히스토리에 남지 않는다. 여정 뒤 일반 채팅에서 "차가 한 말"을 맥락으로 쓰고 싶다면 OC 가 ai/response 를 구독해 assistant 턴으로 저장하는 방법이 있다 — 선택.


3. 변경 B — 세션 모드 ai/mode (여정 중 LLM 답변 생략)

3-1. 토픽

항목 값
토픽 orchestration/{sessionId}/ai/mode
발행 미래차 (enter 직후 / exit 직후)
retain / QoS true / 1
페이로드 {"mode":"car","ttl_sec":1800} 또는 {"mode":"chat"}

topics.go 에 추가:

// AiModeTopic — SUBSCRIBE orchestration/{sessionId}/ai/mode (retained, 미래차 발행)
const AiModeTopic = "orchestration/+/ai/mode"
// AiModeTopicFmt — PUBLISH(삭제용) orchestration/{sessionId}/ai/mode
const AiModeTopicFmt = "orchestration/%s/ai/mode"

3-2. 상태 보관 — 신규 internal/mqttx/ai_mode.go

세션별 (mode, expiresAt). 기존 SessionStore 에 필드를 얹어도 되지만, 미래차 여정은 세션보다 짧고 TTL 이 별개라 분리를 권한다(TimedLock 과 같은 결인 작은 맵).

type AiModeStore struct {
    mu      sync.RWMutex
    entries map[string]aiModeEntry   // sessionID → {mode, expiresAt}
}

func (s *AiModeStore) IsCar(sessionID string) bool   // mode=="car" && time.Now() < expiresAt
func (s *AiModeStore) Set(sessionID, mode string, ttl time.Duration)
func (s *AiModeStore) Clear(sessionID string)

func RegisterAiModeHandler(svc *Service, store *AiModeStore) error {
    return svc.Subscribe(AiModeTopic, 1, func(topic string, payload []byte) {
        // 빈 payload = retained 삭제 → Clear
        // {"mode":"car","ttl_sec":N}  → Set(sid, "car", N초; 0/누락이면 기본 30분, 상한 2시간)
        // {"mode":"chat"}             → Clear
        // 모르는 mode 는 무시 + 로그
    })
}

server.go 의 핸들러 등록 줄에 RegisterAiModeHandler(mqttService, aiModeStore) 추가. RegisterAiChatHandler 에 aiModeStore 인자를 넘긴다.

3-3. ai_chat.go 의 car 모드 분기 — 시스템 프롬프트 조립 전, LLM 호출 전

if aiModeStore.IsCar(sessionID) {
    // 관람객 발화: STT 만 하고 LLM 답변·suggestions 는 생략한다.
    var transcript string
    switch req.Type {
    case "voice":
        transcript = sttOnly(...)          // 기존 voice 경로에서 STT 까지만 (ChatWithAudio 의 전사 부분)
    case "text":
        transcript = req.Text
    }
    // user 턴은 저장한다(여정 뒤 일반 채팅·요약에서 맥락으로 쓰일 수 있음) — 6장 열린 질문
    _ = queries.InsertAiMessage(ctx, ...Role:"user", Transcript: transcript, Content: transcript)
    resp := AiChatResponse{OK: true, ContextID: req.ContextID, Transcript: transcript, Message: ""}
    publish(resp)
    return
}

⚠️ voice 경로 주의: 현재 STT 와 LLM 답변이 llmClient.ChatWithAudio 한 호출에 묶여 있다 (ai_chat.go voice case). car 모드에서는 전사만 필요하므로 전사만 하는 경로가 필요하다 — ChatWithAudio 에 "답변 생략" 옵션을 두거나, STT 전용 함수를 분리한다. 이게 이 변경에서 가장 큰 작업이다.

3-4. 모드 해제 — 세 겹

경로 어디서 무엇을
TTL AiModeStore.IsCar expiresAt 지나면 false (별도 정리 스레드 불필요)
exit 미래차가 {"mode":"chat"} 발행 핸들러가 Clear
signout end_session.go RegisterEndSessionHandler 의 store.DeleteByUUID 직후 aiModeStore.Clear(sid) + retained 삭제: svc.Publish(fmt.Sprintf(AiModeTopicFmt, sid), []byte{}, 1, true) (me 를 지우는 것과 같은 방식)

3-5. 동작 정의 (car 모드)

입력 ai/request 출력 ai/response LLM 히스토리 suggestions
태블릿 type=voice {ok, context_id, transcript} (message:"") ✗ user 턴 ✗
태블릿 type=text {ok, context_id, transcript:text} (message:"") ✗ user 턴 ✗
chat 모드(기본) 지금과 동일 ✓ user+assistant ✓

4. 검증 시나리오

  1. ai/mode 없이 태블릿 텍스트 → 지금과 동일(회귀 없음)
  2. {"mode":"car"} 발행 → 태블릿 텍스트 → message:"", ai/suggestions 미발행
  3. car 모드에서 미래차가 직접 쓴 ai/response 가 태블릿에 그대로 보임(OC 무관)
  4. {"mode":"chat"} → 2 의 요청이 다시 LLM 답변을 받음
  5. ttl_sec:5 로 car → 6초 뒤 태블릿 텍스트 → LLM 답변 받음(TTL)
  6. car 모드 중 signout → retained ai/mode 가 비어 있음(MQTTX 로 재구독해 확인)
  7. OC 재시작 → retained ai/mode 가 다시 들어와 모드 복원

5. 각 변경의 크기

변경 파일 크기
A′ 직접 발행 (OC 없음) 동의만. 대안 passthrough 면 ai_chat.go 분기 ~20줄
B 토픽·상태 topics.go, ai_mode.go(신규), server.go ~80줄
B car 분기 ai_chat.go ~30줄 + STT 전용 경로 분리(voice)
B 해제 end_session.go 2줄

6. 열린 질문 (미래차 팀 의견 병기)

  • car 모드에서 관람객 user 턴을 히스토리에 저장할지 → 저장 (리플렉션·요약 맥락)
  • 미래차가 직접 쓴 문장을 OC 가 구독해 assistant 턴으로 저장할지 → 선택(하면 여정 뒤 채팅이 "차가 한 말"을 앎)
  • ttl_sec 기본값/상한 → 30분 / 2시간
  • 여정 중 관람객의 일반 질문에 답이 없어도 되는가 → 연출 의도로 확인 필요 (이 스펙의 전제)

Clone this wiki locally