Repository navigation
MQTT_SCHEMA ORCHESTRATION_SERVER
이 문서는 오케스트레이션 서버 팀이 작성합니다.
- 개요
- 토픽 목록
- CLIENT → SERVER (요청)
-
SERVER → CLIENT (응답 / 서버 발행)
- 페르소나 생성 응답
- 세션 종료 응답
- AI 채팅 응답
- AI 답변 제안
- FE 내부 요약 응답 5-1. 세션 공유 컨텍스트 5-2. 세션 엔딩
- 대시보드 장소·태스크 목록
- 사용자 정보 조회
- 이벤트 팝업 8-1. 파이프라인 상태
- 태스크 리포트 응답
- 페르소나 전체 목록 스냅샷
- AI 동반자 전체 목록 스냅샷
- 스토어 상품 목록 스냅샷
- 포인트 잔액 업데이트
- 보유 상품 목록
- 디바이스 전체 목록 스냅샷
- 현재 위치 (Vision 연동)
- 사용자 메타 프롬프트 목록 스냅샷
- SPOT 명령 응답/릴레이
- SPOT 로봇 전역 상태
- SPOT 현재 위치 (Vision 연동)
- 반려로봇(REX) 명령 응답/릴레이
- 반려로봇(REX) 로봇 전역 상태
- 태스크 진행 상태 내레이션
- 로봇팔(UF850) 명령 응답/릴레이
- 로봇팔(UF850) 로봇 전역 상태
- 관련 REST API
- QoS / 기타 설정
- 비고
- 미래차 (Future Vehicle) 토픽
- Vision 연동 토픽
| 항목 | 내용 |
|---|---|
| 브로커 |
tcp://127.0.0.1:1883 (기본값, 환경변수 MQTT_BROKER_URL로 변경 가능) |
| 구독 토픽 | orchestration/# |
| 제공값 |
session_id — 페르소나 생성(create-persona) 응답에서 발급되며, 이후 모든 세션 토픽에 사용 |
| 작성 주체 | 오케스트레이션 서버 팀 |
| 최종 수정일 | 2026-09-15 |
| 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) |
| 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 생성) |
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 포함). 실패 응답은 위 대기 없이 즉시 발행 |
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
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/*에도 병행 발행 |
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 직후 자동 발행) |
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 | 요약 결과 텍스트 |
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가 필요하다.
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) |
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) |
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 상세) |
Topic: orchestration/{sessionId}/task/report/request
Payload: 없음 (빈 payload로 발행)
세션에 매핑된
user_tasks전체를 완료(completed) / 건너뜀(skip) / 진행중(in_progress) / 대기(pending) 4가지 상태로 분류해 응답한다. 전체 완료 시 서버가 자동 발행하던 기존 방식은 폐지되었으며, 클라이언트가 원하는 시점에 직접 요청해야 한다.
서버 발행 토픽
| 토픽 | Retained | 설명 |
|---|---|---|
orchestration/{sessionId}/task/report/response |
X | 상태별로 분류된 태스크 목록 + LLM 하루 요약 |
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 | 시나리오 진행 상황 릴레이 (여러 스텝짜리 명령일 때) |
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·반려로봇과 독립적으로 HAhomeassistant.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로
릴레이된다.
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로
릴레이된다.
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) 명령 응답/릴레이" 참고).
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/requesttype=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 저장만).
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·새 envelopeid**로 재발행한다(리스너가 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) 명령 응답/릴레이" 참고).
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) 가 목적지 |
목적지 해석
- 코스 = 이 세션의
user_locations(visit_order 오름차순, 활성 장소만) -
to_location_id가 있으면 → 목적지 = 그 장소 (코스에 없으면rejected). 현재 장소는 응답 정보용으로만 해석. 3번은 건너뜀 -
to_location_id가 없으면: 현재 장소 =from_location_id→ 없으면 위치 이력 최신 → 그것도 없으면 "출발 전". 목적지 = 현재 장소의 다음(visit_order+1). 출발 전이면 첫 장소. 현재가 코스에 없으면(비활성 등) 첫 장소. 현재가 마지막 장소면rejected(already at the last location of the course) - SPOT = 세션에 배정된 활성 spot 디바이스(
GetActiveSpotDeviceForUser), waypoint =device_locations.spot_waypoint_label(백오피스 "SPOT 장소 관리"에서 매핑). 매핑이 없으면rejected(destination not mapped …) -
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 미매핑이면 어느 장소가 문제인지 보이도록). 사용자 위치 자체는 바꾸지 않는다 — 실제 도착은 Visioncctv/person/zone으로 감지돼position·도착 자동화가 돈다.
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/*에도 병행 발행 |
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"
|
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 | 오류 발생 시에만 포함 |
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개) |
Topic: orchestration/{sessionId}/context (retained, QoS 1)
내러티브(§5 여정 내레이션)가 프롬프트에 넣는 재료를 그대로 구조화한 것. 미래차 서버·반려로봇이 구독해 내러티브와 같은 세션 데이터로 말한다. 갱신 계기(
reason):session_start(로그인 발행 직후, 비전 프로필 대기 뒤)vision_profiletask_statusarrivalevent_publishedevent_selectedvehicle_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 | 내러티브 본문 |
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기록 → 엔딩 발행 →/contextretained 삭제. 뒤에 두면 내러티브 히스토리·차량 스텝이 이미 지워져 재료가 없다. 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 재발행. 백오피스 세션 상세 "엔딩" 탭.
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_idintro세션 생성(로그인 발행) 직후 — 오늘 하루 브리핑 — narrative:introarrival장소 도착( cctv/person/zone으로 장소가 바뀐 순간)— narrative:arrival:{location_id}task_statustask/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/…:arrivedlocation_complete(2026-09-19) 한 장소의 태스크가 전부 끝난 순간(완료/건너뜀, 세션·장소당 1회) + location_complete_summary_delay_seconds(기본 5초) 지연location_idnarrative: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_idnarrative:event_precursor:{event_id}task_pre_start(2026-09-26) 장소 도착 내레이션 직후, 그 장소의 첫 대기 태스크(scheduled_time 이른 것 → display_order 최소) — 세션·태스크당 1회. 큐에서 꺼낼 때 태스크가 이미 시작됐거나 장소가 바뀌었으면 취소 task_id,status: pendingnarrative:task_pre_start:{task_id}event_handled(2026-09-26) event/option/select처리 직후 — 고른 선택지 라벨을 재료로(선택 이력user_event_selections)event_idnarrative:event_handled:{event_id}:{option_id}journey_complete(2026-09-26) 마지막 장소(visit_order 최대)의 태스크가 전부 끝난 순간 — 이 장소에서는 location_complete대신 이것만 (여정 내레이션이 꺼져 있으면 기존 장소 완료 요약)location_idnarrative: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 | 오류 메시지 (실패 시에만) |
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" }
필드 설명 rolecurrent(체험 시작 시 차 — 후속) ·recommended(견적 브리핑 LLM) ·delivered(출고 확인 — 후속)description어드민이 "차량" 에 적은 설명(메타정보). LLM 이 읽은 글과 같다 reasonLLM 이 이 차를 고른 이유 / 어드민 메모 sourcellm·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필드 자체가 생략된다.
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 포함). 하나도 없으면 생략. 화면마다 다른 차가 뜨던 문제의 단일 출처 — 견적 히어로·가상 시승·출고 확인·수소 충전 화면이 전부 이걸 본다 |
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일 때)
-
매핑 (세션 생성 시, 발행 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) -
발행 시점 (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})에 남는다
- 장소 도착 (
-
사용자가 이벤트를 못 받은 채 다음 장소로 이동하면 이전 장소 이벤트는 그대로 미발행으로 남는다 (현재 장소 기준으로만 판단).
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에는 포함되지 않는다.
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이 다시 온다.
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 기준, 없으면 빈 문자열) |
[Deprecated] AI 동반자 전체 목록 스냅샷(
orchestration/ai-companion/list)로 대체됐다. 토픽 네이밍에서 "persona" 대신 "AI 동반자"의 영어 표현인 companion을 쓴다. 페이로드는 동일하다. 하위 호환을 위해 계속 발행되며 기능은 그대로 유지된다.
Topic: orchestration/persona/list
Retained. 서버 시작 시 자동 발행. 구독 즉시 최신 목록 수신.
Payload — AI 동반자 전체 목록 스냅샷과 완전히 동일
(id는 UUID v7 문자열). 필드 설명은 §11 참고.
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) |
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 |
Topic: orchestration/{sessionId}/point
Retained. 스토어 구매 완료 후 발행. 향후 포인트가 변동되는 모든 이벤트(태스크 완료 지급 등)에서도 이 토픽으로 통합 발행.
성공 Payload
{
"ok": true,
"point_balance": 12000
}실패 Payload
{
"ok": false,
"error": "포인트가 부족합니다"
}| 필드 | 타입 | 설명 |
|---|---|---|
ok |
boolean | 구매 성공 여부 |
point_balance |
integer | 구매 후 잔여 포인트 (성공 시에만 포함) |
error |
string | 오류 메시지 (실패 시에만 포함) |
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로만 표시 가능하다.
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 화면 전환 시 대상을 식별 |
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 기준).
- 이미 그 zone 안에 서 있으면 그 자리를 유지한다. 위치 메시지는 몇 초마다 들어오므로 매번 다른 인스턴스를 고르면 한 자리에 서 있는데도 도착 이벤트가 되풀이된다.
- 그 zone을 한 번도 밟지 않았으면 가장 앞선 인스턴스(첫 방문 = STEP 1 쪽).
- 두 번째 방문부터는 첫 인스턴스로 돌아가지 않는다. 나머지(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— 위치 변경 이력 전체를 최신순으로 반환
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의 별도 컬럼도 갱신하는지 여부)도 서버 내부 처리용이라 응답에 포함되지 않는다.
Topic: orchestration/{sessionId}/spot/command/response, .../spot/ack, .../spot/plan
"10. SPOT 명령 요청"에서 발행한 명령에 대한 응답/진행 상황 릴레이. 페이로드 형태는
MQTT_SCHEMA-SPOT §4(RESPONSE), §5(plan)를
그대로 따른다 (session_id로 식별해 해당 세션 토픽으로 relay).
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만
아래 필드 하나를 서버가 추가한다.
SPOT 원문(§5)에 target_location_id를 얹는다. 원본 필드는 하나도 바꾸지 않는다.
| 필드 | 타입 | 설명 |
|---|---|---|
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만 세션 토픽으로 릴레이한다.
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 실제 발행 없이 테스트용).
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가 아닌 페이로드가 오면 로그만 남기고 무시한다.
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 전용 신규 구독이다.
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로 마지막 상태만 유지한다.
확인 필요 사항: 모든 태스크의 시작/완료/건너뜀마다 생성할지, 일부 태스크에만 적용할지는 운영해보며 재검토 예정 (현재는 전체 적용 + 기본 꺼짐 설정으로 시작).
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도 그대로 받는다.
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) 명령 요청" 참고.
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": "<위에서 받은 값>" }
모든 /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 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/summarystatus=completed). 이미 진행 중/종결이면 무시. 준비물 챙기기(packing, 2026-09-19):cctv/item/state로 준비물 중 하나라도 챙겨지면(picked)in_progress, 목록 전부 챙겨지면 자동completed(포인트·ai/summary·순차 후속 동일). 단, 준비물 항목이 전부 CCTV 매핑돼 있어야 "전부 챙김"을 판정하며, 미매핑 항목이 하나라도 있으면 자동 완료하지 않는다(태블릿task/complete또는 어드민 강제 완료 필요).
발행 시점: 페르소나 생성(로그인) 완료 시 서버가 자동 발행. 어드민이 사용자 상세 페이지에서 수동 발행도 가능 (
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 | 미래차 상태 응답 (포맷 미확정) |
- 플랜 발행 시점 확정 — 현재 임시로 페르소나 생성 시 발행 중. 실제 시점(차량 공간 진입, CCTV 감지 등) 협의 필요
-
enter발행 주체 확정 — tablet / oc-server / CCTV 중 어디서 발행할지 미결 -
future-vehicle/response페이로드 포맷 확정 — 미래차 팀과 응답 스펙 협의 필요 - 플랜 없을 때 동작 정의 — 해당 페르소나에 연결된 플랜이 없을 때 서버 동작 정책 확정
Vision 팀(카메라 트래킹 서비스)과의 연동. 전체 스키마는 별도 문서
MQTT_SCHEMA-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 |
| 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)을 그대로 재사용하며 별도 완료 로직은 없다.
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 실제 발행 없이 테스트용).
-
zone_id ↔ locations 매핑 확정— 백오피스 장소 관리에서zone_id를 직접 지정하는 방식으로 해결 (locations.zone_id, 미지정 zone/장소 허용, 여러 장소가 같은 zone_id 공유 가능 — 매핑 시 활성 장소 중 display_order가 가장 앞선 하나로 결정) -
— 사람 위치와 동일하게 zone_id를 장소로 매핑해vision/position/spot(로봇) 구독·활용 방안 확정orchestration/{sessionId}/spot/position으로 재발행 (사용자 이력·이벤트 부수효과 없음)
- Google ADK 검토 (프레임워크·모델 비교)
- 페르소나 + 상황 카드 모델 (2차 시나리오 구동 방식)
- Mac Studio 로컬 LLM + Claude Opus 하이브리드 라우팅 전략
- 개발 일정·할 일 점검표 (단계별 진행 점검)
- 로봇 Adapter 개발 온보딩 (HA·MQTT 계정 등록, 토픽 적용, 테스트 송수신, 상태 확인)
- LG ThinQ 가전 연동 (HA 통합 · REST 데이터 취득 · 제어)
- 장소 도착 자동화 (태스크/이벤트/장소 규칙 → SPOT·로봇팔·반려로봇·Hub 자동 명령)
- 엔딩 화면 데이터 출처 점검 (차량 3필드 · 세션 종료 엔딩 5화면)
{ "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 필드 그대로 }