MQTT_SCHEMA ORCHESTRATION_SERVER - zero1ne-26/doc GitHub Wiki

오케스트레이션 서버 MQTT 스키마

이 문서는 오케스트레이션 서버 팀이 작성합니다.


목차

  1. 개요
  2. 토픽 목록
  3. CLIENT → SERVER (요청)
    1. 페르소나 생성 요청
    2. 세션 종료 요청
    3. AI 채팅 요청
    4. FE 내부 요약 요청
    5. 태스크 시작 요청
    6. 태스크 완료 요청
    7. 태스크 건너뛰기 요청
    8. 스토어 상품 구매 요청
    9. 태스크 리포트 조회 요청
    10. SPOT 명령 요청
    11. 태스크 선택지 선택 요청
    12. 이벤트 선택지 선택 요청
    13. 반려로봇(REX) 명령 요청
    14. 세션 모드 설정
    15. 로봇팔(UF850) 명령 요청
    16. SPOT 다음 장소로 이동 요청
  4. SERVER → CLIENT (응답 / 서버 발행)
    1. 페르소나 생성 응답
    2. 세션 종료 응답
    3. AI 채팅 응답
    4. AI 답변 제안
    5. FE 내부 요약 응답 5-1. 세션 공유 컨텍스트 5-2. 세션 엔딩
    6. 대시보드 장소·태스크 목록
    7. 사용자 정보 조회
    8. 이벤트 팝업 8-1. 파이프라인 상태
    9. 태스크 리포트 응답
    10. 페르소나 전체 목록 스냅샷
    11. AI 동반자 전체 목록 스냅샷
    12. 스토어 상품 목록 스냅샷
    13. 포인트 잔액 업데이트
    14. 보유 상품 목록
    15. 디바이스 전체 목록 스냅샷
    16. 현재 위치 (Vision 연동)
    17. 사용자 메타 프롬프트 목록 스냅샷
    18. SPOT 명령 응답/릴레이
    19. SPOT 로봇 전역 상태
    20. SPOT 현재 위치 (Vision 연동)
    21. 반려로봇(REX) 명령 응답/릴레이
    22. 반려로봇(REX) 로봇 전역 상태
    23. 태스크 진행 상태 내레이션
    24. 로봇팔(UF850) 명령 응답/릴레이
    25. 로봇팔(UF850) 로봇 전역 상태
  5. 관련 REST API
    1. 음성 파일 업로드
    2. Admin REST API 공통 응답 규칙
  6. QoS / 기타 설정
  7. 비고
  8. 미래차 (Future Vehicle) 토픽
  9. Vision 연동 토픽

개요

항목 내용
브로커 tcp://127.0.0.1:1883 (기본값, 환경변수 MQTT_BROKER_URL로 변경 가능)
구독 토픽 orchestration/#
제공값 session_id — 페르소나 생성(create-persona) 응답에서 발급되며, 이후 모든 세션 토픽에 사용
작성 주체 오케스트레이션 서버 팀
최종 수정일 2026-09-15

토픽 목록

CLIENT → SERVER

Topic 방향 QoS Retained 설명, orchestration/{sessionId}/context
orchestration/{deviceInternalId}/create-persona C→S 1 - 페르소나 생성 요청
orchestration/{sessionId}/signout C→S 1 - 세션 종료 요청
orchestration/{sessionId}/ai/request C→S 1 - AI 채팅 요청
orchestration/{sessionId}/ai/summarize C→S 1 - FE 내부 요약 요청
orchestration/{sessionId}/task/start C→S 1 - 태스크 시작 요청
orchestration/{sessionId}/task/complete C→S 1 - 태스크 완료 요청
orchestration/{sessionId}/task/skip C→S 1 - 태스크 건너뛰기 요청
orchestration/{sessionId}/task/report/request C→S 1 - 태스크 리포트 조회 요청
orchestration/{sessionId}/store/purchase C→S 1 - 스토어 상품 구매 요청
orchestration/{sessionId}/spot/command C→S 1 - SPOT 명령 요청
orchestration/{sessionId}/task/option/select C→S 1 - 태스크 선택지 선택 요청
orchestration/{sessionId}/event/option/select C→S 1 - 이벤트 선택지 선택 요청
orchestration/{sessionId}/companion/command C→S 1 - 반려로봇(REX) 명령 요청
orchestration/{sessionId}/arm/command C→S 1 - 로봇팔(UF850) 명령 요청
orchestration/{sessionId}/spot/navigate-next C→S 1 - SPOT "다음 장소로 이동" 요청 — to_location_id 지정 시 그 장소로, 없으면 서버가 세션 코스에서 다음 장소 해석 (2026-09-19)
orchestration/{sessionId}/ai/mode C→S 1 O 세션 모드 설정 (미래차가 발행, car/chat)

SERVER → CLIENT

Topic 방향 QoS Retained 설명
orchestration/{deviceInternalId}/create-persona/response S→C 1 X 페르소나 생성 응답
orchestration/{sessionId}/signout/response S→C 1 X 세션 종료 응답
orchestration/{sessionId}/ai/response S→C 1 X AI 채팅 응답
orchestration/{sessionId}/ai/suggestions S→C 1 X AI 답변 제안 2개 (ai/response 직후 자동 발행)
orchestration/{sessionId}/ai/summary S→C 1 X FE 내부 요약 응답 (ai/summarize 요청에 대한 결과) + 여정 내레이션 서버 자발 발행(source: intro | arrival | task_status | car_step, 2026-09-06)
orchestration/{sessionId}/dashboard S→C 1 O 장소·태스크 목록 (페르소나 생성 시 자동 발행)
orchestration/{sessionId}/context S→C 1 O 세션 공유 컨텍스트 — 내러티브 재료(사용자·여정·태스크 결과·이벤트 처리·차량 스텝·최근 발화) 구조화 JSON. 미래차·반려로봇 구독용 (2026-09-26, §5-1)
orchestration/{sessionId}/ending S→C 1 O 세션 엔딩 — 종료 시 1회. 엔딩 화면 재료(사용자·동반자·세션 시간·여정·발화·배려·명령·표정·구매) 구조화 JSON (2026-10-02, §5-2)
orchestration/{sessionId}/me S→C 1 O 사용자 정보 조회 (페르소나 생성 시 자동 발행)
orchestration/{sessionId}/event S→C 1 X 어드민이 발행하는 이벤트 팝업
orchestration/{sessionId}/pipeline/status S→C 1 X 파이프라인 상태 — 이벤트·태스크·장소에 걸린 자동화 실행의 시작/진행중/멈춤/종료. 실행 생성·액션 변화·재시도 때마다 (2026-10-03, §8-1)
orchestration/{sessionId}/task/report/response S→C 1 X 태스크 리포트 조회 응답 (완료/건너뜀/진행중/대기 분류)
orchestration/{sessionId}/point S→C 1 O 포인트 잔액 실시간 업데이트 (구매 완료 등)
orchestration/{sessionId}/store/owned S→C 1 O 보유 상품 목록 — ID 배열 + 상품 상세(products[], 2026-09-06 추가) (로그인 시 + 구매 후 자동 발행)
orchestration/persona/list S→C 1 O [Deprecated] 페르소나 전체 목록 스냅샷 (서버 시작 시 발행) — orchestration/ai-companion/list로 대체
orchestration/ai-companion/list S→C 1 O AI 동반자 전체 목록 스냅샷 (서버 시작 시 발행)
orchestration/user-meta-prompt/list S→C 1 O 사용자 메타 프롬프트(자기소개 예시 문구) 목록 (서버 시작 시 + 관리자 변경 시 발행)
orchestration/store/list S→C 1 O 스토어 상품 목록 스냅샷 (서버 시작 시 + 상품 변경 시 발행)
orchestration/devices/list S→C 1 O 디바이스 전체 목록 스냅샷 (서버 시작 시 + 변경 시 발행)
orchestration/{sessionId}/spot/command/response S→C 1 X SPOT 명령 큐 등록 결과/거부 사유
orchestration/{sessionId}/spot/ack S→C 1 X SPOT 명령 접수·완료 응답 릴레이
orchestration/{sessionId}/spot/plan S→C 1 X SPOT 시나리오 진행 상황 릴레이
orchestration/{sessionId}/spot/navigate-next/response S→C 1 X "다음 장소로 이동" 큐 등록 결과(목적지·waypoint·command_id) 또는 거부 사유 (2026-09-19)
orchestration/spot/{robotId}/state S→C 1 O SPOT 로봇 전역 상태 릴레이
orchestration/spot/{robotId}/waypoints S→C 1 O SPOT 이동 가능 지점 목록 릴레이
orchestration/spot/{robotId}/scenarios S→C 1 O SPOT 실행 가능 시나리오 목록 릴레이
orchestration/spot/{robotId}/estop S→C 2 O SPOT 비상정지 발생·해제 릴레이
orchestration/{sessionId}/spot/position S→C 1 O SPOT 로봇 현재 위치 (구 Vision vision/position/spot zone_id 매핑 — Vision 스키마 2026-08-30에서 삭제, 백오피스 테스트 발행만 남음)
orchestration/{sessionId}/companion/command/response S→C 1 X 반려로봇(REX) 명령 큐 등록 결과/거부 사유
orchestration/{sessionId}/companion/ack S→C 1 X 반려로봇(REX) 명령 접수·완료 응답 릴레이
orchestration/companion/{robotId}/state S→C 1 O 반려로봇(REX) 전역 상태 릴레이
orchestration/companion/{robotId}/fault S→C 1 O 반려로봇(REX) 결함 상태 릴레이
orchestration/companion/{robotId}/event S→C 1 X 반려로봇(REX) 이벤트 릴레이
orchestration/companion/{robotId}/capabilities S→C 1 O 반려로봇(REX) 지원 capability 목록 릴레이
orchestration/{sessionId}/arm/command/response S→C 1 X 로봇팔(UF850) 명령 큐 등록 결과/거부 사유
orchestration/{sessionId}/arm/ack S→C 1 X 로봇팔(UF850) 명령 접수·완료 응답 릴레이
orchestration/arm/{robotId}/state S→C 1 O 로봇팔(UF850) 전역 상태 릴레이
orchestration/{sessionId}/task/narration S→C 1 O 태스크 진행 상태 내레이션 — 지금 장소의 태스크 전부 (도착 시 교체, 태스크 시작/완료/건너뜀 시 LLM 생성)

CLIENT → SERVER (요청)

1. 페르소나 생성 요청

Topic: orchestration/{deviceInternalId}/create-persona

{deviceInternalId} = equipments.internal_id 값

Payload

{
  "persona_id": "019670ab-bbbb-7ccc-d002-ee2233445566",
  "user_meta_answers": [
    { "id": "019670ab-cccc-7ddd-e003-ff3344556677", "value": true },
    { "id": "019670ab-dddd-7eee-f004-001122334455", "value": "언젠가 오로라를 직접 보러 가고 싶어요." }
  ],
  "intro_message": "안녕하세요, 제 이름은 홍길동이에요"
}
필드 타입 필수 설명
persona_id string Y 선택된 페르소나 ID — UUID v7 (orchestration/ai-companion/list에서 수신)
user_meta_answers array N orchestration/user-meta-prompt/list 문항별 응답 목록. 문항의 type이 objective면 value는 true|false, subjective면 value는 자유 입력 문자열
user_meta_answers[].id string Y 응답 대상 문항 ID (user-meta-prompt/list의 id)
user_meta_answers[].value boolean|string Y 문항 type에 따라 true|false 또는 문자열
user_meta string N Deprecated — user_meta_answers 사용 권장. 값이 있으면(그리고 user_meta_answers가 없으면) 그대로 users.description에 저장됨 (하위 호환용)
intro_message string N 사용자가 자기소개(이름 포함)를 말한 내용 — 브라우저가 자체 STT로 전사한 텍스트를 그대로 전달

서버 처리 (user_meta_answers): user_meta_answers가 있으면, objective 문항은 value:true일 때만 그 문항의 text 자체를 문장 조각으로 채택하고(value:false는 채택하지 않음 — 부정문은 만들지 않는다), subjective 문항은 value 문자열을 그대로 채택한다. 채택된 조각을 전부 공백으로 이어붙여 한 줄로 만든 뒤 로그인 시 users.description에 저장한다. 문항에 effect: "has_children"가 설정되어 있고 그 문항의 value가 true면 users.has_children도 함께 true로 갱신한다. user_meta_answers가 없으면 하위 호환으로 user_meta 문자열을 그대로 저장한다.

서버 처리 (intro_message): 값이 있으면 (더 이상 서버가 오디오를 다운로드하거나 STT를 수행하지 않고) 전달받은 텍스트를 그대로 app_settings.user_name_extraction_prompt (백오피스 앱 설정에서 관리)를 시스템 프롬프트로 LLM에 질의해 사용자가 말한 이름만 추출한다. 추출된 이름은 users.username에 저장되고 orchestration/{sessionId}/me의 username으로 내려간다. intro_message가 없거나, LLM 처리에 실패하거나, 이름을 언급하지 않았으면 users.username은 "여행자"로 대체된다 — 이 과정이 실패해도 로그인 자체는 계속 진행된다.

Breaking change (2026-08-10): 기존 voice_file_key(음성 파일 키 업로드 방식)는 완전히 제거되었다. 브라우저가 자체 STT로 만든 텍스트를 intro_message로 바로 보내야 한다.

서버 발행 토픽 (순서대로)

발행 순서 변경 (105): server/session/start를 가장 먼저 발행해 Vision의 외모 분석 (cctv/person/profile, 세션당 1회)이 서버의 LLM 준비 작업과 병렬로 돌게 하고, 아래 로그인 발행(point/me/dashboard/store·owned/future-car/response)은 프로필 수신 후에 한다. app_settings.create_persona_profile_wait_seconds(기본 15초, 0=대기 안 함)까지 기다려도 프로필이 안 오면(Vision 미동작·미추적 등) 프로필 없이 그대로 발행한다 — Vision 장애가 로그인 자체를 막지 않는다.

토픽 Retained 설명
server/session/start X 가장 먼저 — Vision(카메라 트래킹) 서비스에 세션 시작 알림. 별도 브로커/스키마(MQTT_SCHEMA-VISION 참고), QoS 1. 구 vision/session/*에도 병행 발행
(대기) cctv/person/profile 수신 – Vision → 서버. 수신(또는 타임아웃) 후 아래를 발행
orchestration/{sessionId}/point O 초기 포인트 1,000pt 지급
orchestration/{sessionId}/me O 사용자 정보 (아바타 이미지 등) — 외모 분석 수신 시 성별·설명이 반영된 상태
orchestration/{sessionId}/context O 세션 공유 컨텍스트 (§5-1) — 프로필 대기 뒤 reason: session_start 로 첫 발행
orchestration/{sessionId}/dashboard O 장소·태스크 목록 (pending 상태로 초기화) — 선택된 이벤트의 설명이 태스크 선택 LLM 컨텍스트로 반영됨. 이벤트 팝업(orchestration/{sessionId}/event)은 로그인 시 장소에 매핑만 되고 실제 발행은 도착/타이밍 판단 시(event_timing) 이뤄진다
orchestration/{sessionId}/store/owned O 보유 상품 목록 (product_ids + products — 초기 빈 배열)
orchestration/{sessionId}/future-car/request O 미래차 플랜 (type: "plan") — 활성 플랜이 있을 때만
orchestration/{deviceInternalId}/create-persona/response X 마지막 — 성공/실패 응답 (session_id 포함). 실패 응답은 위 대기 없이 즉시 발행

시퀀스 (105 기준)

sequenceDiagram
    autonumber
    participant 태블릿 as 📱 태블릿
    participant 서버 as 🖥 오케스트레이션 서버
    participant DB as 🗄 DB
    participant LLM as 🤖 LLM
    participant Vision as 📷 Vision(CCTV)

    태블릿->>서버: orchestration/{deviceId}/create-persona<br/>{persona_id, user_meta_answers, intro_message}
    Note over 서버: 디바이스별 중복 요청 잠금

    alt persona_id 없음 / 페르소나 조회 실패
        서버-->>태블릿: create-persona/response {ok:false} (대기 없이 즉시)
    else 정상
        서버->>DB: 페르소나 조회, 세션 ID(UUID v7) 발급
        서버->>Vision: server/session/start {session_id} — 가장 먼저 발행
        Note over Vision: VLM 외모 분석 시작<br/>(아래 서버 준비 작업과 병렬)

        par 사용자 생성
            서버->>LLM: intro_message에서 이름 추출
            서버->>DB: users 생성 + 초기 포인트 적립
        and 장소 배정
            서버->>LLM: 셔플 풀 장소 선택 (실패 시 무작위)
            서버->>DB: user_locations 저장 (visit_order 1..N)
        end

        서버->>LLM: 이벤트 매핑(STEP2 일반·STEP3 돌발) + 장소별 태스크 선택
        서버->>DB: user_tasks 시딩
        par 태스크 후처리
            서버->>LLM: 태스크 시간 산정
        and 후처리 병렬 2
            서버->>LLM: vehicle_ready 콘텐츠 생성
        and 후처리 병렬 3
            서버->>LLM: 태스크 설명(description) 생성
        end
        서버->>LLM: 장소별 short_summary 생성

        Vision-->>서버: cctv/person/profile {session_id, description} (세션당 1회)
        서버->>DB: 성별 추출·외모 설명 저장
        Note over 서버,Vision: 서버는 프로필 수신까지 대기 —<br/>app_settings.create_persona_profile_wait_seconds(기본 15초)<br/>초과 시 프로필 없이 진행 (Vision 장애가 로그인을 막지 않음)

        서버-->>태블릿: orchestration/{sessionId}/point (retained)
        서버-->>태블릿: orchestration/{sessionId}/me (retained, 성별·외모 반영)
        서버-->>태블릿: orchestration/{sessionId}/dashboard (retained)
        서버-->>태블릿: orchestration/{sessionId}/store/owned (retained)
        서버-->>태블릿: orchestration/{sessionId}/future-car/request (retained, 활성 플랜 있을 때)
        서버-->>태블릿: create-persona/response {ok:true, session_id, persona}

        Note over 서버: 외모 기반 사용자 사진(에셋) 선택은 비동기 —<br/>완료되면 /me가 user_images 포함으로 재발행됨
    end
Loading

2. 세션 종료 요청

Topic: orchestration/{sessionId}/signout

{sessionId} = create-persona/response의 session_id 값

Payload: 없음 (빈 페이로드)

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/ending O 세션 엔딩 (§5-2) — ended_at 을 찍은 뒤, /context retained 를 지우기 전에 1회 발행. reason: signout
orchestration/{sessionId}/signout/response X 종료 확인 응답
server/session/end X Vision·SPOT 에 세션 종료 알림 — signout 뿐 아니라 강제 종료·replaced·idle 에서도 같은 절차로 발행 (148). 별도 브로커/스키마(MQTT_SCHEMA-VISION 참고), QoS 1. 구 vision/session/*에도 병행 발행

3. AI 채팅 요청

Topic: orchestration/{sessionId}/ai/request

Payload

{
  "context_id": "019670ab-1234-7def-a001-bc2345678901",
  "type": "text",
  "text": "오늘 일정이 어떻게 돼?"
}
{
  "context_id": "019670ab-5678-7abc-b002-cd3456789012",
  "type": "voice",
  "file_key": "recordings/session123/audio.webm"
}
필드 타입 필수 설명
context_id string N 메시지 고유 ID. 비어 있으면 서버가 UUID v7으로 자동 생성
type string N text 또는 voice. 생략 시 file_key/audio 존재 여부로 자동 판별
text string Y(text) 텍스트 메시지 (type=text일 때 필수)
file_key string Y(voice) 음성 파일 키 — voice-recordings 버킷 (type=voice일 때)
audio string Y(voice) 음성 파일 키 — 메인 업로드 버킷 (POST /admin/uploads로 업로드한 경우)

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/ai/response X AI 답변
orchestration/{sessionId}/ai/suggestions X 이어서 할 만한 답변 제안 2개 (ai/response 직후 자동 발행)

4. FE 내부 요약 요청

Topic: orchestration/{sessionId}/ai/summarize

대화 흐름과 무관하게 FE가 특정 텍스트를 요약할 때 사용. AI 채팅(ai/request)과 독립적으로 동작한다.

Payload

{
  "context_id": "019670ab-1234-7def-a001-bc2345678901",
  "context": "요약할 원문 텍스트 내용..."
}
필드 타입 필수 설명
context_id string N 요청 고유 ID (UUID v7). 비어 있으면 서버가 자동 생성
context string 조건부 요약할 원문 텍스트. 프롬프트에 {{context}}가 있으면 그 자리에, 없으면 프롬프트 뒤에 붙는다. 프롬프트가 세션 치환 변수를 하나라도 쓰면 생략 가능(2026-09-19)
task_id string N (2026-09-19) {{task_title}}/{{task_description}}/{{robot_label}} 대상 태스크. 없으면 현재 장소의 in_progress → 첫 pending 태스크
trigger string N (2026-09-19) {{trigger}} 값. 없으면 사용자 요약 요청
environment_state string N (2026-09-19) {{environment_state}} 값. 서버에 환경 센서 소스가 없어 미지정 시 (정보 없음)

prompt 필드는 더 이상 클라이언트가 지정하지 않는다. 요약 프롬프트는 어드민 "앱 설정" 페이지의 ai_summarize_prompt 설정값으로 서버가 관리하며, 요청 수신 시 이 값을 사용해 LLM을 호출한다. 2026-09-06부터 세션에 매핑된 AI 동반자의 prompt(성격·말투 지시)를 system 프롬프트로 함께 태운다 — ai/request 채팅과 같은 말투로 요약이 나온다. 페르소나가 없거나 prompt가 비어 있으면 system 없이 호출.

치환 변수 (2026-09-19): ai_summarize_prompt의 {{...}}를 요청 수신 시 세션 상태로 채운다. 실데이터가 없으면 (정보 없음).

변수 출처
{{persona_name}} {{persona_summary}} 세션 AI 동반자 title / summary
{{user_description}} users.description (CCTV 외형 분석 원문)
{{location_name}} user_location_history 최신 행의 장소
{{task_list}} 현재 장소의 태스크 - 제목 [대기/진행 중/완료/건너뜀] 줄
{{task_title}} {{task_description}} {{robot_label}} task_id(없으면 현재 장소 in_progress → 첫 pending) 태스크; robot_label은 디바이스가 spot이면 SPOT 아니면 반려로봇
{{trigger}} 요청 trigger, 없으면 사용자 요약 요청
{{completed_task_history}} 완료한 태스크 - [장소] 제목 시간순(대상 태스크 제외)
{{vision_state}} 현재 장소·체류시간, 표정(메모리 → user_emotion_history 최신), 성별(있을 때), packing 태스크면 준비물별 챙김/테이블 위/미확인
{{vision_updates}} 최근 장소 이동·표정 변화 이력 최신순 8건 (- HH:MM (N분 전) 장소 이동 → 집)
{{device_state}} 로봇팔 state(온라인/오프라인·IDLE/EXECUTING·수신 경과). REX·SPOT은 서버가 state를 보관하지 않아 실시간 상태 미수집 고정
{{environment_state}} 요청 environment_state, 없으면 (정보 없음) (서버 측 소스 없음)
{{context}} 요청 context 원문. 템플릿에 없으면 기존처럼 프롬프트 뒤에 빈 줄 두고 이어 붙임

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/ai/summary X 요약 결과 텍스트

5. 태스크 시작 요청

Topic: orchestration/{sessionId}/task/start

Payload

{
  "task_id": "019670ab-1111-7111-a111-111111111111"
}
필드 타입 필수 설명
task_id string Y 시작 처리할 태스크 ID (UUID v7)

pending → in_progress 상태로 전환. started_at 기록.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/task/start/response X { "ok": true, "task_id": "...", "status": "in_progress" }
orchestration/{sessionId}/dashboard O 태스크 상태 반영된 장소·태스크 목록 갱신
orchestration/{sessionId}/context O 세션 공유 컨텍스트 갱신 (reason: task_status, §5-1)

pokemon_catch/trash_pickup는 클라이언트가 이 토픽을 보낼 필요가 없다. 사용자가 그 태스크의 장소에 도착하면(cctv/person/zone → 장소 매핑 성공) 서버가 StartUserTaskAndNotify를 자동으로 호출해 pending → in_progress로 전환하고 server/pokemon/start/server/trash/start까지 함께 발행한다(Vision 연동 토픽 참고). 디바이스가 그래도 task/start를 보내면 이미 in_progress라 0행 갱신으로 무시되고 부수효과(포켓몬 재배정 등)는 중복 실행되지 않는다. 다른 타입은 기존과 동일하게 클라이언트의 명시적 task/start가 필요하다.


6. 태스크 완료 요청

Topic: orchestration/{sessionId}/task/complete

Payload

{
  "task_id": "019670ab-1111-7111-a111-111111111111"
}
필드 타입 필수 설명
task_id string Y 완료 처리할 태스크 ID (UUID v7)

pending 또는 in_progress → completed 상태로 전환. completed_at 기록.

서버 발행 토픽

토픽 Retained 조건 설명
orchestration/{sessionId}/dashboard O 항상 완료 상태 반영된 장소·태스크 목록 갱신
orchestration/{sessionId}/context O 항상 세션 공유 컨텍스트 갱신 (reason: task_status, §5-1)
orchestration/{sessionId}/point O 항상 포인트 잔액 업데이트 (+task_complete_point pt)

7. 태스크 건너뛰기 요청

Topic: orchestration/{sessionId}/task/skip

Payload

{
  "task_id": "019670ab-1111-7111-a111-111111111111"
}
필드 타입 필수 설명
task_id string Y 건너뛸 태스크 ID (UUID v7)

pending 또는 in_progress → skip 상태로 전환. 포인트는 지급되지 않는다. completed_at은 기록하지 않는다.

서버 발행 토픽

토픽 Retained 조건 설명
orchestration/{sessionId}/task/skip/response X 항상 { "ok": true, "task_id": "...", "status": "skip" }
orchestration/{sessionId}/dashboard O 항상 건너뛴 상태 반영된 장소·태스크 목록 갱신
orchestration/{sessionId}/context O 항상 세션 공유 컨텍스트 갱신 (reason: task_status, §5-1)

8. 스토어 상품 구매 요청

Topic: orchestration/{sessionId}/store/purchase

Payload

{
  "product_ids": [
    "019670ab-p001-7001-a001-pp1100110011",
    "019670ab-p003-7003-c003-pp3300330033"
  ]
}
필드 타입 필수 설명
product_ids string[] Y 구매할 상품 ID 목록 (UUID v7). 한 번에 묶어서 원자적으로 처리

선택된 상품들의 포인트 합계를 users.point_balance에서 원자적으로 차감한다. 잔액 부족 시 전체 취소 (부분 구매 없음).

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/point O 차감 후 남은 포인트 잔액
orchestration/{sessionId}/store/owned O 구매 후 갱신된 보유 상품 목록 (product_ids + products 상세)

9. 태스크 리포트 조회 요청

Topic: orchestration/{sessionId}/task/report/request

Payload: 없음 (빈 payload로 발행)

세션에 매핑된 user_tasks 전체를 완료(completed) / 건너뜀(skip) / 진행중(in_progress) / 대기(pending) 4가지 상태로 분류해 응답한다. 전체 완료 시 서버가 자동 발행하던 기존 방식은 폐지되었으며, 클라이언트가 원하는 시점에 직접 요청해야 한다.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/task/report/response X 상태별로 분류된 태스크 목록 + LLM 하루 요약

10. SPOT 명령 요청

Topic: orchestration/{sessionId}/spot/command

Payload

{
  "action": "navigate_to",
  "params": { "destination": "p2" }
}
필드 타입 필수 설명
action string Y navigate_to|run_scenario|follow|return_home|stop|stand|sit|power_off|warm_up 중 하나 (SPOT 문서 3절 기준 고정 9종 — stand/sit/power_off는 2026-09-06, warm_up 은 2026-09-29 계약 확장)
params object N action별 파라미터 (예: navigate_to의 destination)

서버가 세션에 배정된 장소(user_locations)를 통해 대상 SPOT 디바이스(devices.type='spot')를 resolve한다. 배정된 장소에 활성 spot 디바이스가 없으면 rejected로 즉시 응답한다.

명령은 디바이스 명령 큐(ministack SQS FIFO, 로봇 단위 직렬화)에 적재되고(status: "queued"), 같은 로봇의 앞 명령이 종결 ack(SUCCESS/ABORTED/FAULT_*/TIMEOUT, 그리고 완료 응답이 오지 않는 REJECTED/BUSY 는 즉시 실패 종결 — 2026-09-21)를 보내거나 타임아웃된 뒤 순서대로 robot/{robotId}/command로 발행된다. 자동화 규칙·이벤트/태스크 선택지 출처의 SPOT 명령도 같은 큐를 타므로 세션이 달라도 한 로봇엔 한 번에 하나만 나간다 (LOCATION_AUTOMATION_DESIGN §4.3). 로봇별로 한 번에 하나의 명령만 실행되도록 오케스트레이션 서버가 큐를 관리한다 (MQTT_SCHEMA-SPOT §8.1).

navigate_to·run_scenario 가 FAULT_NAV 로 끝나면 서버가 3초 뒤 같은 command_id 로 다시 발행한다. 횟수는 앱 설정 spot_fault_nav_retry_max(기본 2, 0=끔). 길막힘은 대개 사람이 잠깐 앞을 막은 것이라 다시 보내면 성공한다. 위치 상실(NOT_LOCALIZED)은 스키마대로 재시도하지 않고, FAULT_HW·FAULT_LEASE·REJECTED·타임아웃은 예전처럼 체인을 멈추고 어드민 재시도 버튼을 기다린다. 재시도해도 실패하면 그때 멈춘다.

응답(ack·plan)의 command_id 가 cmd- 로 시작하면 서버는 그 접두사를 떼고 큐 매칭·명령 로그에 쓴다. 서버가 발행하는 command_id 는 uuidv7 이라 접두사가 붙을 일이 없고, 붙은 채로 맞추면 대기 잡을 못 찾아 타임아웃을 채운다. 세션으로 릴레이하는 페이로드는 로봇이 보낸 원문 그대로다.

warm_up(준비 점검)은 태블릿이 아니라 백오피스에서 쓰는 것이 전제라 관리자 경로도 있다 — POST /api/admin/spot/{robotId}/warm-up 이 세션 없이 같은 큐로 넣고, GET /api/admin/spot/warm-up/{commandId} 가 로봇 응답을 돌려준다. 미준비 응답 (FAULT_HW + fault_detail.code="NOT_READY")은 로봇 고장이 아니라 조치가 필요한 상태이며, fault_detail.message 는 사람이 읽는 안내라 서버·화면 모두 가공하지 않고 그대로 넘긴다.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/spot/command/response X 큐 등록 결과({ok, command_id, status: "queued"}) 또는 거부 사유
orchestration/{sessionId}/spot/ack X 이 세션이 보낸 명령의 SPOT 접수·완료 응답 릴레이
orchestration/{sessionId}/spot/plan X 시나리오 진행 상황 릴레이 (여러 스텝짜리 명령일 때)

11. 태스크 선택지 선택 요청

Topic: orchestration/{sessionId}/task/option/select

Payload

{
  "task_id": "019670ab-1111-7222-8333-444455556666",
  "option_id": "019670ab-aaaa-7bbb-8ccc-ddddeeeeffff"
}
필드 타입 필수 설명
task_id string Y 태스크 ID
option_id string Y 고른 선택지 ID (task_options.id)

선택지에 SPOT 작업(admin이 설정한 task_options.spot_action)이 있으면 선택 즉시 SPOT 명령을 큐잉·발행한다 — 선택 자체가 승인이며 별도 승인 절차는 없다. navigate_to의 destination은 선택 시점에 서버가 동적으로 계산한다: spot_destination_mode='own_location'이면 이 태스크의 장소, 'next_location'이면 이 세션의 다음 방문 장소(user_locations 기준)를 대상으로, 그 장소에 매핑된 waypoint label(device_locations.spot_waypoint_label, 백오피스 "SPOT 장소 관리")을 destination으로 사용한다. 매핑이 없으면 rejected로 응답한다. spot_destination_mode='waypoint'(2026-09-16)이면 계산 없이 백오피스에서 고른 spot_params.destination (waypoint label)을 그대로 쓴다 — SPOT 테스트 화면의 지점 이동과 같은 방식. 선택지 편집기가 SPOT의 robot/{robotId}/waypoints(retained)와 장소 매핑을 읽어 목록에서 고르게 한다. 선택지에 SPOT 작업이 없으면 {ok:true, status:"no_spot_job"}으로 응답한다(에러 아님).

선택지에 반려로봇(REX) 작업(admin이 설정한 task_options.companion_actions — 복수 가능, 102 이전의 단일 companion_capability는 폴백으로 계속 동작)도 있으면 SPOT 작업과 별개로 (다른 큐 그룹이라 병렬) 즉시 큐잉·발행한다. 반려로봇 작업이 여러 개면 전부 한 번에 적재되며, REX 채널 모델(MQTT_SCHEMA-COMPANION_ROBOT "채널 모델")에 따라 채널이 다른 작업(예: say+gesture+led)은 병렬로 발행되고 같은 채널(특히 locomotion)끼리만 순차 실행된다. 반려로봇 잡은 SPOT 해석보다 먼저 적재되므로, SPOT 미배정·목적지 미매핑 등으로 SPOT이 rejected돼도 반려로봇 작업은 그대로 실행된다. 둘 다 있으면 응답의 command_id는 SPOT 것을 우선하고, SPOT 작업이 없고 반려로봇 작업만 있으면 반려로봇 첫 작업의 command_id를 대신 싣는다. 둘 다 없으면 no_spot_job을 그대로 유지한다(호환).

선택지에 LG 가전 전원(task_options.lg_appliance_id + lg_power)이 있으면 SPOT·반려로봇과 독립적으로 HA homeassistant.turn_on/turn_off를 호출한 뒤, 해당 엔티티 state가 목표값이 될 때까지 2초 간격으로 폴링한다(최대 30초). 응답(command_id)에는 영향을 주지 않는다.

태스크 자동 완료: 선택으로 나간 디바이스 명령(SPOT·반려로봇·LG)은 user_task_commands에 기록되고, 그 태스크의 명령이 전부 종결(SPOT/반려로봇 ack 종결 또는 큐 타임아웃, LG 폴링 완료/타임아웃, 적재 실패)되면 서버가 user_tasks를 completed로 바꾸고 task/complete와 동일한 후속 발행(대시보드·내레이션·포인트)을 한다. 실패·타임아웃도 종결로 세어 태스크는 완료 처리된다. 명령이 하나도 없는 선택지는 자동 완료 대상이 아니다(태블릿 task/complete 필요). 자동 완료 뒤 태블릿이 task/complete를 또 보내도 포인트는 재지급되지 않는다.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/task/option/select/response X {ok, command_id, status, error} — SPOT 명령 응답(10번 항목)과 동일 형태. SPOT과 무관한 선택지는 status:"no_spot_job"
orchestration/{sessionId}/location/tasks 외 task/complete 후속 발행 전부 — 조건부: 선택지 디바이스 명령 전부 종결 → 태스크 자동 완료 시 "태스크 완료 요청"과 동일

SPOT 명령이 실제로 큐잉된 경우, 이후 진행 상황은 "10. SPOT 명령 요청"과 동일하게 orchestration/{sessionId}/spot/ack·/plan으로 릴레이된다. 반려로봇(REX) 명령이 큐잉된 경우는 "13. 반려로봇(REX) 명령 요청"과 동일하게 orchestration/{sessionId}/companion/ack로 릴레이된다.


12. 이벤트 선택지 선택 요청

Topic: orchestration/{sessionId}/event/option/select

Payload

{
  "event_id": "019670ab-2222-7333-8444-555566667777",
  "option_id": "019670ab-bbbb-7ccc-8ddd-eeeeffff0000"
}
필드 타입 필수 설명
event_id string Y 이벤트 ID
option_id string Y 고른 선택지 ID (event_options.id)

"11. 태스크 선택지 선택 요청"과 동일한 방식으로 동작한다 — 선택지에 SPOT 작업(admin이 설정한 event_options.spot_action)이 있으면 선택 즉시 SPOT 명령을 큐잉·발행한다(선택 자체가 승인, 별도 승인 절차 없음). 다만 이벤트는 태스크와 달리 고정된 장소가 없는 템플릿이라, navigate_to의 destination은 "이 세션에서 이 이벤트가 실제 매핑된 장소"를 동적으로 역조회해 계산한다: spot_destination_mode='own_location'이면 그 장소, 'next_location'이면 이 세션의 다음 방문 장소(user_locations 기준)를 대상으로, 그 장소에 매핑된 waypoint label(device_locations.spot_waypoint_label, 백오피스 "SPOT 장소 관리")을 destination으로 사용한다. 이 세션에서 해당 이벤트가 아직 어떤 장소에도 매핑되지 않았다면(잘못된 event_id 등) rejected로 응답한다. spot_destination_mode='waypoint'이면 역조회 없이 spot_params.destination을 그대로 쓴다(태스크 선택지와 동일). waypoint 매핑이 없어도 동일하게 rejected. 선택지에 SPOT 작업이 없으면 {ok:true, status:"no_spot_job"}으로 응답한다(에러 아님).

선택지에 반려로봇(REX) 작업(admin이 설정한 event_options.companion_actions — 복수 가능, 채널이 다른 작업은 병렬 발행)도 있으면 SPOT 작업과 별개로(다른 큐 그룹이라 병렬) 즉시 큐잉·발행한다. command_id 우선순위와 no_spot_job 처리는 "11. 태스크 선택지 선택 요청"과 동일하다.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/event/option/select/response X {ok, command_id, status, error} — SPOT 명령 응답(10번 항목)과 동일 형태. SPOT과 무관한 선택지는 status:"no_spot_job"
orchestration/{sessionId}/context O 세션 공유 컨텍스트 갱신 (reason: event_selected, §5-1) — 선택 이력은 user_event_selections 에 남고 journey[].event.state=handled 로 반영

SPOT 명령이 실제로 큐잉된 경우, 이후 진행 상황은 "10. SPOT 명령 요청"과 동일하게 orchestration/{sessionId}/spot/ack·/plan으로 릴레이된다. 반려로봇(REX) 명령이 큐잉된 경우는 "13. 반려로봇(REX) 명령 요청"과 동일하게 orchestration/{sessionId}/companion/ack로 릴레이된다.


13. 반려로봇(REX) 명령 요청

Topic: orchestration/{sessionId}/companion/command

Payload

{
  "capability": "greet",
  "params": { "target": "user", "style": "warm", "_utterance_hint": "안녕하세요" }
}
필드 타입 필수 설명
capability string Y REX capability whitelist 값: stop|say|converse|remind|express_emotion|gesture|greet|approach|look_at|mimic_pose|sit|stand|goto|wander|dock|led|dance|project|show
params object N capability별 파라미터. 예약 키 _priority|_reason|_utterance_hint|_must_complete는 서버가 꺼내 robot/{id}/command envelope의 priority/context로 옮기고 data.params에는 넣지 않는다

REX는 부스에 1대 고정(세션 배정 테이블 없음) — 대상 로봇은 환경변수 COMPANION_ROBOT_ID(기본 REX)로 고정된다. 명령은 디바이스 명령 큐(로봇 단위 직렬화, 그룹 키 companion:{robotId})에 적재되고(status: "queued"), 같은 로봇의 앞 명령이 종결 ack(completed|failed|rejected)를 보내거나 큐 타임아웃(기본 30s)된 뒤 순서대로 CloudEvents envelope(specversion: "1.0", type: "robot.command")으로 robot/{robotId}/command에 발행된다.

단, 태블릿·선택지에서 온 capability: "stop"은 큐를 우회해 priority: "safety"로 즉시 발행되고, 그 로봇 그룹에서 실행 중이던 잡은 ack를 기다리지 않고 cancelled_by_stop으로 즉시 종료된다 (_priority 파라미터로 이 안전 우선순위를 우회할 수 없다). 반면 장소 도착 자동화 규칙의 stop은 액션 체인의 자리를 지킨다 — 앞 액션이 끝난 뒤 발행돼야 취소 대상이 존재하기 때문이다. 이 경우에도 서버는 stop의 ack를 기다리지 않고 발행 즉시 다음 액션으로 넘어간다.

상세 로봇 프로토콜(capability별 params, ack/state/fault/event/capabilities 스키마)은 MQTT_SCHEMA-COMPANION_ROBOT 참고.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/companion/command/response X 큐 등록 결과({ok, command_id, status: "queued"}) 또는 거부 사유(invalid payload|invalid capability|dispatch failed)

명령이 실제로 큐잉된 경우, 이후 진행 상황은 orchestration/{sessionId}/companion/ack로 릴레이된다 ("21. 반려로봇(REX) 명령 응답/릴레이" 참고).


14. 세션 모드 설정

Topic: orchestration/{sessionId}/ai/mode (retained)

Payload

{ "mode": "car", "ttl_sec": 1800 }

또는

{ "mode": "chat" }
필드 타입 필수 설명
mode string Y car|chat
ttl_sec number N mode="car"일 때만 의미 있음. 이 시간(초) 동안 car 모드 유지 — 생략/0 이하면 기본 30분, 2시간을 넘으면 2시간으로 clamp

미래차가 주행 중임을 알리는 세션 모드. car 모드일 때는 관람객이 음성으로 말해도(ai/request type=voice) STT까지만 처리하고 LLM 답변·답변 제안 2개 (ai/suggestions)는 생략한다 — 응답의 message는 빈 문자열("")로 오며 user 발화(transcript)만 히스토리에 저장된다. text 타입 요청에는 영향 없음. **chat 모드(기본)**에서는 지금과 동일하게 LLM 답변·suggestions가 정상 발행된다.

car 모드는 다음 세 가지 경우에 chat으로 해제된다: ① ttl_sec 경과, ② 클라이언트가 {"mode":"chat"}을 다시 발행, ③ 세션 종료(signout) — 이때 서버가 이 토픽의 retained 메시지를 빈 payload로 발행해 지운다. 서버 재시작 시에는 모드가 복원되지 않고 모든 세션이 chat으로 취급된다(메모리 전용 상태).

별도 응답 토픽은 없다(fire-and-forget, retained 저장만).


15. 로봇팔(UF850) 명령 요청

Topic: orchestration/{sessionId}/arm/command

Payload

{
  "action": "pick_basket",
  "params": { "basket": 1 }
}
필드 타입 필수 설명
action string Y pick_basket (현재 유일)
params.basket int Y 집을 바구니 번호: 1|2

로봇팔은 부스에 1대 고정(세션 배정 테이블 없음) — 대상 로봇은 환경변수 ROBOT_ARM_ID(기본 arm-01)로 고정된다. 명령은 디바이스 명령 큐(로봇 단위 직렬화, 그룹 키 robot_arm:{robotId})에 적재되고(status: "queued"), 같은 로봇의 앞 명령이 종결 ack (SUCCESS|FAULT|REJECTED|BUSY)를 보내거나 큐 타임아웃(기본 120s — 실행이 약 30~60초라 다른 디바이스의 30s보다 길다)된 뒤 순서대로 CloudEvents envelope (specversion: "1.0", type: "robot.command", data.command_id = 큐 잡 ID)으로 robot/{robotId}/command에 발행된다. 로봇팔 자체도 명령을 동시 1개만 처리하며 실행 중 수신은 BUSY로 거절한다.

가용성 판정 (2026-09-12): 발행 직전에 robot/{robotId}/state로 추적한 로봇팔 상태를 본다 — connection: "offline"(LWT)이거나 state가 10초 이상 끊겼으면 발행하지 않고 즉시 실패 (arm unavailable: arm_offline | arm_state_stale, 자동화 실행 로그/태스크 명령에 기록). state를 한 번도 받지 못한 로봇팔은 막지 않는다(타임아웃으로 드러남).

BUSY 자동 재전송 (2026-09-12): 로봇팔이 BUSY로 거절하면(서버 재기동·수동 발행·시뮬레이션과 겹친 경우) 잡을 실패로 끝내지 않고, state가 IDLE로 돌아온 뒤 **같은 command_id·새 envelope id**로 재발행한다(리스너가 envelope id로 dedup하므로 id는 새로 발급). 최대 5회, 큐 타임아웃 안에서만. 재전송 수단이 없거나 기한을 넘기면 종전처럼 BUSY를 종결 실패로 기록한다.

상세 로봇 프로토콜(2단계 ack, FAULT의 result/fault_detail 표, state 스키마)은 MQTT_SCHEMA-ROBOT_ARM 참고.

서버 발행 토픽

토픽 Retained 설명
orchestration/{sessionId}/arm/command/response X 큐 등록 결과({ok, command_id, status: "queued"}) 또는 거부 사유(invalid payload|invalid arm action ...|params.basket must be 1 or 2|dispatch failed)

명령이 실제로 큐잉된 경우, 이후 진행 상황은 orchestration/{sessionId}/arm/ack로 릴레이된다 ("24. 로봇팔(UF850) 명령 응답/릴레이" 참고).


16. SPOT 다음 장소로 이동 요청

Topic: orchestration/{sessionId}/spot/navigate-next

(2026-09-19) 태블릿의 "SPOT, 다음 장소로" 버튼. to_location_id 로 목적지 장소를 직접 지정할 수 있고, 없으면 태블릿은 목적지(waypoint)를 몰라도 된다 — 서버가 세션 코스에서 다음 장소를 고른다. 어느 쪽이든 그 장소에 매핑된 SPOT waypoint 로 navigate_to 를 보낸다.

Payload (모든 필드 선택 — 빈 객체 {} 또는 빈 페이로드 허용)

{
  "to_location_id": "019a0b7f-...",
  "from_location_id": "019a0b7e-..."
}
필드 타입 필수 설명
to_location_id string N 목적지 장소 id. 주면 그 장소로 바로 안내 — 단, 이 세션 코스(user_locations)에 포함된 장소여야 하고, 아니면 rejected (destination not in this session's course)
from_location_id string N 지금 있는 장소 id. to_location_id 가 없을 때만 다음 장소 계산에 쓰인다. 비우면 서버가 Vision 위치 이력(user_location_history) 최신 장소를 현재로 본다. 이력도 없으면(출발 전) 첫 장소(visit_order 1) 가 목적지

목적지 해석

  1. 코스 = 이 세션의 user_locations (visit_order 오름차순, 활성 장소만)
  2. to_location_id 가 있으면 → 목적지 = 그 장소 (코스에 없으면 rejected). 현재 장소는 응답 정보용으로만 해석. 3번은 건너뜀
  3. to_location_id 가 없으면: 현재 장소 = from_location_id → 없으면 위치 이력 최신 → 그것도 없으면 "출발 전". 목적지 = 현재 장소의 다음(visit_order+1). 출발 전이면 첫 장소. 현재가 코스에 없으면(비활성 등) 첫 장소. 현재가 마지막 장소면 rejected (already at the last location of the course)
  4. SPOT = 세션에 배정된 활성 spot 디바이스(GetActiveSpotDeviceForUser), waypoint = device_locations.spot_waypoint_label (백오피스 "SPOT 장소 관리"에서 매핑). 매핑이 없으면 rejected (destination not mapped …)
  5. navigate_to {destination: waypoint} 를 디바이스 명령 큐에 적재 (source: tablet) — spot/command 와 같은 큐라 자동화·선택지 출처 명령과 로봇 단위로 직렬화된다

서버 발행 토픽

토픽 Retained 조건 설명
orchestration/{sessionId}/spot/navigate-next/response X 항상 아래 응답
orchestration/{sessionId}/spot/ack X 큐 적재 후 SPOT 접수·완료 응답 릴레이 — command_id 로 매칭해 도착(SUCCESS)/실패(ABORTED/FAULT_*/TIMEOUT) 판단
orchestration/{sessionId}/spot/plan X 조건부 이동이 여러 스텝이면 진행 상황 릴레이

응답 페이로드 (orchestration/{sessionId}/spot/navigate-next/response)

{
  "ok": true,
  "status": "queued",
  "command_id": "019a0c11-...",
  "from_location_id": "019a0b7e-...",
  "from_location_name": "집",
  "to_location_id": "019a0b7f-...",
  "to_location_name": "마트",
  "to_visit_order": 2,
  "destination": "p2",
  "robot_id": "spot-01"
}
필드 타입 설명
ok bool 큐 적재 성공 여부
status string queued | rejected
command_id string 큐 잡 id = SPOT command_id (spot/ack 매칭 키)
error string 거부 사유 — no course assigned to this session / already at the last location of the course / destination not in this session's course / no spot assigned to this session / spot device missing robot_id / destination not mapped to a SPOT waypoint … / invalid payload
from_location_id / from_location_name string 현재 장소로 본 장소 (출발 전이면 없음)
to_location_id / to_location_name / to_visit_order string / string / int 목적지 장소와 방문 순서(1부터)
destination string SPOT 에 보낸 waypoint label
robot_id string 명령을 받은 SPOT

거부 응답에도 해석까지 된 from_*/to_* 는 채워서 내려준다(예: waypoint 미매핑이면 어느 장소가 문제인지 보이도록). 사용자 위치 자체는 바꾸지 않는다 — 실제 도착은 Vision cctv/person/zone 으로 감지돼 position·도착 자동화가 돈다.


SERVER → CLIENT (응답 / 서버 발행)

1. 페르소나 생성 응답

Topic: orchestration/{deviceInternalId}/create-persona/response

성공 Payload

{
  "ok": true,
  "session_id": "01966e3d-4bcd-7aec-8f61-2e8f2f1e5a3b",
  "persona": {
    "id": "019670ab-bbbb-7ccc-d002-ee2233445566",
    "title": "New Journey Planner",
    "summary": "한 줄 요약",
    "avatar_url": "https://example.com/ari.png"
  }
}

실패 Payload

{
  "ok": false,
  "error": "persona not found"
}
필드 타입 설명
ok boolean 성공 여부 (true / false)
session_id string 발급된 세션 ID (UUID v7). 이후 모든 요청 토픽에 사용
persona object 결정된 페르소나. 실패 시 생략
persona.id string 페르소나 ID (UUID v7)
persona.name string 페르소나 이름
persona.title string 직책
persona.summary string 한 줄 요약
persona.avatar_url string 아바타 이미지 URL
error string 에러 메시지. 성공 시 생략

서버 발행 토픽

토픽 Retained 조건 설명
server/session/start X 성공(ok: true) 시에만 이 응답과 함께 Vision(카메라 트래킹) 서비스에 세션 시작을 알림. 별도 브로커/스키마(MQTT_SCHEMA-VISION 참고), QoS 1. 구 vision/session/*에도 병행 발행

2. 세션 종료 응답

Topic: orchestration/{sessionId}/signout/response

Payload

{
  "ok": true,
  "session_id": "01966e3d-4bcd-7aec-8f61-2e8f2f1e5a3b",
  "status": "ended"
}
필드 타입 설명
ok boolean 항상 true
session_id string 종료된 세션 ID
status string 항상 "ended"

3. AI 채팅 응답

Topic: orchestration/{sessionId}/ai/response

Payload

{
  "ok": true,
  "context_id": "019670ab-1234-7def-a001-bc2345678901",
  "transcript": "오늘 일정이 어떻게 돼?",
  "message": "오늘은 오전에 A 미팅, 오후에 B 보고가 예정되어 있어요."
}
필드 타입 설명
ok boolean 성공 여부
context_id string 요청과 동일한 context_id
transcript string 음성 전사 텍스트 (type=voice일 때). 텍스트 요청이면 입력값 그대로
message string AI 응답 텍스트
error string 오류 발생 시에만 포함

4. AI 답변 제안 (suggestions)

Topic: orchestration/{sessionId}/ai/suggestions

ai/response 발행 직후 서버가 자동 발행. AI의 마지막 답변에 대해 사용자가 이어서 할 만한 답변 제안 2개를 LLM이 생성한다. 생성/파싱 실패 시 발행하지 않으며, ai/response 흐름에는 영향을 주지 않는다.

Payload

{
  "context_id": "019670ab-1234-7def-a001-bc2345678901",
  "suggestions": ["응, 좋아", "다시 설명해줄래?"]
}
필드 타입 설명
context_id string 대응하는 ai/response와 동일한 context_id
suggestions string[] 사용자가 이어서 할 만한 답변 제안 (항상 2개)

5-1. 세션 공유 컨텍스트 (2026-09-26)

Topic: orchestration/{sessionId}/context (retained, QoS 1)

내러티브(§5 여정 내레이션)가 프롬프트에 넣는 재료를 그대로 구조화한 것. 미래차 서버·반려로봇이 구독해 내러티브와 같은 세션 데이터로 말한다. 갱신 계기(reason): session_start(로그인 발행 직후, 비전 프로필 대기 뒤) vision_profile task_status arrival event_published event_selected vehicle_step(미래차 route/state) narrative. 같은 세션의 300ms 안 연속 갱신은 마지막 것 하나만 나간다. signout 시 빈 페이로드 retained 로 삭제.

{
  "session_id": "01a0…", "updated_at": "2026-09-26T14:03:10+09:00", "reason": "task_status",
  "user": { "description": "검정 코트, 30대", "gender": "female", "has_children": false, "username": "예찬" },
  "companion": { "title": "Ari", "summary": "New Journey Planner" },
  "journey": [
    { "location_id": "…", "name": "공장", "short_summary": "…", "visit_order": 2, "current": true, "arrived_minutes_ago": 3,
      "tasks": [ { "id": "…", "title": "…", "type": "packing", "status": "completed", "scheduled_time": "14:30",
                   "description": "첫 문장", "packing": { "picked": 2, "total": 3 },
                   "result": "준비물 챙김: A, B / 남음: C; 명령: spot navigate_to→P1 완료" } ],
      "event": { "id": "…", "title": "타이어 펑크", "category": "sudden", "state": "handled",
                 "published_at": "…", "selected_option": "정비소로 이동", "handled_at": "…" } }
  ],
  "vehicle": { "step": 3, "phase": "driving", "from_place": "충전소", "to_place": "마트", "prev_place": "", "next_place": "", "next": "", "updated_at": "…" },
  "recent_narratives": [ { "at": "14:01", "source": "arrival", "text": "…" } ]
}
필드 타입 설명
session_id string 세션 ID (UUID v7)
updated_at string 이번 발행 시각, RFC3339 KST
reason string 발행 계기: session_start | vision_profile | task_status | arrival | event_published | event_selected | vehicle_step | narrative
user object 사용자
user.description string 비전 외형 분석(cctv/person/profile) 결과 + 설문 답 (users.description). 없으면 ""
user.gender string male | female | ""(미수집)
user.has_children boolean 자녀 유무
user.username string 이름. 없으면 생략
companion object AI 동반자
companion.title string 동반자 이름 (personas.title)
companion.summary string 한 줄 소개
journey[] array 방문 순서(visit_order 오름차순)
journey[].location_id string 장소 ID (UUID v7)
journey[].name string 장소명
journey[].short_summary string 장소 한 줄 요약. 없으면 생략
journey[].visit_order integer 방문 순서 (1부터)
journey[].current boolean 현재 위치인가 (마지막 cctv/person/zone 기준)
journey[].arrived_minutes_ago integer 현재 위치일 때만 — 도착 뒤 몇 분 지났나
journey[].tasks[] array 이 장소의 활성 태스크
journey[].tasks[].id string 태스크 ID (UUID v7)
journey[].tasks[].title string 제목
journey[].tasks[].type string 태스크 타입 (tasks.type)
journey[].tasks[].status string pending | in_progress | completed | skipped
journey[].tasks[].scheduled_time string HH:MM. 없으면 생략
journey[].tasks[].description string 설명 첫 문장. 없으면 생략
journey[].tasks[].packing object packing 타입만
journey[].tasks[].packing.picked integer 챙긴 준비물 수
journey[].tasks[].packing.total integer 준비물 전체 수
journey[].tasks[].result string 내러티브 "↳ 결과" 줄과 같은 문자열 — 고른 선택지, 디바이스 명령 상태(최근 3건), 준비물 챙김/남음, 자동화 결과. 없으면 생략
journey[].event object 이 장소에 매핑된 이벤트. 없으면 생략
journey[].event.id string 이벤트 ID (UUID v7)
journey[].event.title string 제목
journey[].event.category string 카테고리
journey[].event.state string pending(미발행) | published(발행됨, 미처리) | handled(선택지 선택함 — user_event_selections)
journey[].event.published_at string 발행 시각 RFC3339 KST. published/handled 일 때만
journey[].event.selected_option string 고른 선택지 라벨. handled 일 때만
journey[].event.handled_at string 선택 시각 RFC3339 KST. handled 일 때만
vehicle object 미래차 future-car/response 의 마지막 값. 받은 적 없으면 생략
vehicle.step integer 미래차 스텝 번호
vehicle.phase string route 수신 시 driving, state 수신 시 그 phase(idle | waiting | driving | arrived | done)
vehicle.from_place / to_place string route 출발지 / 목적지. 없으면 생략
vehicle.prev_place / next_place string state 이전 / 다음 장소. 없으면 생략
vehicle.next string state next (exit 면 마지막 스텝). 없으면 생략
vehicle.updated_at string RFC3339 KST
recent_narratives[] array 이 세션의 최근 내러티브 최대 5개(시간순). 서버 재시작 시 비워진다
recent_narratives[].at string HH:MM KST
recent_narratives[].source string intro | arrival | task_status | car_step | task_pre_start | event_handled | journey_complete | location_complete | event_precursor
recent_narratives[].text string 내러티브 본문

5-2. 세션 엔딩 (2026-10-02)

Topic: orchestration/{sessionId}/ending (retained, QoS 1)

세션이 끝날 때 엔딩 화면이 쓸 재료를 한 페이로드로 1회 발행한다 (기획 "엔딩 가이드_261001", 설계 점검은 ENDING_SCREEN-DATA_SOURCES §2 A·B). 계기(reason): signout(태블릿 종료) · admin(백오피스 강제 종료) · preview(백오피스 미리보기/재발행) · replaced(같은 태블릿이 새 세션을 만들어 이전 세션이 닫힘, 148) · idle(유휴 타임아웃, 148). 발행 순서: ended_at 기록 → 엔딩 발행 → /context retained 삭제. 뒤에 두면 내러티브 히스토리·차량 스텝이 이미 지워져 재료가 없다. retained 라 엔딩 화면이 늦게 붙어도 받는다. journey 는 §5-1 /context 의 journey 와 같은 모양이다. 한 번 온다 (2026-10-10): 세션이 끝나면 서버가 재료를 바로 모으고, LLM 이 ending_line·middle_line·moments[].response(LLM 문장)·years[](연도별 카드)를 다 만든 뒤(보통 10~40초, 최대 45초) retained 로 한 번 발행한다. LLM 이 없거나 다 실패하면 데이터만으로(ending_line: "", middle_line: "", moments[].response 는 규칙 짝, years: []) 그 한 번을 낸다. (예전엔 데이터만 먼저, LLM 뒤에 한 번 더 — 두 번 냈다.)

Payload

{
  "session_id": "01a0e33d-...",
  "published_at": "2026-09-30T22:29:05+09:00",
  "reason": "signout",
  "user": {
    "username": "여행자",
    "gender": "female",
    "has_children": true,
    "description": "검은 코트에 안경 …",
    "user_images": [
      {
        "type": "default",
        "image_url": "https://…"
      }
    ]
  },
  "companion": {
    "id": "…",
    "title": "똑똑한 비서",
    "summary": "…",
    "avatar_url": "https://…"
  },
  "session": {
    "started_at": "2026-09-30T22:16:00+09:00",
    "ended_at": "2026-09-30T22:29:05+09:00",
    "duration_sec": 785,
    "since_date": "2026.09.30"
  },
  "traits": [
    "lives_with_parents",
    "has_children"
  ],
  "journey": [
    {
      "location_id": "…",
      "name": "집",
      "visit_order": 1,
      "tasks": [
        {
          "id": "…",
          "title": "…",
          "type": "…",
          "status": "completed",
          "result": "고른 선택지: … / 명령: …"
        }
      ],
      "event": {
        "…": "…"
      }
    }
  ],
  "task_counts": {
    "total": 6,
    "completed": 4,
    "skipped": 1,
    "in_progress": 0,
    "pending": 1
  },
  "intro_message": "부모님과 살아요",
  "years": [
    {
      "years_later": 1,
      "label": "1 YEAR LATER",
      "title": "부모님과 첫 장거리 여행",
      "situation": "오래 달려 지친 오후",
      "action": "두 시간쯤 지나면 먼저 쉬어 가요.",
      "theme_key": "care"
    },
    {
      "years_later": 2,
      "label": "2 YEARS LATER",
      "title": "멀미한 오후",
      "situation": "굽은 길에서 창문을 연 순간",
      "action": "다음엔 완만한 길로 돌아가요.",
      "theme_key": "relax"
    },
    {
      "years_later": 3,
      "label": "3 YEARS LATER",
      "title": "다시 떠난 봄여행",
      "situation": "익숙한 길에서 웃던 오후",
      "action": "예전에 좋아했던 길부터 보여줘요.",
      "theme_key": "joy"
    }
  ],
  "utterances": [
    {
      "at": "2026-09-30T22:20:00+09:00",
      "text": "조용히",
      "source": "car",
      "location_id": "…",
      "location_name": "차",
      "place": "차에서"
    }
  ],
  "moments": [
    {
      "at": "2026-09-30T22:16:10+09:00",
      "text": "부모님과 살아요",
      "source": "intro",
      "place": "처음에",
      "location_id": "",
      "location_name": "",
      "response": "저녁 재료를 부드러운 걸로 골랐어요",
      "response_source": "llm",
      "care": []
    },
    {
      "at": "2026-09-30T22:20:00+09:00",
      "text": "조용히",
      "source": "car",
      "place": "차에서",
      "location_id": "…",
      "location_name": "차",
      "response": "집에 오니 로봇이 말없이 공기만 바꿔줬어요",
      "response_source": "llm",
      "care": [
        {
          "at": "2026-09-30T22:21:00+09:00",
          "kind": "command",
          "location_id": "…",
          "location_name": "집",
          "task_id": "…",
          "task_title": "공기 바꾸기",
          "text": "LG 가전 on → 공기청정기 (공기 바꾸기)"
        }
      ]
    }
  ],
  "emotions": {
    "samples": 40,
    "counts": {
      "joy": 22,
      "neutral": 18
    },
    "dominant": "joy"
  },
  "narratives": [
    {
      "at": "14:01",
      "at_full": "2026-09-30T14:01:00+09:00",
      "source": "intro",
      "text": "…"
    },
    {
      "at": "22:28",
      "at_full": "2026-09-30T22:28:00+09:00",
      "source": "journey_complete",
      "text": "…",
      "location_id": "…",
      "location_name": "집"
    }
  ],
  "vehicle": {
    "step": 3,
    "phase": "…"
  },
  "vehicle_history": [
    {
      "at": "2026-09-30T14:05:00+09:00",
      "type": "route",
      "step": 1,
      "phase": "driving",
      "from_place": "home",
      "to_place": "mart"
    },
    {
      "at": "2026-09-30T14:09:00+09:00",
      "type": "state",
      "step": 1,
      "phase": "arrived",
      "prev_place": "home",
      "next_place": "cafe",
      "next": "cafe"
    }
  ],
  "vehicles": {
    "current": {
      "id": "…",
      "role": "current",
      "brand": "hyundai",
      "model": "넥쏘",
      "trim": "",
      "image_url": "…",
      "description": "…",
      "reason": "체험 시작 시 타고 있던 차 (앱 설정 session_current_vehicle_id)",
      "source": "rule",
      "set_at": "2026-09-30T14:00:00+09:00"
    },
    "recommended": {
      "id": "…",
      "role": "recommended",
      "brand": "kia",
      "model": "EV9",
      "trim": "GT-line",
      "image_url": "…",
      "description": "…",
      "reason": "…",
      "source": "llm",
      "set_at": "2026-09-30T22:19:00+09:00",
      "confirmed_at": "2026-09-30T22:21:00+09:00"
    },
    "delivered": {
      "id": "…",
      "role": "delivered",
      "brand": "kia",
      "model": "EV9",
      "trim": "GT-line",
      "image_url": "…",
      "description": "…",
      "reason": "출고 확인 — 추천 차와 동일",
      "source": "rule",
      "task_id": "…",
      "set_at": "2026-09-30T22:25:00+09:00"
    }
  },
  "ending_line": "새 차는 처음이지만, 우리는 처음이 아니니까.",
  "middle_line": "오늘 그 말, 기억할게요."
}
필드 타입 설명
session_id string 세션 ID (UUID v7)
published_at string 발행 시각 RFC3339 KST
reason string signout(태블릿 종료) | admin(백오피스 강제 종료) | preview(백오피스 미리보기/재발행) | replaced(같은 태블릿의 새 세션이 이전 세션을 닫음) | idle(앱 설정 session_idle_timeout_minutes 동안 활동 없음) — 148
user object 사용자
user.username string 이름
user.gender string male | female | ""
user.has_children boolean 자녀 유무
user.description string 비전 외형 설명 + 설문 답
user.user_images[] array LLM 이 고른 사용자 사진. 없으면 생략
user.user_images[].type string default(대표) | joy | surprise | neutral | anger
user.user_images[].image_url string 사진 URL
companion object AI 동반자
companion.id string 동반자 ID (UUID v7)
companion.title string 이름
companion.summary string 한 줄 소개
companion.avatar_url string 아바타 URL
session object 세션 시간
session.started_at string 시작 시각 RFC3339 KST (users.created_at)
session.ended_at string 종료 시각 RFC3339 KST. 미리보기(진행 중)면 지금 시각
session.duration_sec integer 시작 → 종료 초
session.since_date string YYYY.MM.DD — 화면의 SINCE 표기
traits[] array of string 성향 칩, 화면 순서 — lives_with_parents(부모님과) | has_children(아이) | has_pet(반려동물). 아이는 users.has_children(메타 문항 효과), 부모님·반려동물은 세션 생성 때 LLM 이 메타 답 + 자기소개를 보고 판별(157, 앱 설정 user_traits_prompt, user_traits 테이블). 판별 전·실패면 빠진다. 없으면 []
journey[] array §5-1 /context 의 journey 와 같은 모양·타입
task_counts object 태스크 집계
task_counts.total / completed / skipped / in_progress / pending integer 전체 / 완료 / 건너뜀 / 진행 중 / 대기
intro_message string create-persona 의 intro_message 원문. 138 부터 저장. 없으면 ""
years[] array 연도별 카드 (158·159, 2026-10-09) — 1·2·3년 후 최대 3장, years_later 순 (오늘·처음 만난 날은 moments[] 로 그린다). 발행 전에 LLM 이 채운다(앱 설정 ending_years_prompt, 비우면 안 만듦). LLM 실패면 []. 목록에 없는 모드·빈 문장·같은 해 중복·0년 카드는 서버가 버려 3장보다 적을 수 있다
years[].years_later integer 1·2·3 = N년 후
years[].label string 화면 머리말 — 1 YEAR LATER | 2 YEARS LATER | 3 YEARS LATER
years[].title / situation / action string 그해의 일 / 그때 상황 / AI 가 하는 일 한 줄
years[].theme_key string 카드 배경 테마 키 — 백오피스 "케어 모드"(care_modes)에서 켠 것 중 LLM 이 설명(description_prompt)을 보고 고른 모드의 theme_key. FE 는 이 값으로 배경 테마를 고른다 (2026-10-09 care_mode 객체를 빼고 이것만)
utterances[] array 차에서 한 말(source == "car")만, 시간순 (2026-10-09). 실제 발화가 0건이고 가상 발화 설정이 켜져 있으면 LLM 이 지어낸 발화(source == "generated", utterances_generated: true)가 대신 들어간다 (2026-10-10). 없으면 [] — 처음에·태블릿 챗 발화는 moments[] 에 있다
utterances[].at / text / source / location_id / location_name / place string 시각 / 내용(STT) / car | generated / 장소 / 자리말(차에서 또는 장소 이름)
moments[] array ⑤+⑥ 합친 것 (2026-10-09) — care[] 는 토픽에서 빠졌고, 전체 발화는 여기 있다 (utterances[] 는 차에서 한 말만). 최근 발화 최대 6개(시간순)마다 그 말에 맞춰 AI 가 해 준 일 한 문장. 화면은 뒤 3개를 쓴다. 없으면 []
moments[].at / text / source / place / location_id / location_name string 발화 — 시각 / 내용(음성이면 STT) / intro|car|chat / 자리말(처음에·차에서·장소 이름·"") / 장소
moments[].response string 그 말에 맞춰 해 준 일 한 문장 (예: 집에 오니 로봇이 말없이 공기만 바꿔줬어요). 맞는 게 없으면 ""
moments[].response_source string llm(발행 전에 LLM 이 발화·해 준 일 전체를 보고 씀, 앱 설정 ending_moment_prompt) | rule(LLM 없음·실패 — 그 말 뒤 ~ 다음 말 전까지 한 일의 첫 문장) | ""(없음)
moments[].care[] array 그 말 뒤 ~ 다음 말 전까지 한 일 {at, kind(event_option|command|automation), location_id, location_name, task_id?, task_title?, text} (규칙 짝). LLM 문장은 시간 밖의 일을 고를 수 있어 이것과 다를 수 있다
emotions object Vision 표정 이력 집계
emotions.samples integer 표본 수
emotions.counts object (string → integer) 표정별 횟수 (joy | surprise | neutral | anger)
emotions.dominant string 최빈 표정. 동률이면 이름순. 표본이 없으면 ""
narratives[] array AI 가 오늘 한 말 전체 — ai/summary 로 나간 내러티브를 발행마다 저장한 것(user_narratives, 147), 시간순. 재발행본에도 그대로 실린다. 147 이전 세션만 메모리 최근 5개로 대신한다
narratives[].at / at_full string HH:MM KST / RFC3339 KST (147 이전 세션은 at_full 생략)
narratives[].source string §5-1 recent_narratives[].source 와 같다 (+ milestone_*)
narratives[].text string 본문
narratives[].location_id / location_name string 발행 시점 세션 위치. 모르면 생략
vehicle object 미래차 마지막 스텝. 타입은 §5-1 vehicle 과 같다. 재발행본에는 없을 수 있다 (메모리) — 이력은 아래 vehicle_history
vehicle_history[] array 미래차 주행 이력 — future-car/response 의 route/state 를 받은 순서대로 전부 (user_vehicle_steps, 147). 없으면 []
vehicle_history[].at string 수신 시각 RFC3339 KST
vehicle_history[].type string route | state
vehicle_history[].step integer 스텝 번호
vehicle_history[].phase / from_place / to_place / prev_place / next_place / next string §5-1 vehicle.* 와 같은 뜻. 빈 값은 생략
vehicles object (role → 차량) 세션에 지정된 차량, 역할별. 셋 다 코드가 채운다 (147): current = 세션 생성 시 앱 설정 session_current_vehicle_id(기본 넥쏘, 비우면 없음) · recommended = 견적 브리핑 LLM (140) · delivered = 출고 준비(vehicle_ready) 태스크 완료 시 추천 차 복사. 없으면 {}
vehicles.<role> object 차량 한 대 — 타입은 §6 대시보드 태스크의 vehicle 과 같다
vehicles.recommended.confirmed_at string 견적 "주문하기" = 견적 브리핑 태스크 완료 시각 RFC3339 KST. 미확정이면 생략. 추천이 바뀌면(LLM 재지정·어드민) 다시 미확정 (147)
ending_line string LLM 엔딩 멘트 (145). LLM 이 만든 뒤 엔딩과 함께 한 번에 나간다(2026-10-10). LLM 이 없거나 실패하면 "". 앱 설정 ending_line_prompt(비우면 생성 안 함). 20자 이내를 요구하되 30자까지는 그대로 두고 그 위는 문장부호에서 끊는다. 어드민 미리보기는 ?line=1 로 동기 생성
middle_line string LLM 엔딩 중간 멘트 (164, 2026-10-12). 앱 설정 ending_middle_line_prompt 로 만든 한 줄 — ending_line 과 같은 재료·변수·길이 규칙(20자 요구·30자 상한), 엔딩 발행 전에 같이 만들어 한 번에 나간다. LLM 없음·실패·프롬프트 비움이면 "". 어드민 미리보기 ?line=1 이 같이 만든다

발화 저장 (138) — ai_messages 에 source(chat|car|intro)·location_id 가 생겼다. 자기소개는 create-persona 때 source=intro 로 남기고(LLM 대화 히스토리에는 넣지 않는다), 채팅·차 모드 발화는 그 시점의 세션 위치를 같이 적는다. 138 이전 세션의 발화는 source=chat·location_id="" 로 보인다.

어드민 — GET /api/admin/users/:id/ending 미리보기(발행 안 함, reason=preview), POST /api/admin/users/:id/ending/publish retained 재발행. 백오피스 세션 상세 "엔딩" 탭.


5. FE 내부 요약 응답

Topic: orchestration/{sessionId}/ai/summary

ai/summarize 요청에 대한 LLM 요약 결과. 요청과 동일한 context_id로 매핑한다.

서버 자발 발행 — 여정 내레이션 (2026-09-06): 태블릿 상단 AI Narrative 용. "지금 왜 이 행동을 하는지"를 하루 앞뒤 흐름과 연결해 설명하는 2~3문장을 LLM이 만들어 요청 없이 이 토픽으로 발행한다. 요청 응답과 구분하도록 source가 붙고, context_id는 narrative:{key}.

source 발행 시점 추가 필드 context_id
intro 세션 생성(로그인 발행) 직후 — 오늘 하루 브리핑 — narrative:intro
arrival 장소 도착(cctv/person/zone으로 장소가 바뀐 순간) — narrative:arrival:{location_id}
task_status task/start·task/complete·task/skip 직후 task_id, status(started|completed|skipped) narrative:task_status:{task_id}:{status}
car_step 미래차 future-car/response의 route(스텝 이동 시작)와 state phase=arrived(스텝 도착) step(1~4) narrative:car_step:{step}:route / …:arrived
location_complete (2026-09-19) 한 장소의 태스크가 전부 끝난 순간(완료/건너뜀, 세션·장소당 1회) + location_complete_summary_delay_seconds(기본 5초) 지연 location_id narrative:location_complete:{location_id}
event_precursor (2026-09-19) 이벤트 팝업 발행이 결정된 뒤 event_precursor_summary_delay_seconds(기본 5초) 지연 — "왜 이 상황이 생기는지" 전조. 이 뒤 event_publish_after_precursor_seconds(기본 5초) 더 기다렸다가 orchestration/{sessionId}/event 발행 event_id, location_id narrative:event_precursor:{event_id}
task_pre_start (2026-09-26) 장소 도착 내레이션 직후, 그 장소의 첫 대기 태스크(scheduled_time 이른 것 → display_order 최소) — 세션·태스크당 1회. 큐에서 꺼낼 때 태스크가 이미 시작됐거나 장소가 바뀌었으면 취소 task_id, status: pending narrative:task_pre_start:{task_id}
event_handled (2026-09-26) event/option/select 처리 직후 — 고른 선택지 라벨을 재료로(선택 이력 user_event_selections) event_id narrative:event_handled:{event_id}:{option_id}
journey_complete (2026-09-26) 마지막 장소(visit_order 최대)의 태스크가 전부 끝난 순간 — 이 장소에서는 location_complete 대신 이것만 (여정 내레이션이 꺼져 있으면 기존 장소 완료 요약) location_id narrative:journey_complete

발화 간격 (2026-09-26): 같은 세션의 내레이션(여정 내레이션 + 장소 완료·이벤트 전조 요약)은 세션별 큐로 순서대로 나가며 직전 발행 뒤 narrative_min_gap_seconds(앱 설정, 기본 1초) 이상 띄운다. 큐에 대기 중인 같은 키는 버리고, 처리 직전 상황이 바뀐 안내(시작 전 → 이미 시작)는 취소한다. 이전(2026-09-26 전)에는 같은 세션의 동시 요청을 버렸다.

location_complete·event_precursor는 프롬프트가 각각 location_complete_summary_prompt / event_precursor_summary_prompt로 분리돼 있고 (앱 설정), 같은 세션의 두 요약이 같은 태스크 완료에서 동시에 생겨도 서로 지연(초)만큼 간격을 두고 나간다. 전조 요약 치환 변수: {{event_title}} {{event_category}} {{event_type}} {{event_description}} {{event_options}} {{trigger}} + 공통({{persona_name}} {{persona_summary}} {{user_description}} {{location_name}} {{task_list}} {{journey}} {{recent}}). 장소 완료 요약 치환 변수: {{task_list}}(이 장소 태스크 + ↳ 결과) {{completed_task_history}} {{next_location_name}} {{next_task_list}} + 공통. event_precursor_summary_enabled=0이면 예전처럼 결정 즉시 이벤트 발행.

LLM에 넘기는 컨텍스트 — 현재 태스크만 보지 않는다: 사용자 프로필(메타 응답 문장·자녀 여부), AI 동반자 (이름·요약, prompt는 system), 오늘 여정 전체(방문 순서대로 장소·상황 요약·장소별 태스크의 상태/타입/ 예정 시각/설명·준비물 챙김 수·예정 이벤트), 현재 위치와 체류 시간, [지금 계기](트리거 설명 — 차량이면 스텝 번호·출발지/도착지·앱 설정 car_step_meanings의 스텝 의미). 이후 장소의 대기 태스크가 곧 "이후 예정"이라 "오후 출장이 있으니 이동 전에 마트에서 준비물을 사 둔다"처럼 뒤 일정을 이유로 연결한다. 프롬프트는 앱 설정 journey_narrative_prompt 하나, 트리거별 초점은 {{focus}}로 서버가 채운다. on/off는 journey_narrative_enabled(기본 1). 켜져 있으면 구 task_status_summary_*(현재 태스크만 보는 요약)를 대체하며, 끄면 구 방식으로 돌아간다. LLM 실패 시 발행하지 않는다. 같은 세션에 내레이션이 생성 중이면 뒤 트리거는 건너뛴다. state는 retain 토픽이라 재접속 시 재전달되는데, 세션별로 같은 스텝·단계는 한 번만 처리하고 종료된 세션은 무시한다.

역할 구분: AI Narrative(이 토픽) = "왜 지금 이 행동을 하는지, 하루의 앞뒤 상황을 연결". AI Chat(ai/request) = "구체적으로 무엇을 챙기고 실행해야 하는지 지원". 내레이션은 준비물 현황·태스크 개수 같은 세부 실행을 다루지 않는다.

반복 억제·결과 연결 (2026-09-15): 서버가 세션별 최근 내러티브 5개를 기억해 프롬프트({{recent}})에 넣고 "이미 말한 것은 반복하지 말고 바뀐 것만" 지시한다. 문장 구조는 현재 상황 → 다음 행동 → 다음 장면 연결(각 1문장). 여정 블록의 태스크마다 ↳ 결과 줄(고른 선택지·로봇/디바이스 명령 상태·챙긴/남은 준비물·자동화 실행 결과)이 붙어 실행 결과를 다음 장면으로 잇는다. 같은 최근 내러티브는 ai/request 시스템 프롬프트에도 들어가고(앱 설정 ai_chat_role_prompt), 챗은 진행 안내를 먼저 꺼내지 않고 실행 지원만 하도록 경계를 둔다. 히스토리는 메모리라 서버 재시작 시 비워진다.

{ "ok": true, "context_id": "narrative:intro", "summary": "오늘 오후 2시에 판교 출장이 있네요. 이동 전에 출장 준비물을 챙기고 마트에서 간식까지 사 두면 차에서 여유롭게 브리핑을 들을 수 있어요.", "source": "intro" }
{ "ok": true, "context_id": "narrative:car_step:2:route", "summary": "이제 마트에서 UAM 스테이션으로 출발할게요. 오후 출장 시간에 맞추려면 여기서 환승하는 게 가장 빨라요.", "source": "car_step", "step": 2 }

구 태스크 상태 요약(journey_narrative_enabled=0일 때) 페이로드:

{ "ok": true, "context_id": "task_status:019670ab-…:started", "summary": "중요한 미팅 전이라 서둘러 출근 준비를 시작했어요.",
  "source": "task_status", "task_id": "019670ab-…", "status": "started" }

성공 Payload

{
  "ok": true,
  "context_id": "019670ab-1234-7def-a001-bc2345678901",
  "summary": "요약된 결과 텍스트입니다."
}

실패 Payload

{
  "ok": false,
  "context_id": "019670ab-1234-7def-a001-bc2345678901",
  "error": "AI processing failed"
}
필드 타입 설명
ok boolean 성공 여부
context_id string 요청과 동일한 context_id
summary string 요약 결과 텍스트 (성공 시)
error string 오류 메시지 (실패 시에만)

6. 대시보드 장소·태스크 목록

Topic: orchestration/{sessionId}/dashboard

Retained. 페르소나 생성 완료 시 서버가 자동 발행. 세션이 끝나면 서버가 이 retained 를 지운다 (빈 페이로드 retained 발행, 2026-10-09) — signout·강제 종료·유휴 종료· 같은 태블릿 새 세션·테스트 세션 만료 어느 길이든. 끝난 세션에는 다시 발행하지 않는다. 태블릿은 빈 페이로드를 받으면 무시해야 한다 (JSON 아님). 예전 세션 ID 로 구독하면 아무것도 오지 않는 게 정상이다 — 예전엔 끝난 세션의 dashboard 가 브로커에 영구히 남아, 옛 세션 ID 로 구독한 태블릿에 그 세션 태스크가 보였다. 태스크는 전체가 아니라 LLM이 선택한 장소당 N개만 포함된다 (N = 어드민 앱 설정 tasks_per_location, 기본값 3). 단, 백오피스 태스크 상세에서 "📌 항상 배정 (고정 태스크)"(tasks.is_fixed=1, 2026-09-19)를 켠 태스크는 선택과 무관하게 그 장소에 항상 포함된다 — 고정 태스크가 N개를 넘으면 고정 전부만 배정되고 LLM/랜덤 선택 슬롯은 0이 된다. 비활성 태스크는 고정이어도 제외. 자녀 조건 필수 풀(2026-10-07): tasks.is_fixed=2 는 자녀 있는 세션, 3 은 자녀 없는 세션의 필수 풀이다. 장소 셔플처럼 같은 장소·같은 조건끼리 한 묶음이 되고, 조건 맞는 세션에는 그 묶음에서 LLM 이 1건만 골라 넣는다 (1건뿐이면 그것, LLM 실패 시 규칙 기반). 조건이 안 맞는 세션에는 후보에서 아예 빠진다. 예) 집: 아이 등원 준비물=2, 외출 준비물·출근 준비물=3 → 자녀 없음이면 외출·출근 중 1건. 판정은 users.has_children.

LLM 선택 기준:

  • 페르소나 이름·summary
  • users.description — CCTV 등 외부 시스템이 기록한 사용자 관찰 정보
  • 동일 device + persona 페어의 이전 세션 태스크 완료 이력 (미완료 태스크 우선)
  • 세션 생성 시 장소에 매핑된 이벤트의 description_llm — 장소별 태스크 선택보다 앞서 user_locations에 이벤트를 매핑한다. STEP2(visit_order=2)에는 일반(normal) 이벤트 1건, STEP3(visit_order=3)에는 돌발(sudden) 이벤트 1건만 매핑하고 STEP1·STEP4(집)에는 매핑하지 않는다. STEP2 일반 이벤트의 description_llm을 태스크 선택 프롬프트의 {{event_note}}로 전달해 이벤트와 연관된 태스크가 뽑히도록 한다. 실제 팝업 발행은 사용자가 그 장소에 도착했을 때(이벤트 팝업).

LLM 실패 시 미완료 우선 → display_order 순 rule-based로 fallback.

장소별 상황 요약: 태스크 시딩이 끝나면 LLM이 장소마다 short_summary(≤10자)를 만들어 저장하고 이 토픽을 한 번 더 발행한다 — 첫 발행에는 short_summary가 비어 있을 수 있으니 클라이언트는 retained 갱신을 그대로 반영하면 된다.

tasks[].type에 따라 태스크 항목의 스키마 자체가 다르다. basic/detailed 등 일반 타입은 description+options[]({id, label, required_product})를 쓰지만, vehicle_ready(출고 준비) / quote_briefing(견적 브리핑) / business_trip_briefing(출장 업무 브리핑) / schedule_briefing(이동/일정 브리핑) / product_recommendation(상품 추천/비교) 5가지 타입은 "LLM 브리핑 계열"로 데이터가 일반화되어 있다 — 모두 동일하게 title/description이 세션마다 LLM이 생성한 값으로 대체되고 issue_title/issue_description/personalization_options[]가 추가된다. 여기에 견적 브리핑 계열(quote_briefing·schedule_briefing·product_recommendation, 2026-09-12 확장)은 vehicle_options[](LLM이 세션별로 결정한 옵션 목록)가 추가로 내려간다(아래 예시 참고). 필드명은 FE 호환을 위해 세 타입 모두 vehicle_options이며, 내용만 다르다 — 견적은 차량 옵션, 이동/일정은 일정·이동 옵션, 상품 추천/비교는 추천 상품.

vehicle (2026-10-02, 140) — quote_briefing 계열과 vehicle_ready 태스크에 이 세션에 지정된 차량이 실린다. 견적 브리핑은 LLM 이 고른 추천 차(role: recommended), 출고 준비는 출고 차(delivered)가 있으면 그것, 없으면 추천 차. 지정 전이면 필드가 생략된다 — FE 는 없으면 예전처럼 그린다.

프롬프트 치환 변수 (140·144) — 브리핑 계열 다섯 프롬프트(title_prompt · description_prompt · issue_title_prompt · issue_description_prompt · vehicle_options_prompt) 전부에서 {{user_info}}(사용자 정보), {{vehicle_list}}(백오피스 "차량" 의 활성 차량 목록 — id | 브랜드 모델 트림 — 설명), {{vehicle}}(이 세션에 지정된 차량 한 대 — 브랜드 모델 트림 — 설명 (추천 이유: …), 없으면 (아직 지정된 차량 없음))이 바뀐다.

추천 차는 견적 브리핑의 description_prompt 를 돌리는 LLM 호출이 고른다 (144). 서버가 그 프롬프트 뒤에 출력 형식을 붙여 {"vehicle_id", "reason", "text"} 로 받고, vehicle_id 가 등록 차량과 맞을 때만 user_vehicles(role=recommended) 에 기록한다 — 지어낸 차는 버린다. text 가 화면의 설명이 된다. 프롬프트에 {{vehicle_list}} 자리가 없으면 서버가 목록을 뒤에 붙인다. 순서: 견적 브리핑은 설명 → (차 기록) → 제목·이슈·옵션 순이고, 출고 준비 등 다른 브리핑은 견적 브리핑이 끝난 뒤에 돌아 {{vehicle}} 에 그 차가 들어간다. 어드민이 PUT /api/admin/users/:id/vehicles/:role 로 덮어쓰면 대시보드가 다시 발행된다.

"vehicle": {
  "id": "019fa6…", "role": "recommended",
  "brand": "kia", "model": "EV9", "trim": "GT-line",
  "image_url": "https://…/ev9.png",
  "description": "대형 전기 SUV. 반려동물·캠핑 생활에.",
  "reason": "반려동물과 캠핑을 자주 다녀 적재 공간이 넓은 차",
  "source": "llm", "task_id": "…", "set_at": "2026-09-30T22:19:00+09:00"
}
필드 설명
role current(체험 시작 시 차 — 후속) · recommended(견적 브리핑 LLM) · delivered(출고 확인 — 후속)
description 어드민이 "차량" 에 적은 설명(메타정보). LLM 이 읽은 글과 같다
reason LLM 이 이 차를 고른 이유 / 어드민 메모
source llm · admin · fe · rule

Payload

{
  "locations": [
    {
      "id": "019670ab-0001-7001-c001-000100010001",
      "name": "본사 로비",
      "short_summary": "방문 등록중",
      "visit_order": 1,
      "cctv": {
        "id": "019670ab-cccc-7ddd-e003-ff3344556677",
        "cctv_key": "hub-01"
      },
      "content": {
        "type": "video",
        "video_url": "/s3/location-videos/2026/08/example.mp4"
      },
      "tasks": [
        {
          "id": "019670ab-0010-7010-c010-001000100010",
          "location_id": "019670ab-0001-7001-c001-000100010001",
          "title": "방문증 수령",
          "type": "basic",
          "description": "1층 안내 데스크에서 방문증을 수령하세요",
          "scheduled_time": "09:30",
          "attach_url": "https://example.com/guide",
          "estimated_duration_min": 10,
          "display_order": 1,
          "status": "pending",
          "completed_at": null,
          "device": {
            "id": "019670ab-dddd-7eee-f003-ff4455667788",
            "name": "안내 로봇 1호",
            "image_url": "https://example.com/robot.png",
            "icon_url": "https://example.com/robot-icon.png"
          },
          "options": [
            { "id": "019670ab-aaaa-7bbb-c001-000100010001", "label": "기본으로 진행" },
            {
              "id": "019670ab-aaaa-7bbb-c002-000100010002",
              "label": "프리미엄 안내 패키지로 진행",
              "required_product": {
                "id": "019670ab-9999-7888-b777-666655554444",
                "title": "프리미엄 안내 패키지",
                "image_url": "https://example.com/product.png",
                "point_price": 500,
                "owned": false
              }
            }
          ]
        }
      ]
    }
  ]
}

vehicle_ready(출고 준비) 타입 예시 (같은 locations[].tasks[] 배열 안에 다른 타입과 섞여 있을 수 있다)

{
  "id": "019670ab-0020-7020-c020-002000200020",
  "location_id": "019670ab-0001-7001-c001-000100010001",
  "title": "나를 위한 신차 준비가 완료되었습니다",
  "type": "vehicle_ready",
  "description": "회원님의 프로필과 선호 패턴을 기반으로 차량 설정을 미리 완료해 두었습니다.",
  "scheduled_time": "",
  "attach_url": "",
  "estimated_duration_min": 0,
  "display_order": 1,
  "status": "pending",
  "vehicle": {
    "id": "01a0fc8e-e496-7459-9c8d-f5c18bd29860",
    "role": "recommended",
    "brand": "kia",
    "model": "EV9",
    "trim": "",
    "image_url": "https://…/ev9.png",
    "description": "대형 SUV",
    "reason": "반려동물과 캠핑을 자주 다녀 적재 공간이 넓은 차",
    "source": "llm",
    "task_id": "019670ab-0030-7030-c030-003000300030",
    "set_at": "2026-09-30T22:19:00+09:00"
  },
  "issue_title": "모든 설정이 이전 차량과 동일하게 적용됩니다",
  "issue_description": "탑승 즉시 편안하게 이용하실 수 있습니다.",
  "personalization_options": [
    { "type": "seat_position", "title": "시트 포지션 & 메모리", "description": "운전석 시트, 사이드 미러, 스티어링 휠" },
    { "type": "climate", "title": "공조 설정", "description": "AUTO / 약풍" },
    { "type": "sound", "title": "사운드 & 미디어", "description": "BOSE / 즐겨찾기 채널 & 볼륨" },
    { "type": "driving_mode", "title": "주행 성향 설정", "description": "ECO 모드 / 스티어링 휠 가벼움" }
  ]
}
필드 타입 설명
locations array 활성 장소 목록 (visit_order 오름차순)
locations[].id string 장소 ID (UUID v7)
locations[].name string 장소명
locations[].short_summary string 이 장소에서 사용자가 지금 무엇을 하는 상황인지 한 줄 요약(10글자 이내, 예: 집 → "출근 준비중", 본사 로비 → "방문 등록중"). 세션 생성 시 장소별 태스크 시딩 직후 LLM이 장소명·선택된 태스크 제목·사용자 정보로 장소마다 1개 생성해 user_locations.short_summary에 저장(세션당 1회, 이후 발행에서 그대로 재사용). 프롬프트는 앱 설정 location_short_summary_prompt({{persona_name}} {{persona_summary}} {{user_description}} {{location_list}}). LLM 실패·생성 전이면 빈 문자열
locations[].visit_order integer 사용자별 랜덤 방문 순서
locations[].cctv object|null 매핑된 CCTV 디바이스 (없으면 필드 생략, has_children 미수집 세션의 폴백용)
locations[].cctv.id string CCTV 디바이스 ID (UUID v7)
locations[].cctv.cctv_key string hub/vision 구분 식별 값
locations[].content object|null users.has_children 값에 따라 선택된 콘텐츠 (해당 분기에 설정이 없으면 필드 생략)
locations[].content.type string video | cctv
locations[].content.video_url string type=video일 때만 포함 — 비디오 파일 경로
locations[].content.cctv object type=cctv일 때만 포함
locations[].content.cctv.id string CCTV 디바이스 ID (UUID v7)
locations[].content.cctv.cctv_key string hub/vision 구분 식별 값
locations[].tasks[].id string 태스크 ID (UUID v7)
locations[].tasks[].location_id string 장소 ID (UUID v7)
locations[].tasks[].title string 태스크 제목
locations[].tasks[].type string 기본값 basic(기본) | detailed(상세) | vehicle_ready(출고 준비) | quote_briefing(견적 브리핑) | business_trip_briefing(출장 업무 브리핑) | schedule_briefing(이동/일정 브리핑) | product_recommendation(상품 추천/비교) | packing(준비물 챙기기) | pokemon_catch(포켓몬 포획) | trash_pickup(쓰레기 처리) | person_action(동작 인식, 2026-09-16) — 백오피스에서 관리자가 커스텀 타입을 자유롭게 추가할 수 있음. vehicle_ready/quote_briefing/business_trip_briefing/schedule_briefing/product_recommendation은 LLM 브리핑 계열로 스키마가 일반화되어 있고(그중 quote_briefing/schedule_briefing/product_recommendation은 vehicle_options[] 추가, quote_briefing 계열과 vehicle_ready 는 세션 지정 차량 vehicle 추가 — 140), packing은 packing_items[], pokemon_catch는 pokemon, person_action은 action_labels[]·action_progress가 추가된다. trash_pickup은 추가 필드 없이 일반 태스크와 스키마가 동일하다
locations[].tasks[].description string 태스크 설명. tasks.description_prompt가 있는 태스크는 관리자가 적은 tasks.description이 아니라 그 프롬프트와 사용자 정보를 보고 LLM이 세션별로 생성한 문장(user_task_descriptions에 캐시, 세션당 1회 — 캐시 없는 태스크를 모아 한 번에 요청). description_prompt가 비었거나 app_settings.task_description_enabled=0이면 tasks.description 그대로. vehicle_ready 계열은 별도 LLM 브리핑 캐시가 우선
locations[].tasks[].scheduled_time string 예약 시간 (HH:MM, 없으면 빈 문자열). scheduled_time_mode='llm'이고 llm_task_time_enabled=1이면 이 값은 관리자가 입력한 값이 아니라 로그인 시 LLM이 사용자별로 새로 계산한 개인화 시각(user_task_schedules에 저장)이다. tasks.scheduled_time(백오피스에 남아있는 값)은 LLM 계산이 꺼져 있거나 실패했을 때만 쓰이는 공용 폴백이다. scheduled_time_mode='manual'이면 항상 tasks.scheduled_time 그대로 내려간다
locations[].tasks[].attach_url string 자료 링크 URL (없으면 빈 문자열)
locations[].tasks[].estimated_duration_min integer 예상 소요 시간 (분, 0이면 미설정)
locations[].tasks[].display_order integer 표시 순서
locations[].tasks[].status string pending | in_progress | completed | skip
locations[].tasks[].completed_at string|null 완료 시각 (ISO 8601, 미완료 시 생략)
locations[].tasks[].device object|null 매핑된 디바이스 정보 (없으면 필드 생략)
locations[].tasks[].device.id string 디바이스 ID (UUID v7)
locations[].tasks[].device.name string 디바이스명
locations[].tasks[].device.image_url string 메인 이미지 URL
locations[].tasks[].device.icon_url string 아이콘 이미지 URL
locations[].tasks[].options array 선택지 목록 (없으면 필드 생략 = 선택지 없는 일반 태스크)
locations[].tasks[].options[].id string 선택지 ID (UUID v7)
locations[].tasks[].options[].label string 선택지 문구
locations[].tasks[].options[].required_product object|null 이 선택지를 프리미엄으로 쓰기 위해 필요한 상품 (없으면 필드 생략 = 기본/무료 선택지)
locations[].tasks[].options[].required_product.id string 상품 ID (UUID v7, store_products.id)
locations[].tasks[].options[].required_product.title string 상품명
locations[].tasks[].options[].required_product.image_url string 상품 이미지 URL
locations[].tasks[].options[].required_product.point_price integer 포인트 가격
locations[].tasks[].options[].required_product.owned boolean 이 세션이 해당 상품을 보유(구매)했는지

LLM 브리핑 계열(vehicle_ready/quote_briefing/business_trip_briefing/schedule_briefing/product_recommendation) 공통 전용 필드

필드 타입 설명
locations[].tasks[].issue_title string 이슈 박스 제목 (어드민이 issue_title_prompt를 비워두면 필드 자체 생략)
locations[].tasks[].issue_description string 이슈 박스 설명
locations[].tasks[].personalization_options array 개인화 옵션 카드 목록 (최대 4개, 없으면 필드 생략) — options와는 별개 필드
locations[].tasks[].personalization_options[].type string 카드 종류 식별자 (어드민 자유 입력, 예: seat_position)
locations[].tasks[].personalization_options[].title string 카드 제목
locations[].tasks[].personalization_options[].description string 카드 설명 — description_prompt를 참고해 LLM이 생성

packing(준비물 챙기기) 추가 전용 필드

백오피스 태스크 상세에서 관리자가 입력한 준비물 목록이 그대로 내려간다(LLM 생성 없음, 세션 무관).

{
  "id": "…", "type": "packing", "title": "출장 준비물 챙기기",
  "packing_items": [
    { "name": "여권" },
    { "name": "충전기" }
  ]
}
필드 타입 설명
locations[].tasks[].action_labels string[] 완료 조건 동작 라벨 (type='person_action'일 때만). AND 조건 — 이 배열의 동작을 전부 달성해야 서버가 태스크를 자동 완료한다(진행 중 우선, 대기면 시작→완료; task/complete 불필요). 순서는 무관하고, 한 번 달성한 동작은 태스크가 끝날 때까지 유지된다. 진행 상황은 action_progress로 내려간다. 라벨은 Vision 스키마의 17종
locations[].tasks[].action_progress object 동작 달성 현황 (type='person_action'일 때만). 아래 두 배열의 합집합은 항상 action_labels와 같다. 동일 취급 묶음(2026-09-21): Vision이 잘 구분하지 못하는 동작은 앱 설정 person_action_equivalent_labels(기본 stretch oneself = cheer up)로 한 묶음으로 보며, 묶음 중 하나만 감지돼도 묶음 전체가 cleared로 들어간다
locations[].tasks[].action_progress.cleared string[] 달성한 동작 라벨. 달성 순서대로
locations[].tasks[].action_progress.remaining string[] 미달성 동작 라벨. 비면 태스크가 완료된다
locations[].tasks[].packing_items array 준비물 목록 (type='packing'일 때만, 비어 있으면 필드 생략)
locations[].tasks[].packing_items[].name string 준비물 이름
locations[].tasks[].packing_items[].cctv_slot integer | 생략 CCTV 물건 슬롯(I1~I5) — 백오피스 "CCTV 관리"에서 이름이 매핑된 경우만
locations[].tasks[].packing_items[].cctv_state string | 생략 in_zone(테이블 위) | out_of_zone(가져감) | unknown — 매핑되고 cctv/item/state 수신 후. 상태가 바뀌면 이 토픽이 재발행된다
locations[].tasks[].packing_items[].picked boolean | 생략 챙김 여부 — cctv_state 가 out_of_zone(영역 밖) 또는 unknown(미인식, 집어 들어 가려짐) 이면 true, in_zone 이면 false (2026-09-12부터 unknown 포함). cctv_state 있을 때만. ※ 물건 집음 자동화 트리거는 오탐 방지를 위해 in_zone→out_of_zone 전환만 판정

pokemon_catch(포켓몬 포획) / trash_pickup(쓰레기 처리) 추가 전용 필드

관리자는 타입만 지정하고 값을 직접 입력하지 않는다 — 태스크가 시작(장소 도착 시 서버가 자동으로, 5. 태스크 시작 요청 참고)되는 순간 서버가 pokemon_catch면 포켓몬 3종(psyduck|wobbuffet|exeggutor) 중 하나를 랜덤 배정해 server/pokemon/start로 Vision에 알린다. trash_pickup은 배정할 값이 없어 추가 필드가 없다 — 일반 태스크와 동일한 스키마.

이 대시보드 pokemon/pokemon_image_url 필드는 Vision과 주고받는 내부 코드(psyduck 등)를 그대로 내려주지 않는다 — 백오피스 VISION 관리 → CCTV 관리 → 포켓몬 포획 라벨/이미지 매핑에서 코드별로 지정한 표시 라벨(app_settings 키 pokemon_label_{code}, 기본값 고라파덕/마자용/나시)과 업로드 이미지(app_settings 키 pokemon_image_{code}, S3/ministack URL)로 변환해 내려준다 (internal/mqttx/task_pokemon.go ResolvePokemonLabel/ResolvePokemonImageURL). 라벨을 아직 설정하지 않았으면 기본 한글명, 그마저 없으면 코드 원문 순으로 폴백하고, 이미지는 업로드 전이면 pokemon_image_url 필드 자체가 생략된다.

{
  "id": "…", "type": "pokemon_catch", "title": "포켓몬 포획", "status": "in_progress",
  "pokemon": "고라파덕",
  "pokemon_image_url": "/s3/persona-images/pokemon-images/2026/09/....png"
}
필드 타입 설명
locations[].tasks[].pokemon string | 생략 type='pokemon_catch'이고 태스크가 시작되어 배정이 끝난 경우만 포함 (pending이면 아직 배정 전이라 생략). CCTV 관리에서 매핑한 표시 라벨이며 psyduck 같은 Vision 내부 코드가 아니다
locations[].tasks[].pokemon_image_url string | 생략 배정된 포켓몬의 이미지 URL. CCTV 관리에서 해당 코드에 이미지를 업로드하지 않았으면 필드 자체가 생략된다

완료 처리는 Vision이 cctv/pokemon/complete/cctv/trash/complete를 발행하면 서버가 세션의 진행중 태스크를 찾아 자동으로 완료 처리한다 — 태블릿/디바이스가 별도로 task/complete를 보낼 필요가 없다(Vision 연동 토픽 참고).

견적 브리핑 계열(quote_briefing 견적 브리핑 / schedule_briefing 이동·일정 브리핑 / product_recommendation 상품 추천·비교) 추가 전용 필드

세 타입은 백오피스 화면 구조(프롬프트 4종 + vehicle_options_prompt)와 payload 스키마가 완전히 동일하다. schedule_briefing·product_recommendation은 2026-09-12 추가.

필드 타입 설명
locations[].tasks[].vehicle_options array LLM이 세션별로 결정한 옵션 목록 (최대 4개, 없으면 필드 생략). 필드명은 타입과 무관하게 vehicle_options 고정 — 견적: 차량 옵션 / 이동·일정: 이동 수단·일정 옵션 / 상품 추천·비교: 추천 상품
locations[].tasks[].vehicle_options[].name string 옵션명 (예: 견적 RSPA, 이동·일정 KTX 14:00, 상품 스타일러 슬림)
locations[].tasks[].vehicle_options[].type string 옵션 구분 라벨. 견적은 관례상 기본형 | 구독형이며, 다른 타입은 프롬프트에서 정한 구분값(예: 추천/비교)을 그대로 씀
locations[].tasks[].vehicle_options[].description string 옵션 설명 (예: 좁은 골목 주차에 적합)
locations[].tasks[].vehicle object 이 세션에 지정된 차량 (140). quote_briefing 계열은 추천 차(recommended), vehicle_ready 는 출고 차(delivered)가 있으면 그것 없으면 추천 차. 지정 전이면 필드 생략 — FE 는 없으면 예전처럼 그린다
locations[].tasks[].vehicle.id string 차량 ID (UUID v7, vehicles.id)
locations[].tasks[].vehicle.role string current | recommended | delivered
locations[].tasks[].vehicle.brand string 제조사 (hyundai | kia | genesis | 자유 입력)
locations[].tasks[].vehicle.model string 모델명
locations[].tasks[].vehicle.trim string 트림. 없으면 ""
locations[].tasks[].vehicle.image_url string 대표 이미지 URL. 없으면 ""
locations[].tasks[].vehicle.description string 어드민이 "차량" 에 적은 설명(메타정보). LLM 이 읽은 글과 같다
locations[].tasks[].vehicle.reason string LLM 이 이 차를 고른 이유 / 어드민 메모. 없으면 ""
locations[].tasks[].vehicle.source string llm | admin | fe | rule
locations[].tasks[].vehicle.task_id string 지정이 나온 태스크 ID. 없으면 생략
locations[].tasks[].vehicle.set_at string 지정 시각 RFC3339 KST
locations[].tasks[].vehicle.confirmed_at string recommended 에만 — 견적 브리핑 태스크 완료(=주문하기)로 확정된 시각 RFC3339 KST. 미확정이면 생략 (147)

quote_briefing 태스크 예시 (vehicle_options 포함):

{
  "type": "quote_briefing",
  "title": "고객님 맞춤 견적이 준비되었습니다",
  "vehicle": {
    "id": "01a0fc8e-e496-7459-9c8d-f5c18bd29860",
    "role": "recommended",
    "brand": "kia",
    "model": "EV9",
    "trim": "",
    "image_url": "https://…/ev9.png",
    "description": "대형 SUV",
    "reason": "반려동물과 캠핑을 자주 다녀 적재 공간이 넓은 차",
    "source": "llm",
    "task_id": "019670ab-0030-7030-c030-003000300030",
    "set_at": "2026-09-30T22:19:00+09:00"
  },
  "vehicle_options": [
    { "name": "RSPA", "type": "기본형", "description": "좁은 골목 주차에 적합" },
    { "name": "OTA 소프트웨어 구독", "type": "구독형", "description": "차량 소프트웨어 자동 업데이트" }
  ]
}

옵션 목록은 백오피스에서 직접 작성하지 않는다 — 관리자는 태스크의 vehicle_options_prompt 하나만 작성하고(세 타입 공통), 세션 시딩 시 LLM이 {{user_info}}(사용자 설명, Vision 외모 분석 반영)를 참고해 옵션 구성(name/type/description)을 직접 결정한 뒤 user_task_vehicle_options 테이블에 캐시한다.

vehicle_ready(출고 준비) 생성 시점 (캐싱): title/description/issue_title/issue_description/ personalization_options[].description은 어드민이 입력한 title_prompt/description_prompt/ issue_title_prompt/issue_description_prompt(태스크) 및 옵션별 description_prompt를 참고해 세션 최초 태스크 시딩 시점(로그인 직후) 딱 한 번 LLM이 생성해 캐시한다. scheduled_time이 user_task_schedules에 캐시되는 것과 동일한 패턴이며, 대시보드가 세션 중 여러 번 재발행돼도 재생성하지 않고 캐시된 값을 그대로 재사용한다. 프롬프트에 참고하는 "사용자 간략 정보"(이름/설명/자녀 유무)는 LLM 컨텍스트로만 쓰이고 이 payload에는 포함되지 않는다. LLM이 없거나 생성에 실패하면 title은 어드민이 입력한 관리용 제목(백오피스 목록에 보이는 값)으로 폴백하고 나머지는 빈 값으로 처리되어 필드 자체가 생략된다.

태스크는 여러 개의 선택지를 가질 수 있고(없을 수도 있음), 각 선택지마다 문구(label)와 선택적으로 필요한 상품을 백오피스 태스크 상세 화면에서 지정한다. 서버는 태스크 콘텐츠(title/description/attach_url 등) 자체는 이중화하지 않고 선택지별 required_product.owned 플래그만 내려주며, 실제 기본/프리미엄 선택지를 어떻게 다르게 보여줄지는 클라이언트가 이 플래그를 보고 렌더링한다.

태스크 원본(tasks) 테이블에는 persona_id(integer, nullable, NULL = 공통(전체 페르소나)) 컬럼이 있다. 이 값은 필터링에만 쓰이며 위 payload 자체에는 포함되지 않는다 — persona_id가 NULL이거나 사용자의 persona_id와 일치하는 태스크만 tasks 배열에 실려 발행된다.

locations[].content 선택 규칙: 장소(locations)마다 자녀 있음/없음 두 분기의 콘텐츠(has_children_content_type+value, no_children_content_type+value)를 백오피스에서 각각 video(비디오 파일 경로) 또는 cctv(CCTV 디바이스) 타입으로 독립적으로 설정한다. 서버는 이 payload를 만들 세션의 users.has_children 값(기본 false, 추후 사용자에게 직접 받는 값)에 따라 두 분기 중 하나를 골라 content로 내려준다. 해당 분기에 값이 설정되어 있지 않으면(빈 문자열, 또는 type=cctv인데 참조된 디바이스가 없으면) content 필드 자체가 생략된다.


7. 사용자 정보 조회

Topic: orchestration/{sessionId}/me

Retained. 페르소나 생성 완료 시 자동 발행. 세션 종료 시 빈 페이로드로 retained 삭제.

Payload

{
  "image_url": "https://example.com/ari.png",
  "username": "홍길동",
  "has_children": false,
  "gender": "female",
  "ai_companion": {
    "id": "019670ab-aaaa-7bbb-c001-dd1122334455",
    "title": "Ari",
    "prompt": "당신은 여행 플래너 Ari입니다. …",
    "avatar_url": "https://example.com/ari.png"
  },
  "user_emotion": "joy",
  "user_images": [
    { "type": "default",  "image_url": "/s3/persona-images/user-assets/2026/08/….png" },
    { "type": "joy",      "image_url": "/s3/persona-images/user-assets/2026/08/…-joy.png" },
    { "type": "surprise", "image_url": "/s3/persona-images/user-assets/2026/08/…-surprise.png" },
    { "type": "neutral",  "image_url": "/s3/persona-images/user-assets/2026/08/…-neutral.png" },
    { "type": "anger",    "image_url": "/s3/persona-images/user-assets/2026/08/…-anger.png" }
  ],
  "vehicles": {
    "current": { "id": "01a0fc8e-e498-7631-bc0e-69b33ff28805", "role": "current", "brand": "hyundai", "model": "넥쏘", "trim": "", "image_url": "…",
                 "description": "수소전기 SUV", "reason": "체험 시작 시 타고 있던 차 (앱 설정 session_current_vehicle_id)", "source": "rule", "set_at": "2026-10-03T10:00:00+09:00" }
  }
}

재발행: 세션 생성 시 1회 발행되고, 이후 아래 경우 같은 토픽으로 다시 발행된다(retained 갱신). ① user_images가 정해지거나 바뀔 때 — Vision 외모 분석 수신 → LLM 에셋 선택, 어드민 재선택/수동 지정. ② 관람객 표정 변화 — cctv/person/emotion(Vision이 표정이 바뀔 때만 발행)을 받으면 user_emotion을 갱신해 재발행한다(같은 값 재수신은 무시). 클라이언트는 user_images에서 type == user_emotion인 사진을 보여주고, 그 type이 없으면 default를 쓴다. 첫 수신 이후에도 구독을 유지해야 갱신을 받는다. 서버 재시작 시 표정 기억은 초기화된다(다음 표정 샘플부터 다시 판단). ③ 성별 수집 — cctv/person/profile 분석으로 users.gender가 채워지는 순간 gender를 실어 재발행한다 (2026-09-13). ④ 차량 변경 (147) — 견적 브리핑 태스크 완료(추천 확정), 출고 준비 태스크 완료(출고 차), 어드민 PUT/DELETE /api/admin/users/:id/vehicles/:role 때 vehicles 를 갱신해 재발행한다. 세션 생성 시 LLM 추천은 첫 발행에 이미 들어 있다.

필드 타입 설명
image_url string AI 동반자 아바타 이미지 URL (ai_companion.avatar_url과 동일 — 하위 호환용)
ai_companion object 이 세션에 배정된 AI 동반자. retained라 재접속·다른 클라이언트도 이 토픽만 구독하면 누가 배정됐는지 알 수 있다 (summary는 내려주지 않음)
ai_companion.id string AI 동반자 ID (UUID v7, orchestration/ai-companion/list의 id)
ai_companion.title string 이름/직책
ai_companion.prompt string AI 시스템 프롬프트 원문 (personas.prompt) — 태블릿 측 LLM 호출용
ai_companion.avatar_url string 아바타 이미지 URL
user_emotion string | 생략 관람객 현재 표정 — joy | surprise | neutral | anger (Vision cctv/person/emotion — 표정이 바뀔 때만 수신). 아직 표정 샘플이 없으면 생략. 바뀌면 이 토픽이 재발행된다
user_images array | 생략 사용자 사진 — 백오피스 "에셋 관리"의 사진 풀(user_assets) 중 Vision 외형 설명(cctv/person/profile — 2026-08-31부터 VLM 줄글 description, 구 속성 맵도 병행 수신)을 LLM이 각 에셋의 description_llm과 대조해 고른 에셋의 사진 전체를 type별로 담은 배열. Vision 분석이 아직 안 왔거나 후보가 없으면 생략
user_images[].type string default(대표 사진, 항상 첫 원소) | joy | surprise | neutral | anger. 백오피스에 등록된 표정만 포함되며 순서는 default, joy, surprise, neutral, anger
user_images[].image_url string 사진 URL
username string 사용자 이름 — create-persona 요청의 intro_message에서 LLM으로 추출되거나, 추출하지 못했으면(자기소개 미제공, 이름 미언급 등) "여행자"
has_children boolean 자녀 유무 — users.has_children 값 그대로 (create-persona 요청의 user_meta_answers 중 effect: "has_children" 문항 응답으로 결정, 기본 false)
gender string | 생략 성별 — male | female. Vision cctv/person/profile 분석 결과(users.gender)이며 관람객 입력값이 아니다. 세션 시작 직후 프로필이 아직 안 왔으면 생략되고, 수집되는 순간 이 토픽이 재발행된다(2026-09-13 추가). 클라이언트는 필드가 없을 수 있음을 전제할 것
vehicles object (role → 차량) | 생략 세션 차량 (147) — current(지금 타는 차, 세션 생성 시 앱 설정 session_current_vehicle_id, 기본 넥쏘) · recommended(견적 브리핑 LLM 이 고른 차, 140) · delivered(출고 준비 태스크 완료 시 추천 차 복사). 차량 한 대의 타입은 §6 대시보드 태스크 vehicle 과 같다(confirmed_at 포함). 하나도 없으면 생략. 화면마다 다른 차가 뜨던 문제의 단일 출처 — 견적 히어로·가상 시승·출고 확인·수소 충전 화면이 전부 이걸 본다

8. 이벤트 팝업

Topic: orchestration/{sessionId}/event

어드민이 POST /admin/events/:id/publish로 수동 발행하거나, 서버가 아래 규칙으로 자동 발행한다.

자동 발행 — 매핑과 시점 (2026-09-15 기본 방식 변경)

  • 매핑은 아래 1과 같다(세션 생성 시 STEP2 일반·STEP3 돌발 각 1건, 발행 X).
  • 발행 시점(기본, 앱 설정 event_timing_mode=task_completed): 그 장소에 매핑된 태스크(장소당 3개) 중 하나라도 task/complete로 완료되는 순간 즉시 발행한다. 장소는 완료된 태스크의 location_id로 정하므로 Vision 위치 이력이 없어도 동작한다. 도착·태스크 시작·건너뜀은 발행 계기가 아니며, LLM 판단·보류 횟수· event_timing_llm_enabled·event_timing_max_deferrals·event_timing_prompt는 이 모드에서 전부 무시된다. 장소당 1회 발행 후 event_published_at이 기록되면 같은 장소의 다음 완료에는 다시 나가지 않는다.
  • event_timing_mode=llm으로 바꾸면 아래 2의 예전 방식(LLM 판단)으로 돌아간다.

자동 발행 — 매핑과 시점 판단 (event_timing_mode=llm일 때)

  1. 매핑 (세션 생성 시, 발행 X): user_locations의 STEP2(visit_order=2)에 일반(normal) 이벤트 1건, STEP3(visit_order=3)에 돌발(sudden) 이벤트 1건을 LLM이 선택해 event_id에 저장한다. STEP1·STEP4(집)에는 이벤트가 없다. (app_settings.event_selection_prompt)

  2. 발행 시점 (LLM 판단): 아래 트리거가 올 때마다 사용자의 현재 장소에 매핑된 미발행 이벤트가 있으면 LLM에게 "지금 띄울지"를 묻는다.

    • 장소 도착 (cctv/person/zone → 장소가 바뀐 순간)
    • 태스크 상태 변경 (task/start, task/complete, task/skip)

    LLM 입력: 페르소나, 사용자 관찰 정보, 이벤트(제목·종류·설명), 현재 장소·방문 순서·도착 후 경과 분, 이 장소 태스크 목록과 각 상태, 지금까지 보류 횟수. 출력: {"publish": bool, "reason": "..."} (app_settings.event_timing_prompt — 2026-09-06 마이그레이션 110이 기본 문안을 시드해 백오피스 앱 설정에서 바로 보이고 편집 가능. 비우면 서버 기본 프롬프트 — 돌발은 태스크 몰입 중에, 일반은 태스크 하나를 끝낸 직후에 띄우도록 유도).

    • publish=true → 발행 + event_published_at 기록 (이후 재판단 없음)
    • publish=false → event_deferral_count+1, 다음 트리거에서 재판단
    • 보류가 app_settings.event_timing_max_deferrals(기본 3)에 도달하면 LLM을 묻지 않고 다음 트리거에서 강제 발행
    • LLM 오류 시: 도착 트리거면 발행(예전 동작), 태스크 트리거면 보류
    • app_settings.event_timing_llm_enabled=0이면 LLM 없이 도착 즉시 발행
    • 마지막 판단은 user_locations.event_last_decision(JSON {trigger, publish, reason, forced, at})에 남는다
  3. 사용자가 이벤트를 못 받은 채 다음 장소로 이동하면 이전 장소 이벤트는 그대로 미발행으로 남는다 (현재 장소 기준으로만 판단).

Payload (choice 타입, 선택지별 프리미엄 상품 + 디바이스 매핑 있음)

{
  "event_id": "019670ab-0005-7005-d005-000500050005",
  "title": "점심 메뉴를 선택하세요",
  "type": "choice",
  "category": "normal",
  "description": "지금 시간이면 배가 고프실 것 같아요. 점심 메뉴를 골라주세요!",
  "image_url": "https://example.com/lunch.png",
  "options": [
    { "id": "019670ab-0011-7011-f011-001100110011", "label": "한식" },
    {
      "id": "019670ab-0022-7022-a022-002200220022",
      "label": "프리미엄 오마카세",
      "required_product": {
        "id": "019670ab-9999-7888-b777-666655554444",
        "title": "프리미엄 식사 패키지",
        "image_url": "https://example.com/product.png",
        "point_price": 800,
        "owned": false
      }
    }
  ],
  "cctv": [ { "id": "019fb331-610e-7d41-80f0-fbed67f77cda", "cctv_key": "mart_01" } ],
  "device": {
    "id": "019670ab-dddd-7eee-f003-ff4455667788",
    "name": "안내 로봇 1호",
    "image_url": "https://example.com/robot.png",
    "icon_url": "https://example.com/robot-icon.png"
  }
}

Payload (confirm 타입, 돌발 이벤트)

{
  "event_id": "019670ab-0006-7006-e006-000600060006",
  "title": "오후 일정을 확인하셨나요?",
  "type": "confirm",
  "category": "sudden"
}
필드 타입 설명
event_id string 이벤트 ID (UUID v7)
title string 팝업 제목
type string choice (선택지 포함) · confirm (확인 버튼만) · pipeline (118, 2026-09-12: 버튼별 로봇 명령 파이프라인 — 태블릿은 choice 와 동일하게 options[] 를 버튼으로 표시하고, 버튼을 누르면 event/option/select 를 보낸다. 서버가 그 버튼의 단계(SPOT 시나리오 → 로봇팔 → SPOT 이동 …)를 앞 단계 완료를 기다리며 순서대로 실행)
category string sudden (돌발) 또는 normal (일반)
description string 사용자에게 노출되는 설명 (선택 사항, 비어있으면 필드 생략)
image_url string 이미지 URL (선택 사항)
options array 선택지 목록 (choice·pipeline 타입만 포함, 없을 수도 있음)
options[].id string 선택지 ID
options[].label string 선택지 라벨
options[].required_product object|null 이 선택지를 프리미엄으로 쓰기 위해 필요한 상품 (없으면 필드 생략 = 기본/무료 선택지)
options[].required_product.id string 상품 ID (UUID v7, store_products.id)
options[].required_product.title string 상품명
options[].required_product.image_url string 상품 이미지 URL
options[].required_product.point_price integer 포인트 가격
options[].required_product.owned boolean 이 세션이 해당 상품을 보유(구매)했는지
device object|null 매핑된 로봇/디바이스 정보 (없으면 필드 생략)
device.id string 디바이스 ID
device.name string 디바이스명
device.image_url string 메인 이미지 URL
device.icon_url string 아이콘 이미지 URL
cctv array 이 이벤트가 뜰 때 보여줄 CCTV (어드민 이벤트 편집 "보여줄 CCTV", events.cctv_device_id, 최대 2). 장소의 cctv[] 와 같은 모양. 매핑 없으면 생략 (146)
cctv[].id string CCTV 디바이스 ID (UUID v7)
cctv[].cctv_key string hub/vision 구분 식별 값

어드민 백오피스에는 설명 필드가 2개 있다: description(사용자에게 노출 — 위 payload에 포함됨)과 description_llm(LLM 참고용 상세 설명). description_llm은 세션 생성 시 자동 이벤트 선택과 이후 태스크 선택 LLM 프롬프트({{event_note}})의 컨텍스트로만 쓰이며, 이 팝업 payload에는 포함되지 않는다.



8-1. 파이프라인 상태 (2026-10-03)

Topic: orchestration/{sessionId}/pipeline/status (QoS 1, retained 아님)

이벤트에 연결된 제어 파이프라인의 시작/진행중/종료를 알려 달라는 요구. 파이프라인 = 자동화 실행(automation_runs) 한 건. pipeline 타입 이벤트의 버튼(option_selected 규칙)뿐 아니라 장소 도착·태스크 소유 규칙도 같은 실행 모델이라 전부 이 토픽으로 나간다 — owner_type/event_id 로 거른다.

언제: 실행이 만들어질 때(started, 조건이 안 맞으면 skipped), 액션 하나가 상태를 바꿀 때마다(running / held / failed / completed), 어드민이 재시도를 눌렀을 때. retained 로 두지 않는다 — 한 세션에 실행이 여럿 겹칠 수 있어 "마지막 하나" 가 뜻이 없다. 늦게 붙은 화면은 §5-1 /context 의 journey[].event.state 로 결과만 본다.

Payload

{
  "session_id": "01a0e33d-…",
  "run_id": "01a1…",
  "automation_id": "019f…",
  "automation_name": "장보기 — 마트로 이동",
  "owner_type": "event",
  "owner_id": "019fa605-…",
  "event_id": "019fa605-…",
  "option_id": "01a0…",
  "trigger": "option_selected",
  "location_id": "019fa605-…",
  "state": "running",
  "step": { "done": 1, "total": 3, "current": "act-2" },
  "actions": [
    { "action_id": "act-1", "device_type": "companion", "action": "say", "status": "completed", "completed_at": "2026-10-03T10:00:03Z" },
    { "action_id": "act-2", "device_type": "spot", "action": "navigate_to", "status": "dispatched", "dispatched_at": "2026-10-03T10:00:04Z" },
    { "action_id": "act-3", "device_type": "hub", "action": "START_SCENARIO", "status": "queued" }
  ],
  "reason": "action_update",
  "fired_at": "2026-10-03T19:00:00+09:00",
  "published_at": "2026-10-03T19:00:04+09:00"
}
필드 타입 설명
session_id string 세션 ID
run_id string 실행 ID (automation_runs.id). 한 파이프라인의 메시지들을 묶는 키
automation_id / automation_name string 규칙
owner_type string event | task | location — 규칙이 붙은 곳
owner_id string 그 이벤트/태스크/장소 ID
event_id string owner_type=event 일 때 owner_id 와 같다. 이벤트 파이프라인만 보려면 이 필드로 거른다
option_id string 눌린 선택지. started 메시지에만 (이후 메시지에서는 생략)
trigger string option_selected | arrive | leave 등. started 에만
location_id string 실행 당시 장소
state string started(첫 액션이 큐에 들어감) | running(돌고 있음) | held(액션 실패로 멈춤 — 어드민 재시도 대기) | completed(전부 종결, 실패 없음) | failed(종결됐는데 실패 있음) | skipped(조건이 안 맞아 실행 안 함)
skip_reason string skipped 일 때 이유
step.done / step.total integer 종결된 액션 수 / 전체
step.current string 지금 돌고 있는 액션 id. 없으면 생략
actions[] array 액션별 상태 (queued | dispatched | completed | failed | timeout | skipped), 디바이스 응답, 오류, 시도 횟수, 재시도 대기 여부, 시각(UTC)
reason string 이번 발행 계기: run_created | action_update | retry
fired_at / published_at string RFC3339 KST

held 가 오면 서버는 더 진행하지 않는다 — 실패 시 멈춤이 기본이다(128). 백오피스 실행 로그나 스태프 화면에서 재시도를 누르면 reason: retry 로 running 이 다시 온다.

9. 태스크 리포트 응답

Topic: orchestration/{sessionId}/task/report/response

orchestration/{sessionId}/task/report/request 요청에 대한 응답. 세션의 user_tasks 전체를 상태별로 분류해 반환한다 (LLM 하루 요약은 completed 목록 기준으로만 생성).

Payload

{
  "ok": true,
  "session_id": "01966e3d-4bcd-7aec-8f61-2e8f2f1e5a3b",
  "total_count": 8,
  "completed_count": 5,
  "skipped_count": 1,
  "in_progress_count": 1,
  "pending_count": 1,
  "completed": [
    {
      "task_id": "019670ab-1010-7abc-b010-101010101010",
      "title": "방문증 수령",
      "scheduled_time": "09:30",
      "location_name": "본사 로비",
      "persona_name": "Ari",
      "status": "completed",
      "started_at": "2026-07-18T09:27:00Z",
      "completed_at": "2026-07-18T09:28:00Z",
      "forced_by_admin": false
    }
  ],
  "skipped": [],
  "in_progress": [],
  "pending": [],
  "summary": "오늘 하루 5개의 태스크를 완료하셨어요! ..."
}
필드 타입 설명
ok boolean 처리 성공 여부
error string 실패 시에만 존재하는 에러 메시지 — 실패 시 payload는 { "ok": false, "error": "..." } 만 반환된다
session_id string 세션 ID
total_count integer 전체 태스크 수
completed_count / skipped_count / in_progress_count / pending_count integer 상태별 태스크 수
completed / skipped / in_progress / pending array 상태별 태스크 목록 (아래 항목 구조는 4개 배열 공통)
completed[].task_id string 태스크 ID (UUID v7)
completed[].status string completed | skip | in_progress | pending
completed[].started_at string 시작 시각 (ISO 8601). 시작 전이면 필드 자체가 생략
completed[].completed_at string 완료 시각 (ISO 8601). 미완료면 필드 자체가 생략
completed[].forced_by_admin boolean 어드민 강제 변경 여부 (상태 무관)
summary string LLM이 생성한 하루 요약 문장 (completed 기준, 없으면 빈 문자열)

10. 페르소나 전체 목록 스냅샷

[Deprecated] AI 동반자 전체 목록 스냅샷(orchestration/ai-companion/list)로 대체됐다. 토픽 네이밍에서 "persona" 대신 "AI 동반자"의 영어 표현인 companion을 쓴다. 페이로드는 동일하다. 하위 호환을 위해 계속 발행되며 기능은 그대로 유지된다.

Topic: orchestration/persona/list

Retained. 서버 시작 시 자동 발행. 구독 즉시 최신 목록 수신.

Payload — AI 동반자 전체 목록 스냅샷과 완전히 동일 (id는 UUID v7 문자열). 필드 설명은 §11 참고.


11. AI 동반자 전체 목록 스냅샷

Topic: orchestration/ai-companion/list

Retained. 서버 시작 시 자동 발행. 구독 즉시 최신 목록 수신. orchestration/persona/list와 동일한 페이로드를 동시에 발행한다.

Payload — AI 동반자 객체 배열

[
  {
    "id": "019670ab-aaaa-7bbb-c001-dd1122334455",
    "title": "Ari",
    "summary": "New Journey Planner",
    "prompt": "당신은 여행 플래너 Ari입니다. …",
    "avatar_url": "https://example.com/ari.png"
  }
]
필드 타입 설명
id string AI 동반자 ID — UUID v7 문자열 (정수 아님). create-persona 요청의 persona_id, orchestration/{sessionId}/me의 ai_companion.id와 같은 값
title string 이름/직책 (백오피스 "AI 동반자" 이름)
summary string 한 줄 요약
prompt string LLM 시스템 프롬프트 원문 — 태블릿 표시용이 아니므로 UI에 노출하지 말 것
avatar_url string 아바타 이미지 URL (상대 경로 /s3/... 또는 절대 URL)

12. 스토어 상품 목록 스냅샷

Topic: orchestration/store/list

Retained. 서버 시작 시 + 상품/디바이스 변경 시 자동 발행.

Payload

{
  "products": [
    {
      "id": "019670ab-aaaa-7bbb-c001-dd1122334455",
      "title": "안심 귀갓길 동행",
      "description": "1일권",
      "image_url": "https://example.com/product1.png",
      "point_price": 5000,
      "display_order": 1,
      "devices": [
        {
          "id": "019670ab-dddd-7eee-f003-ff4455667788",
          "name": "안내 로봇 1호",
          "image_url": "https://example.com/robot.png",
          "icon_url": "https://example.com/robot-icon.png"
        }
      ]
    }
  ]
}
필드 타입 설명
products array 활성 상품 목록 (is_active = 1)
products[].id string 상품 ID (UUID v7)
products[].title string 상품 제목
products[].description string 상품 설명
products[].image_url string 이미지 URL
products[].point_price integer 포인트 금액 (0 = 무료)
products[].display_order integer 표시 순서
products[].devices array 매핑된 로봇/디바이스 목록
products[].devices[].id string 디바이스 ID (UUID v7)
products[].devices[].name string 디바이스명
products[].devices[].image_url string 메인 이미지 URL
products[].devices[].icon_url string 아이콘 이미지 URL

13. 포인트 잔액 업데이트

Topic: orchestration/{sessionId}/point

Retained. 스토어 구매 완료 후 발행. 향후 포인트가 변동되는 모든 이벤트(태스크 완료 지급 등)에서도 이 토픽으로 통합 발행.

성공 Payload

{
  "ok": true,
  "point_balance": 12000
}

실패 Payload

{
  "ok": false,
  "error": "포인트가 부족합니다"
}
필드 타입 설명
ok boolean 구매 성공 여부
point_balance integer 구매 후 잔여 포인트 (성공 시에만 포함)
error string 오류 메시지 (실패 시에만 포함)

14. 보유 상품 목록

Topic: orchestration/{sessionId}/store/owned

Retained. 로그인(페르소나 생성) 시 + 구매 완료 후 서버가 자동 발행. 구독 즉시 현재 보유 상품 목록 수신.

2026-09-06 변경: ID 배열(product_ids)만 내려주던 것에 상품 상세 products[]를 추가. product_ids는 그대로 유지되므로 기존 클라이언트는 수정 없이 동작한다.

Payload

{
  "product_ids": [
    "019670ab-aaaa-7bbb-c001-dd1122334455",
    "019670ab-bbbb-7ccc-d002-ee2233445566"
  ],
  "products": [
    {
      "id": "019670ab-aaaa-7bbb-c001-dd1122334455",
      "title": "안심 귀갓길 동행",
      "description": "1일권",
      "image_url": "https://example.com/product1.png",
      "point_price": 5000,
      "display_order": 1,
      "is_active": true,
      "purchased_at": "2026-09-06T10:15:30.123456Z",
      "devices": [
        {
          "id": "019670ab-dddd-7eee-f003-ff4455667788",
          "name": "안내 로봇 1호",
          "image_url": "https://example.com/robot.png",
          "icon_url": "https://example.com/robot-icon.png"
        }
      ]
    }
  ]
}
필드 타입 설명
product_ids string[] 사용자가 구매한 상품 ID 목록 — UUID v7 (중복 제거). 구매 내역 기준이라 이후 삭제된 상품 ID도 포함. 구매 내역 없으면 빈 배열
products array 보유 상품 상세. store/list의 products[]와 같은 형태에 is_active·purchased_at을 더한 것. 상품이 삭제되면 여기서는 빠지고 product_ids에만 남는다. 최초 구매 시각 오름차순
products[].id string 상품 ID (UUID v7) — product_ids의 값과 동일
products[].title string 상품 제목
products[].description string 상품 설명
products[].image_url string 이미지 URL
products[].point_price integer 현재 포인트 금액 (구매 당시 금액이 아님)
products[].display_order integer 표시 순서
products[].is_active boolean 상품 활성 여부 — 구매 후 비활성화된 상품은 store/list에는 없지만 여기엔 false로 남는다
products[].purchased_at string 최초 구매 시각 (ISO 8601, UTC). 같은 상품을 여러 번 샀어도 첫 구매 시각 하나
products[].devices array 매핑된 로봇/디바이스 목록 (store/list와 동일 구조, 없으면 빈 배열)
products[].devices[].id string 디바이스 ID (UUID v7)
products[].devices[].name string 디바이스명
products[].devices[].image_url string 메인 이미지 URL
products[].devices[].icon_url string 아이콘 이미지 URL

보유 여부만 필요하면 product_ids를, 카드 렌더링 등 상세가 필요하면 products를 쓰면 된다. store/list와 대조하지 않아도 되며, 비활성 상품(is_active: false)은 store/list에 없으므로 products로만 표시 가능하다.


15. 디바이스 전체 목록 스냅샷

Topic: orchestration/devices/list

Retained. 서버 시작 시 + 디바이스 생성·수정·삭제 시 자동 발행. 구독 즉시 현재 목록 수신. 활성(is_active=true) 디바이스만 포함.

Payload

{
  "devices": [
    {
      "id": "019670ab-aaaa-7bbb-c001-dd1122334455",
      "location_ids": ["019670ab-bbbb-7ccc-d002-ee2233445566"],
      "type": "robot",
      "name": "안내 로봇 1호",
      "image_url": "https://example.com/robot.png",
      "icon_url": "https://example.com/robot-icon.png",
      "description": "로비 안내 전담 로봇"
    },
    {
      "id": "019670ab-cccc-7ddd-e003-ff3344556677",
      "location_ids": [],
      "type": "cctv",
      "name": "로비 CCTV",
      "image_url": "",
      "icon_url": "",
      "description": "로비 천장 카메라",
      "cctv_key": "hub-01"
    }
  ]
}
필드 타입 설명
devices[].id string 디바이스 UUID v7
devices[].location_ids array<string> 배치된 장소 ID 목록 (빈 배열 = 미배치, 여러 장소에 배치될 수 있음)
devices[].type string robot | device | cctv
devices[].name string 디바이스명
devices[].image_url string 메인 이미지 URL (빈 문자열이면 없음)
devices[].icon_url string 아이콘 이미지 URL — 탭 선택용 소형 이미지 (빈 문자열이면 없음)
devices[].description string 설명
devices[].cctv_key string type=cctv일 때만 포함(그 외에는 필드 생략). hub/vision 구분 식별 값 — 태블릿이 이 값으로 CCTV 화면 전환 시 대상을 식별

16. 현재 위치 (Vision 연동)

Topic: orchestration/{sessionId}/position

Retained. Vision(카메라 트래킹) 서비스가 발행하는 cctv/person/zone을 서버가 수신해, 좌표에 담긴 zone_id를 백오피스에서 매핑해 둔 장소(locations.zone_id)로 재가공한 뒤 세션 전용 토픽으로 재발행한다. 자세한 내용은 Vision 연동 토픽 섹션 참고.

코스 순서 강제 (2026-10-11) — 세션 코스(STEP1~N)에서는 한 번에 ±1 STEP 만 움직인다. Vision 이 보낸 zone_id 가 지금 STEP 이면 머물고, 바로 다음·바로 앞 STEP 이면 이동(도착 처리)한다. 그 밖(두 칸 이상 건너뛰기·코스에 없는 장소)은 무시하고 location 을 지금 STEP 장소로 낸다(도착·이탈 처리 없음). 같은 zone 을 쓰는 장소(예: 집 STEP1·STEP4)도 이 순서로 고른다. 백오피스 위치 테스트·도착 시뮬레이션은 이 제한을 받지 않는다.

Payload

{
  "timestamp": "2026-08-03T13:36:23.123Z",
  "x": 100.0,
  "y": 100.0,
  "location": {
    "id": "019670ab-0001-7001-c001-000100010001",
    "name": "집",
    "cctv": {
      "id": "019670ab-cccc-7ddd-e003-ff3344556677",
      "cctv_key": "hub-01"
    },
    "content": {
      "type": "video",
      "video_url": "/s3/location-videos/2026/08/example.mp4"
    }
  }
}
필드 타입 설명
timestamp string UTC ISO8601 (예: 2026-08-03T13:36:23.123Z) — Vision이 보낸 Unix epoch 초를 변환
x number 월드 좌표 X (cm)
y number 월드 좌표 Y (cm)
location object | 생략 Vision이 보낸 zone_id에 매핑된 장소 — 매핑된 장소가 없으면 필드 자체가 생략됨 (zone_id 자체는 응답에 포함되지 않음)
location.id string 장소 ID (UUID v7)
location.name string 장소명
location.cctv object|null 매핑된 CCTV 디바이스 (없으면 필드 생략, has_children 미수집 세션의 폴백용)
location.cctv.id string CCTV 디바이스 ID (UUID v7)
location.cctv.cctv_key string hub/vision 구분 식별 값
location.content object|null users.has_children 값에 따라 선택된 콘텐츠 (해당 분기에 설정이 없으면 필드 생략) — 선택 규칙은 대시보드 장소·태스크 목록의 locations[].content 설명과 동일
location.content.type string video | cctv
location.content.video_url string type=video일 때만 포함
location.content.cctv object type=cctv일 때만 포함 (id, cctv_key)

zone_id → 장소 매핑 규칙: 한 세션(사용자)에게 배정된 장소(user_locations) 중 동일 zone_id를 공유하는 인스턴스가 여러 개 있을 수 있다(예: 방문 순서 1번째 "집-아침"과 4번째 "집-저녁"이 모두 zone_id='home'). 이때 서버는 visit_order 오름차순 후보 중에서 다음 순서로 고른다 (2026-09-27 기준).

  1. 이미 그 zone 안에 서 있으면 그 자리를 유지한다. 위치 메시지는 몇 초마다 들어오므로 매번 다른 인스턴스를 고르면 한 자리에 서 있는데도 도착 이벤트가 되풀이된다.
  2. 그 zone을 한 번도 밟지 않았으면 가장 앞선 인스턴스(첫 방문 = STEP 1 쪽).
  3. 두 번째 방문부터는 첫 인스턴스로 돌아가지 않는다. 나머지(2번째 이후) 중 아직 방문하지 않은 가장 앞선 것을 고르고, 나머지를 전부 방문했으면 그 안에서만 라운드로빈한다 (끝에 닿으면 1번 인덱스로 되돌아간다 — 0번은 첫 방문 전용).

후보가 하나뿐이면 당연히 그것만 쓴다. 이 세션에 배정된 후보가 하나도 없으면(예: 대시보드 배정 전) 전역 매핑(해당 zone_id를 가진 활성 장소 중 display_order가 가장 앞선 것)으로 폴백한다.

규칙은 internal/mqttx/vision.go의 pickZoneCandidate에 있고 vision_zone_candidate_test.go가 표로 고정한다.

DB 기록: 위 규칙으로 매핑된 장소가 직전 기록과 다를 때만 user_location_history(user_id, location_id, changed_at)에 한 행 기록한다 (같은 장소를 유지하는 동안의 반복 좌표 수신은 기록하지 않음). changed_at은 Vision 메시지 자체의 timestamp. 조회는 두 가지:

  • GET /admin/users/:id/location — DB 기준 최신 위치 + 변경 시각 1건 (retained MQTT 값이 아님, 아직 기록이 없으면 404)
  • GET /admin/users/:id/location/history — 위치 변경 이력 전체를 최신순으로 반환

17. 사용자 메타 프롬프트 목록 스냅샷

Topic: orchestration/user-meta-prompt/list

Retained. 서버 시작 시 + 관리자가 목록을 변경할 때마다 자동 발행. 자기소개 입력 화면에서 보여줄 문항 목록 — 사용자가 각 문항에 응답한 결과를 orchestration/{deviceInternalId}/create-persona 요청의 user_meta_answers 필드에 담아 보낸다 ("1. 페르소나 생성 요청" 참고).

Payload

{
  "user_meta_prompts": [
    {
      "id": "019670ab-cccc-7ddd-e003-ff3344556677",
      "text": "나는 운동을 한다",
      "type": "objective"
    },
    {
      "id": "019670ab-dddd-7eee-f004-001122334455",
      "text": "다시 한번 돌아가 보고 싶은 순간",
      "type": "subjective"
    }
  ]
}
필드 타입 설명
user_meta_prompts[].id string 프롬프트 ID (UUID v7) — 응답 시 user_meta_answers[].id로 그대로 사용
user_meta_prompts[].text string 문항 텍스트. type=objective면 참/거짓을 묻는 1인칭 진술문(예: "나는 운동을 한다"), type=subjective면 자유 입력을 유도하는 문구
user_meta_prompts[].type string objective(참/거짓 — 클라이언트는 value:true|false로 응답) | subjective(자유 입력 — 클라이언트는 value:문자열로 응답)

display_order/is_active는 응답에 포함되지 않는다 — 서버가 이미 활성 항목만 display_order 순서대로 배열에 담아 보내므로 클라이언트가 별도로 정렬·필터링할 필요가 없다. effect(문항 답변이 users의 별도 컬럼도 갱신하는지 여부)도 서버 내부 처리용이라 응답에 포함되지 않는다.


18. SPOT 명령 응답/릴레이

Topic: orchestration/{sessionId}/spot/command/response, .../spot/ack, .../spot/plan

"10. SPOT 명령 요청"에서 발행한 명령에 대한 응답/진행 상황 릴레이. 페이로드 형태는 MQTT_SCHEMA-SPOT §4(RESPONSE), §5(plan)를 그대로 따른다 (session_id로 식별해 해당 세션 토픽으로 relay).


19. SPOT 로봇 전역 상태

Topic: orchestration/spot/{robotId}/state (retained), .../waypoints (retained), .../scenarios (retained), .../estop (retained, QoS 2)

세션과 무관한 SPOT 로봇 전역 상태를 릴레이한다 (robot/{robotId}/state 등, MQTT_SCHEMA-SPOT §5, §6 페이로드 기준). 특정 세션이 아니라 로봇 단위이므로 어떤 클라이언트든 활성 세션과 무관하게 구독할 수 있다. waypoints·scenarios·estop은 원문 그대로이고, state만 아래 필드 하나를 서버가 추가한다.

orchestration/spot/{robotId}/state — 1Hz, retained

SPOT 원문(§5)에 target_location_id를 얹는다. 원본 필드는 하나도 바꾸지 않는다.

{
  "connection": "online",
  "timestamp": "2026-08-08T04:12:03Z",
  "fsm_state": "NAVIGATING",
  "battery_pct": 54,
  "commands_accepted": true,
  "current_waypoint_label": "p1",
  "target_waypoint_label": "p2",        // SPOT 원문
  "target_location_id": "01J...",       // ★ 서버 추가 — target_waypoint_label을 이 로봇의 장소 매핑으로 해석한 locations.id
  "map_id": "map_0807_2112",
  "faults": []
  // ... 나머지 SPOT §5 필드 그대로
}
필드 타입 설명
target_location_id string | null target_waypoint_label을 SPOT 관리 페이지에서 매핑한 장소(device_locations.spot_waypoint_label → locations.id)로 해석한 값. 이동 중이 아니거나(target_waypoint_label null) 매핑된 장소가 없으면 null. 필드는 항상 존재한다.
  • 해석 기준: devices.robot_id = {robotId} (type=spot)에 배정된 장소 중 spot_waypoint_label이 일치하는 첫 행.
  • 태블릿·백오피스는 이 값으로 "SPOT이 어느 장소로 가는 중인지"를 장소 이름/지도와 바로 연결할 수 있다 (orchestration/{sessionId}/spot/position은 Vision 기반 현재 위치, 이 필드는 SPOT이 보고한 목적지).
  • 페이로드가 JSON 객체가 아니면(브로커 LWT 등) 가공 없이 원문을 릴레이한다.
  • SPOT이 MQTT_SCHEMA-SPOT §2대로 CloudEvents 봉투로 보내면 data 안의 상태에 target_location_id를 넣고 봉투는 그대로 릴레이한다(평문이면 최상위에). robot/{robotId}/ack·plan도 봉투/평문 모두 받아 data만 세션 토픽으로 릴레이한다.

20. SPOT 현재 위치 (Vision 연동)

Topic: orchestration/{sessionId}/spot/position

Retained. Vision(카메라 트래킹) 서비스가 발행하는 vision/position/spot을 서버가 수신해, zone_id를 백오피스에서 매핑해 둔 장소(locations.zone_id)로 변환한 뒤 세션 전용 토픽으로 재발행한다. 현재 위치 (Vision 연동)의 SPOT 버전이며, 세션 주체(사람) 위치와 별개 토픽이다.

Payload

{
  "timestamp": "2026-08-03T13:36:23.123Z",
  "x": 100.0,
  "y": 100.0,
  "location": {
    "id": "019670ab-0001-7001-c001-000100010001",
    "name": "집"
  }
}
필드 타입 설명
timestamp string UTC ISO8601 — Vision이 보낸 Unix epoch 초를 변환
x number 월드 좌표 X (cm)
y number 월드 좌표 Y (cm)
location object | 생략 zone_id에 매핑된 장소 — 매핑된 장소가 없으면 필드 자체가 생략됨 (zone_id는 응답에 포함되지 않음)
location.id string 장소 ID (UUID v7)
location.name string 장소명

사람 위치(§16)와의 차이:

  • zone_id → 장소 매핑 규칙은 §16과 동일하다(세션에 배정된 user_locations 후보 우선, 없으면 전역 locations.zone_id 매핑으로 폴백).
  • 다만 SPOT 위치는 사용자 상태를 바꾸지 않는다 — user_location_history 기록, 도착 시 돌발 이벤트 발행, location.cctv/location.content 선택은 모두 하지 않고 장소 id/name만 붙여 그대로 내려준다.

백오피스 테스트 발행: SPOT 관리 → 테스트 화면의 "SPOT 위치 테스트" 카드에서 zone/x/y를 임의로 지정해 POST /admin/users/:id/spot-position/test로 이 재발행 로직을 그대로 태울 수 있다 (Vision 실제 발행 없이 테스트용).


21. 반려로봇(REX) 명령 응답/릴레이

Topic: orchestration/{sessionId}/companion/command/response, .../companion/ack

"13. 반려로봇(REX) 명령 요청"에서 발행한 명령에 대한 응답/진행 상황 릴레이.

command/response Payload

{ "ok": true, "command_id": "01a02e5e-e98a-78e1-bbde-fa8bbe7b4be4", "status": "queued" }
{ "ok": false, "status": "rejected", "error": "invalid capability" }
필드 타입 설명
ok boolean 성공 여부
command_id string 큐에 적재된 명령 ID (UUID v7). 거부 시 생략
status string queued | rejected
error string 거부 사유 (거부 시에만)

ack Payload (REX가 보낸 CloudEvents envelope의 data만 벗겨 그대로 릴레이)

{
  "session_id": "sess-1",
  "command_id": "01a02e5e-e98a-78e1-bbde-fa8bbe7b4be4",
  "robot_id": "REX",
  "status": "completed",
  "reason": null,
  "result": null,
  "timestamp": "2026-08-23T11:30:00.000Z"
}
필드 타입 설명
session_id string 명령을 보낸 세션 ID
command_id string 대응하는 명령 ID
robot_id string 로봇 ID (COMPANION_ROBOT_ID, 기본 REX)
status string accepted|started|completed|failed|rejected. completed/failed/rejected만 종결로 간주해 디바이스 명령 큐를 진행시킨다. accepted/started는 릴레이만 하고 큐는 그대로 대기
reason string|null 상태 설명 (실패 사유 등)
result object|null capability별 결과 데이터 (있으면)
timestamp string REX가 기록한 시각

원본은 CloudEvents 1.0 envelope(specversion/type/source/id/time/subject/datacontenttype/data)이며, 서버는 data 필드만 벗겨 위 형태로 릴레이한다. SPOT과 같은 robot/+/ack 토픽을 공유하며, robot/{id}/...의 {id}가 COMPANION_ROBOT_ID와 같으면 페이로드 형식과 무관하게 REX 경로, 아니면 최상위 specversion 유무(envelope=REX, 평문=SPOT)로 폴백 판별한다. SPOT 스키마도 envelope를 규정하고 있어 형식만으로 가르면 SPOT 큐가 전부 타임아웃되기 때문이다. REX 경로에 envelope가 아닌 페이로드가 오면 로그만 남기고 무시한다.


22. 반려로봇(REX) 로봇 전역 상태

Topic: orchestration/companion/{robotId}/state (retained), .../fault (retained), .../event (retained 아님), .../capabilities (retained)

세션과 무관한 REX 전역 상태·결함·이벤트·capability 목록을, CloudEvents envelope의 data만 벗겨 그대로 릴레이한다 (robot/{robotId}/state·/fault·/event·/capabilities 원본, MQTT_SCHEMA-COMPANION_ROBOT 참고). 특정 세션이 아니라 로봇 단위이므로 어떤 클라이언트든 활성 세션과 무관하게 구독할 수 있다.

state는 SPOT과 같은 robot/+/state 토픽을 공유한다 — ack와 같은 규칙으로 robot_id == COMPANION_ROBOT_ID면 REX, 아니면 specversion 유무로 폴백 분기한다. REX LWT(offline)처럼 envelope가 아닌 페이로드도 REX 경로로 가며, 이때는 원문 그대로 릴레이한다. fault/event/capabilities는 REX 전용 신규 구독이다.


23. 태스크 진행 상태 내레이션

Topic: orchestration/{sessionId}/task/narration (retained)

설계 근거 — 로봇 축이 아니라 태스크 축: 반려로봇(REX)·SPOT은 부스에 각 1대뿐이고 로봇당 한 번에 job 하나만 실행한다. 그런데 태스크의 device_id는 실제 로봇 명령 발송과 무관한 순수 표시용 메타데이터라(실제 로봇 구동은 태스크/이벤트 선택지의 spot_action/ companion_capability나 arrival_automations가 별도로 담당) 이 토픽은 "지금 어떤 로봇이 움직이는가"가 아니라 **"지금 사용자가 진행 중인 태스크가 무엇인가"**를 서술한다. 그 태스크가 devices.type = 'spot'에 매핑돼 있으면 SPOT이 하는 일로, 그 외(매핑 없음 포함)에는 기본값인 반려로봇이 하는 일로 표현할 뿐이다.

자동 발행 — 트리거: 태스크 시작(task/start) / 완료(task/complete) / 건너뛰기 (task/skip) 처리 직후, 해당 태스크에 대해 서버가 LLM으로 4개 문장을 생성해 **세션의 누적 태스크 리스트 안에서 그 태스크 항목만 갱신(upsert)**하고, 리스트 전체를 다시 발행한다. companion_behavior(반려로봇 자율 행동 판단)와는 완전히 독립된 기능이며, 로봇 ack/DeviceQueue 상태와는 연동하지 않는다(§ 확인 필요 사항 참고 — 이후 옵션 선택 경로에 task_id 연결이 정비되면 재검토).

LLM 입력: 페르소나 요약, 현재 장소명, 진행 중인 태스크 제목·설명, 같은 장소의 태스크 목록, 트리거 종류(started/completed/skipped). 태스크의 device_id가 devices.type='spot'에 매핑돼 있으면 "SPOT이 하는 일", 그 외(매핑 없음 포함)에는 기본값 "반려로봇이 하는 일"이라는 힌트를 프롬프트에 넣는다 — 이 구분은 별도 응답 필드가 아니라 생성되는 문장 안에 자연스럽게 녹아든다(예: "SPOT이 ~").

응답 payload: session_id는 토픽 경로에 이미 있으므로 넣지 않는다. 누적 리스트이므로 같은 task_id가 시작 시점엔 "지금 하고 있는 일"로, 완료/건너뜀 시점엔 같은 항목이 "무엇이 어떻게 됐는지"로 교체된다(새 항목 추가 아님).

{
  "tasks": [
    {
      "task_id": "01966e3d-...",
      "title": "제로원님의 저녁을 위해 SPOT이 마트에서 장을 보고 있어요.",
      "situation": "저녁에 필요한 추가 식재료가 있어요.",
      "ai_judgment": "SPOT이 마트로 이동해 식재료를 구입해 집으로 배송해요.",
      "result": "SPOT이 마트로 이동하고 있어요."
    },
    {
      "task_id": "01966e3d-...(다른 태스크)",
      "title": "...",
      "situation": "...",
      "ai_judgment": "...",
      "result": "..."
    }
  ]
}
필드 타입 설명
tasks array 관람객이 지금 있는 장소의 태스크 전부(2026-10-08~, 지금 장소 = 마지막 도착 기록). 예) 세션 12개 중 마트에 있으면 마트 3개. 아직 어디에도 도착 전이면 빈 배열. 내레이션이 생긴(시작/완료/건너뛴) 태스크가 최근 갱신순으로 앞에, 아직 상태가 안 바뀐 태스크는 코스 순서로 뒤에
tasks[].task_id string tasks.id(UUID v7 PK) 그대로 — 같은 값이 오면 새 항목이 아니라 기존 항목 교체
tasks[].title string LLM이 생성한 헤드라인. 내레이션이 아직 없는 태스크는 태스크 제목
tasks[].situation/ai_judgment/result string LLM이 생성한 한 문장씩. 내레이션이 아직 없는 태스크는 빈 문자열
tasks[].status string 태스크 지금 상태 pending | in_progress | completed | skip (2026-10-08 추가)

발행 시점: 세션 시딩 직후 1회 + 장소에 도착할 때마다(그 장소 태스크로 교체) + 태스크 시작/완료/건너뜀 내레이션이 생길 때마다 + 어드민 태스크 교체 시. 다른 장소로 가도 앞 장소의 내레이션 문장은 서버 메모리에 남아 있어, 그 장소로 돌아오면 다시 보인다. 같은 세션의 트리거는 차례로 처리한다(예전엔 동시 트리거를 버려 항목이 빠질 수 있었다).

LLM 내레이션 문장은 서버 프로세스 메모리에만 쌓인다(DB 저장 안 함) — 서버 재시작 시 문장은 초기화되지만, 태스크 목록 자체는 DB 에서 다시 채우므로 항목이 빠지지는 않는다.

설정값 (app_settings, 관리자 CRUD로 노출):

키 기본값 설명
task_narration_llm_enabled false 기능 전체 on/off — 신기능이라 기본 꺼둠
task_narration_prompt (기본 템플릿) 관리자 재정의 가능

에러 처리: LLM 에러/JSON 파싱 실패/태스크 조회 실패 — 전부 로그만 남기고 조용히 스킵, 강제 폴백 없음(부가 연출이므로 안 하는 게 잘못하는 것보다 낫다). DB에 이력을 남기지 않고 retained MQTT로 마지막 상태만 유지한다.

확인 필요 사항: 모든 태스크의 시작/완료/건너뜀마다 생성할지, 일부 태스크에만 적용할지는 운영해보며 재검토 예정 (현재는 전체 적용 + 기본 꺼짐 설정으로 시작).


24. 로봇팔(UF850) 명령 응답/릴레이

Topic: orchestration/{sessionId}/arm/command/response, .../arm/ack

"15. 로봇팔(UF850) 명령 요청"에서 발행한 명령에 대한 응답/진행 상황 릴레이. command/response payload 구조는 SPOT/REX와 동일({ok, command_id, status, error}).

ack Payload (로봇팔이 보낸 CloudEvents envelope의 data만 벗겨 그대로 릴레이)

{
  "robot_id": "arm-01",
  "session_id": "sess-1",
  "command_id": "01a02e5e-e98a-78e1-bbde-fa8bbe7b4be4",
  "status": "SUCCESS",
  "result": "ok",
  "timestamp": "2026-09-04T10:00:41.000Z"
}
필드 타입 설명
robot_id string 로봇 ID (ROBOT_ARM_ID, 기본 arm-01)
session_id string 명령을 보낸 세션 ID (없으면 세션 릴레이 생략)
command_id string 대응하는 명령 ID
status string ACCEPTED|SUCCESS|FAULT|REJECTED|BUSY. ACCEPTED만 진행 중 — 나머지는 전부 종결로 간주해 디바이스 명령 큐를 진행시킨다 (REJECTED/BUSY는 접수 실패 1회로 끝, 이후 완료보고 없음)
result string 종결 ack의 실행 결과 코드 (ok|returned|homed|unreachable|need_human)
fault_detail object {code, message} — FAULT/REJECTED/BUSY 세부
timestamp string 로봇팔이 기록한 시각 (RFC3339 UTC)

SPOT·REX와 같은 robot/+/ack 토픽을 공유하며, robot/{id}/...의 {id}가 ROBOT_ARM_ID와 같으면 로봇팔 경로로 분기한다(REX의 robot_id 우선 규칙과 동일 — 판별 순서는 로봇팔 → REX → SPOT). 스키마상 모든 메시지가 envelope지만 시뮬레이터의 평문 ack도 그대로 받는다.


25. 로봇팔(UF850) 로봇 전역 상태

Topic: orchestration/arm/{robotId}/state (retained)

세션과 무관한 로봇팔 전역 상태(1Hz)를 CloudEvents envelope의 data만 벗겨 그대로 릴레이한다 (robot/{robotId}/state 원본 — {robot_id, connection, timestamp, fsm_state, command_id}, MQTT_SCHEMA-ROBOT_ARM 참고). 리스너 death 시 LWT가 connection: "offline"을 retain 발행하며, envelope가 아닌 페이로드 (LWT 등)는 원문 그대로 릴레이한다. timestamp가 10초 이상 오래되면 연결 이상으로 간주한다. 서버도 이 state를 추적해 로봇팔 명령의 가용성 판정(offline/stale이면 즉시 실패)과 BUSY 재전송 시점(IDLE 복귀) 판단에 쓴다 — "15. 로봇팔(UF850) 명령 요청" 참고.


관련 REST API

1. 음성 파일 업로드

AI 채팅 요청(orchestration/{sessionId}/ai/request)에서 type=voice를 사용할 때, 음성 파일을 먼저 REST API로 업로드한 뒤 반환된 file_key를 MQTT 페이로드에 포함합니다.

Endpoint: POST /api/voice/upload

Request

Content-Type: multipart/form-data
필드 타입 필수 설명
file file Y 음성 파일. 지원 형식: .webm, .mp4, .m4a, .mp3, .ogg, .wav (기본 .webm)
session_id string Y 페르소나 생성 시 발급받은 세션 ID

Response 200 OK

{
  "file_key": "voice/01966e3d-4bcd-7aec-8f61-2e8f2f1e5a3b/2026-07-18/019670ab-9abc-7def-c003-de4567890123.webm",
  "size": 48200
}
필드 타입 설명
file_key string S3 오브젝트 키. MQTT ai/request의 file_key 필드에 그대로 사용
size integer 업로드된 파일 크기 (bytes)

오류 응답

상태코드 원인
400 session_id 누락 또는 file 누락
500 스토리지 업로드 실패

사용 흐름

1. POST /voice/upload  (multipart: file + session_id)
        ↓
2. 응답에서 file_key 획득
        ↓
3. MQTT orchestration/{sessionId}/ai/request 발행
   { "type": "voice", "file_key": "<위에서 받은 값>" }

2. Admin REST API 공통 응답 규칙

모든 /admin/ 하위 API의 응답은 아래 규칙을 따릅니다.

성공 응답

케이스 상태코드 Body 형태 설명
엔티티 조회 / 생성 / 수정 200 { ...entity fields } 해당 엔티티 필드를 직접 반환
삭제 / 액션 완료 200 { "ok": true } 성공 여부만 반환
삭제 + 추가 정보 200 { "ok": true, "key": value } 예: { "ok": true, "session_id": "..." }

삭제 응답 예시

{ "ok": true }

생성/수정 응답 예시 (페르소나)

{
  "id": "019670ab-aaaa-7bbb-c001-dd1122334455",
  "title": "야근 잦은 개발자",
  "summary": "야근 잦은 개발자",
  "prompt": "...",
  "avatar_url": "https://example.com/jiho.png"
}

에러 응답

모든 에러는 동일한 형태로 반환됩니다.

{ "error": "에러 메시지" }
상태코드 원인
400 잘못된 요청 (필수 필드 누락, 유효성 오류)
404 리소스 없음
500 서버 내부 오류

QoS / 기타 설정

항목 내용
QoS 모든 토픽 QoS 1
Retained orchestration/persona/list, orchestration/ai-companion/list, orchestration/store/list, orchestration/{sessionId}/dashboard, orchestration/{sessionId}/me, orchestration/{sessionId}/store/owned, orchestration/{sessionId}/point, orchestration/companion/{robotId}/state, orchestration/companion/{robotId}/fault, orchestration/companion/{robotId}/capabilities, orchestration/arm/{robotId}/state
세션 종료 시 orchestration/{sessionId}/me·orchestration/{sessionId}/context에 빈 페이로드로 retained 삭제
MQTT 브로커 기본 tcp://127.0.0.1:1883
구독 패턴 orchestration/#

비고

세션 흐름 요약

시스템 전체 그림은 drawio/architecture.drawio 참고.

── 앱 기동 시 1회 (retained 구독) ─────────────────────────────────────────
1. 구독: orchestration/ai-companion/list   → AI 동반자 전체 목록 (retained, id는 UUID 문자열)
2. 구독: orchestration/store/list          → 스토어 상품 목록 (retained)
3. 구독: orchestration/devices/list        → 활성 디바이스 목록 (retained)

── 페르소나 생성 (로그인) ──────────────────────────────────────────────────
4. 발행: orchestration/{deviceId}/create-persona
         → { persona_id, intro_message?, user_meta_answers? }
5. 수신: orchestration/{deviceId}/create-persona/response
         → { ok, session_id, persona } — session_id를 이후 모든 세션 토픽에 사용
   (서버) server/session/start { session_id } 발행 → Vision이 이 사람을 추적 시작

── 로그인 직후 서버가 자동 발행 (세션 시딩) ─────────────────────────────────
6.  수신: orchestration/{sessionId}/point
          → 초기 포인트 지급 (app_settings: initial_point_balance, 기본 1,000pt)
7.  (내부) 장소 배정 + 이벤트 매핑 — 발행 없음
          → 활성 장소를 랜덤 순서(visit_order 1..4)로 user_locations에 배정
            (STEP1·4 = 집, STEP2·3 = 차량/공원/마트 중 두 곳, app_settings: location_selection_prompt —
             {{pool_list}}의 장소마다 활성 태스크 목록(제목 — tasks.selection_description "장소 선택용 설명",
             공통 + 이 페르소나)이 함께 실려 LLM이 태스크 내용을 보고 장소를 고른다, 116)
          → STEP2(visit_order=2)에 일반(normal) 1건, STEP3(visit_order=3)에 돌발(sudden) 1건만
            LLM이 선택해 user_locations.event_id에 매핑 (STEP1·4는 없음,
            app_settings: event_selection_prompt). 후보는 create-persona 디바이스에 매핑된
            이벤트 + 전역 이벤트. STEP2 이벤트의 description_llm은 9번 태스크 선택 시
            {{event_note}} 컨텍스트. 팝업 발행 시점은 22번(LLM 판단) 참고
8.  수신: orchestration/{sessionId}/me  (retained)
          → { image_url, username, has_children,
              ai_companion: { id, title, prompt, avatar_url },
              user_emotion?, user_images?: [{ type, image_url }] }
          ※ user_images는 Vision 외모 분석 후(18번)에 채워져 **같은 토픽으로 재발행**되고,
             user_emotion은 표정(최근 3초 최빈값)이 바뀔 때마다 재발행됨
9.  수신: orchestration/{sessionId}/dashboard  (retained)
          → 장소·태스크 목록
          ※ LLM이 장소당 N개 선택 (app_settings: tasks_per_location, 기본 3개)
          ※ 입력: persona, 동일 device+persona 이전 세션 완료 이력, users.description,
            STEP2 이벤트 description_llm
          ※ scheduled_time: tasks.scheduled_time 기본. scheduled_time_mode='llm' +
            app_settings.llm_task_time_enabled=1이면 세션별 개인화 시각(user_task_schedules)
10. 수신: orchestration/{sessionId}/store/owned   → 보유 상품 목록 product_ids + products 상세 (retained, 초기 빈 배열)
    수신: orchestration/{sessionId}/ai/summary    → 여정 내레이션 intro (오늘 하루 브리핑, 비동기 — LLM 수 초)
11. 수신: orchestration/{sessionId}/future-car/request  (retained)
          → 미래차 플랜 { type:"plan", plan:{ persona, stops:[{id, kind, place, contexts[]}] } }
            — 활성 플랜 존재 시에만. stops[].id = 백오피스 경유지 UUID

── 세션 중 — 태블릿 요청/응답 ──────────────────────────────────────────────
12. 발행: orchestration/{sessionId}/task/start
    수신: …/task/start/response { ok, status:"in_progress" } · …/dashboard (retained)
13. 발행: orchestration/{sessionId}/task/complete  (또는 task/skip — 포인트 미지급)
    수신: …/dashboard (retained) · …/point (+app_settings.task_complete_point, 기본 100pt)
    ※ 12·13은 22번 "이벤트 발행 시점 판단"의 트리거이기도 하다
14. 발행: orchestration/{sessionId}/ai/request  → type=text | voice(/api/voice/upload 선행)
    수신: …/ai/response · …/ai/suggestions (후속 제안 2개 자동)
15. 발행: orchestration/{sessionId}/store/purchase
    수신: …/point (차감 후 잔액) · …/store/owned (retained)
16. 발행: orchestration/{sessionId}/task/option/select · …/event/option/select
    수신: …/task/option/select/response · …/event/option/select/response
          { ok, command_id, status:"queued" | "no_spot_job" | "rejected" }
          ※ 선택지에 spot_action이 있으면 SPOT 명령을 디바이스 명령 큐에 적재 (23번)
17. 발행: orchestration/{sessionId}/spot/command { action, params }
    수신: …/spot/command/response { ok, command_id, status:"queued" }
          → 이후 …/spot/ack · …/spot/plan 으로 진행 상황 릴레이 (23번)

── 세션 중 — Vision 연동 (서버 자동) ────────────────────────────────────────
18. (Vision) cctv/person/profile — 세션 시작 6~10초 후 1회
    → users.gender 저장, 외모 요약을 users.description에 추가
    → 에셋 관리 사진 풀에서 LLM이 1장 선택(app_settings: asset_selection_prompt)
      → users.asset_id 저장 → 8번 …/me 재발행 (user_images 포함)
19. (Vision) cctv/person/zone — zone이 바뀔 때만
20. (Vision) cctv/item/state — 홈 테이블 물건 I1~I5 영역 안/밖, 초당 1회 (백오피스 "CCTV 관리" 매핑 시 dashboard packing_items[].picked 반영, 상태 변화 시 dashboard 재발행)
    - **물건 집음 자동화 트리거(103)**: 매핑된 물건이 `in_zone→out_of_zone`으로 전환되는 순간, **미완료(pending/in_progress) packing 태스크의 장소에 현재 있는**(user_location_history 최신 행 기준, packing은 명시적 task/start 없이 진행되기도 함) 세션별로 그 태스크의 준비물 목록과 대조해 — 목록에 없으면 `wrong_item`, 있으면 `right_item` 트리거로 그 태스크에 붙은 자동화 규칙(`arrival_automations.trigger`)을 발화한다. 발화 정책(세션당 1회/매번+쿨다운)·조건·실행 로그는 도착 자동화와 동일하며, 액션 params 문자열의 `{item_name}`이 집은 물건 이름으로 치환된다. 매핑 안 된 슬롯·`unknown→out_of_zone` 전환은 판정하지 않는다. say 액션에 예약 파라미터 `_llm_instruction`(어드민 지시문)이 있으면 발화 시점에 LLM이 집은 물건·준비물 진행 상황·페르소나를 반영한 문장을 생성해 `params.text`를 채운다(약 1~3초 소요, 프롬프트는 `app_settings.item_pick_say_prompt`) — 실패 시 정적 `text`로 폴백, 폴백도 없으면 스킵. `_llm_instruction`은 로봇으로 나가는 payload에서 항상 제거된다.
    수신: orchestration/{sessionId}/position  (retained)
          → { timestamp, x, y, location?: { id, name, cctv?, content? } }
          ※ zone_id → 세션 배정 장소(미방문 인스턴스 우선, home은 마지막 인스턴스 고정) 매핑
    (서버) 장소가 바뀐 순간 = "도착": user_location_history 기록 →
          ┌ 22번 이벤트 발행 시점 판단 (trigger=arrival)
          ├ 직전 장소 이탈 자동화 규칙 평가 (trigger=leave, 아래 24번) — 114
          └ 도착 자동화 규칙 평가 (아래 24번)
20. (Vision) vision/position/spot — Vision 스키마 2026-08-30에서 삭제, 서버 구독만 유지
    수신: orchestration/{sessionId}/spot/position  (retained) → { timestamp, x, y, location?: {id, name} }
21. (Vision) cctv/person/emotion (표정이 바뀔 때만) → user_emotion_history 저장 + 현재 표정 변경 시 …/me 재발행

── 세션 중 — 서버 자동 판단/발행 ────────────────────────────────────────────
22. 수신: orchestration/{sessionId}/event  — 이벤트 팝업 (retain 없음, 장소당 1회)
          트리거: 장소 도착(19) · 태스크 시작/완료/건너뜀(12·13)
          → 현재 장소에 매핑된 미발행 이벤트가 있으면 LLM이 "지금 띄울지" 판단
            (app_settings: event_timing_prompt / event_timing_max_deferrals /
             event_timing_llm_enabled=0이면 도착 즉시 발행)
          → { event_id, title, type, category, description, options[], device? }
23. 디바이스 명령 큐 (ministack SQS FIFO, 디바이스별 직렬화)
          16·17·24에서 들어온 SPOT/Hub/로봇팔/반려로봇 명령을 앞 명령 완료(ack/timeout) 후
          순서대로 발행: robot/{robotId}/command · diorama/{device}/request · …
          완료 신호: robot/{robotId}/ack 종결 status · diorama/{device}/response
    수신: orchestration/{sessionId}/spot/ack · …/spot/plan (릴레이)
          orchestration/spot/{robotId}/state · waypoints · scenarios · estop (로봇 전역, retained)
24. 도착/이탈 자동화 (19번 도착 시)
          세션에 그 장소에서 배정된 태스크(user_tasks)·이벤트(user_locations.event_id)·장소 자체에
          붙은 규칙(arrival_automations)을 모아 보유 아이템·태스크 상태·발화 정책으로 거른 뒤
          액션 체인을 23번 큐에 적재. 결과는 automation_runs(백오피스 실행 로그)
          - trigger=arrive: 도착한 장소의 규칙. 태스크 규칙의 액션은 user_task_commands로 추적해
            전부 종결되면 태스크 자동 completed
          - 순차 실행(117, tasks.execution_mode=sequential): 같은 장소에서 앞 순서(display_order)의
            사용자 배정 태스크가 전부 종결(completed/skip)되기 전에는 그 태스크의 arrive 규칙·자동 시작을
            보류(automation_runs skip_reason=waiting_predecessor). 앞 태스크가 종결되는 순간
            (12·13번, 디바이스 명령 자동 완료, 어드민 강제 변경) 사용자가 아직 그 장소에 있으면
            대기가 풀린 순차 태스크의 규칙만 다시 평가·실행하고, 떠났으면 다음 도착 때 평가.
            parallel(기본)은 종전처럼 도착 즉시 실행
          - trigger=option_selected(118, pipeline 이벤트 전용): 관람객이 팝업 버튼을 누르면
            (event/option/select) 그 버튼에 붙은 단계(액션 체인)를 실행 — 앞 단계의 종결 ack 를 기다린 뒤
            다음 단계 발행. 선택지 응답은 {ok:true, status:"pipeline_queued"}. 결과는 automation_runs
            (백오피스 이벤트 상세 → 파이프라인 → 최근 이력)
          - trigger=leave(114): 장소가 바뀔 때 **직전 장소**의 규칙(후처리 연출). 도착 규칙보다 먼저
            큐에 적재되고, 태스크 추적 없음. Vision은 프레임에서 사라져도 발행하지 않으므로
            "다른 장소 도착"만이 이탈 신호 — 마지막 장소는 세션 종료로 이탈이 발화하지 않음

── 세션 종료 ───────────────────────────────────────────────────────────────
25. 발행: orchestration/{sessionId}/task/report/request (빈 payload)
    수신: orchestration/{sessionId}/task/report/response
          → 완료/건너뜀/진행중/대기 분류 + AI 요약
26. 발행: orchestration/{sessionId}/signout
    수신: orchestration/{sessionId}/signout/response { ok, status:"ended" }
    (서버) server/session/end 발행 · …/me retained 삭제(빈 payload)
    ※ 마지막 MQTT 행위 후 24시간 지나면 자동 종료 (expire-sessions job)

태스크 상태 전이

pending → in_progress → completed
           (task/start)  (task/complete)
           (로봇 명령 발행*)  (명령 전부 종결 → 자동 완료*)

pending / in_progress → skip
                        (task/skip, 포인트 미지급)

* 2026-09-19: 태스크 소유 자동화 규칙이나 선택지로 SPOT·로봇팔·반려로봇·LG·Hub 명령이 나가는 순간 서버가 태스크를 in_progress 로 올린다(상세 타입처럼 태블릿이 task/start 를 보내지 않는 태스크도 로봇이 일하는 동안 "진행 중"으로 보인다). 이 전이도 task/start 와 동일한 부수 효과를 낸다 — 대시보드 재발행, 이벤트 발행 시점 판단, 내레이션, ai/summary(status=in_progress). 명령이 전부 종결되면 자동 completed(→ ai/summary status=completed). 이미 진행 중/종결이면 무시. 준비물 챙기기(packing, 2026-09-19): cctv/item/state 로 준비물 중 하나라도 챙겨지면(picked) in_progress, 목록 전부 챙겨지면 자동 completed(포인트·ai/summary·순차 후속 동일). 단, 준비물 항목이 전부 CCTV 매핑돼 있어야 "전부 챙김"을 판정하며, 미매핑 항목이 하나라도 있으면 자동 완료하지 않는다(태블릿 task/complete 또는 어드민 강제 완료 필요).


미래차 (Future Vehicle) 토픽

발행 시점: 페르소나 생성(로그인) 완료 시 서버가 자동 발행. 어드민이 사용자 상세 페이지에서 수동 발행도 가능 (POST /admin/future-car/publish).

서버 → 미래차

Topic 방향 QoS 설명
orchestration/{sessionId}/future-vehicle/request S→미래차 1 plan 타입 데이터 발행

plan 타입 Payload

stops 배열의 순서 자체가 경유 순서다 (별도 순서 필드 없음).

{
  "type": "plan",
  "plan": {
    "persona": {
      "id": "019670ab-bbbb-7ccc-d002-ee2233445566",
      "title": "지호",
      "summary": "야근 잦은 개발자"
    },
    "stops": [
      {
        "id": "019670ab-5555-7666-b001-000000000001",
        "kind": "waypoint",
        "place": "회사",
        "contexts": [
          { "context_id": "c1", "kind": "meeting_doc", "title": "오전 회의록", "payload": {} }
        ]
      },
      { "id": "019670ab-5555-7666-b001-000000000002", "kind": "charging", "place": "충전소", "contexts": [] },
      {
        "id": "019670ab-5555-7666-b001-000000000003",
        "kind": "waypoint",
        "place": "마트",
        "contexts": [
          { "context_id": "c3", "kind": "grocery", "title": "장보기 목록", "payload": {} }
        ]
      },
      {
        "id": "019670ab-5555-7666-b001-000000000004",
        "kind": "destination",
        "place": "집",
        "contexts": [
          { "context_id": "c4", "kind": "schedule", "title": "내일 일정", "payload": {} }
        ]
      }
    ]
  }
}
필드 타입 설명
type string 항상 "plan"
plan.persona.id string 페르소나 UUID v7
plan.stops[].id string 경유지 고유 ID — future_vehicle_stops.id (UUID v7). 백오피스 미래차 플랜의 경유지와 1:1, 역방향 보고(도착/완료 등)에 이 값을 쓰면 된다
plan.stops[].kind string waypoint | charging | destination
plan.stops[].place string 장소명 (회사, 충전소, 집 등). 배열 순서가 곧 경유 순서
plan.stops[].contexts[].context_id string 컨텍스트 참조 ID (c1, c2...)
plan.stops[].contexts[].kind string meeting_doc | grocery | schedule | ...

클라이언트 → 미래차

동일 토픽 orchestration/{sessionId}/future-vehicle/request에 type 필드로 구분:

type 발행 주체 설명 예시 payload
enter tablet / oc-server 미래차 공간 진입 { "type": "enter" }
start tablet 주행 시작 (step 1로 이동) { "type": "start" }
advance tablet 다음 step으로 이동 { "type": "advance" }
context_action tablet 컨텍스트 액션 실행 { "type": "context_action", "context_id": "c1", "action_kind": "open_meeting_doc" }
exit tablet 하차 (step 4 도착 후) { "type": "exit" }

미래차 → 서버/클라이언트

Topic 방향 QoS 설명
orchestration/{sessionId}/future-vehicle/response 미래차→S/C 1 미래차 상태 응답 (포맷 미확정)

TODO 목록

  • 플랜 발행 시점 확정 — 현재 임시로 페르소나 생성 시 발행 중. 실제 시점(차량 공간 진입, CCTV 감지 등) 협의 필요
  • enter 발행 주체 확정 — tablet / oc-server / CCTV 중 어디서 발행할지 미결
  • future-vehicle/response 페이로드 포맷 확정 — 미래차 팀과 응답 스펙 협의 필요
  • 플랜 없을 때 동작 정의 — 해당 페르소나에 연결된 플랜이 없을 때 서버 동작 정책 확정

Vision 연동 토픽

Vision 팀(카메라 트래킹 서비스)과의 연동. 전체 스키마는 별도 문서 MQTT_SCHEMA-VISION 참고 — 여기서는 오케스트레이션 서버가 실제로 발행/구독하는 부분만 다룬다.

서버 → Vision

Topic 방향 QoS Retained 설명
server/session/start S→Vision·SPOT 1 X 페르소나 생성(로그인) 시 가장 먼저 발행. 같은 태블릿에 이전 활성 세션이 있으면 그 세션의 server/session/end 를 먼저 내고 새 start 를 낸다 (148)
server/session/end S→Vision·SPOT 1 X 세션이 끝나는 모든 길에서 발행 (148, 2026-10-04): 태블릿 signout · 백오피스 강제 종료 · 같은 태블릿의 새 create-persona(이전 세션 replaced) · 유휴 타임아웃(앱 설정 session_idle_timeout_minutes, 기본 15분, 0=24시간; idle). SPOT 은 이걸 받아야 충전 위치로 돌아간다 (SPOT §3-1)
server/pokemon/start S→Vision 1 X pokemon_catch 태스크 시작(task/start) 시, 포켓몬 3종 중 서버가 랜덤 배정한 값과 함께 발행
server/trash/start S→Vision 1 X trash_pickup 태스크 시작(task/start) 시 발행

Payload

{
  "session_id": "019670ab-bbbb-7ccc-d002-ee2233445566"
}
필드 타입 설명
session_id string 오케스트레이션 서버가 발급한 세션 ID (create-persona/response의 session_id와 동일)

server/session/start, server/session/end 모두 동일한 payload 형태(session_id만 포함)를 사용한다.

Payload (server/pokemon/start)

{
  "session_id": "abc123",
  "pokemon": "psyduck"
}
필드 타입 필수 설명
session_id string Y 세션 ID
pokemon string Y 태스크 시작 시 서버가 랜덤 배정한 포켓몬 (psyduck|wobbuffet|exeggutor)

Payload (server/trash/start)

{
  "session_id": "abc123"
}
필드 타입 필수 설명
session_id string Y 세션 ID

Vision → 서버

Topic 방향 QoS 설명
cctv/person/zone Vision→S 1 방문객(세션 주체)의 zone (바뀔 때만, none=매핑 없음). 구 vision/position/person도 병행 구독
vision/position/spot Vision→S 0 SPOT 로봇의 실시간 좌표 (payload는 cctv/person/zone과 동일)
cctv/person/emotion Vision→S 0 방문객(세션 주체)의 표정 인식 결과 — 약 0.5초 간격
cctv/pokemon/complete Vision→S 1 포켓몬 포획 완료 — 수신 시 해당 세션의 진행중 pokemon_catch 태스크를 자동 완료 처리
cctv/trash/complete Vision→S 1 쓰레기 플로깅 완료 — 수신 시 해당 세션의 진행중 trash_pickup 태스크를 자동 완료 처리

Payload (Vision 발행)

{
  "session_id": "abc123",
  "timestamp": 1690000000.123,
  "x": 100.0,
  "y": 100.0,
  "zone_id": "home"
}
필드 타입 필수 설명
session_id string Y 세션 ID
timestamp number Y Unix epoch 초 (소수점 포함)
x number Y 월드 좌표 X (cm)
y number Y 월드 좌표 Y (cm)
zone_id string Y 현재 위치한 zone (vehicle/park/home/mart/cafe)

vision/position/spot(SPOT 로봇 위치)도 같은 payload 형태로 수신하며, orchestration/{sessionId}/spot/position으로 재발행한다 (SPOT 현재 위치 참고).

Payload (cctv/person/emotion, Vision 발행)

{
  "session_id": "abc123",
  "timestamp": 1690000000.123,
  "emotion": "joy"
}
필드 타입 필수 설명
session_id string Y 세션 ID
timestamp number Y Unix epoch 초 (소수점 포함)
emotion string Y 인식된 표정 (joy|surprise|neutral|anger)

수신 시 zone_id 매핑 같은 재발행 처리는 하지 않고, 매 수신마다 user_emotion_history에 한 행씩 그대로 저장한다(위치 이력과 달리 변경 여부를 판단하지 않음 — 백오피스 사용자 상세 화면의 감정 추세 차트·최근 감정 표시용). session_id가 비어 있거나 emotion이 4종 값이 아니면 저장하지 않고 무시한다.

Payload (cctv/pokemon/complete, Vision 발행)

{
  "session_id": "abc123",
  "pokemon": "psyduck"
}
필드 타입 필수 설명
session_id string Y 세션 ID
pokemon string Y 포획 완료된 포켓몬 (psyduck|wobbuffet|exeggutor) — 서버는 이 값으로 검증하지 않고, 세션의 진행중 pokemon_catch 태스크를 그대로 완료 처리한다

Payload (cctv/trash/complete, Vision 발행)

{
  "session_id": "abc123"
}
필드 타입 필수 설명
session_id string Y 세션 ID

타입별 응답 차이: cctv/pokemon/complete는 pokemon 필드를 포함하지만 cctv/trash/complete는 session_id만 온다 — pokemon_catch/trash_pickup 모두 관리자가 값을 직접 입력하지 않는 타입이다. 태스크가 시작되는 시점(클라이언트의 task/start가 아니라, 사용자가 그 태스크의 장소에 도착하면 서버가 자동으로 시작 처리한다 — 5. 태스크 시작 요청 참고)에 서버가 pokemon_catch면 3종 중 하나를 랜덤 배정해 server/pokemon/start로, trash_pickup이면 server/trash/start만 발행한다. 완료 처리는 각 complete 토픽 수신 시 태스크 완료 공용 로직 (ProcessTaskCompletion)을 그대로 재사용하며 별도 완료 로직은 없다.

서버 → 클라이언트 (zone_id 매핑 재발행)

cctv/person/zone 수신 시, zone_id를 어드민이 백오피스 장소 관리 (locations.zone_id)에서 매핑해 둔 location으로 변환해 세션 전용 토픽으로 재발행한다. 매핑된 장소가 없으면(zone_id가 어느 장소에도 지정되지 않음) location 필드는 생략된다.

여러 장소가 같은 zone_id를 공유할 수 있다 (예: "집-아침"/"집-저녁" 모두 zone_id="home"). 이 경우 활성(is_active=1) 장소 중 display_order가 가장 앞선 하나가 매핑 결과로 내려간다.

Topic 방향 QoS Retained 설명
orchestration/{sessionId}/position S→C 1 O 현재 위치 (Vision zone_id를 매핑한 장소)
orchestration/{sessionId}/spot/position S→C 1 O SPOT 로봇 현재 위치 (vision/position/spot 재발행, 장소 id/name만 포함 — §20)

Payload

{
  "timestamp": "2026-08-03T13:36:23.123Z",
  "x": 100.0,
  "y": 100.0,
  "location": {
    "id": "019670ab-0001-7001-c001-000100010001",
    "name": "집"
  }
}

수신한 zone_id에 매핑된 장소가 없을 때는 location 필드가 생략된다:

{
  "timestamp": "2026-08-03T13:36:23.123Z",
  "x": 100.0,
  "y": 100.0
}
필드 타입 설명
timestamp string UTC ISO8601 (예: 2026-08-03T13:36:23.123Z) — Vision이 보낸 Unix epoch 초를 변환
x number 월드 좌표 X (cm)
y number 월드 좌표 Y (cm)
location object|생략 Vision이 보낸 zone_id에 매핑된 장소 (매핑 없으면 필드 생략). zone_id 자체는 응답에 포함되지 않음
location.id string 장소 ID (UUID v7)
location.name string 장소명

백오피스 테스트 발행: 사용자 상세(/task-completions/{sessionUuid})의 "위치 테스트" 탭에서 zone/x/y를 임의로 지정해 POST /admin/users/:id/position/test로 이 재발행 로직을 그대로 태울 수 있다 (Vision 실제 발행 없이 테스트용).

TODO 목록

  • zone_id ↔ locations 매핑 확정 — 백오피스 장소 관리에서 zone_id를 직접 지정하는 방식으로 해결 (locations.zone_id, 미지정 zone/장소 허용, 여러 장소가 같은 zone_id 공유 가능 — 매핑 시 활성 장소 중 display_order가 가장 앞선 하나로 결정)
  • vision/position/spot(로봇) 구독·활용 방안 확정 — 사람 위치와 동일하게 zone_id를 장소로 매핑해 orchestration/{sessionId}/spot/position으로 재발행 (사용자 이력·이벤트 부수효과 없음)
⚠️ **GitHub.com Fallback** ⚠️