Skip to content

MQTT_SCHEMA FUTURE_CAR

ansunny1170 edited this page Aug 28, 2026 · 22 revisions

Future Car MQTT 스키마 (main-2026)

이 문서는 Future Car 팀이 작성합니다. 위키 MQTT_SCHEMA-FUTURE_CAR(2026-07-19판)를 main-2026 실제 구현 기준으로 갱신한 것입니다. 초안이 아니라 현재 배포되어 도는 코드를 기술합니다 — 스키마와 코드가 어긋나면 코드가 정본입니다.

  • 대상 구현: zer01ne-future-car-server 브랜치 main-2026
  • 배포: Mac Studio (Tailscale) · FastAPI + paho-mqtt
  • 최종 수정일: 2026-08-28 (차량 마이크 STT 트리거 · task/start · basic 만 수행 · 8장 전체 흐름 서술)

한눈에 — 미래차는 누구와 무엇을 주고받나

            plan · dashboard · me                          enter · exit
   OC  ───────────────────────────────────────▶  미래차  ◀──────────────  태블릿
   OC  ◀──────────────────────────────────────  미래차  ──────────────▶  태블릿
            task/start · task/complete · ai/mode        state · route · step · error · ai/response(질문) · ai/suggestions
                                                   ▲
                                      차량 마이크(STT) — 관람객 발화가 스텝을 이끈다
상대 미래차가 받는 것 미래차가 보내는 것
OC plan(페르소나·장소 카탈로그) · dashboard(차 앞뒤 장소·태스크) · me(동행자 프롬프트) task/start · task/complete · ai/mode
태블릿 enter · exit state · route · step · error · ai/response·ai/suggestions(채팅 질문과 답 후보 — 직접 발행)
관람객(차량 마이크) 발화(STT → POST /ambient/utterance) — 스텝 진행 트리거 —

2026-08-28 정책: 스텝 진행은 차량 화면에 연결한 마이크로만 한다. 미래차 모드(enter~exit) 동안 태블릿은 마이크·타이핑·제안 칩을 잠근다(tablet-fe 작업). ai/response.transcript 구독은 남아 있지만 정식 경로가 아니다.

즉 데이터는 OC 에서 받고, 조작은 태블릿에서 받고, 결과는 양쪽에 보낸다. OC 와의 상호작용이 적은 게 아니라 "받는 것 4, 보내는 것 2~3" 으로 오히려 OC 쪽이 더 많다. 태블릿과는 요청 2종·응답 4종이 전부다.

구현 스펙(담당자 전달용): OC · tablet-fe

목차


0. 2026-07-19판 대비 변경점

먼저 읽어야 하는 부분입니다. 기존 문서를 알고 계신 분은 여기만 봐도 차이를 알 수 있습니다.

# 항목 2026-07-19판 main-2026 (현재)
1 RESPONSE 타입 state · reaction 2종 state · step · error 3종. reaction 은 계약에 남아있으나 발행하지 않음
2 스텝 콘텐츠 전달 명시 없음 step 메시지가 주력. 미래차 화면이 재생할 연출 데이터(data)를 통째로 실어 보냄
3 plan.stops 스텝 수·장소를 결정 생성 입력으로 쓰지 않음. plan.persona 만 사용, 스텝 수는 4개 고정
4 여정 소재 plan.stops[].place orchestration/{sid}/dashboard 토픽에서 차 위치의 이전/다음 장소·태스크를 추출
5 start 독립 트리거 (step1 진입) 폐기. 진행은 advance 로 통일. 아웃바운드(NextAction)에서는 제거했고, 인바운드만 별칭으로 받아준다
6 context_action 미래차가 reaction 생성 no-op. 수신해도 아무 동작 없음(디스패치 안정성 위해 핸들러만 유지)
7 phase=arrived 진입 미정 미래차 화면의 렌더 완료 보고로 진입 (아래 6장)
8 retained request 언급 없음 retain 재전송분은 무시. 서버 재시작 시 과거 세션 대량 재생성을 막기 위함 (아래 3장 ⚠️)
9 state 필드 — prev_place · next_place 추가 (대기 화면이 "어디서 왔는지"를 표시)
10 에러 통지 없음 error 타입 + 코드 4종
11 진행 트리거 태블릿 advance 버튼 관람객 발화 — 차량 마이크 STT → POST /ambient/utterance. advance 폐기 — 태블릿은 enter·exit 만 보낸다
12 스텝 생성 시점 plan 수신 직후 4개 일괄 발화가 도착할 때 하나씩. 미리 만들지 않는다
13 태블릿 채팅 무관 미래차가 ai/response·ai/suggestions 에 질문과 답 후보를 직접 발행한다(3-2-1). 관람객의 답은 차량 마이크로 받는다
14 RESPONSE 타입 3종 + route (이번 구간의 출발/도착 stop_id)
15 state.next advance | exit exit 하나. 마지막 스텝 도착 때만 실린다
16 구독 토픽 request · dashboard + ai/response · me
17 차량 태스크 제목만 소재로 사용 type=basic 만 step 당 1개씩 AI 가 골라 수행. 스텝 발행 시 task/start, 렌더 완료 시 task/complete(서버는 완료분을 빼서 중복 방지). 그 외 타입(돋보기 모달형)은 태블릿에서 관람객이 완료 — 미래차는 손대지 않는다
18 step4 질문 있음 없음. 마지막 step 뒤에는 질문을 띄우지 않는다
19 ai/request 지시문 동봉 쓰지 않는다. 질문은 ai/response 직접 발행(A′). passthrough 는 OC 가 직접 발행을 거절했을 때의 대안으로만 남김(env OC_CHAT_PUBLISH=request)

1. 개요

항목 내용
브로커 MQTT_BROKER_URL (기본 tcp://127.0.0.1:1883)
계정 user future-car / MQTT_USERNAME·MQTT_PASSWORD 로 오버라이드
client_id future-car (MQTT_CLIENT_ID) — ⚠️ 아래 주의 참고
페이로드 평문 JSON · snake_case · session_id 는 토픽 경로에만 (페이로드에 없음)
분기 방식 request/response 2토픽 + type 필드
MQTT 접속 주체 미래차 FastAPI 서버 1개. 브라우저(전시 화면 /ambient)는 MQTT 미접속 → 서버가 WebSocket 으로 중계
스텝 수 4 고정 (STEP_COUNT = 4). plan.stops 개수와 무관

⚠️ client_id 충돌 주의. 같은 client_id 로 두 인스턴스가 붙으면 브로커가 먼저 붙은 쪽을 끊습니다(session taken over). 로컬 개발 서버를 띄울 땐 반드시 MQTT_CLIENT_ID 를 다르게 주세요. 증상은 "메시지가 조용히 사라짐"이라 원인을 찾기 어렵습니다.


2. 토픽 목록

구분 Topic 방향 QoS Retain 설명
REQUEST orchestration/{sessionId}/future-car/request OC·태블릿 → 미래차 1 OC 가 plan 을 retain 발행 중 type: plan · enter · exit (start·advance 폐기·별칭 · context_action no-op)
RESPONSE orchestration/{sessionId}/future-car/response 미래차 → 태블릿·OC 1 state 만 retain=true type: state · route · step · error
DASHBOARD orchestration/{sessionId}/dashboard OC → 전체 1 retain=true 방문자의 모든 장소·태스크. 미래차는 구독만
ME orchestration/{sessionId}/me OC → 전체 1 retain=true ai_companion.prompt — 스텝 생성 프롬프트 앞에 붙일 인격. 구독만
AI RESPONSE orchestration/{sessionId}/ai/response OC → 전체 + 미래차 → 태블릿 1 false OC 발행분: transcript = 관람객 발화(STT) → 미래차가 스텝 재료로. 미래차 발행분: message = 채팅에 띄울 질문(A′)
AI SUGGESTIONS orchestration/{sessionId}/ai/suggestions 미래차 → 태블릿 1 false 미래차 질문에 대한 답 후보 칩 2개
AI MODE orchestration/{sessionId}/ai/mode 미래차 → OC 1 true enter 직후 {"mode":"car","ttl_sec":1800}, exit 직후 {"mode":"chat"}. OC 구현 완료(2026-08-24) — car 동안 type=voice 는 STT 만, signout 시 retained 삭제
TASK START orchestration/{sessionId}/task/start 미래차 → OC 1 false {"task_id"} — 스텝 발행(주행 시작) 시 그 스텝의 basic 태스크를 진행 중으로. OC 가 dashboard 를 in_progress 로 재발행 → 태블릿 카드 "진행 중". TASK_START_ENABLED=false 로 끌 수 있다
TASK COMPLETE orchestration/{sessionId}/task/complete 미래차 → OC 1 false {"task_id"} — 렌더 완료 시 같은 태스크를 완료로. 태블릿이 보내는 것과 동일 페이로드. OC 가 dashboard·point 재발행(응답 토픽 없음)

구독은 전부 와일드카드다 — session_id 는 토픽에서 추출한다.

orchestration/+/future-car/request
orchestration/+/dashboard
orchestration/+/me
orchestration/+/ai/response

3. REQUEST (future-car/request)

⚠️ retained 재전송분은 무시합니다

OC 가 plan 을 retain=true 로 발행합니다. 그러면 미래차 서버가 재시작해 재구독하는 순간 브로커가 과거 방문자들의 plan 을 전부 다시 보냅니다. 그대로 처리하면 세션마다 4스텝씩 LLM 재생성이 돌아, 재배포 1회에 36세션 × 4 ≈ 144회 호출이 발생합니다(실측).

그래서 retain 플래그가 선 REQUEST 메시지는 전부 버립니다.

이게 라이브 발행을 막지 않는 이유: MQTT 명세상 retain 플래그는 새 구독의 결과로 재전송될 때만 세워집니다. 이미 구독 중인 클라이언트에게 실시간으로 배달되는 메시지에는 서지 않습니다. 즉 OC 는 지금처럼 retain=true 로 계속 발행하면 되고, 정상 여정은 그대로 처리됩니다.

3-1. plan (OC → 미래차)

페르소나 생성 완료 시 1회 발행. 미래차는 이걸 받는 즉시 백그라운드로 4스텝을 순차 생성합니다.

{
  "type": "plan",
  "plan": {
    "persona": { "id": 12, "name": "지호", "summary": "야근 잦은 개발자" },
    "stops": []
  }
}
필드 타입 필수 미래차가 쓰는가
type string Y —
plan.persona object Y ✅ 생성 입력. 구조는 자유(dict 통째로 LLM 에 전달)
plan.stops[] array Y (빈 배열 허용) ❌ 쓰지 않음. 계약 호환을 위해 필드는 유지
  • 스텝 수는 stops 와 무관하게 항상 4개입니다. 전시 정책상 고정입니다.
  • 여정 소재(장소·태스크)는 stops 가 아니라 dashboard 토픽에서 옵니다(4장).
  • 동일 plan 재수신은 무시합니다(값 비교). 진행 중인 여정이 리셋되지 않습니다. 단 이미 done 인 세션은 동일 plan 이어도 새 여정으로 다시 시작합니다 — 그러지 않으면 그 session_id 가 영구히 잠깁니다.
  • 다른 plan 이 오면 전체 리셋 후 처음부터 재생성합니다.

3-2. enter

미래차 공간 입장. → 대기 화면(step0)으로 전환. 발행 주체: 태블릿.

{ "type": "enter" }

응답: state · phase=waiting (next 없음)

그리고 미래차가 곧바로 ai/response 에 고정 질문 "출발 할까요?" 와 ai/suggestions 답 후보 2개를 발행하고(3-2-1), ai/mode {car} 를 retained 로 올린다. 질문은 태블릿 채팅에 뜨고, 관람객이 차에 대고 답하면 그 답이 step1 생성의 재료가 된다(6-2). → 진행 흐름은 7장·8장 참고.

3-2-1. 여정 중 태블릿 채팅 — 미래차 직접 발행 + 세션 모드

결정(2026-08-23): 두 부분으로 나뉜다.

A′ — 미래차 문장은 미래차가 직접 쓴다 (적용됨, OC 작업 없음) 미래차는 enter 직후와 각 step 렌더 완료 후 orchestration/{sid}/ai/response 에 {ok:true, context_id, message:"질문"} 과 ai/suggestions {context_id, suggestions:[답 후보 2개]} 를 직접 발행한다. 태블릿은 이미 모르는 context_id 의 message 를 AI 말풍선으로, suggestions 를 칩으로 그린다 — 태블릿 변경 없음. OC 는 이 토픽을 구독하지 않으므로 영향 없지만, 계약상 OC 가 발행자인 토픽이라 동의를 구한다. 거절 시 대안: ai/request 에 passthrough:true(LLM 없이 그대로) 구현 요청 — 미래차는 env 한 줄로 그 경로로 돌아간다.

B — 세션 모드 ai/mode (OC 구현 완료 2026-08-24, 미래차 발행 중) enter 직후 {"mode":"car","ttl_sec":1800}, exit 직후 {"mode":"chat"} (retained). car 동안 OC 는 관람객 음성 발화(type=voice)에 STT 는 하되 LLM 답변·후속 제안을 생략한다(transcript 만, message 없음). 안전장치: TTL 만료·exit·signout(retained 삭제) 세 겹. ⚠️ OC 의 car 분기는 type=text(칩 탭·타이핑)에는 걸리지 않는다 — 그래서 태블릿이 미래차 모드 동안 입력을 잠그는 것(9-2-1)이 1차 방어고, ai/mode 는 2차다. 2026-08-28 정책으로 관람객 발화는 차량 마이크로만 받으므로, 여정 중 태블릿 AI 와의 대화는 없다.

3-3. advance · start (폐기 — 인바운드 별칭으로만 남음)

둘 다 쓰지 마세요. 스텝 진행은 버튼이 아니라 관람객 발화가 이끈다(7장).

  • 아웃바운드에서는 완전히 제거됐다. NextAction 은 exit 하나뿐이다.
  • 인바운드는 받아준다 — 받으면 "발화 없이 강제 진행" 한다(현장 비상용·디버그용). 지워버리면 구 태블릿은 아무 응답도 못 받고 멈춘다(unknown type 은 에러조차 발행하지 않음).
  • 받으면 /mqtt 트래픽 로그에 deprecated 로 남는다. 이 기록이 더는 안 보이면 별칭을 제거해도 안전하다.
  • 미래차 /mqtt 디버그 화면의 수동 발행에서 advance 는 강제 진행 버튼으로 여전히 쓸 수 있다.

3-5. exit

여정 종료. → 작별 화면 고정.

{ "type": "exit" }

응답: state · phase=done

이후 새 plan 이 오기 전까지 어떤 요청도 상태를 바꾸지 못하며, error · JOURNEY_ENDED 가 돌아옵니다.

3-6. context_action (현재 no-op)

{ "type": "context_action", "context_id": "c1", "action_kind": "open_meeting_doc" }

ambient 는 사전 생성된 step/error 로만 응답하므로 수신해도 아무 동작이 없습니다(로그만 남김). reaction 도 발행하지 않습니다.


4. DASHBOARD (orchestration/{sessionId}/dashboard)

OC 가 방문자의 모든 장소(집/공원/마트/차 …)와 태스크를 retain 발행합니다. 미래차는 구독만 하며, 여기서 차량 부분만 골라 스텝 생성의 여정 소재로 씁니다.

미래차가 읽는 부분

{
  "locations": [
    { "name": "집",  "visit_order": 1, "tasks": [ { "title": "..." } ] },
    { "name": "차",  "visit_order": 2, "tasks": [ { "title": "충전소 경유" } ] },
    { "name": "마트", "visit_order": 3, "tasks": [] }
  ]
}
필드 용도
locations[].name 차량 장소 판별 기준. 이름이 차 로 시작하면 차량으로 본다
locations[].visit_order 정렬 키. 차 바로 앞/뒤 장소를 prev_place/next_place 로 뽑는다
locations[].tasks[] 차에 걸린 태스크. id·title·type·description·status 를 추린다. description 은 planning 메타(planning_task_id: … 같은 key: value 줄)를 걷어낸 첫 문장만 쓴다. type=basic 만 미래차가 수행한다(태블릿에 조작 UI 가 없는 타입 — 그 외는 돋보기 모달에서 관람객이 완료). step 당 1개 — 어느 것을 할지는 AI 가 고른다. 서버는 아직 안 끝난 basic 태스크만(tasks_remaining) 매 턴 넘기고, AI 가 그중 하나를 task_key 로 보고하면 스텝 발행 때 task/start, 관람객이 연출을 본 뒤(렌더 완료) task/complete 를 발행하고 다음 턴 목록에서 뺀다. 4 step 이라 basic 이 1개면 나머지 step 은 태스크 없이 간다(몰아서 처리하지 않음). status 는 OC 재발행으로 in_progress→completed 로 돌아오며, completed·skip 만 "끝남" 으로 본다

판별 규칙과 그 이유

  • 이름이 1순위 판별 기준입니다. id 는 시드마다 정수/UUID 로 제각각이라 불안정합니다.
  • 운영 브로커의 retain 메시지 193건을 검증한 결과 차량 장소 이름은 차, 차량, 차량 - 이동 세 변형이 관측됐습니다 → 접두사 차 로 판별합니다.
  • device.name 은 쓰지 않습니다. Physical AI Hub 라는 device 가 집 과 차 양쪽에 등장한 사례가 있어 신뢰할 수 없습니다.
  • 접두사는 코드 배포 없이 바꿀 수 있게 DASHBOARD_VEHICLE_PREFIXES 환경변수(콤마 구분)로 오버라이드됩니다.

주의

  • 차량 장소가 아예 없는 세션이 정상적으로 존재합니다 — OC 가 관람객마다 장소를 랜덤 배정하기 때문입니다. 이 경우 persona 만으로 생성하며 경고 로그를 남깁니다.
  • plan 과 dashboard 는 별도 토픽이라 도착 순서가 보장되지 않습니다. 미래차는 생성 시점에 "그 순간 최신값"을 조회하는 방식으로 대응합니다.
  • retain 재전송분은 기록 없이 버립니다 — 구독 직후 과거 세션 200건가량이 한꺼번에 밀려와 디버그 화면의 링버퍼를 덮어쓰기 때문입니다.

5. RESPONSE (future-car/response)

type 3종입니다. 전부 같은 토픽으로 나갑니다.

5-1. state — 상태 전이 (retain=true)

{
  "type": "state",
  "step": 2,
  "phase": "arrived",
  "contexts": [],
  "next": "advance",
  "prev_place": "회사",
  "next_place": "집"
}
필드 타입 설명
type string "state" 고정
step integer 현재 스텝 (0~4)
phase string idle | waiting | driving | arrived | done
next string | 없음 태블릿 주 버튼 판단. 값은 항상 하나다(아래 표 참고). 해당 없으면 필드 자체가 생략됨
prev_place string | 없음 dashboard 기준 차 이전 장소. 대기 화면이 "어디서 왔는지" 표시
next_place string | 없음 dashboard 기준 차 다음 장소. 마지막 스텝에서 다음 공간으로 인계
scene · stop · contexts — plan.stops 를 안 쓰므로 비어서 나갑니다. 렌더에 쓰지 마세요

next 는 exit 하나뿐이고, 마지막 스텝(4) 도착 때만 실린다. 그 외에는 필드 자체가 없다. 진행 버튼이 사라졌으므로 태블릿이 관리할 버튼은 exit 하나다.

상황 phase next
plan 수신 idle (없음)
enter 직후 waiting (없음) — 버튼 대신 채팅 질문이 뜬다
step 1·2·3 렌더 완료 arrived (없음) — 채팅 질문이 뜬다
step 4 렌더 완료 arrived exit
재생 중(step 메시지) driving (state 를 발행하지 않음)
exit 직후 done (없음)

직렬화 시 null 필드는 아예 빠집니다(exclude_none). next 가 없으면 "다음 동작 없음"입니다.

5-2. step — 스텝 콘텐츠 (retain=false)

{
  "type": "step",
  "step": 1,
  "phase": "driving",
  "data": { "step": 1, "bgv": {...}, "bgm": {...}, "assets_timeline": [...] }
}
필드 타입 설명
step integer 스텝 번호 (1~4)
phase string 발행 시점 phase (보통 driving)
data object 미래차 화면 전용 연출 데이터(classic StepInfo 형식). 태블릿은 해석할 필요 없음

retain 하지 않는 이유: step 은 "지금 이걸 재생하라"는 1회성 명령입니다. retain 하면 나중에 접속·재접속한 구독자에게 브로커가 지난 step 을 자동 재전달해 엉뚱한 재생이 일어납니다.

5-3. route — 이번 구간 (retain=false)

step 직전에 발행된다. 태블릿이 "지금 어디서 어디로 가는 중인지"를 그린다.

{ "type": "route", "step": 2, "from_stop_id": "0199…aaa", "from_place": "home", "to_stop_id": "0199…bbb", "to_place": "mart" }
필드 설명
step 이 구간이 속한 스텝 (1~4)
from_stop_id · to_stop_id 태블릿이 쓰는 기준. plan.stops[].id 중 하나
from_place · to_place 사람이 로그를 읽을 때의 보조값. home·park·mart·factory·charging_station·drone
  • LLM 이 채우지 못한 값은 필드가 생략된다(null 아님). stop_id 가 없으면 place 로 폴백하거나 이전 구간을 유지하라.
  • 여정은 장소 5개를 잇는 4구간이다. P0(최초 출발)·P4(최종 도착)는 dashboard(차 앞뒤 장소)에서, P1~P3 는 LLM이 고른다. 서버는 연속성을 교정하지 않는다 — 프롬프트가 책임진다.

5-3-1. error (retain=false)

{ "type": "error", "step": 2, "code": "STEP_NOT_READY", "message": "step 2 준비 안 됨" }
code 언제 태블릿 대응
STEP_NOT_READY 생성이 아직 안 끝났는데 진행 요청 잠시 후 재시도 가능
GENERATION_FAILED LLM 생성 실패 재시도해도 안 됨 — 운영자 개입. 요청 없이도 실패 즉시 푸시됨
PLAN_MISSING plan 없이 진행 요청 OC 의 plan 발행 확인
JOURNEY_ENDED exit 이후 요청 / 마지막 스텝 초과 정상 종료 상태. 새 plan 대기

5-4. reaction (계약에 존재하나 미발행)

계약 모델은 남아있지만 ambient 파이프라인은 발행하지 않습니다. 태블릿은 구현하지 않아도 됩니다.


6. 비-MQTT 인바운드 — 렌더 완료 보고 · 관람객 발화

6-1. 렌더 완료 보고 — 여정의 심장

미래차 화면(/ambient)이 해당 스텝의 asset(대사·팝업·음성)을 전부 재생하면 서버에 알린다.

POST http://creator-mac-studio.tail69930a.ts.net:8100/ambient/step-rendered
{ "session_id": "...", "step": 2 }

MQTT 가 아니라 HTTP 인 이유: request 토픽은 태블릿·OC 의 명령 채널이고, 이건 화면 자신의 사실 보고다. 브로커가 죽어도 도달해야 하고, 태블릿 요청과 섞이면 트래픽 해석이 어려워진다.

이 보고를 받으면 서버가 세 가지를 한다:

  1. phase=arrived 로 올리고 state 발행 (step 4 면 next=exit)
  2. 그 스텝에서 AI 가 고른 차량 태스크를 task/complete 로 발행 (step 당 1개, 몰아서 처리 없음. 시작은 스텝 발행 때 task/start 로 이미 알렸다)
  3. 그 스텝의 question 을 ai/response 에, answer_suggestions 를 ai/suggestions 에 발행 → 태블릿 채팅에 다음 질문과 답 후보가 뜬다. step 4 는 발행하지 않는다 — 다음 step 이 없어 답이 와도 갈 곳이 없다
  4. 재생 중 미뤄둔 관람객 발화가 있으면 곧바로 다음 스텝 생성을 시작

⚠️ 예전에는 "버튼을 켜도 된다"는 신호였지만, 이제 이 보고가 안 오면 여정이 그 자리에서 멈춘다. 질문이 안 뜨니 관람객이 답할 수 없고, 답이 없으니 다음 스텝이 안 만들어진다.

서버가 걸러내는 것: 이미 다음 스텝으로 넘어간 뒤 도착한 지각 보고, 같은 스텝 중복 보고, exit 이후 보고 → 전부 무시.

6-2. 관람객 발화 (차량 마이크 STT) — 스텝 진행의 정식 유입구

차량 화면(/ambient)이 마이크로 받아 STT 한 관람객 발화를 보낸다. 화면은 관람객 차례(대기 화면, 스텝 렌더 완료 뒤 ~ 다음 step 수신 전, 서버 error 뒤)에만 마이크를 열고, 재생 중에는 닫는다(우리 TTS 를 받아 적는 자기수신 방지).

POST /ambient/utterance      { "session_id": "...", "transcript": "커피 마시고 싶어요", "source": "car-stt" }
                             → { "ok": true, "result": "collected" }       (closed / empty 면 ok:false)
GET  /ambient/utterance/{session_id}      ← 지금까지 수집된 발화 목록

ai/response 수신과 같은 판정 경로를 탄다 — 수집 창(enter~exit)이 닫혀 있으면 똑같이 버려진다. 수집되면 곧바로 다음 스텝 생성이 시작되고, 화면은 서버가 수집했다는 응답을 받으면 그 창에서는 더 듣지 않는다(중복 트리거 방지). /mqtt 나 curl 로 흉내낼 때는 source:"debug" 로 보내면 트래픽 로그에서 구분된다.


7. 상태머신 (phase)

진행은 태블릿 버튼이 아니라 관람객 발화가 이끈다 — 차량 마이크 STT(6-2). 태블릿이 보내는 것은 enter·exit 둘뿐이고, 미래차 모드 동안 태블릿 입력은 잠긴다.

stateDiagram-v2
    [*] --> idle: plan 수신
    idle --> waiting: enter (+ 채팅에 "출발 할까요?")
    waiting --> driving: 관람객 발화 → step1 생성·송출
    driving --> arrived: 화면 렌더 완료 (+ 채팅에 그 스텝의 question)
    arrived --> driving: 관람객 발화 → step N+1 생성·송출
    arrived --> done: exit (step 4 도착 후)
    done --> idle: 새 plan
Loading
  • 스텝은 미리 만들지 않는다. 발화가 도착한 그 시점에 LLM 이 다음 스텝 하나를 만든다(수 초). 그동안 미래차 화면은 직전 스텝 화면이 그대로 유지되다가 새 step 이 오면 이어 재생한다 — 대기 화면·스피너는 없다.
  • 재생 중(driving)에 발화가 오면 버리지 않고 미룬다. 렌더 완료 시 곧바로 다음 생성이 시작된다.
  • 발화 수집 창은 enter 이후 ~ exit 이전. 그 밖의 발화·채팅은 여정과 무관하므로 무시한다.
  • ai/response.transcript 구독은 남아 있다(태블릿 잠금 전 테스트·비상용). 미래차 자신이 발행한 질문은 context_id 로 걸러낸다.
  • next 가 실리는 곳은 step 4 arrived 의 exit 뿐이다.

8. 전체 흐름 — 탑승 직전부터 하차까지

이 절만 읽어도 한 여정이 어떻게 굴러가는지 알 수 있게 쓴다. 세부 스키마는 3~6장을 참조.

등장인물은 넷이다.

누구 역할 이 문서에서의 표기
OC (orchestration-server) 페르소나·장소·태스크의 정본. plan·dashboard·me 를 주고, 미래차가 보내는 task/start·task/complete·ai/mode 를 받는다 OC
태블릿 (tablet-fe) 관람객이 들고 다니는 화면. 차에 탈 때 enter, 내릴 때 exit 를 보낸다. 차 안에서는 입력이 잠긴다 태블릿
미래차 서버 (이 문서의 주인공, Mac Studio :8100) 발화를 재료로 스텝을 LLM 생성해 화면에 밀어 넣고, 태스크 완료와 채팅 질문을 발행한다 서버
미래차 화면 (/ambient, 차 안 디스플레이 + 마이크) 스텝을 재생하고, 재생이 끝나면 서버에 보고하고, 관람객 차례에 마이크를 열어 STT 한 발화를 서버로 보낸다 화면

관람객은 태블릿을 들고 차에 타고, 차 안에서는 차에 대고 말한다. 스텝은 그 말이 이끈다.

8-0. 여정이 시작되기 전에 이미 갖춰져 있는 것

  1. 관람객이 태블릿에서 페르소나를 만들면 OC 가 plan·dashboard·me 를 retained 로 발행한다. 서버는 plan 을 받는 순간 세션(session_id)을 만들고 state phase=idle 을 발행한다(5-1).
  2. 서버는 dashboard 에서 차 장소(name 이 "차"로 시작)의 앞뒤 장소(P0/P4)와 태스크를 뽑아 둔다(4장). 태스크 중 type=basic 만 차가 수행할 후보다 — 태블릿에 조작 UI 가 없어 서버가 완료해야 하는 타입. 그 외(vehicle_ready·detailed·quote_briefing …)는 태블릿 돋보기 모달에서 관람객이 완료하므로 차는 건드리지 않는다.
  3. me.ai_companion.prompt 는 스텝 생성 프롬프트 맨 앞에 붙는 인격(동행자)이다.
  4. 화면은 WS(/ws/futurecar)로 서버에 붙어 있다. 새 plan(state idle)이 오면 그 세션을 따라간다.

8-1. 단계별 — 누가 무엇을 쏘고, 각자 무엇을 하나

# 시점 · 트리거 서버가 하는 일 서버가 내보내는 것 화면 태블릿 OC
① 탑승 — 태블릿이 "차로 이동"을 확정하며 enter 발행 발화 수집 창 열기, phase=waiting state waiting · ai/mode {car, ttl 1800}(retained) · ai/response message="출발 할까요?" + ai/suggestions 칩 2개 대기 화면. 마이크 열림("듣고 있어요") 채팅에 "출발 할까요?" 표시. 입력 잠금(미래차 모드) car 모드 저장 — 이후 음성 발화는 STT 만
② 관람객이 차에 말함 화면이 STT 한 문장을 POST /ambient/utterance 로 받음 → 창 안이면 수집 → 즉시 step 1 생성 시작 (없음 — 생성 중) 마이크 정지("잠시만요"). 대기 화면 유지 — —
③ 스텝 생성 완료 (LLM 수 초) 컨텍스트(페르소나·앞뒤 장소·누적 발화·남은 basic 태스크·동행자 프롬프트) → LLM JSON. route 확정(P0/P4 는 dashboard 우선, stop_id 는 plan 카탈로그 매핑), task_key 검증 route → step(연출 데이터) → task/start {task_id}(그 스텝에 basic 태스크를 골랐을 때) step 재생 시작. 마이크 닫힘(우리 TTS 자기수신 방지) 구간(from→to) 표시. basic 카드 "진행 중" dashboard 를 in_progress 로 재발행. 이벤트 타이밍 판단(task_started 트리거)
④ 렌더 완료 — 화면이 POST /ambient/step-rendered phase=arrived. step 4 면 next=exit. 그 스텝의 basic 태스크 task/complete. step 1~3 이면 그 스텝의 question 을 채팅에 state arrived · task/complete {task_id} · ai/response message=question + ai/suggestions 마지막 장면 유지. 마이크 열림(step 1~3) 카드 "완료", 채팅에 다음 질문 dashboard completed·point 재발행. 이벤트 타이밍 판단(task_completed 트리거)
⑤ ②~④ 반복 (step 2 → 3 → 4) 발화 하나 = 스텝 하나. 미리 만들지 않는다
⑥ step 4 렌더 완료 phase=arrived next=exit. 질문 없음. 태스크 완료만 state arrived step=4 next=exit · task/complete 엔딩 화면("체험이 모두 끝났습니다"). 마이크 안 열림 하차(차를 떠나 다음 장소로 이동) 확정 가능
⑦ 하차 — 태블릿이 exit 발행 창 닫기, phase=done state done · ai/mode {chat} 작별 화면 입력 잠금 해제 chat 모드 복귀
— 다음 관람객의 plan 새 세션 idle state idle 대기

읽는 법 세 가지:

  • "다음으로"는 관람객의 말이다. 버튼이 아니다. 발화가 없으면 여정은 그 자리에 멈춘다(관람객이 답을 안 하는 상황은 전시 운영이 막는다).
  • 렌더 완료 보고(④)가 여정의 심장이다. 이게 안 오면 태스크 완료도, 다음 질문도, 다음 스텝도 없다(6-1).
  • 태스크는 스텝 하나에 하나. 시작은 스텝 발행 때(③), 완료는 렌더 완료 때(④). 그래서 태블릿에는 스텝 내내 "진행 중"이 보인다. 몰아서 처리하지 않는다.

8-2. 메시지 순서 (정확한 토픽)

①  plan               OC → 서버                     orchestration/{sid}/future-car/request   (retain=true)
   dashboard · me     OC → 서버                     orchestration/{sid}/dashboard · /me      (retain=true)
   ← state            phase=idle                    orchestration/{sid}/future-car/response  (retain=true)

②  enter              태블릿 → 서버                  orchestration/{sid}/future-car/request   {"type":"enter"}
   ← state            phase=waiting
   → ai/mode          {"mode":"car","ttl_sec":1800}  orchestration/{sid}/ai/mode              (retain=true)  [서버 → OC]
   → ai/response      message="출발 할까요?"          orchestration/{sid}/ai/response          ★ 태블릿 채팅
   → ai/suggestions   ["네, 출발해요!", …]            orchestration/{sid}/ai/suggestions       ★ 칩(표시만, 탭 잠금)

③  (관람객이 차량 마이크에 대고 말함)
   ⇢ POST /ambient/utterance  {session_id, transcript:"네, 출발해요", source:"car-stt"}   [화면 → 서버, HTTP]
   ⟳ step 1 생성 (LLM, 수 초)
   ← route            step=1 · from/to stop_id · from/to place                          ★ 태블릿 구간 표시
   ← step             step=1 · phase=driving · data={assets_timeline…}                  [화면 재생]
   → task/start       {"task_id":…}                 orchestration/{sid}/task/start           [서버 → OC] basic 만
   … 화면이 step 1 재생 (마이크 닫힘) …
   ⇢ POST /ambient/step-rendered  {session_id, step:1}                                   [화면 → 서버, HTTP]
   ← state            step=1 · phase=arrived
   → task/complete    {"task_id":…}                 orchestration/{sid}/task/complete        [서버 → OC]
   → ai/response      message=<step1 question>      + ai/suggestions                         ★ 태블릿 채팅

④  ③ 반복 (step 2, 3)

⑤  step 4 렌더 완료
   ← state            step=4 · phase=arrived · next=exit            (질문 없음)              ★ 하차 가능
   → task/complete    (step 4 에 basic 태스크를 골랐다면)

⑥  exit               태블릿 → 서버                  {"type":"exit"}
   ← state            phase=done
   → ai/mode          {"mode":"chat"}                                                       [서버 → OC]

→ 서버가 OC/태블릿 토픽에 발행, ← 서버가 future-car/response 에 발행, ⇢ 화면이 서버에 HTTP.

8-3. 한 스텝의 안쪽 — 시간 순

 arrived(N-1)         driving(N)                              arrived(N)
 ───┬───────────┬───────┬────────────────────────────┬──────────┬───────────────▶
    │ 발화 수집  │ 생성   │ step N 재생                 │ 렌더 완료 │ 질문 · 발화 수집
    │ (마이크 ON)│ (OFF) │ (마이크 OFF · task 진행 중)  │ 보고      │ (마이크 ON)
    └ utterance ┘       └ route → step → task/start ┘          └ state → task/complete → question
  • 생성 중에는 state 를 따로 내보내지 않는다. 화면은 직전 장면을 유지하고, 태블릿은 route 가 올 때까지 이전 구간을 보여준다. 생성은 보통 10~30초(구조화 출력 실패 시 자유 형식으로 1회 재시도, 120초 타임아웃).
  • 화면은 서버가 발화를 수집했다는 응답(result:"collected")을 받으면 그 창에서는 더 듣지 않는다 — 같은 창에서 두 문장이 두 번 생성을 걸지 않게. 다음 창(다음 렌더 완료)에 다시 연다.
  • 서버 쪽 창은 enter~exit 하나다. 그 안에서 스텝별 발화는 step_at 으로 태깅돼 누적되고, 매 생성에 지금까지의 발화 전부가 재료로 들어간다.

8-4. 예외와 그때의 동작

상황 동작
스텝 생성 실패(LLM 오류·파싱 실패) error {code:"GENERATION_FAILED"} 발행. 화면은 마이크를 다시 열고, 관람객이 다시 말하면 재시도한다. 태블릿은 JOURNEY_ENDED 이외의 에러만 표시
창 밖 발화(enter 전, exit 후) result:"closed" — 버린다. 화면은 계속 듣지만 enter 전엔 마이크를 열지 않는다
재생 중 발화 화면 마이크가 닫혀 있어 정상적으론 생기지 않는다. ai/response 경유(태블릿 잠금 전·비상)로 들어오면 버리지 않고 미뤄 두었다가 렌더 완료 직후 다음 생성에 쓴다 — 이때는 질문 없이 넘어간다
렌더 완료 보고 지각·중복 무시(ignored_stale·ignored_duplicate·ignored_done). 이미 다음 스텝으로 넘어갔거나 같은 스텝을 두 번 보고한 경우
태블릿이 enter 를 두 번 두 번째는 무시(태블릿도 phase 가드로 안 보낸다). exit 는 waiting·driving·arrived 어디서든 받는다 → 그 자리에서 done
advance·start 가 오면 폐기된 별칭 — 받으면 발화 없이 강제 생성한다(현장 비상·디버그용, /ambient dev 패널)
서버 재시작 state·스텝 결과는 DB 에서 복원되지만 발화 누적·대화 히스토리는 복원되지 않는다(다음 스텝 품질 저하). dashboard·me·plan 은 retained 라 재구독으로 돌아온다
OC 이벤트 팝업이 차 스텝 중에 뜸 OC 는 task/start·task/complete 를 이벤트 발행 판단의 트리거로 쓴다. 팝업은 태블릿에만 뜨고 차는 모른다 — 차가 이벤트에 반응할지는 미정(11장)
마이크 권한 거부·STT 오류 화면 하단 알약이 붉게 표시된다(not-allowed·network). 운영자는 /ambient dev 패널의 advance(강제 진행)로 여정을 이어갈 수 있다

9. tablet-fe 연동 가이드

tablet-fe 60d353c 기준 1·2·3 은 구현돼 있다(future-car-store, TaskCompleteDialog 의 enter/exit). 남은 것은 9-2-1 입력 잠금이다.

9-1. 구독

orchestration/{session_id}/future-car/response      QoS 1

session_id 는 페르소나 생성 시 발급받은 값이다. 태블릿이 여러 대면 반드시 자기 세션으로 고정한다. state 는 retain=true 라 늦게 붙어도 마지막 상태를 즉시 받는다. route·step·error 는 retain 이 없다.

채팅은 추가 구독이 필요 없다. 미래차가 orchestration/{sid}/ai/response 에 {ok, context_id, message} 로 질문을, ai/suggestions 에 {context_id, suggestions[]} 로 답 후보를 직접 발행한다 — 태블릿이 이미 구독하는 토픽이고, 모르는 context_id 의 message 를 AI 말풍선으로, suggestions 를 칩으로 그리는 기존 로직이 그대로 동작한다.

9-2. 상태 → 화면

받은 메시지 태블릿
state · phase=idle 새 여정. 지난 구간·에러 비우기
state · phase=waiting 탑승됨. 곧 채팅에 "출발 할까요?" 가 뜬다. 입력 잠금 시작(9-2-1)
route 구간 표시 갱신 (from_stop_id → to_stop_id, 없으면 place·이전 구간 유지)
step · phase=driving 미래차 재생 중 — data 는 무시, phase·step 만 취한다
state · phase=arrived (step 1~3) 곧 채팅에 다음 질문이 뜬다. 관람객은 차에 대고 답한다
state · phase=arrived · next=exit 하차 가능(canExit). 관람객이 차를 떠나 다음 장소로 이동을 확정하면 exit
state · phase=done 여정 끝. 입력 잠금 해제
error 5-3-1 표에 따라 처리 (JOURNEY_ENDED 는 표시하지 않음)

advance 버튼은 없다. 다음 스텝은 관람객이 차에 말하면 자동으로 진행된다.

9-2-1. 미래차 모드 입력 잠금 (⬜ 미구현 — 2026-08-28 정책)

phase ∈ {waiting, driving, arrived} 인 동안 채팅 텍스트 입력·마이크·제안 칩 탭을 잠근다. 미래차가 띄운 질문과 칩은 표시만 한다(칩 disabled). 안내 문구 예: "차량 안에서는 차에 직접 말씀해 주세요". phase=done(또는 idle) 에서 푼다.

이유: 스텝 진행은 차량 마이크로만 한다. 태블릿에서 답하면 OC 의 AI 동반자가 따로 답을 해 차와 태블릿이 "따로 노는" 상황이 생기고 (OC ai/mode=car 는 음성만 막는다), 미래차도 그 답을 스텝 재료로 받아 버린다.

9-3. 발행

orchestration/{session_id}/future-car/request       QoS 1 · retain=false
언제 페이로드
다음 목적지가 차량이고 이동을 확정할 때(탑승) {"type":"enter"}
지금 장소가 차량이고 다음 장소로 이동을 확정할 때(하차) {"type":"exit"}

이게 전부다. advance·start 는 폐기됐다(받아주긴 하지만 deprecated 로 기록되고 강제 진행된다). plan 은 OC 가 발행한다. 중복 발행 가드: enter 는 phase 가 미수신/idle 일 때만, exit 는 waiting·driving·arrived 에서만.

⚠️ retain=false 로 발행한다. 태블릿 요청을 retain 하면 미래차 서버 재시작 시 브로커가 재전송하는데, 서버는 retained REQUEST 를 전부 버리므로 조용히 무시된다.

9-4. 반드시 지켜야 할 것

  1. step.data 를 해석하지 마라. 미래차 화면 전용 연출 데이터다. 태블릿이 볼 필드는 type·step·phase·next 와 route 의 stop_id·place 뿐이다.
  2. null 필드는 생략되어 온다. next 키의 존재 여부로 판단하라(next === null 비교 금지). route 의 stop_id 도 마찬가지.
  3. 채팅 질문은 미래차가 띄운다. 태블릿이 "다음으로" 안내 문구를 따로 만들지 마라 — 이중으로 뜬다.
  4. 미래차 모드에서는 입력을 잠근다(9-2-1). 관람객이 답하지 않으면 진행되지 않는다 — 현장 스태프가 차에 말하도록 가이드한다(설계 전제).
  5. 차량 태스크 카드는 손대지 않는다. type=basic 태스크의 in_progress→completed 는 미래차가 task/start·task/complete 로 바꾸고 OC 가 dashboard 로 내려준다. 그 외 타입(돋보기 모달)은 지금처럼 관람객이 태블릿에서 완료한다.

9-5. 연동 테스트

주소 용도
http://creator-mac-studio.tail69930a.ts.net:8100/mqtt 주고받은 MQTT 원문을 시간순으로 확인 + 수동 발행 (DB 이력 토글로 재시작 전 것도)
POST …:8100/ambient/utterance {session_id, transcript, source:"debug"} 관람객 발화 흉내 — 차량 화면 없이 여정을 한 스텝씩 돌려볼 수 있다
GET …:8100/ambient/session/{session_id} 세션 스냅샷(phase·step·완료 태스크·수집 발화)
GET …:8100/logs?session_id=… 서버 진행 로그(DB)
http://creator-mac-studio.tail69930a.ts.net:8100/docs REST API (Swagger)

전시망(Tailscale) 안에서만 열린다. 미래차 서버 주소가 바뀌면 이 표를 갱신할 것.

/mqtt 에서 enter 를 누르고 /ambient/utterance 로 발화를 넣으면 어떤 메시지가 어떤 순서로 나가는지 8-2 그대로 눈으로 확인할 수 있다.


10. QoS · retain · 기타 설정

항목 값
QoS 1 (모든 토픽)
retain — REQUEST plan OC 발행 true (미래차는 재전송분만 무시)
retain — REQUEST 태블릿 트리거 false
retain — RESPONSE state true (신규 구독자 즉시 최신 상태)
retain — RESPONSE route · step · error false
retain — AI REQUEST (미래차 발행) false
retain — DASHBOARD OC 발행 true
페이로드 평문 JSON · snake_case · null 필드 생략
연결 옵션 AutoReconnect (reconnect_delay 1~30초). 브로커가 죽어 있어도 서버 부팅을 막지 않음
ACL orchestration/# 하위라 현 ACL(future-car 계정)로 통과 — 변경 불필요

11. 남은 합의 항목

  • ✅ enter 발행 주체 — 태블릿(2026-08-20). tablet-fe 구현 완료(TaskCompleteDialog).
  • ✅ OC 세션 모드 ai/mode — OC 구현 완료(2026-08-24). ai/response 직접 발행은 OC 가 구독하지 않는 토픽이라 영향 없음(정식 동의는 받아 둘 것).
  • ⬜ tablet-fe: 미래차 모드 입력 잠금(9-2-1) — 2026-08-28 정책. 이게 없으면 태블릿 답변이 스텝 재료로 섞여 들어간다.
  • ⬜ 이벤트(돌발 팝업)와 차의 관계 — 미정. OC 는 task/start·task/complete 를 이벤트 발행 판단 트리거로 쓰므로 차 스텝 중 태블릿에 팝업이 뜰 수 있다. 차가 반응하려면 orchestration/{sid}/event 구독이 필요한데, 페이로드에 장소 참조가 없어 OC 에 location_id 추가(또는 dashboard.locations[].event 요약)를 요청해야 한다.
  • ⬜ 충전소 하드웨어 연동 — route.to_place=charging_station 인 스텝의 렌더 완료 = 충전소 도착. 어느 브로커·토픽·페이로드로 알릴지 담당 팀과 미정.
  • ⬜ exit 이후 다음 공간 전환 신호 — 주체/토픽 미확정. 미래차는 state.next_place 로 "다음 장소가 어디인지"까지만 알려준다.
  • ⬜ 운영 규칙: 차가 완료해야 하는 차 태스크는 백오피스에서 type=basic 으로 등록한다(타입이 곧 완료 주체 스위치).
  • ⬜ AsyncAPI 반영 — RESPONSE 4종(state·route·step·error)을 Orchestration MQTT API 의 future-car 채널에 반영.
  • ℹ️ reaction 폐기 여부 — 계약에만 남아있고 발행하지 않는다.
  • ℹ️ 인증 없음 — /mqtt/publish·/ambient/utterance 로 전시 중인 여정을 조작할 수 있다. Tailscale 망 안이라 우선순위는 낮으나, 외부 노출 시 대응 필요.

Clone this wiki locally