# Affinity Bubble API `https://api.affinitybubble.com` 자유응답·댓글 같은 정성 텍스트 묶음을 입력하면 의미 클러스터, 메타 라벨, sentiment, 2D 좌표가 포함된 어피니티 버블 데이터를 돌려준다. Cloudflare 엣지에서 실행되며 응답은 시각화 라이브러리에 그대로 넣을 수 있는 JSON. --- ## 1. 인증 모든 호출은 서비스 키가 필요하다. ``` Authorization: Bearer ab_live_ ``` 키 발급은 운영자에게 요청한다 (현재 Phase 1 — CLI 발급). 인증 실패 시 `401`. ```bash curl https://api.affinitybubble.com/v1/whoami \ -H "Authorization: Bearer ab_live_..." # { "keyId": "svckey_...", "name": "Acme Inc", "prefix": "ab_live_a1b2" } ``` --- ## 2. 엔드포인트 ### 2.1 `GET /v1/health` 인증 불필요. 상태 점검용. ```bash curl https://api.affinitybubble.com/v1/health # { "status": "ok", "version": "v1", "time": "2026-05-27T08:56:41.409Z" } ``` ### 2.2 `GET /v1/whoami` 키 메타데이터 조회. 통합 테스트에 유용. ### 2.3 `POST /v1/bubbles` — 비동기 시작 큰 입력을 동기로 받으면 Workers wall-time 30s + 클라이언트 fetch timeout 위험이 있다. 비동기 endpoint는 즉시 `202` + `instanceId` 만 돌려주고, 실제 분석은 Cloudflare Workflows로 백그라운드 실행한다. ```bash curl -i https://api.affinitybubble.com/v1/bubbles \ -H "Authorization: Bearer ab_live_..." \ -H "Content-Type: application/json" \ -d '{"title":"...","texts":["..."]}' # HTTP/1.1 202 # {"instanceId":"job_xxx","statusUrl":"/v1/bubbles/jobs/job_xxx","status":"queued"} ``` 요청 body 와 옵션은 `/v1/bubbles/sync` 와 동일 (`params.labelOption` 등 모두 지원). 응답에는 결과 데이터가 없고 `instanceId` 만 들어 있다. **externalKey 옵션 — 캐싱 short-circuit** 설문·BI 페이지처럼 "이미 분석했나? 했으면 그대로 보여줘" 패턴이 필요할 때, 호출자가 안정된 식별자(`surveyId-qId` 등)를 같이 보내면 row 가 그 키로 저장된다. 같은 externalKey 로 다시 호출하면 즉시 `200 { status:'complete', bubbleId, cached:true }` 로 끝나고 LLM 비용은 0. ```jsonc { "title": "4번 문항", "texts": ["..."], "externalKey": "survey-abc-q4", // 1~200자 임의 문자열 "force": false // true 면 기존 row 삭제 후 재분석 } ``` - 격리: 인덱스 `(key_id, external_key) UNIQUE` 라 키별로 독립. 다른 키가 같은 externalKey 를 써도 충돌 없음 - 조회: `GET /v1/bubbles/by-key/:externalKey` (§2.5) - 본문 갱신: `force: true`. 기존 row 가 지워지고 새 분석 시작 ### 2.4 `GET /v1/bubbles/jobs/:id` — 폴링 ```bash curl https://api.affinitybubble.com/v1/bubbles/jobs/job_xxx \ -H "Authorization: Bearer ab_live_..." ``` 응답: ```jsonc { "instanceId": "job_xxx", "status": "running", // queued | running | complete | errored "currentStep": "refine", // preparing | embedding | normalizing | kmeans | refine | outlier | meta-gen | meta-cat | umap | persist | done "progress": 65, // 0–100 "detail": "9 coarse clusters", // optional, step에 따라 부가 정보 "bubbleId": "abc...", // complete 시점에 채워짐 "usage": { ... }, // complete 시점에 채워짐 (sync 응답의 usage 와 동일) "error": "...", // errored 시점에만 "updatedAt": 1779963704079 } ``` KV TTL 24시간. 그 이후엔 `bubbleId` 로 `GET /v1/bubbles/:id` 호출. ### 2.5 `GET /v1/bubbles/by-key/:externalKey` — 안정된 식별자 조회 `POST` 시 같이 보낸 `externalKey` 로 row 조회. 호출 키가 만든 row 만 매칭 (다른 키 row 는 `404`). ```bash curl https://api.affinitybubble.com/v1/bubbles/by-key/survey-abc-q4 \ -H "Authorization: Bearer ab_live_..." # 200 → { id, title, pipeline, createdAt, externalKey, data: {...} } # 404 → { error: { type: "not_found", message: "No bubble for this externalKey" } } ``` ### 2.6 `GET /v1/bubbles/:id` — 완성된 버블 조회 비동기 호출이 끝난 뒤 결과를 받거나, 과거에 만든 버블을 다시 가져올 때. ```bash curl https://api.affinitybubble.com/v1/bubbles/abc... \ -H "Authorization: Bearer ab_live_..." ``` 응답은 `{ id, title, pipeline, createdAt, data: { texts, labels, metaLabels, meta } }`. 다른 키의 버블은 `403`. ### 2.7 `POST /v1/bubbles/sync` 텍스트를 즉시 분석해 버블 데이터를 반환한다. 동기식이라 응답까지 보통 5~15초 (1000 texts 기준). #### Request ```http POST /v1/bubbles/sync Authorization: Bearer ab_live_... Content-Type: application/json { "title": "치폴레 한국 출시 반응", "texts": ["커피 좋아요", "스벅 별로...", ...], "params": { "labelOption": "5단어 이내, ~해요체로 종결" }, "config": { "sampleSize": 1000, "embeddingDims": 768, "maxMetaLabels": 10, "refineConcurrency": 10, "shuffleSeed": 0 } } ``` | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `title` | string | ✅ | — | 1~200자 | | `texts` | string[] | ✅ | — | 1~5000개 | | `weights` | number[] | — | (모두 1) | `texts`와 1:1 정렬된 버블 크기 가중치. **생략 시 각 행 1** → 동일 텍스트 중복수가 크기(=빈도). 동일 텍스트는 weight 합산, 0/음수는 1로 처리. 공유 버블에 그대로 반영. ↓ 가중치 가이드 | | `report` | boolean | — | `false` | `true`면 인사이트 리포트도 함께 생성해 응답 `insight`(markdown)로 반환(행에 캐시). 비동기는 완료 후 `GET /:id/insight`가 즉시 캐시 반환. | | `params.reportPreset` | string | — | `bubble` | 리포트 형식. `bubble`=차트 설명형 / `summary`·`executive`·`story`·`briefing`=한줄·세줄·상세 3단 요약(데이터적응 / 임원 / 기자 내러티브 / 실무 브리핑 톤; 응답에 구조화 `report{oneLine,threeLine,detailed}` 포함) / `custom`=`reportOption`을 통째 지시문으로 받는 자유형. `reportPreset`은 생성 시(POST)만 적용 — `GET /:id/insight`는 미지원. | | `params.reportOption` | string | — | — | 리포트 자유 지시문(톤·관점·길이·강조, 최대 500자). `custom`이면 리포트 작성 지시문 전체로 쓰임. `GET /:id/insight?reportOption=` 쿼리로도 가능(캐시 미사용·미저장). | | `params` | Record | — | `{}` | 라벨 옵션(`labelOption`·`metaLabelOption`)·`lens`·`polish`·`emoji`·`reportPreset`·`reportOption` 등 자연어 옵션. (모델은 내부 관리 — 사용자 지정 불가) | | `config.sampleSize` | int | — | 1000 | 중복 제거 후 무작위 샘플링 상한 | | `config.embeddingDims` | int | — | 768 | 텍스트·라벨·메타 임베딩 차원. OpenAI text-embedding-3-small의 Matryoshka 옵션. 토큰 비용은 dims와 무관 (입력 토큰 기준 과금) | | `config.maxMetaLabels` | int | — | 10 | 메타 라벨 개수 상한. 실제 개수는 `√(label수)` | | `config.refineConcurrency` | int | — | 10 | refine 단계 동시 LLM 호출 수 | | `config.shuffleSeed` | int | — | 0 | 샘플링 재현성을 위한 시드 | #### 가중치(weights) 가이드 버블 면적은 weight에 선형 비례. 원시 지표를 그대로 넣으면 소수 항목이 화면을 독식하니 **압축 변환**을 권장(호출자가 변환해서 전송, 서버는 받은 값을 합산). | 지표 | 권장 변환 | 이유 | |---|---|---| | 댓글 수 | `1 + Math.sqrt(n)` | 큰 값 완만하게, 기준선 1로 0·1 구분 | | 좋아요 수 | `1 + Math.log1p(likes)` | 치우친 분포 압축 | | 단순 빈도/중요도 | 변환 없이 양의 정수 | 그대로 크기 | `Math.sqrt(c)`만 쓰면 c=0·c=1이 둘 다 1이 되니 **기준선 1을 더한다**(0→1, 1→2, 4→3, 항상 양수·단조증가). 값이 없거나 0/음수면 서버가 1로 처리. 예: `weights = comments.map(c => 1 + Math.sqrt(c))`. #### Response (200) ```jsonc { "id": "rO_EFF1huT3B1k6Y", "title": "치폴레 한국 출시 반응", "pipeline": "v2", "data": { "texts": [ { "text": "...", "label": "치폴레 오픈 기대감", "embedding2D": [1.23, -0.4] } ], "labels": [ { "label": "치폴레 오픈 기대감", "metaLabel": "브랜드 상륙 기대 및 환호", "embedding2D": [0.5, 0.2], "sentimentScore": 5 } ], "metaLabels": [ { "metaLabel": "브랜드 상륙 기대 및 환호", "embedding2D": [0, 0] } ], "assignments": [ { "id": 0, "label": "치폴레 오픈 기대감", "metaLabel": "브랜드 상륙 기대 및 환호", "sentimentScore": 5 }, { "id": 1, "label": null, "metaLabel": null, "sentimentScore": null } ], "pipeline": "v2", "meta": { "sampleSize": 179, "coarseK": 7, "subgroupCount": 23, "metaLabelCount": 5, "fallbackClusters": 0, "skipClusters": 0 } }, "usage": { "textCount": 179, "embeddingTokens": 4780, "llmInputTokens": 15340, "llmOutputTokens": 2650, "durationMs": 7901 } } ``` - `data.texts[i].label` 은 `data.labels[*].label` 중 하나거나 `"기타"`. - `data.labels[i].metaLabel` 은 `data.metaLabels[*].metaLabel` 중 하나거나 `"기타"`. - `sentimentScore` 는 sub-cluster 전체의 감정 강도 정수 1~5 (1=매우 부정, 3=중립, 5=매우 긍정). - `embedding2D` 는 UMAP으로 축소된 좌표. 같은 호출 안의 텍스트/라벨/메타 좌표는 같은 좌표계가 아니다 — 각 그룹마다 별도 UMAP을 돌린다. - `data.assignments` 는 **입력 `texts` 배열과 1:1로 정렬된** 결과 (`id` = 입력 인덱스). 원본 데이터(여러 컬럼)에 결과를 되돌려 조인할 때 쓴다. ↓ 아래 참고. #### 원본 행에 조인 — `assignments` `data.texts` 는 내부적으로 **중복 제거 + 셔플 + 샘플링** 되므로 **입력 순서가 보존되지 않는다.** 원본(여러 컬럼 중 텍스트 컬럼만 보낸 데이터)에 라벨을 되돌려 붙이려면 `data.texts` 가 아니라 **`data.assignments`** 를 쓴다. `assignments[i]` 는 보낸 `texts[i]` 의 결과이고, `id` 가 곧 입력 인덱스다. - 길이 = 보낸 `texts` 길이 (1:1). - 같은 텍스트가 여러 번 있으면 **모두 같은 라벨**을 받는다 (내부 dedup 결과를 되돌려 매핑). - 빈/공백 텍스트는 `label: null` (그 행만 결과 없음). ```js const { data } = await res.json() // rows = 원본 [{ id?, text, age, region, ... }] — texts 는 rows.map(r => r.text) 로 보낸 것 rows.forEach((row, i) => { const a = data.assignments[i] // 입력 순서 그대로 정렬됨 row.label = a.label // null 이면 빈 텍스트였던 행 row.metaLabel = a.metaLabel row.sentimentScore = a.sentimentScore }) ``` > 텍스트 값으로 조인하지 말 것 — 출력 텍스트는 sanitize(공백 압축·HTML 디코드·`"`→`'`)되어 원본과 글자가 달라지고, 중복은 하나로 합쳐진다. **인덱스(`assignments[i]`) 조인**이 정답. #### Errors | HTTP | type | 의미 | |---|---|---| | 400 | `invalid_request` | 본문 검증 실패. `issues` 배열에 Zod 상세 | | 401 | `unauthorized` | Authorization 헤더 누락/형식 오류/무효 키/만료 | | 500 | `pipeline_error` | 임베딩·LLM·DB 단계에서 오류. `message` 에 원인 | | 500 | `internal_error` | 예상 못한 오류. 재시도 권장 | 모든 에러 응답은 `{ error: { type, message, ...} }` 형태. #### curl 전체 예제 ```bash curl https://api.affinitybubble.com/v1/bubbles/sync \ -H "Authorization: Bearer ab_live_..." \ -H "Content-Type: application/json" \ --data-binary @payload.json ``` `payload.json`: ```json { "title": "고객 NPS 자유응답 2026Q1", "texts": ["배송이 너무 느려요", "디자인은 만족합니다", "..."] } ``` --- ## 3. Quick Start — 한 줄짜리 클라이언트 라이브러리 API 호출 + 시각화 + 배경 transparent + 클릭 popup 까지 묶은 작은 wrapper. [dist/affinitybubble-client.js](../dist/affinitybubble-client.js) 한 파일이면 동작한다. ```html
``` > Workers Assets 로 서빙되며 5분 edge cache. 버전 잠금이 필요하면 GitHub commit hash 로 `dist/affinitybubble-client.js` 를 직접 vendor 하세요. API 호출은 직접 하고 응답만 가지고 있는 경우엔 `render` 만 호출: ```js AffinityBubble.render({ target: '#chart', response: existingJson }) ``` ### 설문 패턴 (`createOrShow`) 페이지가 뜨면 먼저 캐시를 묻고, 있으면 즉시 렌더 / 없으면 분석. 결과는 같은 externalKey 로 자동 저장되어 다음 방문에 캐시 hit. ```js AffinityBubble.createOrShow({ apiKey, target: '#chart', externalKey: 'survey-abc-q4', // 호출자의 안정된 식별자 title: '4번 문항 — 주관식', texts: responses.map(r => r.text), // force: true // 본문 갱신해 재분석할 때만 }) // { svg, response, cached } ``` ### 비동기 + 진행 표시 (`createAsync`) 큰 입력(500 texts 이상)에서는 동기 호출이 wall-time 한계에 부딪힌다. `createAsync` 는 내부적으로 `POST /v1/bubbles` → 폴링 → `GET /v1/bubbles/:id` 까지 자동 처리하고, 시작 즉시 **회색 placeholder 버블 + progress bar** 를 그려준다. step 이 진행되며 progress 가 채워지고, 끝나면 실제 버블로 교체된다. ```js AffinityBubble.createAsync({ apiKey: 'ab_live_...', target: '#chart', title: '제목', texts: [/* ... */], pollIntervalMs: 1000, onProgress: ({ status, currentStep, progress, detail }) => { // 선택 사항: 본인 UI 도 업데이트 }, }) ``` 옵션: | Key | 기본 | 의미 | |---|---|---| | `pollIntervalMs` | 1000 | 폴링 간격 (ms) | | `maxWaitMs` | 300000 | 폴링 timeout. 초과 시 throw | | `showProgressUI` | true | placeholder + progress bar 자동 표시. `false` 면 `onProgress` 만 사용 | | `onProgress` | — | 각 폴링 결과를 받는 콜백. `{ status, currentStep, progress, detail, bubbleId?, usage?, error? }` | placeholder 는 `texts` 를 그대로 단일 회색 메타 셀(`#d8d8d8`) 에 leaf 로 넣어 그리므로, 첫 화면부터 사용자 데이터가 dim하게 보인다. 전체 동작 예시: [demo/quickstart.html](../demo/quickstart.html) (`createAsync` + 100 texts + 진행 카드). ### 외부 협력자에게 보낼 1-page 템플릿 복붙 그대로 동작하는 최소 HTML. `apiKey` 와 `texts` 만 채우면 끝. 회색 placeholder → progress bar → 실 버블 흐름이 자동 진행된다. ```html Affinity Bubble — quick test
``` 전달용 한 줄 설명: > `https://api.affinitybubble.com/affinitybubble-client.js` 한 파일을 `voronoi-bubble-dist` 와 같이 로드한 뒤 `AffinityBubble.createAsync({ apiKey, target, title, texts })` 한 번 호출하면 끝. 시작 즉시 회색 placeholder 가 뜨고 진행 단계가 progress bar 로 채워진 뒤 실제 버블로 교체됩니다. 캐시는 edge 5분이라 SDK 갱신 직후엔 잠시 지연될 수 있다는 점만 안내해 두면 된다. ## 4. 시각화 — voronoi-bubble 로 직접 렌더 (전체 샘플) [Quick Start](#3-quick-start--한-줄짜리-클라이언트-라이브러리)의 래퍼를 쓰지 않고 직접 그릴 때의 **복붙 가능한 완전 예시**. 응답 `data` 와 라이브러리 입력이 거의 1:1이라 join 한 번이면 된다. 라이브러리: [voronoi-bubble-dist](https://github.com/pxd-uxtech/voronoi-bubble-dist) (3-level Voronoi treemap). ```html Affinity Bubble
``` ### 응답 → 라이브러리 입력 매핑 | 라이브러리 필드 | 출처 (응답 `data`) | 의미 | |---|---|---| | `metaLabel` (depth 1) | `labels[].metaLabel` (text→label→meta join) | 큰 묶음 (메타 라벨) | | `label` (depth 2) | `texts[].label` | sub-cluster 라벨 | | `text` (depth 3) | `texts[].text` | 개별 원문 | | `bubbleSize` | `1` 고정 | 셀 가중치 | | `sentimentScore` | `labels[].sentimentScore` (1~5) | 감성 색상용 | | `positions` | `metaLabels[].embedding2D` | 메타 배치 좌표 | 주요 옵션: `positions`(`'auto'` 가능) · `sentiment`(감성 색상) · `labelMode` · `pebble*`(둥근 셀) · `showPercent`(비율 라벨) · `clickFunc`(popup) · `renderGroupLabel`/`metaLabelRenderer`(HTML 라벨 커스텀) · `keyColors`/`colorFunc`(색상). 전체 옵션은 [voronoi-bubble-dist README](https://github.com/pxd-uxtech/voronoi-bubble-dist), 동작 예시는 [demo/](../demo/) (특히 [demo/sample2.html](../demo/sample2.html))·[docs/v2-pipeline.md](./v2-pipeline.md) 참고. --- ## 5. 모델 선택 분석 각 단계에 쓰는 LLM 모델은 **서비스가 내부적으로 관리**한다(품질·비용 최적화, 모델 교체 자유). 사용자가 `params`로 모델을 지정할 수는 없으며, `*Model` 류 파라미터는 요청에서 무시된다. ## 6. 라벨 톤·길이 옵션 `params` 객체로 라벨/메타 라벨 생성에 자연어 규칙을 추가할 수 있다. 빈 문자열이면 무시. | Key | 단계 | 예시 | |---|---|---| | `labelOption` | refine sub-cluster의 1차 라벨 생성 | `"5단어 이내, '~해요'체로 종결"` | | `metaLabelOption` | meta 라벨 생성 | `"4단어 이내, 앞에 적절한 이모지 1개"` | 규칙은 프롬프트의 "Additional user rule" 섹션에 그대로 주입되므로 자연어로 자유롭게 적으면 된다 — 톤, 길이, 이모지, 종결 어미 등 모두 가능. ```json { "title": "치폴레 한국 출시 반응", "texts": ["..."], "params": { "labelOption": "5단어 이내, '~해요'체", "metaLabelOption": "4단어 이내, 앞에 적절한 이모지 1개" } } ``` --- ## 7. 사용량 모든 호출은 키 단위로 토큰·텍스트 수·소요시간이 집계된다. 응답의 `usage` 객체에 호출별 수치가 들어 있고, 일별 누적 사용량은 운영자에게 요청해 확인할 수 있다 (서비스 키 관리자 UI는 추후 공개 예정). --- ## 8. 한도 - `texts`: 한 요청당 **plan별 상한** — `basic`(무료) 100개 / `business` 5000개. 초과 시 `400 plan_limit_exceeded`. 100개 초과 분석은 biz@affinitybubble.com 으로 문의. - `title`: 1 ~ 200자 - 응답 시간: 보통 5~15초 (1000 texts 기준). 큰 입력은 비동기 엔드포인트(`POST /v1/bubbles` → 폴링)를 사용 — §2.3 / Quick Start `createAsync`. - Rate limit: MVP 단계는 무제한, 추후 키별 분당 60회 등으로 조정 예정 --- ## 9. 변경 이력 - 2026-06-06 — `data.assignments`(입력 순서 1:1 결과, 원본 조인용) 추가 · `unlabeled` 제거(빈 라벨은 outlier 재배치로 실제 라벨/기타 부여) · plan별 texts 상한(basic 1000 / business 5000) · 시각화 라이브러리 `voronoi-bubble-dist@53bb2ae` - 2026-05-27 — v1 초안 (`/v1/bubbles/sync` 동기 엔드포인트, V2 파이프라인)