Repository navigation
FUTURE_CAR SPEC OC
- 작성: 미래차 팀, 2026-08-23 · 대상 코드:
orchestration-server64065c7 - 미래차가 OC 에 요청하는 변경은 3 절(세션 모드) 하나다. 2 절은 OC 작업이 없고 "동의"만 필요하다. 1 절은 이미 동작 중인 것(참고).
- 계약 정본: 위키
MQTT_SCHEMA-FUTURE_CAR§3-2-1
| 방향 | 토픽 | OC 코드 | 비고 |
|---|---|---|---|
| OC → 미래차 |
future-car/request type=plan (retained) |
mqttx/future_vehicle.go PublishFutureVehiclePlan
|
stops[] = {id, kind, place, contexts}. 미래차는 stops 를 장소 카탈로그로만 쓴다 |
| OC → 미래차 |
dashboard (retained) |
mqttx/location_tasks.go |
차 장소의 tasks[] 를 여정 태스크로 쓴다. status 로 완료 여부 판단 |
| OC → 미래차 |
me (retained) |
mqttx/create_persona.go PersonaDisplayPayload.ai_companion.prompt
|
스텝 생성 시스템 프롬프트 앞에 붙인다 |
| OC → 미래차 | ai/response |
mqttx/ai_chat.go |
transcript = 관람객 발화. 미래차가 구독해 스텝 생성 재료로 쓴다 |
| 미래차 → 태블릿 |
ai/response {ok, context_id, message} · ai/suggestions
|
(OC 코드 없음) | 미래차가 직접 발행한다(2장). OC 는 구독하지 않는 토픽이라 영향 없음 |
| 미래차 → OC |
ai/mode (retained) |
신규 — 3장 |
{"mode":"car","ttl_sec":1800} / {"mode":"chat"} 를 이미 발행 중(OC 가 구독하기 전까지는 retained 로 남을 뿐) |
| 미래차 → OC |
task/complete {task_id}
|
mqttx/task_complete.go |
태블릿과 동일 페이로드. OC 가 dashboard·point 재발행 |
미래차가 태블릿 채팅에 띄우는 문장("출발 할까요?", 각 step 의 질문)은 LLM 답변이 아니라 정해진 문장이다.
OC 를 거치면 LLM 이 문장을 바꾸고, 지시문이 대화 기록에 남고, 세션당 1건 락에 우리 요청이 조용히 드롭됐다.
그래서 미래차가 orchestration/{sid}/ai/response 에 직접 발행한다(브로커 ACL 상 future-car 계정은 orchestration/# 쓰기 가능).
| 미래차가 발행 | 페이로드 | 태블릿 동작 |
|---|---|---|
ai/response |
{ "ok": true, "context_id": "<미래차 생성>", "message": "출발 할까요?" } |
chat-store.receiveAiResponse 가 모르는 context_id 의 message 를 AI 말풍선으로 추가 — 이미 그렇게 동작
|
ai/suggestions |
{ "context_id": "<같은 값>", "suggestions": ["네, 출발해요!", "잠깐만요"] } |
답 후보 칩 표시 — 이미 그렇게 동작. 탭하면 ai/request type=text 로 나가 transcript 가 된다 |
OC 가 할 일은 없다. 이 두 토픽은 OC 가 구독하지 않는다. 다만 계약상 OC 가 발행자인 토픽에 미래차가 같이 쓰는
것이므로 동의가 필요하다(AsyncAPI 문서·모니터링 관점). 거절하면 대안으로 ai/request 에 passthrough: true
(LLM·히스토리·suggestions 생략하고 text 를 그대로 message 로)를 구현해 달라고 요청한다 — 미래차는 env 한 줄
(OC_CHAT_PUBLISH=request)로 그 경로로 돌아간다.
미래차가 직접 쓰는 메시지는 OC 의 ai_messages 히스토리에 남지 않는다. 여정 뒤 일반 채팅에서 "차가 한 말"을
맥락으로 쓰고 싶다면 OC 가 ai/response 를 구독해 assistant 턴으로 저장하는 방법이 있다 — 선택.
| 항목 | 값 |
|---|---|
| 토픽 | orchestration/{sessionId}/ai/mode |
| 발행 | 미래차 (enter 직후 / exit 직후) |
| retain / QoS | true / 1 |
| 페이로드 |
{"mode":"car","ttl_sec":1800} 또는 {"mode":"chat"}
|
topics.go 에 추가:
// AiModeTopic — SUBSCRIBE orchestration/{sessionId}/ai/mode (retained, 미래차 발행)
const AiModeTopic = "orchestration/+/ai/mode"
// AiModeTopicFmt — PUBLISH(삭제용) orchestration/{sessionId}/ai/mode
const AiModeTopicFmt = "orchestration/%s/ai/mode"세션별 (mode, expiresAt). 기존 SessionStore 에 필드를 얹어도 되지만, 미래차 여정은 세션보다
짧고 TTL 이 별개라 분리를 권한다(TimedLock 과 같은 결인 작은 맵).
type AiModeStore struct {
mu sync.RWMutex
entries map[string]aiModeEntry // sessionID → {mode, expiresAt}
}
func (s *AiModeStore) IsCar(sessionID string) bool // mode=="car" && time.Now() < expiresAt
func (s *AiModeStore) Set(sessionID, mode string, ttl time.Duration)
func (s *AiModeStore) Clear(sessionID string)
func RegisterAiModeHandler(svc *Service, store *AiModeStore) error {
return svc.Subscribe(AiModeTopic, 1, func(topic string, payload []byte) {
// 빈 payload = retained 삭제 → Clear
// {"mode":"car","ttl_sec":N} → Set(sid, "car", N초; 0/누락이면 기본 30분, 상한 2시간)
// {"mode":"chat"} → Clear
// 모르는 mode 는 무시 + 로그
})
}server.go 의 핸들러 등록 줄에 RegisterAiModeHandler(mqttService, aiModeStore) 추가.
RegisterAiChatHandler 에 aiModeStore 인자를 넘긴다.
if aiModeStore.IsCar(sessionID) {
// 관람객 발화: STT 만 하고 LLM 답변·suggestions 는 생략한다.
var transcript string
switch req.Type {
case "voice":
transcript = sttOnly(...) // 기존 voice 경로에서 STT 까지만 (ChatWithAudio 의 전사 부분)
case "text":
transcript = req.Text
}
// user 턴은 저장한다(여정 뒤 일반 채팅·요약에서 맥락으로 쓰일 수 있음) — 6장 열린 질문
_ = queries.InsertAiMessage(ctx, ...Role:"user", Transcript: transcript, Content: transcript)
resp := AiChatResponse{OK: true, ContextID: req.ContextID, Transcript: transcript, Message: ""}
publish(resp)
return
}llmClient.ChatWithAudio 한 호출에 묶여 있다
(ai_chat.go voice case). car 모드에서는 전사만 필요하므로 전사만 하는 경로가 필요하다
— ChatWithAudio 에 "답변 생략" 옵션을 두거나, STT 전용 함수를 분리한다. 이게 이 변경에서
가장 큰 작업이다.
| 경로 | 어디서 | 무엇을 |
|---|---|---|
| TTL | AiModeStore.IsCar |
expiresAt 지나면 false (별도 정리 스레드 불필요) |
exit |
미래차가 {"mode":"chat"} 발행 |
핸들러가 Clear
|
signout |
end_session.go RegisterEndSessionHandler 의 store.DeleteByUUID 직후 |
aiModeStore.Clear(sid) + retained 삭제: svc.Publish(fmt.Sprintf(AiModeTopicFmt, sid), []byte{}, 1, true) (me 를 지우는 것과 같은 방식) |
입력 ai/request
|
출력 ai/response
|
LLM | 히스토리 | suggestions |
|---|---|---|---|---|
태블릿 type=voice
|
{ok, context_id, transcript} (message:"") |
✗ | user 턴 | ✗ |
태블릿 type=text
|
{ok, context_id, transcript:text} (message:"") |
✗ | user 턴 | ✗ |
| chat 모드(기본) | 지금과 동일 | ✓ | user+assistant | ✓ |
-
ai/mode없이 태블릿 텍스트 → 지금과 동일(회귀 없음) -
{"mode":"car"}발행 → 태블릿 텍스트 →message:"",ai/suggestions미발행 - car 모드에서 미래차가 직접 쓴
ai/response가 태블릿에 그대로 보임(OC 무관) -
{"mode":"chat"}→ 2 의 요청이 다시 LLM 답변을 받음 -
ttl_sec:5로 car → 6초 뒤 태블릿 텍스트 → LLM 답변 받음(TTL) - car 모드 중
signout→ retainedai/mode가 비어 있음(MQTTX 로 재구독해 확인) - OC 재시작 → retained
ai/mode가 다시 들어와 모드 복원
| 변경 | 파일 | 크기 |
|---|---|---|
| A′ 직접 발행 | (OC 없음) | 동의만. 대안 passthrough 면 ai_chat.go 분기 ~20줄 |
| B 토픽·상태 |
topics.go, ai_mode.go(신규), server.go
|
~80줄 |
| B car 분기 | ai_chat.go |
~30줄 + STT 전용 경로 분리(voice) |
| B 해제 | end_session.go |
2줄 |
- car 모드에서 관람객 user 턴을 히스토리에 저장할지 → 저장 (리플렉션·요약 맥락)
- 미래차가 직접 쓴 문장을 OC 가 구독해 assistant 턴으로 저장할지 → 선택(하면 여정 뒤 채팅이 "차가 한 말"을 앎)
-
ttl_sec기본값/상한 → 30분 / 2시간 - 여정 중 관람객의 일반 질문에 답이 없어도 되는가 → 연출 의도로 확인 필요 (이 스펙의 전제)
- Google ADK 검토 (프레임워크·모델 비교)
- 페르소나 + 상황 카드 모델 (2차 시나리오 구동 방식)
- Mac Studio 로컬 LLM + Claude Opus 하이브리드 라우팅 전략
- 개발 일정·할 일 점검표 (단계별 진행 점검)
- 로봇 Adapter 개발 온보딩 (HA·MQTT 계정 등록, 토픽 적용, 테스트 송수신, 상태 확인)
- LG ThinQ 가전 연동 (HA 통합 · REST 데이터 취득 · 제어)
- 장소 도착 자동화 (태스크/이벤트/장소 규칙 → SPOT·로봇팔·반려로봇·Hub 자동 명령)
- 엔딩 화면 데이터 출처 점검 (차량 3필드 · 세션 종료 엔딩 5화면)