# 속성 분석 만들기 — Affinity Bubble API `https://api.affinitybubble.com` 리뷰·VOC처럼 **한 문장에 여러 얘기가 섞인 텍스트**에서 "무엇을 따지는가(속성)"와 "그것에 만족하는가(감성)"를 **분리해** 돌려준다. 속성마다 언급률·순감성·쟁점도가 나온다. 학계 용어로는 ABSA(속성 기반 감성 분석)다. **공통 사항(가입·키 발급·크레딧·한도)은 이 문서에 없다.** → https://api.affinitybubble.com/llms.txt 를 먼저 읽는다. **전제: 완성된 버블이 있어야 한다.** 속성 분석은 버블 위에 얹는 레이어이며 **버블 자체는 바뀌지 않는다.** 먼저 https://api.affinitybubble.com/llms/bubbles.txt 로 버블을 만든다. ## 이 문서를 스킬로 만들기 - **순서**: 버블 생성 → `POST /v1/bubbles/:id/aspects` (비동기) → `GET /v1/bubbles/jobs/:id` 폴링 → `GET /v1/bubbles/:id/aspects` 로 결과 - **본문에 원본 `texts[]`를 꼭 동봉한다.** 입력 순서 그대로. 이게 정확도를 가른다(§3) - **읽을 것**: `data.map`(속성별 지표) · `data.aspects`(속성 정의) · `data.meta.alignment` - **키**: 스킬 파일에 넣지 말고 별도 파일(chmod 600)에 두고 경로만 참조한다 --- ## 1. 언제 쓰나 — 버블만으로 안 될 때 버블은 **텍스트 한 줄을 한 군집에** 넣는다. 그래서 이런 리뷰는 갈라지지 않는다. > "배송은 빨랐는데 소음이 너무 커요. 가격은 그만하면 괜찮고요." 버블에서는 이 한 줄이 어느 한 군집으로 간다. 속성 분석은 같은 줄에서 **배송(+)·소음(−)· 가격(+)** 셋을 따로 잡는다. | 이런 데이터 | 쓸 것 | |---|---| | 한 줄에 한 가지 얘기 (설문 자유응답 "가장 좋았던 점은?") | 버블만으로 충분 | | 한 줄에 여러 얘기 (상품 리뷰, 앱스토어 리뷰, VOC) | **속성 분석** | | "무슨 말이 많은가"가 아니라 "무엇이 쟁점인가" | **속성 분석** | ## 2. 엔드포인트 ### 2.1 `POST /v1/bubbles/:id/aspects` — 속성 분석 시작 (비동기) ```bash curl -X POST https://api.affinitybubble.com/v1/bubbles/rO_EFF1huT3B1k6Y/aspects \ -H "Authorization: Bearer ab_live_..." -H 'Content-Type: application/json' \ -d '{ "texts": ["배송은 빨랐는데 소음이 커요", "..."] }' # 202 → { "instanceId": "ajob_xxx", "statusUrl": "/v1/bubbles/jobs/ajob_xxx", "status": "queued" } ``` | Field | Type | Required | Notes | |---|---|---|---| | `texts` | string[] | **권장** | 버블에 넣었던 **원본 텍스트를 입력 순서 그대로**. 생략하면 정확도가 떨어진다(§3) | | `params` | Record | — | 임계값 노브(§6). 모델은 내부 관리라 지정할 수 없다 | | `force` | boolean | — | `true`면 기존 결과를 버리고 다시 돌린다 | **이미 결과가 있으면(`force` 없이) 즉시 200**으로 끝난다 — `{ status: "complete", segmentsId, cached: true }`. 잡을 다시 돌리지 않으므로 크레딧도 새로 들지 않는다. 폴링은 버블과 같은 `GET /v1/bubbles/jobs/:id`를 쓴다. ### 2.2 `GET /v1/bubbles/:id/aspects` — 결과 ```jsonc { "id": "aseg_...", "bubbleId": "rO_EFF1huT3B1k6Y", "createdAt": 1757000000, "data": { "aspects": [ { "id": "a3", "name": "소음", "definition": "작동 중 발생하는 소리에 대한 언급", "group": "성능", "emoji": "🔊" } ], "map": [ { "aspectId": "a3", "mentionCount": 680, "mentionRate": 0.34, "informativeMentionRate": 0.39, "netSentiment": -0.2, "salienceWeightedSentiment": -0.24, "pos": 180, "neg": 420, "neu": 80, "polarization": 0.3, "examples": ["탈수할 때 덜컹거려요", "..."] } ], "rows": [ { "id": 0, "aspects": { "a3": { "salience": 0.8, "present": true, "sentiment": -0.6 } } }, { "id": 1, "lowInfo": true, "lowInfoBucket": "positive", "aspects": {} } ], "lowInfo": { "count": 210, "rate": 0.105, "positive": 150, "negative": 20, "neutral": 30, "unknown": 10 }, "meta": { "bubbleId": "rO_EFF1huT3B1k6Y", "textCount": 2000, "rowCount": 2000, "sampleSize": 120, "aspectCount": 9, "droppedAspects": 3, "lowInfoCount": 210, "scoreChunks": 20, "scoreChunksFailed": 0, "alignment": "exact-by-id", "note": "행(rows)은 입력 순서(id)로 원본 텍스트와 1:1 정렬 — ..." } } } ``` `POST` 전에 부르면 404다. ### 2.3 지표가 뜻하는 것 | 필드 | 범위 | 뜻 | |---|---|---| | `mentionRate` | 0~1 | 전체 텍스트 중 이 속성을 언급한 비율 | | `informativeMentionRate` | 0~1 | **저정보 행을 뺀** 분모 기준 언급률. "굿", "최고" 같은 행이 많은 데이터에서 이쪽이 실제 체감에 가깝다 | | `netSentiment` | −1~+1 | 언급들의 평균 감성. −1이 전부 불만, +1이 전부 칭찬 | | `salienceWeightedSentiment` | −1~+1 | 그 속성을 **비중 있게** 말한 언급에 가중한 평균. `netSentiment`와 크게 다르면 "지나가듯 칭찬, 작정하고 불만" 같은 구조다 | | `polarization` | 0~1 | `min(칭찬, 불만) / (칭찬+불만)`. **높을수록 갈린다.** 0.5가 반반 | | `pos` · `neg` · `neu` | 건수 | 칭찬·불만·중립 언급 수 | | `examples` | string[] | 대표 원문 | | `coverageWarning` | string? | 표본이 적어 수치를 믿기 어려울 때만 붙는다. **있으면 그 속성은 수치로 말하지 않는다** | **평균만 보면 놓친다.** `netSentiment`가 0에 가까운 속성이 둘 있어도, 하나는 아무도 신경 안 쓰는 것이고 다른 하나는 칭찬과 불만이 정면으로 갈리는 것이다. 가르는 값이 `polarization`이다. ### 2.4 저정보 행 (`lowInfo`) "굿", "최고예요", "👍" 처럼 **속성 단서가 없는 짧은 행**은 속성 점수화에서 빼고 따로 센다. 평가는 남아 있으므로 `lowInfoBucket`(`positive`/`negative`/`neutral`/`unknown`)으로 분류해 `lowInfo` 요약에 담는다. 사라지지 않는다 — 어디로 갔는지 응답에 남는다. 별점 4~5점짜리 짧은 리뷰가 많은 커머스 데이터에서 이 비율이 10~30%까지 나온다. 그래서 언급률을 두 벌(`mentionRate` / `informativeMentionRate`) 준다. ## 3. 정확도를 가르는 것 — `texts[]` 동봉 `meta.alignment`가 두 값을 갖는다. **이게 이 API에서 가장 중요한 한 가지다.** | 값 | 언제 | 무엇이 다른가 | |---|---|---| | `exact-by-id` | 본문에 `texts[]`를 **입력 순서대로** 보냈을 때 | 각 행을 **자기 원문으로** 점수화. `rows[i].id`가 `texts[i]`와 1:1 | | `label-aggregated` | `texts[]`를 생략했을 때 | 행 점수가 **군집 라벨 단위 집계(근사)**. multi-label 데이터에서 라벨 평균은 위험하다 | 별점·연령·앱 같은 **행 메타와 교차분석**할 거면 `exact-by-id`여야 한다. `sourceId`·`rows[].id`가 곧 `texts[]`의 인덱스라서, 클라이언트가 자기 데이터프레임과 인덱스로 조인한다. `meta.note`에 어느 쪽인지 사람이 읽는 문장으로도 적힌다. ## 4. 한 속성 파고들기 — 드릴다운 `POST /v1/bubbles/:id/aspects/:aspectId/drilldown` 그 속성을 언급한 문장에서 **그 속성에 해당하는 조각(스팬)만** 떼어 **하위 버블**로 다시 만든다. "소음" 안이 *탈수 덜컹거림 / 밤에 돌리기 부담 / 초기 소음*으로 갈라진다. 문장에 섞여 있던 다른 속성 얘기(멀티라벨 노이즈)가 빠지는 것이 요점이다. ```bash curl -X POST https://api.affinitybubble.com/v1/bubbles/$ID/aspects/a3/drilldown \ -H "Authorization: Bearer ab_live_..." -H 'Content-Type: application/json' \ -d '{ "texts": ["...", "..."] }' # 202 → { "instanceId": "djob_xxx", "statusUrl": "/v1/bubbles/jobs/djob_xxx" } ``` - **`texts[]`는 필수다.** 없으면 422 `texts_required` — 스팬을 원문에서 떼어내야 하기 때문이다. - 결과는 **독립된 버블**이다. `GET .../drilldown`이 `childBubbleId`와 버블 `data`를 돌려주므로, 버블 문서의 시각화·리포트·공유를 그대로 쓸 수 있다. - 이미 만든 적이 있으면 즉시 200 + `cached: true`. ## 5. 속성 요약 칩 — 하위 버블 없이 `POST /v1/bubbles/:id/aspects/:aspectId/subthemes` (동기) 한 속성을 **3~6개의 짧은 경험 서술**로 바로 요약한다. 드릴다운처럼 버블을 새로 만들지 않고 JSON을 그 자리에서 돌려준다. 상품 페이지 위젯처럼 "칩 몇 개"만 필요할 때 쓴다. ```jsonc { "parentBubbleId": "rO_EFF1huT3B1k6Y", "aspectId": "a3", "aspectName": "소음", "n": 680, "chips": [ { "label": "탈수할 때 덜컹거려요", "n": 210, "sentiment": -0.7, "example": "탈수 들어가면 옆집까지 들릴 것 같아요", "quotes": [ { "review": "배송은 빨랐는데 탈수 들어가면 옆집까지 들릴 것 같아요", "s": 9, "e": 28, "sourceId": 41 } ] } ] } ``` - `quotes[].s`·`e`는 **원문 안의 하이라이트 범위**다. 원문을 그대로 보여주고 그 구간만 강조하면 된다 — 요약문이 아니라 사람이 쓴 문장이 화면에 남는다. - `sourceId`는 `texts[]`의 인덱스다. 별점·작성일 같은 행 메타를 여기로 조인한다. - `texts[]` 필수(없으면 422). ## 6. 조절할 수 있는 것 (`params`) 전부 선택이다. 기본값으로 먼저 돌려 보고, 결과를 보고 조절한다. | 키 | 기본 | 뜻 | |---|---|---| | `aspectMaxAspects` | 12 | 속성 개수 상한 | | `aspectDomainMin` | 3 | 도메인 특화 속성 최소 개수 (일반 속성만 나오는 것을 막는다) | | `aspectSampleN` | 120 | 코드북을 만들 때 읽는 표본 수 | | `aspectSampleRegions` | 30 | 표본을 고르게 퍼뜨릴 구간 수 | | `aspectTextChars` | 200 | 점수화에 넣는 텍스트 길이 상한 | | `aspectSalienceThreshold` | 0.2 | 이 비중 미만의 언급은 "언급 안 함"으로 본다 | | `aspectSentPos` · `aspectSentNeg` | 0.2 | 칭찬/불만으로 셀 감성 경계 | | `aspectFreqFloor` | 0.03 | 언급률이 이보다 낮은 속성은 버린다 (`meta.droppedAspects`에 수로 남는다) | | `aspectCoverageFloor` | 5 | 이보다 표본이 적으면 `coverageWarning`을 붙인다 | | `aspectMinInformativeChars` | 12 | 이보다 짧고 속성 단서가 없으면 저정보 | | `aspectLowInfoTokenFloor` | 4 | 의미 토큰이 이보다 적고 속성 단서가 없으면 저정보 | 모델 관련 키(`*Model`)는 **요청에서 제거된다.** 모델 선택은 내부 관심사다. ## 7. 크레딧 | 작업 | 크레딧 | |---|---| | `POST /:id/aspects` | 텍스트 수 기준 — **500건까지 15**, 이후 100건마다 +3 | | `POST .../drilldown` | 3 (속성 1개 고정) | | `POST .../subthemes` | 2 (속성 1개 고정) | | `GET` 계열 | 무료 | 예: 2,000건 → `(5 + ⌈1500/100⌉) × 3` = **60 크레딧**. 캐시된 결과를 다시 받는 것(`cached: true`)은 새로 과금하지 않는다. ## 8. 에러 | status | type | 뜻 | |---|---|---| | 404 | `not_found` | 버블이 없거나, `POST /:id/aspects`를 아직 안 돌렸다 | | 403 | `forbidden` | 다른 키가 소유한 버블 | | 422 | `texts_required` | 드릴다운·칩에 `texts[]`를 안 보냈다 | | 422 | `subthemes_failed` | 그 속성을 언급한 텍스트가 없거나 대상이 부족하다 | | 402 | `insufficient_credits` | 크레딧 부족 (`required`·`balance` 동봉) | | 500 | `workflow_start_failed` | 잡 시작 실패 — 재시도 | ## 9. 한계 (과장하지 않는다) - **속성 이름과 정의는 LLM이 만든다.** 도메인 표준 분류가 아니다. 보고서에 넣기 전에 `data.aspects`를 한 번 읽어 본다. - **`meta.scoreChunksFailed`가 0이 아니면 언급 수가 실제보다 적다.** 재시도 후에도 실패한 배치가 있다는 뜻이라, 그 수치로 "언급이 줄었다"고 말하면 안 된다. - **표본이 작으면 쟁점도가 튄다.** `coverageWarning`이 붙은 속성은 순위·비교에서 뺀다. - **`label-aggregated`는 근사다.** 교차분석에 쓰지 않는다(§3). - 텍스트가 수십 건이면 속성 분석은 과한 도구다. 그냥 읽는 편이 빠르다.