메인 콘텐츠로 건너뛰기
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)

POST /v1/chat/{profile_id}/recommend
신규 chat 또는 기존 chat 의 follow-up. 즉시 200 으로 식별자만 반환하고, 결과는 폴링.

Request

{
  "chat_id": "(Optional) UUID",
  "prompt":  "오늘 저녁 카레 땡기는데"
}
필드타입필수설명
chat_idstring×없으면 신규 chat 생성. 있으면 그 chat 의 새 라운드로 추가
promptstring사용자 질의
🧑 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

{
  "success": true,
  "data": {
    "chat_id":  "a14b2f9c-3d5e-4f2a-9a8b-1c2d3e4f5a6b",
    "round_id": "8f53c2b0-1234-...-..."
  }
}
이후 GET /v1/chat/{profile_id}/recommend/{chat_id} 로 폴링 — 마지막 라운드의 pipeline_progress.step4 == "done" 이면 완료.

2. 채팅 리스트 (페이징)

GET /v1/chat/{profile_id}/recommend
쿼리기본값설명
page00-indexed
limit100페이지 크기 (최대 500)
orderdesccreated_at 정렬, desc / asc

Response 200

{
  "success": true,
  "data": {
    "items": [
      {
        "chat_id":      "...",
        "total_rounds": 2,
        "rounds": [
          { "round_no": 1, "round_id": "..." },
          { "round_no": 2, "round_id": "..." }
        ],
        "created_at":   "2026-04-29T05:28:08Z"
      },
      {
        "chat_id":      "...",
        "total_rounds": 1,
        "rounds": [
          { "round_no": 1, "round_id": "..." }
        ],
        "created_at":   "2026-04-28T22:11:00Z"
      }
    ],
    "pageInfo": {
      "total": 42,
      "page":  0,
      "limit": 100,
      "order": "desc"
    }
  }
}
각 원소는 chat_id + total_rounds + 라운드 식별자 미니 배열 ({round_no, round_id} 만) + created_at. 라운드 본문 (prompt / 결과) 은 디테일 / 단일 라운드 엔드포인트에서. 페이징 메타는 pageInfo 로 묶임.

3. 채팅 디테일 (rounds[])

GET /v1/chat/{profile_id}/recommend/{chat_id}
폴링 진입점. 호출할 때마다 진행 중(KICKED_OFF)인 라운드들을 llm-demo 에 한 번 더 묻고, 완료됐으면 스냅샷을 DB 에 적재. 이미 완료된 라운드는 그대로 반환.

Response 200 — 진행 중 (마지막 라운드 in_progress)

🔑 스키마 일관성 (2026-05-09~) — pipeline_result 가 아직 비어있어도 키들이 항상 존재합니다. 클라이언트는 키 존재 여부가 아닌 값 기반 분기 (pipeline_progress.stepN == "done", items.length > 0) 로 작성해야 합니다.
{
  "success": true,
  "data": {
    "chat_id":         "a14b2f9c-...",
    "profile_id":      "6b97957c-...",
    "profile_summary": { "name": "佐藤花子", "gender": "F", "age": 32, "locale": "ja" },
    "created_at":      "2026-04-29T05:28:08Z",
    "rounds": [
      {
        "round_no":         1,
        "round_id":         "8f53c2b0-...",
        "prompt":           "오늘 저녁 카레 땡기는데",
        "created_at":       "2026-04-29T05:28:08Z",
        "updated_at":       "2026-04-29T05:28:08Z",
        "status":           "in_progress",
        "back_list_status": null,
        "pipeline_progress": {
          "step1": "running",
          "step2": "pending",
          "step3": "pending",
          "step4": "pending"
        },
        "todos":             null,
        "recommended_items": [],
        "notifications":     { "ad": null, "coupon": [] }
      }
    ]
  }
}
step별 데이터 가용 시점 (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 추천 아님)
{
  "success": true,
  "data": {
    "chat_id":      "...",
    "profile_id":   "...",
    "profile_summary": { ... },
    "created_at":   "...",
    "rounds": [
      {
        "round_no":         1,
        "round_id":         "...",
        "prompt":           "오늘 저녁 카레 땡기는데",
        "created_at":       "...",
        "updated_at":       "...",
        "status":           "completed",
        "back_list_status": "completed",
        "todos": {
          "recipe_title":           "매콤 소고기 카레",
          "intro_message":          "오늘 메뉴는 매콤 소고기 카레 어떠세요?...",
          "recipe_description":     "일본식 카레...",
          "recommendation_message": "오늘 같은 날에 잘 어울리는 메뉴예요. 프로필 정보를 반영한 개인화 추천 사유가 들어갑니다.",
          "ingredients": [
            {
              "name":          { "ko": "두부",  "en": "tofu",         "jp": "豆腐" },
              "category":      "Pantry",
              "quantity":      1,
              "quantity_unit": { "ko": "모",    "en": "block",        "jp": "丁" }
            },
            {
              "name":          { "ko": "배추",  "en": "napa cabbage", "jp": "白菜" },
              "category":      "Produce",
              "quantity":      0.25,
              "quantity_unit": { "ko": "포기",  "en": "head",         "jp": "玉" }
            }
          ],
          "items": [
            {
              "quantity": 1,
              "product":  { "id": 196, "name": "...", "price": 5900, "price_unit": "₩", ... }
            }
          ],
          "total": 5900
        },
        "recommended_items": [
          {
            "rec_id":                   "uuid-...",
            "reason":                   "cross-sell",
            "reason_detail":            { "ko": "Cross-sell ...", "en": "Cross-sell ...", "ja": "Cross-sell ..." },
            "reason_detail_marketing":  { "ko": "오늘 저녁상 한층 풍성하게.", "en": "오늘 저녁상 한층 풍성하게.", "ja": "오늘 저녁상 한층 풍성하게." },
            "product":                  { "id": 512, "name": "카베르네 소비뇽 750ml", ... }
          }
        ],
        "notifications": {
          "ad": {
            "signage": { "x": 4, "y": 1, "radius": 2 },
            "items": [
              {
                "item_id":                196,
                "rec_id":                 "uuid-...",
                "item_name":              "히게타 간장 1L",
                "item_name_en":           "Higeta Soy Sauce 1L",
                "brand":                  "Higeta",
                "zone_id":                12,
                "zone_name":              "Pantry",
                "type":                   "ads",
                "discount_pct":           5,
                "applied_score":          0.85,
                "priority_score":         0.95,
                "method":                 "copurchase_lift",
                "reason_type":            "cross-sell",
                "reason_detail":          { "ko": "Cross-sell ...", "en": "Cross-sell ...", "ja": "Cross-sell ..." },
                "reason_detail_marketing":{ "ko": "카레와 함께 즐기기 좋은 간장이에요.", "en": "...", "ja": "..." },
                "rank":                   1,
                "price":                  860,
                "price_unit":             "₩",
                "product":                { "id": 196, "name": "...", "price": 860, "price_unit": "₩", "location": { "x": 4, "y": 1, "radius": 2 }, "...": "..." }
              }
            ]
          },
          "coupon": [
            {
              "item_id":      881,
              "rec_id":       "uuid-...",
              "item_name":    "...",
              "type":         "coupon",
              "zone_id":      76,
              "discount_pct": 10,
              "price":        3600,
              "price_unit":   "₩",
              "...":          "...",
              "product": {
                "id":           881,
                "name":         "깊은 볶음 참깨 드레싱 380ml",
                "brand":        "큐피",
                "barcode":      "4906028184943",
                "price":        3600,
                "price_unit":   "₩",
                "src_url":      "https://cdn.ones1ght.com/store/product/no_image.png",
                "quantity":     100,
                "section_code": "PA",
                "section_name": "식료품",
                "location":     { "x": 28, "y": 22.794, "radius": 0.5 }
              },
              "location":     { "x": 28, "y": 22.794, "radius": 0.5 }
            }
          ],
          "coupon_spot": [
            {
              "item_id":                42,
              "rec_id":                 null,
              "item_name":              "프로틴 바 50g",
              "item_name_en":           "Protein Bar 50g",
              "brand":                  "...",
              "zone_id":                null,
              "zone_name":              null,
              "type":                   "coupon",
              "discount_pct":           10,
              "applied_score":          null,
              "priority_score":         null,
              "method":                 null,
              "reason_type":            null,
              "reason_detail":          null,
              "reason_detail_marketing":null,
              "rank":                   1,
              "price":                  1200,
              "price_unit":             "₩",
              "product":                { "id": 42, "name": "...", "price": 1200, "...": "..." },
              "location":               { "x": 12.3, "y": 4.5, "radius": 1.0 }
            }
          ]
        }
      },
      {
        "round_no": 2,
        "round_id": "...",
        "prompt":   "와인 말고 다른거 추천해줄래",
        "...": "꼬리질문 라운드 — 이전 prompt 가 컨텍스트로 들어감"
      }
    ]
  }
}
라운드 필드설명
round_no1-indexed 순번. 몇 번째 질문인지 (rounds[].length 의 마지막 원소가 곧 총 라운드 수)
statusin_progress / completed / error
back_list_statusllm-demo 의 not_started / in_progress / completed (스냅샷 없으면 null)
todos구매 후보 + recipe 메타. 각 원소 {quantity, product}. total = Σ(product.price × quantity)
todos.recommendation_messagestep1 (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, rankspot_no ASC 인덱스. location 은 product.location 이 아닌 스팟 자체 좌표 {x, y, radius}
notifications.*[].item_idstore catalog product id. llm-demo 가 부착하지 못하면 null — 항목 자체는 살아있고 item_name / item_name_en 으로 카드 렌더 가능
notifications.*[].productstore DB 매칭 결과 객체 통째 — id / name / brand / barcode / price / price_unit / src_url(이미지) / location / section_code / section_name / quantity 를 그대로 노출 (매칭 실패 시 null)
notifications.*[].price / price_unitproduct.price / product.price_unit 평탄화 노출 — 카드 가격 hot path 용 (매칭 실패 시 둘 다 null). 종전의 price_yen 은 제거 — 매장 DB 단일 출처
notifications.*[].type"ads" (사이니지) / "coupon" (앱) — 그룹 키와 동일하지만 항목 단위 처리 시에도 사용
notifications.*[].rec_id추천 이벤트 추적 id — /trigger/signageitems[].rec_id / /trigger/couponrec_id 로 보냄
notifications.*[].reason_type / reason_detail / reason_detail_marketingstep4 추천 사유 3종 — reason_type = 카테고리 태그 ("cross-sell" / "zone_efficiency" 등), reason_detail = 운영자/대시보드용 분석 문구, reason_detail_marketing = 고객 노출 카피 (사이니지/쿠폰 화면). reason_detail / reason_detail_marketing{ko, en, ja} 객체 (upstream 단일 locale 동안엔 세 키 동일 값)
last_errorstatus: error 일 때만 (예: meal_suggest failed: upstream timeout / upstream chat session expired)

4. 단일 라운드 조회

GET /v1/chat/{profile_id}/recommend/{chat_id}/{round_id}
라운드 1개만 조회. 디테일과 동일한 자동 새로고침 동작 (KICKED_OFF / CREATED 상태면 llm-demo 한 번 더 묻고 완료/에러 전이) — 라운드 단위로 폴링하고 싶을 때 사용. 응답 shape 은 디테일의 rounds[i] 와 동일 (chat 메타 / profile_summary 없이 해당 라운드만):
{
  "success": true,
  "data": {
    "round_no":         2,
    "round_id":         "...",
    "prompt":           "...",
    "created_at":       "...",
    "updated_at":       "...",
    "status":           "completed",
    "back_list_status": "completed",
    "todos":            { ... },
    "recommended_items":  [ ... ],
    "notifications":    { "ad": { "signage": ..., "items": [...] }, "coupon": [...] }
  }
}
소유권 (tenant + api_key + profile + chat → round) 어긋나면 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장 크로스페이드)itemsdiscount_pct desc 정렬 후 top 1 단일 상품 카드. 가로/세로 자동 분기. reason_detail 을 노출 카피로 사용
2번 (매장 내 사이니지)어드민 등록 슬라이드 9슬롯 (3 슬라이드 × 3 상품)3섹션 구성 — ① forgotten[0] (미픽업 상품) ② items[1] || items[0] (광고 1건, 인덱스 기반) ③ store-API signage_eventenabled=true 1건 랜덤
각 섹션은 데이터가 없으면 자동 숨김 (예: forgotten 미첨부 시 해당 영역 미렌더). 사이니지 1번은 단일 상품 카드 디자인이라 discount_pct 정렬이 우선 의미를 가지며, 2번은 광고 옆에 “미픽업 / 행사” 동시 노출이 목적이라 인덱스 1을 우선 사용 (인덱스 0 은 더 강한 신호로 다른 노출에 쓰일 수 있음).
POST /v1/chat/{profile_id}/trigger/signage
Authorization: Bearer {SDK_KEY}
Content-Type: application/json

Request — Enter (영역 진입)

최소 페이로드는 chat_id + round_id 만으로 충분합니다. action 생략 시 "enter" 가 default.
{
  "chat_id":  "c_91ab",
  "round_id": "r_77fe"
}

Request — Exit (영역 이탈)

{
  "chat_id":  "c_91ab",
  "round_id": "r_77fe",
  "action":   "exit"
}
응답: { "success": true, "data": { "event_ids": [], "accepted": true, "action": "exit" } }

Request — Enter (items override + forgotten)

items 를 명시적으로 함께 보내면 그 값이 우선 사용됩니다 (라이브 데이터, 부분 셋 등). forgotten 으로 사용자가 매장에서 잊은/미픽업 상품 목록을 첨부할 수도 있습니다 (2026-05-20~):
{
  "chat_id":  "c_91ab",
  "round_id": "r_77fe",
  "items": [
    {
      "rec_id":        "rec_a1b2c3",
      "item_id":       196,
      "item_name":     "스파게티니",
      "item_name_en":  "Spaghettini",
      "brand":         "De Cecco",
      "zone_id":       12,
      "zone_name":     "Pantry",
      "price_yen":     4900,
      "discount_pct":  17,
      "applied_score": 0.85,
      "priority_score":0.95,
      "method":        "copurchase_lift",
      "reason_detail": "...",
      "reason_detail_marketing": "..."
    }
  ],
  "forgotten": [
    { "item_id": 882, "name": "양파", "quantity": 1 },
    { "any": "shape", "is": "fine" }
  ]
}
필드필수설명
chat_id추천 받았던 chat
round_id그 round
itemsoptional생략/빈 배열 시 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_marketingoptionalFIREHOSE / 사이니지에 표시. 사이니지별 노출 정책은 위 “사이니지 번호별 화면 노출 정책” 표 참조 (1번 = discount_pct top 1 단일 카드, 2번 = items[1] || items[0] 광고 + forgotten + event 3섹션)
forgottenoptional사용자가 매장에서 잊은/미픽업 상품 목록. iOS sdk 가 todos.items 중 해당 항목을 그대로 첨부. 각 원소 schema 는 자유 (any) — Gateway 는 가공 없이 영속화 payload + 어드민 SSE fanout + store-API forward 모두 root 수준으로 통과시킴. 빈 배열 / 누락 시 키 자체 누락. exit 액션에서는 무시

Response 202

{ "success": true, "data": { "event_ids": ["evt_..."], "accepted": true } }
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 푸시 만 일어납니다.
POST /v1/chat/{profile_id}/trigger/coupon
Authorization: Bearer {SDK_KEY}
Content-Type: application/json

Request

최소 페이로드는 chat_id + round_id + rec_id 만으로 충분합니다. Gateway 가 라운드의 영속화된 notifications.coupon[] 에서 rec_id 매칭으로 메타를 자동 보충합니다.
{
  "chat_id":  "c_91ab",
  "round_id": "r_77fe",
  "rec_id":   "rec_x9y8z7"
}
전체 메타를 명시하면 그 값이 우선 사용됩니다:
{
  "chat_id":       "c_91ab",
  "round_id":      "r_77fe",
  "rec_id":        "rec_x9y8z7",
  "kind":          "issued",
  "item_id":       512,
  "item_name":     "올리브유",
  "item_name_en":  "Olive Oil",
  "brand":         "Colavita",
  "zone_id":       76,
  "zone_name":     "Pantry",
  "price_yen":     9900,
  "discount_pct":  20,
  "applied_score": 0.78,
  "priority_score":0.72,
  "method":        "copurchase_lift",
  "reason_detail": "...",
  "reason_detail_marketing": "..."
}
필드필수설명
chat_id / round_id같은 의미
rec_id추천 이벤트 추적 id (round 의 영속 데이터 매칭 키)
kindoptional"issued" / "redeemed" / "dismissed" — 기본값 issued
나머지optional생략 시 round 의 영속 데이터에서 자동 보충. 명시 시 그 값 우선

Response 202

{ "success": true, "data": { "event_id": "evt_...", "accepted": true } }

7. 개인 맞춤 추천 노출 로그 — POST /trigger/recommend

iOS sdk 가 recommended_items[] (step3) 중 한 항목을 앱에서 노출/탭 처리할 때 1회성 로그로 호출합니다. 사이니지 / store-API 와 무관 — (1) sdk_chat_event 영속화 (2) 테넌트 어드민 SSE 푸시 만.
POST /v1/chat/{profile_id}/trigger/recommend
Authorization: Bearer {SDK_KEY}
Content-Type: application/json

Request

{
  "chat_id":  "c_91ab",
  "round_id": "r_77fe",
  "rec_id":   "rec_a1b2c3"
}
필드필수설명
chat_id / round_id같은 의미
rec_id추천 이벤트 추적 id (recommended_items[].rec_id 또는 id)
Gateway 가 라운드의 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

{ "success": true, "data": { "event_id": "evt_...", "accepted": true } }

8. 라운드 바코드 모음 — GET /recommend/{chat_id}/{round_id}/barcodes

라운드의 product 들을 카테고리별로 묶어 EAN-13 PNG (base64) 와 함께 반환합니다. 어드민/시연용 — store-API 재호출 없음 (round 평탄화 데이터 + ZXing 즉석 PNG).
GET /v1/chat/{profile_id}/recommend/{chat_id}/{round_id}/barcodes
Authorization: Bearer {SDK_KEY}

Response 200

{
  "success": true,
  "data": {
    "chat_id":  "...",
    "round_id": "...",
    "todos":         [ /* 장보기 리스트 product 들 */ ],
    "recommends":    [ /* 개인 맞춤 추천 product 들 */ ],
    "notifications": {
      "ads":    [ /* 사이니지 ads product 들 */ ],
      "coupon": [ /* 쿠폰 product 들 */ ]
    }
  }
}
각 product 항목 — 5개 필드만:
{
  "name":          "두부",
  "barcode":       "8801234567890",
  "price":         1980,
  "price_unit":    "원",
  "barcode_image": "data:image/png;base64,iVBORw0KGgo..."
}
  • barcode 가 13자리 숫자가 아니거나 checksum 오류면 barcode_image: null — 클라이언트가 null 가드만 처리.
  • 동일 product 가 여러 카테고리에 나타날 수 있음.

폴링 가이드

  • 권장 간격: ~2 초 (전체 파이프라인 평균 ~46s)
  • rounds 배열 마지막 원소의 status 만 보면 됨 — 이전 라운드들은 항상 completed (또는 error) 로 고정
  • 새 라운드 추가 (POST 두 번째 호출 후) 폴링하면 배열 길이 +1, 마지막 원소가 다시 in_progress 로 시작
  • error / TTL 만료 시: 새 chat 생성 (chat_id 안 보내고 POST) 권장

에러 코드

HTTPcode원인
400INVALID_REQUESTbody 검증 실패 (prompt 누락 등)
404NOT_FOUNDprofile / chat 미존재 또는 소유권 불일치
402INSUFFICIENT_CREDIT테넌트 크레딧 잔액 부족
409REFINEMENT_CAP_EXCEEDED같은 라운드에서 꼬리질문 8 회 초과 — 새 라운드/새 chat 으로 리셋
409PRIOR_ROUND_IN_FLIGHT같은 chat 의 직전 라운드 step2 도 아직 미완료 — pipeline_progress.step2 == "done" 확인 후 재시도. step3/4 진행 중이어도 step2 만 끝나면 follow-up OK
500INTERNAL_ERROR업스트림 장애 등

전체 예제

SDK_KEY="osk_..."
PROFILE_ID="..."

# 1. 첫 라운드 — chat_id 없이 호출
RESP=$(curl -s -X POST https://api.ones1ght.com/v1/chat/$PROFILE_ID/recommend \
  -H "Authorization: Bearer $SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"오늘 저녁 카레 땡기는데"}')
CHAT_ID=$(echo "$RESP" | jq -r '.data.chat_id')

# 2. 폴링 (2s 간격) — 마지막 round 가 completed 될 때까지
while true; do
  DETAIL=$(curl -s https://api.ones1ght.com/v1/chat/$PROFILE_ID/recommend/$CHAT_ID \
    -H "Authorization: Bearer $SDK_KEY")
  LAST_STATUS=$(echo "$DETAIL" | jq -r '.data.rounds[-1].status')
  [ "$LAST_STATUS" = "completed" ] && { echo "$DETAIL" | jq '.data.rounds[-1]'; break; }
  [ "$LAST_STATUS" = "error" ]     && { echo "$DETAIL" | jq '.data.rounds[-1].last_error'; break; }
  sleep 2
done

# 3. 꼬리질문 — 같은 chat_id 로 follow-up
curl -X POST https://api.ones1ght.com/v1/chat/$PROFILE_ID/recommend \
  -H "Authorization: Bearer $SDK_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"chat_id\":\"$CHAT_ID\",\"prompt\":\"와인 말고 다른거 추천해줄래\"}"

# 4. 누적 리스트
curl https://api.ones1ght.com/v1/chat/$PROFILE_ID/recommend?page=0\&limit=20 \
  -H "Authorization: Bearer $SDK_KEY"