Client SDK 키(
osk_ 접두사)로 인증. 추천은 비동기(즉시 반환) + 폴링 구조 — POST 가 라운드를 시작하고, 클라이언트가 GET 디테일로 상태를 폴링.모델
- chat_id = 하나의 대화. 첫 라운드 때 발급되고, 이후 같은 chat_id 로 꼬리질문을 누적.
- round_id = 라운드 식별자. POST 한 번 = round 1 개. 각 라운드는 prompt + 결과 스냅샷을 보유.
- 꼬리질문 컨텍스트 (2026-05-19~): 같은 chat_id 의 직전 COMPLETED 라운드들이 (user prompt, assistant meal JSON) 페어로 자동 누적되어 llm-demo
conversation_history로 전달됩니다. 즉 클라이언트는 단순히{chat_id, prompt}만 보내면 됩니다 —prompt가 “와인 추가” / “돼지고기 말고 다른거로” 같은 짧은 정제 발화여도 직전 ingredients 를 앵커 삼아 정제됩니다. meal 복원 실패한 라운드는 묵묵히 건너뜁니다. - 응답 언어:
profile.locale우선, 없으면 테넌트 기본 언어, 그래도 없으면ko. 메시지성 텍스트 (recipe_title/intro_message/recipe_description/recommendation_message/notes등) 가 그 언어로 출력됩니다. 지원 언어:ko/en/ja. reason필드는 예외: 테넌트 어드민 화면에 알림으로 노출되며 UI 언어 토글 시 데이터 재요청 없이 즉시 표시 언어를 바꿔야 하므로 항상{ko, en, ja}객체로 옴 (3개 언어 모두 채워짐).- 꼬리질문 한도: 한 라운드 안에서 refinement 8 회 초과 시
409 REFINEMENT_CAP_EXCEEDED. 새 라운드(POST) 또는 새 chat 으로 리셋. - 진행 중 라운드 보호 (2026-05-20~): 같은 chat 의 직전 라운드 step2 가 아직 끝나지 않은 상태에서 POST 를 한 번 더 호출하면
409 PRIOR_ROUND_IN_FLIGHT. step2 완료(=pipeline_progress.step2 == "done", todos 가 채워진 시점) 이후엔 step3/step4 가 백그라운드 진행 중이어도 follow-up 즉시 허용. 라운드들은 step2 boundary 까지 직렬화되고 step3/4 는 라운드별 독립 백그라운드.
1. 추천 요청 (kickoff)
Request
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
chat_id | string | × | 없으면 신규 chat 생성. 있으면 그 chat 의 새 라운드로 추가 |
prompt | string | ○ | 사용자 질의 |
🧑 persona 는 프로필에서 자동 주입 — 별도 body 필드 없음. 서버가GET /v1/profile/{profile_id}의data.persona를 읽어 값이 있으면 user_query prelude 에persona(페르소나: ...)로 prepend. 프로필 등록/수정 시점에PUT /v1/profile/{profile_id}body 의persona로 갱신 (자유서술 문자열, nullable).
⚡ fire-and-forget — 이 엔드포인트는 즉시(~100ms) 응답합니다. LLM 호출은 백그라운드에서 진행되므로 결과는 폴링으로 받습니다. RefinementCapExceeded 같은 LLM 에러도 동기 4xx 가 아닌round.status=error+last_error로 영속화 → polling 으로 인지.
Response 202
GET /v1/chat/{profile_id}/recommend/{chat_id} 로 폴링 — 마지막 라운드의 pipeline_progress.step4 == "done" 이면 완료.
2. 채팅 리스트 (페이징)
| 쿼리 | 기본값 | 설명 |
|---|---|---|
page | 0 | 0-indexed |
limit | 100 | 페이지 크기 (최대 500) |
order | desc | created_at 정렬, desc / asc |
Response 200
chat_id + total_rounds + 라운드 식별자 미니 배열 ({round_no, round_id} 만) + created_at. 라운드 본문 (prompt / 결과) 은 디테일 / 단일 라운드 엔드포인트에서. 페이징 메타는 pageInfo 로 묶임.
3. 채팅 디테일 (rounds[])
KICKED_OFF)인 라운드들을 llm-demo 에 한 번 더 묻고, 완료됐으면 스냅샷을 DB 에 적재. 이미 완료된 라운드는 그대로 반환.
Response 200 — 진행 중 (마지막 라운드 in_progress)
🔑 스키마 일관성 (2026-05-09~) —pipeline_result가 아직 비어있어도 키들이 항상 존재합니다. 클라이언트는 키 존재 여부가 아닌 값 기반 분기 (pipeline_progress.stepN == "done",items.length > 0) 로 작성해야 합니다.
pipeline_progress.stepN == "done" 시점부터):
step1(~5s): 별도 키는 비어있음. (LLM 응답 완료 + meal 생성)step2(~10s):todos.recipe_title / intro_message / recipe_description / recommendation_message / ingredients[] / items[].product채워짐step3(~15s):recommended_items[]채워짐 (reason= 카테고리 태그,reason_detail= 분석 문구,reason_detail_marketing= 고객 카피)step4(~20s):notifications.ad.items(사이니지) +notifications.coupon[](쿠폰) 채워짐notifications.coupon_spot[]은 step 무관 — 매장에 등록된 쿠폰 스팟 매스터를 매 폴링마다 store-API 에서 가져와 채워집니다 (LLM 추천 아님)
Response 200 — 완료 (todos / recommended_items 채워짐)
| 라운드 필드 | 설명 |
|---|---|
round_no | 1-indexed 순번. 몇 번째 질문인지 (rounds[].length 의 마지막 원소가 곧 총 라운드 수) |
status | in_progress / completed / error |
back_list_status | llm-demo 의 not_started / in_progress / completed (스냅샷 없으면 null) |
todos | 구매 후보 + recipe 메타. 각 원소 {quantity, product}. total = Σ(product.price × quantity) |
todos.recommendation_message | step1 (meal) 의 개인화 추천 사유 — 프로필(name / gender / age / extra) 신호를 반영한 사람 친화적 코멘트. recipe_description (메뉴 일반 설명) 과 별개. profile locale 로 출력 |
todos.items | 매장 동선 순서대로 정렬됨 (2026-05-20~) — product.section_code 기준 안정 정렬. 순서: 농산물(PR) → 델리(DL) → 베이커리/제과(BK/SW/SN) → 주류(AL) → 유제품/음료(DY/DR) → 식료품(PA) → 육류(MT) → 수산물(FI) → 냉동식품(FR). 매장 picker 가 카테고리를 따라 한 번에 픽업할 수 있도록. 표에 없는 코드(EC/HH/CK 등)는 맨 끝. 같은 카테고리 안 순서는 원래 순서 유지 |
recommended_items | 메뉴와 어울리는 cross-sell. reason = 카테고리 태그 ("cross-sell" / "upsell" / "impulse"), reason_detail = 운영자/대시보드용 분석 문구 (lift/CVR/co_count 포함), reason_detail_marketing = 고객 노출 카피, product = store 병합 결과. reason_detail / reason_detail_marketing 은 항상 {ko, en, ja} 객체 — 현재 upstream 이 단일 locale 만 주는 동안엔 세 키에 동일 값 복사 (shape 만 일관) |
notifications.ad | 사이니지 ads 묶음 객체 { signage, items }. signage = 사이니지 1대의 {x, y, radius} (매칭 실패 시 null). items = type=ads 추천 목록 (top 3) |
notifications.coupon | 앱 내 쿠폰 발급/사용용 항목 배열. 각 항목에 location {x, y, radius} 가 박혀옴 — 항목의 item_id (= store product id) 로 매장 product 의 Section 좌표를 매칭 (매칭 실패 시 null) |
notifications.coupon_spot | 매장 어드민에 등록된 쿠폰 스팟 매스터 (LLM 추천 아님, 매 폴링마다 store-API 에서 가져옴). coupon[] 과 동일한 19 필드 schema — iOS 가 같은 디코더로 처리 가능. type = "coupon", rec_id / reason_* / applied_score / priority_score / method 등 추천 시그널은 모두 null. discount_pct ← 어드민 discount_value, rank ← spot_no ASC 인덱스. location 은 product.location 이 아닌 스팟 자체 좌표 {x, y, radius} |
notifications.*[].item_id | store catalog product id. llm-demo 가 부착하지 못하면 null — 항목 자체는 살아있고 item_name / item_name_en 으로 카드 렌더 가능 |
notifications.*[].product | store DB 매칭 결과 객체 통째 — id / name / brand / barcode / price / price_unit / src_url(이미지) / location / section_code / section_name / quantity 를 그대로 노출 (매칭 실패 시 null) |
notifications.*[].price / price_unit | product.price / product.price_unit 평탄화 노출 — 카드 가격 hot path 용 (매칭 실패 시 둘 다 null). 종전의 price_yen 은 제거 — 매장 DB 단일 출처 |
notifications.*[].type | "ads" (사이니지) / "coupon" (앱) — 그룹 키와 동일하지만 항목 단위 처리 시에도 사용 |
notifications.*[].rec_id | 추천 이벤트 추적 id — /trigger/signage 의 items[].rec_id / /trigger/coupon 의 rec_id 로 보냄 |
notifications.*[].reason_type / reason_detail / reason_detail_marketing | step4 추천 사유 3종 — reason_type = 카테고리 태그 ("cross-sell" / "zone_efficiency" 등), reason_detail = 운영자/대시보드용 분석 문구, reason_detail_marketing = 고객 노출 카피 (사이니지/쿠폰 화면). reason_detail / reason_detail_marketing 은 {ko, en, ja} 객체 (upstream 단일 locale 동안엔 세 키 동일 값) |
last_error | status: error 일 때만 (예: meal_suggest failed: upstream timeout / upstream chat session expired) |
4. 단일 라운드 조회
rounds[i] 와 동일 (chat 메타 / profile_summary 없이 해당 라운드만):
NOT_FOUND 404.
5. 사이니지 ads 트리거 송신 — POST /trigger/signage
iOS UWB 가 매장 사이니지 location radius 진입/이탈 시 각각 호출합니다. action 필드로 분기 (default "enter").
- enter: 라운드의
notifications.ad.items를 사이니지로 forward → 사이니지가 RECOGNIZED 화면으로 전환. 서버는 (1) item 별 sdk_chat_event 영속화 (2) 테넌트 어드민 SSE 푸시 (3) store-API 로 forward. - exit: 사이니지 IDLE 즉시 복귀. 영속화/SSE skip, store-API forward 만.
사이니지 번호별 화면 노출 정책
Gateway 는items[] 전체를 store-API 로 forward 하고, 어느 항목을 어떻게 노출할지는 사이니지 클라이언트(매장 매핑 번호별 화면)가 결정합니다.
| 사이니지 | IDLE 화면 | ACTIVE 화면 (RECOGNIZED) 노출 데이터 |
|---|---|---|
| 1번 (입장 사이니지) | 어드민 등록 슬라이드 1~4 (4장 크로스페이드) | items 를 discount_pct desc 정렬 후 top 1 단일 상품 카드. 가로/세로 자동 분기. reason_detail 을 노출 카피로 사용 |
| 2번 (매장 내 사이니지) | 어드민 등록 슬라이드 9슬롯 (3 슬라이드 × 3 상품) | 3섹션 구성 — ① forgotten[0] (미픽업 상품) ② items[1] || items[0] (광고 1건, 인덱스 기반) ③ store-API signage_event 중 enabled=true 1건 랜덤 |
forgotten 미첨부 시 해당 영역 미렌더). 사이니지 1번은 단일 상품 카드 디자인이라 discount_pct 정렬이 우선 의미를 가지며, 2번은 광고 옆에 “미픽업 / 행사” 동시 노출이 목적이라 인덱스 1을 우선 사용 (인덱스 0 은 더 강한 신호로 다른 노출에 쓰일 수 있음).
Request — Enter (영역 진입)
최소 페이로드는chat_id + round_id 만으로 충분합니다. action 생략 시 "enter" 가 default.
Request — Exit (영역 이탈)
{ "success": true, "data": { "event_ids": [], "accepted": true, "action": "exit" } }
Request — Enter (items override + forgotten)
items 를 명시적으로 함께 보내면 그 값이 우선 사용됩니다 (라이브 데이터, 부분 셋 등). forgotten 으로 사용자가 매장에서 잊은/미픽업 상품 목록을 첨부할 수도 있습니다 (2026-05-20~):
| 필드 | 필수 | 설명 |
|---|---|---|
chat_id | ✅ | 추천 받았던 chat |
round_id | ✅ | 그 round |
items | optional | 생략/빈 배열 시 round 의 영속 데이터에서 자동 lookup. 명시 시 그 값 우선 사용 |
items[].rec_id | ✅ (items 보낼 때) | 항목별 추천 이벤트 추적 id |
items[].item_id / item_name / item_name_en / brand / zone_id / zone_name / price_yen / discount_pct / applied_score / priority_score / method / reason_type / reason_detail / reason_detail_marketing | optional | FIREHOSE / 사이니지에 표시. 사이니지별 노출 정책은 위 “사이니지 번호별 화면 노출 정책” 표 참조 (1번 = discount_pct top 1 단일 카드, 2번 = items[1] || items[0] 광고 + forgotten + event 3섹션) |
forgotten | optional | 사용자가 매장에서 잊은/미픽업 상품 목록. iOS sdk 가 todos.items 중 해당 항목을 그대로 첨부. 각 원소 schema 는 자유 (any) — Gateway 는 가공 없이 영속화 payload + 어드민 SSE fanout + store-API forward 모두 root 수준으로 통과시킴. 빈 배열 / 누락 시 키 자체 누락. exit 액션에서는 무시 |
Response 202
event_ids 는 영속화된 항목 수만큼 반환. 호출 빈도 = location 진입 횟수 (한 라운드 + 한 사이니지 = 1 호출).
서버는 item 별로 step4 의 store_reasoning.analyst_comment 를 raw pipeline 에서 추출해 analyst_comment 키로 SSE payload 에 부착 — 어드민 user-analytics 의 사이니지 이벤트에 ‘추천 근거’ 로 노출.
6. 쿠폰 발급/사용 송신 — POST /trigger/coupon
iOS sdk 가 라운드의 notifications.coupon[] 항목을 앱 내에서 발급/사용/거절 처리할 때 항목 1 개 단위로 호출합니다. 사이니지와 무관하므로 (1) 이벤트 영속화 (2) 테넌트 어드민 SSE 푸시 만 일어납니다.
Request
최소 페이로드는chat_id + round_id + rec_id 만으로 충분합니다. Gateway 가 라운드의 영속화된 notifications.coupon[] 에서 rec_id 매칭으로 메타를 자동 보충합니다.
| 필드 | 필수 | 설명 |
|---|---|---|
chat_id / round_id | ✅ | 같은 의미 |
rec_id | ✅ | 추천 이벤트 추적 id (round 의 영속 데이터 매칭 키) |
kind | optional | "issued" / "redeemed" / "dismissed" — 기본값 issued |
| 나머지 | optional | 생략 시 round 의 영속 데이터에서 자동 보충. 명시 시 그 값 우선 |
Response 202
7. 개인 맞춤 추천 노출 로그 — POST /trigger/recommend
iOS sdk 가 recommended_items[] (step3) 중 한 항목을 앱에서 노출/탭 처리할 때 1회성 로그로 호출합니다. 사이니지 / store-API 와 무관 — (1) sdk_chat_event 영속화 (2) 테넌트 어드민 SSE 푸시 만.
Request
| 필드 | 필수 | 설명 |
|---|---|---|
chat_id / round_id | ✅ | 같은 의미 |
rec_id | ✅ | 추천 이벤트 추적 id (recommended_items[].rec_id 또는 id) |
recommended_items[] 에서 rec_id 매칭으로 item_name / price / reason_detail / reason_detail_marketing 등 메타를 자동 lookup 해 payload 에 박습니다. step3 의 surface_reasoning.analyst_comment 도 raw pipeline 에서 함께 추출해 analyst_comment 키로 부착 — 어드민 user-analytics 의 ‘추천 근거’ 표시용.
Response 202
8. 라운드 바코드 모음 — GET /recommend/{chat_id}/{round_id}/barcodes
라운드의 product 들을 카테고리별로 묶어 EAN-13 PNG (base64) 와 함께 반환합니다. 어드민/시연용 — store-API 재호출 없음 (round 평탄화 데이터 + ZXing 즉석 PNG).
Response 200
barcode가 13자리 숫자가 아니거나 checksum 오류면barcode_image: null— 클라이언트가 null 가드만 처리.- 동일 product 가 여러 카테고리에 나타날 수 있음.
폴링 가이드
- 권장 간격: ~2 초 (전체 파이프라인 평균 ~46s)
rounds배열 마지막 원소의status만 보면 됨 — 이전 라운드들은 항상completed(또는error) 로 고정- 새 라운드 추가 (POST 두 번째 호출 후) 폴링하면 배열 길이 +1, 마지막 원소가 다시
in_progress로 시작 error/ TTL 만료 시: 새 chat 생성 (chat_id 안 보내고 POST) 권장
에러 코드
| HTTP | code | 원인 |
|---|---|---|
| 400 | INVALID_REQUEST | body 검증 실패 (prompt 누락 등) |
| 404 | NOT_FOUND | profile / chat 미존재 또는 소유권 불일치 |
| 402 | INSUFFICIENT_CREDIT | 테넌트 크레딧 잔액 부족 |
| 409 | REFINEMENT_CAP_EXCEEDED | 같은 라운드에서 꼬리질문 8 회 초과 — 새 라운드/새 chat 으로 리셋 |
| 409 | PRIOR_ROUND_IN_FLIGHT | 같은 chat 의 직전 라운드 step2 도 아직 미완료 — pipeline_progress.step2 == "done" 확인 후 재시도. step3/4 진행 중이어도 step2 만 끝나면 follow-up OK |
| 500 | INTERNAL_ERROR | 업스트림 장애 등 |