# 퍼소나 만들기 — Affinity Bubble API `https://api.affinitybubble.com` 인터뷰·VOC처럼 **사람마다 여러 발화가 있는** 데이터를 넣으면, 행동 변수를 뽑아 사람마다 채점하고, 축과 군집으로 묶어 **퍼소나 카드**를 만든다. 근거 없는 문장은 넣지 않는다 — 모든 서술에 원문 발화 id가 붙는다. **공통 사항(가입·키 발급·크레딧·한도)은 이 문서에 없다.** → https://api.affinitybubble.com/llms.txt 를 먼저 읽는다. 버블(`/v1/bubbles`)과 다른 점: 버블은 **텍스트 1건**이 분석 단위이고, 퍼소나 도구는 **사람 1명**이 분석 단위다. 한 사람의 발화 묶음 전체를 한 번에 읽는다. ## 이 문서를 스킬로 만들기 한 번 읽었으면 아래를 담아 재사용 가능한 스킬로 저장해 두는 것을 권한다. - **흐름**: `POST /v1/personas`(발화 업로드) → `POST /:id/run`(5단계 실행) → `GET /v1/personas/jobs/:jobId`(폴링) → `GET /:id/cards`(결과) - **입력 한 줄**: `{ "userId": "U1", "utterance": "..." }` — 같은 사람의 발화는 같은 userId - **5단계**: variables(행동변수) → scores(채점) → axes(축) → clusters(군집) → cards(카드). 단계별로 조회·편집·부분 재실행이 된다 - **한 번에 끝내기**: 생성 요청에 `"autorun": true` 를 넣으면 업로드와 동시에 실행한다 - **규모**: 프로젝트당 사용자 200명까지. 넘으면 업로드 시점에 400으로 막힌다 - **키**: 스킬 파일이 아니라 `~/.affinitybubble/credentials`(chmod 600) 같은 별도 파일에 사용자–발화 데이터에서 **행동변수 코드북을 자동으로 발견**하고, 사용자 한 명씩 근거를 달아 채점한 뒤, 축·군집을 거쳐 **근거를 되짚을 수 있는 퍼소나 카드**를 만든다. 버블과 별개 리소스라 버블 없이 단독으로 돈다. 다섯 단계가 각각 조회·편집 가능한 리소스로 남고, 상류를 고치면 그 아래만 다시 돌린다. 인증은 §1과 같다(`Authorization: Bearer ab_live_...`). 프로젝트 id 접두어는 `pp_`, 잡 id 접두어는 `ppjob_`. > 이 절은 **API 계약**이다. 각 단계가 내부에서 무엇을 하는지, 어떤 상수에 기대는지, 지금 > 무엇이 미심쩍은지는 [personas-pipeline.md](personas-pipeline.md)에 있다 — 파이프라인을 > 고치러 왔다면 그쪽을 먼저 읽는 편이 빠르다. | 메서드·경로 | 용도 | |---|---| | `POST /v1/personas` | 분석 생성 (발화 업로드, `autorun` 옵션) → §9.3 | | `GET /v1/personas` | 목록 (최근 100건: `id`·`title`·`status`·`userCount`·`utteranceCount`·`createdAt`) | | `GET /v1/personas/:id` | 프로젝트 메타 + `stages`(단계별 `revision`·`stale`·`updatedAt`) | | `DELETE /v1/personas/:id` | 삭제 — 발화·단계·편집 이력까지. 실행 중이면 409 `project_busy` | | `POST /v1/personas/:id/run` | 실행 (`from`/`until`/`params`/`force`) → §9.6 | | `GET /v1/personas/jobs/:jobId` | 잡 폴링 (KV TTL 24시간) → §9.3 | | `POST /v1/personas/:id/rows` | 발화 추가 (증분) → §9.9 | | `GET /v1/personas/:id/variables` · `PATCH` | 행동변수 코드북 조회·편집 → §9.7 · §9.8 | | `GET /v1/personas/:id/scores` | 사용자별 점수표 (근거 id 포함) | | `GET /v1/personas/:id/axes` · `PATCH` | 핵심 축 조회 · 이름/`primary3` 편집 | | `GET /v1/personas/:id/clusters` · `PATCH` | 군집 조회 · `k` 변경(즉시 재계산) | | `GET /v1/personas/:id/cards` | **퍼소나 카드 (최종 산출물)** | | `POST /v1/personas/:id/evidence` | 근거 id 여러 개 → 발화 원문 (화면 선로딩용) | | `GET /v1/personas/:id/evidence/:utteranceId` | 근거 id → 발화 원문 → §9.10 | 프로젝트 `status`는 **커밋된 결과물**을 나타낸다. 실행이 무엇을 맡았는지가 아니라 지금 무엇이 완성돼 있는지가 기준이다. | status | 뜻 | |---|---| | `draft` | 발화만 저장됨. 아직 한 번도 실행하지 않았다 | | `running` | 실행 중. 이 동안 편집·발화 추가는 `409 project_busy` | | `partial` | **5단계가 다 채워지지 않았거나 일부가 낡았다.** `until`로 멈춘 실행 뒤, 그리고 `PATCH` 3종·`POST /:id/rows`로 하류를 낡게 만든 뒤가 여기다 — `ready`였던 프로젝트는 그 편집 시점에 `partial`로 내려간다. 편집·발화 추가는 `partial`에서도 계속 열려 있다(막는 것은 `running`뿐) | | `ready` | 5단계가 모두 있고 전부 최신 | | `errored` | 실행이 실패했고 맡은 단계도 마치지 못했다 | `status`는 요약이다. 어느 단계가 있고 낡았는지는 `stages`가 정확히 준다. **실패 사유는 `status`가 아니라 잡의 `error`에서 읽는다** — 5단계가 전부 최신인데 실행 꼬리에서 실패했다면 결과물은 멀쩡하므로 `ready`로 남고, 맡은 단계를 다 끝낸 뒤 꼬리만 실패했다면 `partial`로 남는다. ## 1. 세분화 모듈(`/v1/bubbles/:id/personas`)과 어느 쪽을 쓸까 이름이 둘 다 "퍼소나"라 헷갈린다. **입력 한 줄이 무엇인가**로 갈린다. | | 세분화 모듈 `POST /v1/bubbles/:id/personas` | 퍼소나 도구 `POST /v1/personas` | |---|---|---| | 입력 단위 | **텍스트 1건** = 분석 단위 1개 | **사용자 1명** = 분석 단위 1개 (그 사람의 발화 묶음 전체를 한 번에 읽음) | | 전제 | 완성된 버블이 있어야 함 | 없음. 단독 실행 | | 근거 추적 | 없음 (점수만) | 점수·카드 문장마다 `evidenceIds` → `GET /:id/evidence/:utteranceId`로 원문 | | 축 | 고정 뼈대 8축 + 도메인축 | 데이터에서 전량 상향식 (뼈대 없음) | | 산출물 | 버블 행에 성향 레이어를 덧댐 | 퍼소나 카드 자체 | | 편집 | 없음 | 코드북·축·군집을 PATCH하고 그 아래만 다시 실행 | | 응답 | 동기 | 비동기(잡 폴링) — 6명 × 48발화 실측 약 110초 | **고르는 기준**: 한 사람이 여러 번 말한 데이터(인터뷰 전사, 사용자별 여러 리뷰, 응답자별 여러 문항)를 사람 단위로 묶어 보고 싶으면 퍼소나 도구다. 익명 리뷰 500건처럼 한 줄이 곧 한 사람이면 세분화 모듈이 싸고 빠르다. 잘못 고르면 결과가 쓸모없다. 사람 단위 데이터를 세분화 모듈에 넣으면 같은 사람의 발화가 서로 다른 세그먼트로 흩어진다. 거꾸로 익명 한 줄짜리를 퍼소나 도구에 넣으면 사용자당 발화가 1건이라 대부분 변수가 근거 부족으로 `null`이 되고(생성 응답이 `low_evidence_users`로 미리 경고한다) 군집이 의미를 갖지 못한다. ## 2. 다섯 단계 | 순서 | 단계 | 만드는 것 | LLM | |---|---|---|---| | 1 | `variables` | 행동변수 코드북 — 변수별 정의·경계·양극·1~4점 앵커·근거규칙 + 제외된 후보(`rejected`)와 이유 | ○ | | 2 | `scores` | 사용자 × 변수 점수(1.0~4.0). 근거 발화별 1~4점의 평균이며, 점수마다 `evidenceIds`·`confidence`·`reason` | ○ (사용자 수 × 1콜 **+ 재판정**) | | 3 | `axes` | 변수를 묶은 핵심 축 + 사용자별 축 좌표 | ○ (합성축 명명 1콜) | | 4 | `clusters` | 축 좌표로 사용자를 k개 군집으로 | ✗ 순수 계산 | | 5 | `cards` | 퍼소나 카드 — 이름·**요약**·needs·barriers·인용·가설 | ○ (1콜) | 각 단계는 끝나는 즉시 저장된다. 3단계에서 실패해도 1·2단계 결과는 남고, 그 지점부터 다시 돌릴 수 있다. **품질 불변식** — 문서상 약속이 아니라 코드가 강제한다. - 근거(`evidenceIds`)가 없는 점수는 `null`이다. 중간값으로 얼버무리는 경로가 없다. - 근거를 못 댄 `needs`·`barriers` 문장은 카드에서 **제거**된다. - `summary`(퍼소나 요약 1~2문장)는 같은 게이트에 **근거 2건 이상**을 더 요구한다 — 요약은 여러 발화의 종합이라 한 발화로는 뒷받침되지 않는다. 못 대면 `summary: null` 이며, 지어낸 문장을 남기지 않는다. 호출자는 `null` 일 때 군집 프로필 수치(`share`·`distinctiveVariables`)로 대체해 보여주면 된다. 근거 id는 그 군집 멤버의 발화여야 하며, 다른 퍼소나의 발화를 근거로 단 문장도 제거된다. - 군집 멤버 전원이 `null`이라 평균을 낼 실측치가 없는 변수는 `distinctiveVariables`에서 빠지고 `unmeasuredVariables`에 이름이 남는다. "순위가 낮아 빠진 것"과 "아무도 채점되지 않아 빠진 것"을 구분할 수 있어야 하기 때문이다. - 군집의 **소수만** 채점된 변수(2명 미만, 단 군집 크기가 그보다 작으면 전수면 인정)도 `distinctiveVariables`에서 빠져 `thinlyMeasuredVariables`(`{ name, n }`)로 간다. `distinctiveVariables`는 `|vsMean|` 순으로 정렬되므로, 표본이 적어 값이 튄 변수가 1순위로 올라가 한 사람의 발화가 군집 전체의 정의가 되는 일을 막는다. 남아 있는 항목에는 실측 인원 `n`이 함께 실린다. - 카드는 입력에 없는 속성(나이·성별·직업·거주지·소득)을 만들지 않는다. ## 3. 한 번에 끝까지 — `autorun` ```bash curl -X POST https://api.affinitybubble.com/v1/personas \ -H "Authorization: Bearer ab_live_..." -H 'Content-Type: application/json' \ -d '{ "title": "구매 탐색 인터뷰 2026-07", "autorun": true, "rows": [ { "userId": "U1", "utterance": "새 제품은 리뷰부터 찾아봐요.", "topic": "구매 탐색" }, { "userId": "U1", "utterance": "별점 낮은 리뷰를 먼저 읽어요." }, { "userId": "U2", "utterance": "인스타에서 보고 예쁘면 바로 결제해요." } ] }' ``` 아래 응답은 **위 3행이 아니라 사용자 6명 × 발화 48건짜리 실측 입력**에서 받은 것이다(요청 예시는 형식만 보여 준다). 위 curl을 그대로 돌리면 `userCount: 2`에 `low_evidence_users` 경고가 붙는다 — 사용자당 발화가 5건 미만이기 때문이다(§9.5). ```jsonc // 202 — 발화는 저장됐고 5단계가 백그라운드에서 돈다 { "id": "pp_itBaNP1o1lyF", "userCount": 6, // 분석 대상 사용자 수 (아래 9.5 참고) "utteranceCount": 48, // 저장된 전체 행 수 (걸러진 행 포함) "profile": { "utterancesPerUser": { "min": 8, "median": 8, "max": 8 }, "axisPath": { "recommended": "synthetic", "reason": "사용자 6명 / 변수 11개 = 변수당 0.5명. EFA 권장조건(변수당 5~10명, 전체 100명+) 미달", "available": ["synthetic", "pca"] } }, "warnings": [], "jobId": "ppjob_n8E-VzSYgsCS5wqH", "statusUrl": "/v1/personas/jobs/ppjob_n8E-VzSYgsCS5wqH" } ``` `autorun` 없이 부르면 발화만 저장하고 `201`을 준다(`jobId` 없음). 워크플로 시작만 실패한 경우도 `201`이며, 발화는 저장돼 있고 `warnings`에 `autorun_failed`가 붙는다 — 그때는 `POST /:id/run`으로 직접 시작한다. **폴링** — `GET /v1/personas/jobs/:jobId` ```jsonc { "instanceId": "ppjob_n8E-...", "status": "running", // queued | running | complete | errored "currentStep": "variables", // preparing | variables | scores | axes | clusters | cards | done "progress": 19, // 0–100 "detail": "codebook", // variables 단계는 micro codes → codebook → trial coding → refine "usage": { "textCount": 48, "embeddingTokens": 2089, "llmInputTokens": 29417, "llmOutputTokens": 29087, "durationMs": 112208 }, "error": "...", // errored 시점에만 "liveStatus": "running", // 워크플로 엔진이 보고하는 상태(KV 기록과 별개) "partial": { // 단계 커밋 **전에** 화면을 채우기 위한 미확정 결과 "variables": { "phase": "candidates", // candidates(합성 직후, quality 미측정) | refined(품질·탈락 확정) "variables": [ /* PersonaVariable — candidates 단계의 quality 는 0이라 믿지 말 것 */ ], "rejected": [ { "name": "...", "reason": "..." } ] // refined 부터 }, "scores": [ // 채점이 끝난 사용자만 순서대로 누적 (최대 40명) { "userId": "U1", "scores": [ { "variableId": "v0", "score": 3 } ] } ] } } ``` `partial` 은 **확정 값이 아니다.** 점수는 값만 담고 `reason`·`evidenceIds` 가 없으며(잡 레코드 크기 상한), 40명을 넘으면 그 이후 사용자는 실리지 않는다. 최종 값은 항상 단계 조회 (`/:id/variables`, `/:id/scores`)에서 받아 **덮어써야** 한다. 존재하지 않을 수도 있는 필드다 — `variables` 단계 초반(micro codes)과 TTL 만료 후에는 없다. 왜 있나: 코드북은 몇 분, 채점은 사용자 수 × 1콜이다. 단계 커밋까지 화면이 비어 있으면 그 시간이 정지처럼 보인다. 서버가 이미 아는 것을 그때그때 흘려보내 같은 카드·같은 표를 채워 나가게 하는 용도다. KV TTL 24시간. 그 뒤에는 `GET /v1/personas/:id`의 `stages`로 확인한다. `llmOutputTokens`에는 **모델의 추론(reasoning) 토큰이 포함**된다 — 위 실측에서 29,087 중 18,119이 추론 토큰이다. 출력 길이의 지표로 읽으면 안 된다. ```bash until [ "$(curl -s https://api.affinitybubble.com/v1/personas/jobs/$JOB \ -H "Authorization: Bearer $KEY" | jq -r .status)" = "complete" ]; do sleep 5; done curl -s https://api.affinitybubble.com/v1/personas/$ID/cards -H "Authorization: Bearer $KEY" ``` ## 4. 단계별로 진행 `autorun`을 끄고 코드북만 먼저 만들어 사람이 고친 뒤 아래로 내려가는 흐름. ```bash # 1) 발화 업로드 (LLM 0콜) ID=$(curl -s -X POST https://api.affinitybubble.com/v1/personas \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ --data-binary @rows.json | jq -r .id) # 2) 코드북만 생성 — until으로 멈춘다 curl -s -X POST https://api.affinitybubble.com/v1/personas/$ID/run \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"until":"variables"}' # 202 → { "jobId": "ppjob_...", "willRun": ["variables"], "status": "queued" } # 3) 코드북 확인 → 손보기 curl -s https://api.affinitybubble.com/v1/personas/$ID/variables -H "Authorization: Bearer $KEY" curl -s -X PATCH https://api.affinitybubble.com/v1/personas/$ID/variables \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"remove":["v3"],"add":[{"name":"가격 민감도","definition":"가격이 결정에 미치는 비중"}]}' # 4) 나머지를 이어서 — from을 주면 그 단계부터 curl -s -X POST https://api.affinitybubble.com/v1/personas/$ID/run \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"from":"scores"}' # 202 → { "willRun": ["scores","axes","clusters","cards"], ... } ``` **`until`로 멈춘 실행은 정상 종료다.** 잡은 `complete`가 되고 `usage`가 기록된다. 남은 단계는 실행되지 않는다. **단계를 다시 만들면 그 아래가 `stale`이 된다.** 실행이 단계를 커밋하면 **하류 전체**가 낡음으로 표시된다. 그래서 위 3번에서 코드북만 다시 만들면 이미 있던 `scores` 이하가 낡고, 4번의 `run {}`은 `from`을 주지 않아도 `willRun: ["scores","axes","clusters","cards"]`로 이어서 돈다. 실측: 전 단계가 최신인 프로젝트에 `run {"from":"clusters","until":"clusters"}`를 부르면 `cards`만 `stale: true`가 되고 (상류는 그대로) 다음 `run {}`이 `willRun: ["cards"]` · `status: "queued"`를 돌려준다. `PATCH`는 범위가 더 좁다 — 편집이 무엇을 무효화하는지에 따라 다르고, 정확한 범위는 §9.8의 표에 있다(예: 축 이름 변경은 좌표를 바꾸지 않으므로 `cards`만). **진행 중인 프로젝트의 `status`는 `partial`이다** — 5단계가 다 채워지고 전부 최신일 때만 `ready`다. `until`로 멈췄다고 `errored`가 되지는 않는다. ## 5. 입력 계약 — `rows` | 필드 | 타입 | 필수 | 설명 | |---|---|---|---| | `rows[].utterance` | string | ✅ | 발화 원문 | | `rows[].userId` | string | — | **생략 시 각 행이 독립 사용자**가 된다(자동 부여 `R0001`, `R0002`…). 리뷰·설문 자유응답을 같은 파이프라인에 태우기 위한 규칙이다 | | `rows[].utteranceId` | string | — | 생략 시 `{userId}-001`부터 자동. **근거 id로 그대로 쓰이므로** 원본 행 키가 있으면 넣는 것이 좋다. 단 이 값은 `GET /:id/evidence/:utteranceId`의 **URL 경로 조각**이 된다 — `/`를 **날것으로** 넣으면 경로 조각이 갈라져 그 발화를 조회할 수 없다(응답은 `404 { error: { type: "not_found", message: "Route not found" } }`로, 이 API의 표준 형태이지만 발화 조회의 404와는 메시지가 다르다). 퍼센트 인코딩(`%2F`)하면 한 조각으로 살아남아 정상 조회된다. 인코딩을 신경 쓰지 않으려면 영숫자·`-`·`_`만 쓰는 편이 안전하다 | | `rows[].topic` / `source` / `timestamp` | string | — | 분석에 쓰이지 않고 `GET /:id/evidence/:utteranceId`가 그대로 돌려준다 | | `title` | string | — | 1~200자. 생략 시 "제목 없는 퍼소나 분석" | | `autorun` | boolean | — | `true`면 생성 직후 5단계를 시작하고 `202` | **행 품질 태그** — 빈 발화·중복·초단문(6자 미만)은 **삭제하지 않고** `quality`(`ok`\|`short`\|`dup`\|`empty`)만 붙여 저장한다. 채점에는 `ok`만 실린다. - `utteranceCount` = 저장된 전체 행 수(걸러진 행 포함). - `userCount` = **`ok` 발화가 하나라도 있는 사용자 수**. 발화가 전부 걸러진 사용자는 세지 않는다 — "6명 분석"이 실제 채점 인원과 일치해야 하기 때문이다. - 경고는 `duplicate_rows`·`empty_rows`만 별도 코드로 나간다. `short` 행에는 전용 경고가 없고, 그 사용자의 `ok` 발화가 5건 미만이면 `low_evidence_users`로 잡힌다. - `ok` 발화가 하나도 없으면 `400 invalid_request`("분석 가능한 발화가 없습니다"). - **`utteranceId`는 프로젝트 안에서 고유해야 한다.** `POST /:id/rows`는 충돌을 `400`으로 막지만 **생성 요청은 막지 않는다** — 한 배치에 같은 `utteranceId`를 두 번 넣으면 둘 다 저장되고, `GET /:id/evidence/:utteranceId`는 입력 순서상 **첫 행**을 돌려준다. 근거가 어느 원문을 가리키는지 모호해지므로 업로드 전에 중복을 없앨 것. **⚠️ 익명 입력의 중복 판정 비대칭 (알려진 제약)** `userId`를 생략한 행은 우리가 번호를 붙이므로 신원 정보가 없다. 그래서 중복 판정 기준이 두 경로에서 다르다. | | 생성 `POST /v1/personas` | 증분 `POST /:id/rows` | |---|---|---| | `userId` 있는 행 | `userId` + 원문이 같으면 `dup` | 같음 (프로젝트 전체와 비교) | | `userId` 없는 행 | 각 행이 다른 사용자이므로 **같은 원문도 `dup`이 아니다** | **원문만으로 `dup` 판정** | 같은 데이터를 한 번에 보내는 것과 두 번에 나눠 보내는 것이 다른 `userCount`를 낼 수 있다. 실측: 익명 3행(1·2행 동일 원문)을 생성으로 보내면 `userCount: 3` · 경고 없음. 같은 원문을 `POST /:id/rows`로 다시 보내면 `duplicates: 1` · `duplicate_rows` 경고로 잡히고 새 사용자로 세지 않는다. 후자는 같은 배치 재전송을 막기 위한 것이고, 정말로 똑같은 문장을 쓴 서로 다른 익명 응답자는 두 번째가 유실된다. **익명 데이터는 한 번에 올리는 것을 권장**하며, 재전송 방지가 필요하면 `userId`를 직접 부여할 것. ## 6. 실행 — `POST /:id/run` ```jsonc { "from": "scores", // 생략 시 "가장 위쪽의 없거나 stale인 단계"부터 "until": "cards", // 생략 시 cards "params": { "axisMethod": "pca", "k": "4" }, "force": false // true면 신선도 판정을 무시하고 from(기본 variables)부터 전부 } ``` 응답에는 **실행 전에 계산한 `willRun`이 반드시 들어간다.** "코드북을 고쳤는데 무엇이 다시 도나"를 버튼 누르기 전에 알아야 한다. | 요청 | 응답 | |---|---| | 다시 돌 단계가 있음 | `202 { jobId, statusUrl, willRun: [...], status: "queued" }` | | 전 단계가 최신 | `200 { willRun: [], status: "up_to_date", message: "모든 단계가 최신입니다" }` | | `until`이 `from`보다 앞 | `200 { willRun: [], status: "up_to_date" }` — 아무것도 돌지 않는다 | | `from` 위쪽 단계가 없음 | `409 upstream_missing` | | `from`/`until` 오타 | `400 invalid_request` (+ `issues`) — 조용히 5단계를 다 돌려 비용을 물리지 않는다 | | 워크플로 시작 실패 | `500 workflow_start_failed` | `params`는 **문자열 맵**이다. `axisMethod`(`auto`\|`synthetic`\|`pca`\|`efa`)와 `k`를 받는다. `k`도 문자열로 보낸다 — `{"k": 4}`처럼 숫자로 보내면 `400 invalid_request`다. **모델 지정 파라미터(`*Model`)는 버려진다** — §5와 같은 정책이다. `force: true`는 신선도 판정을 무시하고 **`from`(생략 시 `variables`)부터 다시 실행**하며, "이미 채점된 사용자 건너뛰기"도 끈다. `from`만 준 실행은 단계 선택이지 전수 재채점이 아니다. **부분 재채점** — 코드북 변경 범위에 따라 채점 호출량이 달라진다(실측: 변수 4개 중 1개만 정의를 고친 뒤 `run {"from":"scores"}`를 부르면 채점 프롬프트가 1,450~1,771토큰 → 704~758토큰으로 줄어든다. 그 변수 열만 다시 묻는다). | 편집 | 다시 채점하는 범위 | |---|---| | 변수 이름만 변경 | 없음 (채점 결과를 바꾸는 필드가 아니라 해시가 그대로) | | 정의·경계·앵커·근거규칙 변경 | 그 변수 열만, 전체 사용자 | | 변수 추가 | 추가된 변수 열만 | | 변수 삭제 | 없음 (열만 빠진다. 단 `scores`를 다시 조립해야 하므로 stale이 된다) | | 채점 기록이 없거나 해시 기록이 없음 | 전체 (확인하지 못한 것을 "그대로"로 치지 않는다) | > ⚠️ **위 표는 `scores` 단계 안에서 어느 열을 다시 묻는지에 대한 것이다.** `force`는 이 표를 넓히지 않는다 — 열 선택은 언제나 해시 비교가 정하고 `force`는 그 위에서 사용자 건너뛰기만 끈다. > > ⚠️ **그러나 `force: true`에 `from`을 주지 않으면 `variables`부터 다시 돈다 — 코드북이 새로 생성되고 `PATCH`로 손본 내용은 사라진다.** 실행 전에 알 수 있다: `202` 응답의 `willRun`에 `"variables"`가 들어 있다. 고친 코드북을 지키려면 **`from`을 명시**할 것 — `{"from":"scores"}`. > > 거기에 `force`까지 줄지는 **무엇을 고쳤는지**에 달렸다. 정의·앵커처럼 **해시가 바뀌는 편집**이면 그 열은 `force` 없이도 전체 사용자에게 다시 묻으므로 `force`는 아무것도 바꾸지 않는다. **이름만 바꾼 편집**(해시 불변)에서는 `force`가 없으면 이미 채점된 사용자를 건너뛰므로, 그때만 `{"from":"scores","force":true}`가 의미가 있다. > > `from`을 생략한 `force`는 새 코드북의 해시가 이전 기록과 **하나도** 맞지 않으므로 **전 열**을 다시 매긴다. ## 7. 단계 조회 ``` GET /v1/personas/:id/variables|scores|axes|clusters|cards ``` 응답은 단계 데이터에 `revision`·`stale`·`origin`(`llm`\|`human`\|`compute`)을 얹은 형태다. **낡은 단계도 그대로 돌려준다** — 데이터를 지우지 않고 `stale: true`로만 알린다. 아직 만들어지지 않은 단계는 `404`. 아래는 §9.3 입력(사용자 6명 × 발화 48건)의 첫 자동 실행 응답에서 첫 퍼소나만 발췌한 것이다 — 값은 손대지 않았다. ```jsonc // GET /:id/cards { "revision": 1, "stale": false, "origin": "llm", "personas": [{ "id": "p0", "name": "심층 정보 검증 및 단점 탐색형", // 행동 패턴 기반. "얼리어답터 민수" 류 금지 "size": 3, "share": 0.5, "axisProfile": { "ax1": { "value": 1, "vsMean": 1 } }, // vsMean = 전체 평균과의 차 "distinctiveVariables": [ { "variableId": "v0", "name": "정보 검증 및 탐색 깊이", "mean": 4, "vsMean": 1.5, "n": 3 } // n = 실측 인원 ], "unmeasuredVariables": [], // 이 군집에 실측치가 아예 없는 변수 "thinlyMeasuredVariables": [], // 소수만 채점돼 구별 변수에서 뺀 것 { name, n } "memberUserIds": ["U1", "U2", "U3"], "contexts": [ // 멤버가 **처한 조건**. 군집 입력에는 안 들어간다(§9.x) { "name": "영유아가 있음", "matched": 2, "of": 3, "evidenceIds": ["U1-002"] } ], "needs": [ { "text": "광고 문구 대신 실사용 후기, 단점, 성분, 벤치마크 점수 등 객관적이고 구체적인 검증 정보를 확인하고자 함", "evidenceIds": ["U1-001", "U1-003", "U2-001", "U3-001"] } ], "barriers": [ { "text": "광고성 홍보나 검증되지 않은 신생 브랜드, 일치하지 않는 정보에 대해 불신을 느끼고 구매 후보에서 제외함", "evidenceIds": ["U2-001", "U2-002", "U3-002"] } ], "quotes": [ { "userId": "U1", "utteranceId": "U1-001", "text": "새 이어폰 하나 사는데도 유튜브 리뷰를 열 개는 봤어요. 실사용 3개월 후기 같은 걸 꼭 찾아봅니다." } ], "hypotheses": ["(가설) 상세 페이지에 객관적인 스펙 비교표와 실제 단점을 보완하는 가이드를 제공하면 구매 전환율이 높아질 것이다."], "confidence": { "clusterStability": 1, "codingConfidence": 0.98 } }] } ``` 이 실행에서 축과 변수가 각각 1개인 것은 사용자 6명짜리 표본의 결과다 — 이유는 §9.13에 있다. `evidenceIds`의 발화는 전부 `memberUserIds`에 속한다(다른 퍼소나의 발화를 근거로 단 문장은 코드가 제거한다). `contexts`는 변수 단계의 `contextConditions`를 이 군집 멤버와 교집합한 것이다. 조건은 고른 것이 아니라 처한 것이라 군집·축·점수에 들어가지 않지만(같은 생활 조건을 같은 취향으로 묶지 않기 위해), 카드에서는 나이·직업을 지어내지 않고 "어떤 상황의 사람인가"를 말할 수 있는 유일한 재료다. LLM 을 더 부르지 않는 순수 집계다. - `matched`/`of` — 멤버 중 몇 명에게서 관측됐는가. 전원이 아닌 조건을 전원의 속성으로 읽지 않도록 화면은 이 비율을 그대로 보여야 한다. - 멤버 **1명**에게만 관측된 조건은 싣지 않는다. 그 사람의 사정이지 유형의 특징이 아니다. - `evidenceIds`는 멤버의 발화만 남긴다. 조건 자체의 근거 목록은 군집을 모르므로 그대로 실으면 다른 군집 사람의 말이 이 카드의 근거로 걸린다. - 조건 추출 이전에 만든 프로젝트에는 이 필드가 **없다**(optional). `hypotheses`는 **사실이 아니라 검증할 것**이다. 근거를 댄 `needs`·`barriers`와 섞지 않도록 분리해 둔 자리다. `cards` 응답에는 `personas` 옆에 `narration: { failed, truncated, error?, missingClusters, outOfRangeIndexes }`이 함께 온다. 카드의 프로필(크기·축·차별 변수·인용)은 순수 계산이지만 **서술(`name`·`needs`·`barriers`·`hypotheses`)은 LLM 1콜**이다. 그 콜이 실패하면 카드는 이름이 `퍼소나 1`이고 세 배열이 전부 빈 껍데기로 남는데, 그 모양은 "근거를 못 대서 비었다"와 겉으로 같다. 어느 쪽인지 구분할 수 있어야 하므로 유실을 여기에 싣는다. **판정 규칙 (`outOfRangeIndexes`가 빈 경우에 한해)**: 어떤 퍼소나의 서술이 비었을 때, **`missingClusters`에 그 군집 인덱스(`p0` → `0`)가 있으면 유실**이고, 없으면 근거가 부족해서 비었다는 뜻이다. `outOfRangeIndexes`가 비어 있지 않으면 **이 규칙을 쓰지 말 것.** 모델이 `clusterIndex`를 밀려 매기면 짝이 한 칸씩 어긋나고, 어긋난 카드는 다른 군집의 근거를 달았다가 군집 단위 검증에 걷혀 비게 된다 — 그 군집들은 `missingClusters`에 **없는데도** 서술이 비어 있다. 이때는 카드 전체를 신뢰하지 말고 `run {"from":"cards"}`로 다시 만든다. | 필드 | 뜻 | |---|---| | `failed` | 서술 LLM 호출 자체가 실패 → 모든 군집의 서술이 없다(이때 `missingClusters`는 전체 인덱스) | | `truncated` | 그 실패가 출력 예산 초과로 잘린 경우 | | `error` | 실패 사유(200자로 절단) | | `missingClusters` | 서술을 받지 못한 군집 인덱스. **`failed: false`여도 비어 있지 않을 수 있다** | | `outOfRangeIndexes` | 모델이 군집 범위 밖 `clusterIndex`를 낸 값들. 있으면 서술이 다른 군집에 붙었을 수 있으니 카드를 신뢰하지 말 것 | | `returnedCount` | 모델이 낸 서술 개수. **군집 수와 다르면** 위 두 배열이 비어 있어도 무언가 어긋난 것이다(같은 `clusterIndex`를 두 번 낸 응답은 커버리지를 채우면서 카드 하나를 버린다) | `failed: false`인데 `missingClusters`가 비지 않는 경우가 있는 이유: 서술 스키마는 `personas[]`의 **개수를 강제하지 못한다.** 군집이 6개인데 5개만 돌아온 응답도 오류 없이 통과하므로, 짝을 못 찾은 군집은 조용히 빈 카드가 된다. 그래서 실패 여부와 별개로 세어 올린다. 어느 경우든 복구는 `run {"from":"cards"}` 재시도다. 정상은 `{ failed: false, truncated: false, missingClusters: [], outOfRangeIndexes: [], returnedCount: <군집 수> }`이며, 이 필드가 없는 응답은 필드가 생기기 전에 저장된 단계다. `GET /:id/axes`의 `method`는 `synthetic`\|`pca`\|`pca_as_efa_fallback`이다. **`efa`는 응답에 나오지 않는다** — `axisMethod: "efa"`를 요청해도(또는 자동 라우팅이 EFA를 권고해도) 1차 구현에 polychoric 상관이 없어 PCA + varimax가 돌기 때문에 `pca_as_efa_fallback`으로 표기된다. 조용히 PCA를 돌려놓고 EFA라고 부르지 않는다. `pca`는 `axisMethod: "pca"`를 명시했을 때만 나온다. `methodReason`에 판정 근거가, `diagnostics.exploratoryOnly`·`diagnostics.warnings`에 표본 미달 경고가 들어간다. ⚠️ **`varianceExplained`의 뜻이 method마다 다르다.** | method | `members[].loading` | `varianceExplained` | |---|---|---| | `synthetic` | 방향만. `+1`\|`-1` (역문항이면 `-1`) | **그 축에 배정된 변수 비율**(3/8 = 0.38). 설명 분산이 아니다 | | `pca` · `pca_as_efa_fallback` | 실제 적재량(고유벡터 × √고유값) | 그 축이 설명하는 분산 비율 | 합성축은 변수의 **의미**와 **실제 점수의 상관**을 함께 보고 LLM이 묶은 것이다 — 요인분석으로 추출한 축이 아니다. 프롬프트에 |r| ≥ 0.4 인 쌍을 방향과 함께 싣고(함께 채점된 응답자 5명 이상인 쌍만, 최대 20쌍), 결과를 다시 데이터로 채점한다: - `members[].flipped` — 나머지와 반대로 움직여(평균 상관 ≤ -0.3) **방향을 자동으로 뒤집은** 멤버. 역문항은 "안 맞는 변수"가 아니라 방향만 반대인 것이므로 표시하고 끝내지 않고 고친다. 축 좌표도 교정된 부호로 계산한다. 양극 설명은 LLM 의 원래 판단으로 쓰여 있으니 대조하려면 이 표식을 보면 된다 - `coherence` — 방향을 맞춘 뒤 그 축 멤버들의 **평균 상관**. 0.3 미만이면 의미로만 묶인 축이고, `null` 은 멤버가 1개거나 겹치는 응답자가 부족해 잴 수 없었다는 뜻(0 과 구분된다) - `members[].weak` — 뒤집어도 |평균 상관| < 0.3 인 멤버. 방향 문제가 아니라 그 축에 기여가 없다는 뜻이다 - 위 세 경우 모두 `diagnostics.warnings` 에 문장으로 올라온다 즉 "의미로 묶고 데이터로 검산한다". 상관 자체는 응답자 8~10명 기준이라 불안정하므로 프롬프트에서도 "목록에 없는 쌍은 무관이 아니라 모른다"로 지시한다. 사용자 8명 규모(앱 경로 상한 10명)에서는 언제나 이 경로이므로, 축을 통계적 구조로 읽어서는 안 된다. 같은 이유로 군집 좌표도 이 합성축 평균이라, 군집 분리는 LLM의 묶음 선택에 좌우된다. `GET /:id/cards`의 카드 한 장은 **연구가 지지하는 최소 구성**을 따른다. 실무자들이 퍼소나를 "추상적·비인격적·오도함·주의 분산"으로 평가한다는 조사(Matthews, Judge & Whittaker, CHI 2012)에 따라 사진·나이·직업·배경 서사는 만들지 않고, 대신 아래 셋을 싣는다. | 필드 | 뜻 | 게이트 | |---|---|---| | `voice` | 카드 맨 위에 걸리는 **대표 한마디**. 차별 근거 발화를 1인칭 한 문장으로 다듬은 것 | 근거 1건 + 60자 상한. 못 대면 `null` | | `goals` | 이루려는 **상태**(Cooper 의 end goal). 행동·기능요구가 아니다 | 근거 **2건 이상**. 못 대면 그 항목이 사라진다 | | `prevalence` | 상위 차별 변수 3개를 **동시에** 만족하는 멤버 수 `{matched, of, variables}` | 순수 계산. 뚜렷하게 갈리는 변수가 없으면 `null` | `prevalence` 가 있는 이유: 속성을 더할수록 그 조합에 해당하는 실제 사람은 급격히 줄어든다 (Chapman, Love, Milham et al., HFES 2008 — 여섯 데이터셋 실측). 개별 평균만 보이면 그 사실이 감춰지므로 카드가 스스로 말하게 한다. 실제로 `3명 중 0명` 이 나오는 카드가 있다 — 그 카드의 특징 나열이 과했다는 뜻이다. 점수가 `null`(근거 없음)인 칸은 **해당하지 않는 것으로 센다** — 모르는 것을 맞다고 세면 유병률이 부푼다. `GET /:id/scores`는 사용자 × 변수 전체와 근거 id를 담아 응답이 커진다. 화면에는 `cards`를, 드릴다운에만 `scores`를 쓰는 편이 낫다. `quality.escalated`는 상위 모델로 재판정된 점수 칸 수, `quality.failedUsers`는 채점 호출이 실패해 전 변수가 `null`인 사용자다. `GET /:id/variables`의 `merged` 는 **채점이 거의 같게 나와(상관 0.9+) 하나로 합친** 변수들이다. 예전에는 커버리지가 낮은 쪽을 그냥 버렸다 — 남은 쪽의 좁은 정의만 남고 버린 쪽이 더 나은 표현이었어도 사라졌다. 이제 쌍이 있을 때만 1콜을 더 써서 둘을 아우르는 변수를 새로 쓴다. ```jsonc "merged": [ { "into": "세탁물 분류 정밀도", "from": ["세탁물 분류 수준", "분류 기준 개수"], "correlation": 0.93 }, // 상관이 높아도 재는 것이 다르면 합치지 않고 둘 다 남긴다 { "into": "세제 투입 기준", "from": ["세제 투입 기준", "세제 종류 수"], "correlation": 0.91, "keptSeparate": true, "reason": "하나는 양, 하나는 종류를 잰다" } ] ``` 합친 변수는 winner 의 `id` 를 유지하고 `hash` 만 다시 계산한다 — 시험코딩 예시·품질이 그 id 에 붙어 있고, `hash` 가 바뀌어야 새 정의로 다시 채점된다. LLM 호출이 실패하면 예전 동작 (loser 를 `rejected` 로)으로 되돌아간다. `GET /:id/variables`에는 코드북 외에 두 가지 진단이 함께 온다. - `rejected[]` — 후보에서 탈락한 변수와 **이유**. "왜 이 변수는 없나"가 첫 질문이므로 반드시 낸다. LLM이 스스로 버린 것과 시험 코딩 수치(`근거 연결률` · `변별력` · `중복도`)로 걸러낸 것이 함께 들어간다. - `microCodes` — 행동코드 추출 결과 `{ batches, failed, truncated, codes, list?, groups? }`. `list`는 고유 코드 + 빈도(상한 120종), `groups`는 그것을 읽기 좋게 묶은 것이다 — **묶음은 표시용이고 실제 통합은 코드북이 한다.** 묶기에 실패하면 `groups`가 없고, 그때는 평면 목록으로 읽으면 된다. 둘 다 이 필드가 생기기 전 프로젝트에는 없다. 집계 부분은 다음과 같다. `failed > 0`이면 **그 배치 표본의 행동코드가 코드북에 아예 반영되지 않았다**(그만큼 덜 본 코드북이다). `truncated`는 그중 응답이 예산에 걸려 잘린 건수로, 프록시 오류·스키마 불일치와 대처가 다르다. 정상 실행은 `failed: 0`이다. 이 필드가 없는 응답은 필드가 생기기 전에 저장된 단계다. - `trial` — 변수 발견 때 돌린 시험 코딩(`scoredUsers` · `nullRate`). `measuredAtRevision`은 그 수치가 **어느 revision의 변수 집합에서 측정됐는지**다. 사람이 `PATCH`로 코드북을 고치면 측정 대상이 달라지므로, 옛 수치를 현재 코드북의 측정치인 척 두지 않기 위해 함께 남긴다. ## 8. 편집 3종과 무엇이 낡는가 편집은 **상류를 고치면 하류를 `stale`로 표시**한다. 데이터는 지우지 않는다 — 다시 돌릴지 이대로 볼지는 호출자가 정한다. | 편집 | 무엇이 바뀌나 | stale이 되는 단계 | 응답의 `staleStages` | |---|---|---|---| | `PATCH /:id/variables` | 코드북(이름·정의·경계·양극·앵커·근거규칙 / 추가 / 삭제) | `scores` · `axes` · `clusters` · `cards` | `["scores","axes","clusters","cards"]` | | `PATCH /:id/axes` | 축 이름·양극·`primary3` | `cards`만 — 좌표를 바꾸지 않으므로 군집은 그대로 | `["cards"]` | | `PATCH /:id/clusters` | `k` | `cards`만 — 군집은 응답 안에서 **즉시** 다시 계산된다(LLM 0콜) | `["cards"]` | | `POST /:id/rows` | 발화 추가 | `scores` · `axes` · `clusters` · `cards` (코드북은 유지) | — (`nextRun`으로 알린다) | 네 경로 모두 **실제로 무언가를 바꿨을 때** `ready`였던 프로젝트를 `partial`로 내린다 — 카드가 방금 고친 코드북·축 이름·`k`와 더 이상 맞지 않으므로 `ready`는 거짓이다. 이어지는 `run`이 5단계를 다시 채우면 `ready`로 돌아온다. **아무것도 바뀌지 않은 요청은 강등하지 않는다**(아래 무동작 규칙). **아무것도 바뀌지 않은 편집은 revision을 올리지도, 하류를 낡게 만들지도, `status`를 내리지도 않는다.** 없는 id만 보낸 요청은 `ignored`와 경고로 돌아오고 `staleStages: []`다 — 오타 하나로 LLM 재실행 비용을 물리지 않는다. `PATCH /clusters`도 같다: **배정이 그대로면** 무동작이다(요청한 `k`가 아니라 계산 결과로 판정하므로, `k=5`를 요청해 `k=3`으로 줄어들었고 이미 `k=3`이면 무동작이다 — `k_clamped` 경고는 그대로 온다). **낡은 단계는 편집할 수 없다** — `409 stage_stale`("먼저 재실행하세요"). 낡은 축 위에서 이름만 고치면 재실행 때 그 편집이 사라지므로 정직한 거부다. 실행 중인 프로젝트도 `409 project_busy`. ### `PATCH /:id/variables` ```jsonc { "update": [{ "id": "v0", "name": "정보 탐색 깊이", "definition": "…", "anchor1": "…" }], "remove": ["v3"], "add": [{ "name": "가격 민감도", "definition": "가격이 결정에 미치는 비중" }], // 한 번에 최대 5개 "autoComplete": true // 기본 true } ``` - `update`·`remove`·`add` 중 하나는 있어야 한다(`400`). 변수를 전부 지울 수는 없다(`400`). - `add`는 **이름·정의만** 준다. `autoComplete: true`(기본)면 나머지 코드북 항목(경계·양극·1~4점 앵커·근거규칙)을 LLM이 채운다. 1~4점 앵커를 손으로 쓰게 하면 아무도 쓰지 않고, 앵커가 빈 변수는 판정 기준 없이 채점 프롬프트에 나가 전부 `null`이 된다. - `add` 상한은 **5개**다(초과 시 `400`, `issues[].code = "too_big"`). 자동 완성 실측이 변수당 completion 약 1,600토큰이라 5개면 검증된 예산의 절반이다. 더 필요하면 두 번 부른다. - `autoComplete: false`로 추가하면 `anchors_unfilled` 경고가 붙는다. 자동 완성이 실패하면 편집 **전체가 취소**되고 `502 autocomplete_failed` — 앵커가 빈 반쪽 코드북을 저장하지 않는다. - 새 변수 id는 서버가 매긴다(`v7` 등). 분석가가 추가한 변수는 시험 코딩을 거치지 않으므로 `quality: null`이다(0으로 채우면 멀쩡한 변수가 불량으로 보인다). 응답의 `willRescore`가 **다음 실행에서 다시 채점될 변수 id**다. ```jsonc // 이름만 고친 경우 — 실측 { "revision": 3, "updated": ["v0"], "willRescore": [], "staleStages": ["scores","axes","clusters","cards"], "warnings": [] } // 정의를 고친 경우 — 실측 { "revision": 4, "updated": ["v0"], "willRescore": ["v0"], "staleStages": ["scores","axes","clusters","cards"] } ``` 이름만 바꾸면 `willRescore`가 비지만 `scores`는 여전히 stale이다 — 변수 이름을 실어 나르는 산출물을 다시 조립해야 하기 때문이다. 이때 이어지는 실행에서 **채점 LLM 호출은 0콜**이고(이미 채점된 사용자를 그대로 잇는다), 축 명명과 카드 생성은 다시 LLM을 부른다. ### `PATCH /:id/axes` ```jsonc { "rename": [{ "id": "ax1", "name": "탐색 주도성", "poleLow": "즉흥 결정", "poleHigh": "체계적 비교" }], "primary3": ["ax1", "ax2", "ax3"] } ``` 이름·양극·`primary3`만 고친다. 좌표는 그대로이므로 군집은 유지되고 축 이름을 실어 나르는 `cards`만 낡는다. 없는 축 id는 `ignored`에 남고 `unknown_axis_ids` 경고가 붙는다. ### `PATCH /:id/clusters` ```jsonc { "k": 4 } // 1~12 정수(숫자). 벗어나면 400 ``` k 변경은 LLM이 필요 없어 응답 안에서 즉시 다시 계산해 새 `assignments`를 돌려준다. 사용자 피드백 루프가 가장 빨리 도는 지점이다. > `run`의 `params.k`는 **문자열**, 이 엔드포인트의 `k`는 **숫자**다. `params`가 `Record`이라 그렇다. - 요청한 k가 사용자 수에 비해 크면 **군집당 평균 2명 이상**이 되도록 줄이고 `k_clamped` 경고를 붙인다(실측: 사용자 6명 × `k=5` → `k=3`). - **군집 입력은 축 좌표가 아니라 행동변수 점수 전부다.** 축은 여러 변수를 2~3차원으로 묶은 것이라, 어느 축에도 못 들어간 변수가 군집에서 통째로 빠진다(실측: 변수 8개 중 3개). 좌표는 각 점수에서 척도 중앙(2.5)을 뺀 편차이므로 **부호가 곧 극**이다. - 채점되지 않은 칸(`score: null`)은 **채우지 않는다.** 거리는 둘 다 관측된 변수만으로 재고 관측 비율로 보정한다. 채점된 변수가 **하나도 없는** 사용자만 빼며, 그 목록은 `diagnostics.excludedUsers` 와 `variable_scores_missing` 경고의 `count` 에 남는다. - 다시 계산한 배정이 기존과 같으면 **무동작**이다 — `revision`이 그대로이고 `staleStages: []`이며 `status`도 내려가지 않는다. ##### `layout` — 산점도 좌표 `clusters` 응답에는 2D 배치가 함께 실린다. **군집과 같은 거리에서 나오므로 그림과 군집이 어긋나지 않는다** — 축 좌표로 그리면 축에 못 묶인 변수가 그림에서 빠져 "나란히 붙었는데 다른 군집"이 생긴다. 좌표는 `k`와 무관하므로 k 슬라이더를 움직여도 점은 제자리에 있다. ```jsonc "layout": { "method": "mds", // 거리 행렬의 고유분해(주좌표분석). 결정론적이다 "coords": [{ "userId": "U1", "x": 1.41, "y": -0.3 }], "explained": 0.78, // 2D가 원래 거리의 얼마를 설명하는가(0~1) "disconnectedPairs": 0, // 겹치는 관측이 없어 잴 수 없던 쌍의 수 "axisArrows": [ // 축을 평면에 회귀시킨 방향(단위벡터) { "axisId": "ax1", "dx": 0.92, "dy": 0.39, "r2": 0.71 } ] } ``` - **`x`/`y` 자체에는 이름이 없다.** MDS 좌표는 회전·반사 자유도가 있어 "가로축이 무엇"이라고 부를 수 없다. 대신 `axisArrows`가 "이 방향으로 갈수록 그 축이 높아진다"를 말해 준다. - `r2`가 낮은 화살표는 **그 축이 이 평면에서 방향을 갖지 못한다**는 뜻이므로 화면이 지워야 한다. 표본이 4명 미만이거나 좌표가 한 직선에 몰리면 `r2: 0`, `dx: 0`, `dy: 0`이 된다. - `explained`가 낮거나 `disconnectedPairs`가 크면 배치 자체가 약한 근거 위에 있다 — 그림을 그만큼 덜 믿어야 한다. - 오래된 프로젝트에는 `layout`이 없을 수 있다. 없으면 화면은 축 좌표로 되돌아간다. > **왜 UMAP이 아닌가.** UMAP은 국소 이웃 그래프를 확률적으로 최적화하므로 결과가 초기화에 따라 흔들리고, n이 이웃 수 규모로 작으면 보존할 국소 구조 자체가 없다. 앱 경로는 응답자 상한이 10명이고 API 경로도 수십 명 규모라 그 구간에서 UMAP이 만드는 것은 구조가 아니라 무늬다. 표본이 수백 명대가 되면 `method`를 갈라 붙인다. ## 9. 발화 추가 — `POST /:id/rows` ```jsonc // 신규 응답자 U9 2건 + 기존 응답자 U1에 1건 { "rows": [ { "userId": "U9", "utterance": "매장에서 직접 써보고 결정하는 편입니다" }, { "userId": "U9", "utterance": "실물 색이 사진과 달라서 반품한 적이 있어요" }, { "userId": "U1", "utterance": "요새는 커뮤니티 장터 후기까지 찾아 읽습니다" } ] } ``` **코드북은 그대로 두고 채점부터 다시** 돈다. 다음 실행에서 새 사용자만 채점되고, 코드북까지 갱신하려면 `run {"from":"variables"}`를 명시해야 한다. 새 사용자는 분석 대상 목록(`mappingUserIds`)에 함께 들어간다 — 생성 때 `mappingUserIds`를 주지 않아도 그 시점의 사용자 전원이 목록으로 저장되므로, 갱신하지 않으면 실행이 새 사용자를 걸러 낸다. ```jsonc // 200 — 위 요청의 실측 응답 (기존에 발화 54건·응답자 8명이 있던 프로젝트) { "added": 3, "analyzable": 3, "duplicates": 0, "newUsers": ["U9"], // 이번 배치에서 처음 등장한 사용자 "appendedTo": ["U1"], // 기존 사용자에게 덧붙인 것 — 그 사용자는 다시 채점된다 "utteranceCount": 57, "userCount": 9, "profile": { "utterancesPerUser": { "min": 2, "median": 8, "max": 10 }, "axisPath": { "recommended": "synthetic", "reason": "…", "available": ["synthetic","pca"] } }, "warnings": [ // 발화가 5건 미만인 응답자는 **프로젝트 전체 기준**으로 매번 다시 판정된다. // U9는 방금 2건이 들어왔을 뿐이고 U7·U8은 이전 배치에서 적게 들어온 응답자다. { "code": "low_evidence_users", "userIds": ["U7","U8","U9"], "message": "발화가 5건 미만 — …" }, { "code": "existing_users_appended", "userIds": ["U1"], "message": "기존 응답자에게 발화를 덧붙였습니다 — …" } ], "nextRun": { "from": "scores", "willRun": ["scores","axes","clusters","cards"] } } ``` > `utterancesPerUser.min`이 5보다 작으면 응답에는 **반드시** `low_evidence_users`가 함께 온다. 이 경로에서 경고가 비어 있는 응답을 기대하지 말 것. - `newUsers`와 `appendedTo`는 결과가 전혀 다르다 — 후자는 그 사용자의 점수가 다시 매겨진다. 어느 쪽인지 응답에서 구분할 수 있어야 하므로 둘을 나눠 돌려준다. - 플랜 상한은 **프로젝트 총량**에 걸린다(초과 시 `400 limit_exceeded`). 요청당으로만 보면 상한만큼 반복 추가해 우회할 수 있고, 채점 비용은 요청 수가 아니라 총 발화 수에 비례한다. - 호출자가 준 `utteranceId`가 이미 있으면 `400`이다(어느 쪽이 맞는지 서버가 정할 수 없다). 자동 생성 id는 빈 번호로 다시 매긴다. - 실행 중에는 `409 project_busy`. - `low_evidence_users`는 **프로젝트 전체 발화 수**로 다시 판정한다 — 발화가 이미 많은 사용자가 2건 더 넣었다고 근거 부족으로 잡히면 거짓 경고다. ## 10. 근거 역참조 — `GET /:id/evidence/:utteranceId` 카드·점수의 `evidenceIds`(그리고 `quotes[].utteranceId`)를 원문으로 되짚는다. 이 역참조가 되어야 "왜 이 문장이 이 퍼소나에 붙었나"에 답할 수 있다. ```bash curl -s https://api.affinitybubble.com/v1/personas/$ID/evidence/U1-001 \ -H "Authorization: Bearer $KEY" ``` ```jsonc { "userId": "U1", "utteranceId": "U1-001", "utterance": "새 이어폰 하나 사는데도 유튜브 리뷰를 열 개는 봤어요. 실사용 3개월 후기 같은 걸 꼭 찾아봅니다.", "topic": "구매 전 조사", "source": null, "timestamp": null, "quality": "ok", // 채점에 실린 행인지. ok가 아닌 행은 근거로 인용될 수 없다 "ambiguous": false // 같은 utteranceId를 가진 행이 둘 이상인지 } ``` 카드의 `quotes[].text`는 160자로 잘린 표시용이다. **원문 전체는 이 엔드포인트에서** 받는다. 없는 id는 `404 not_found`("Utterance not found"), 없는 프로젝트는 `404`("Project not found"), 다른 키의 프로젝트는 `403 forbidden`. **`ambiguous: true`면 그 근거는 신뢰할 수 없다.** 생성 요청이 호출자가 준 `utteranceId`의 중복을 막지 않으므로(§9.5) 같은 id를 가진 행이 둘 있을 수 있다. 그때 이 엔드포인트는 **입력 순서상 첫 행**을 돌려주고 `ambiguous: true`를 세운다 — 답이 안정적일 뿐 옳다는 보장이 없기 때문이다. 실측: 한 배치에 `utteranceId: "DUP-1"`을 두 행에 넣으면 첫 행(`userId: "UA"`)이 나오고 `ambiguous: true`, 겹치지 않는 id는 `ambiguous: false`. `true`가 보이면 데이터를 고쳐 다시 업로드해야 한다. ## 10.a 근거 일괄 역참조 — `POST /:id/evidence` 히트맵처럼 한 화면의 여러 점수에 마우스를 올려 원문을 즉시 보여줄 때 쓴다. 화면이 실제로 참조하는 id만 한 번에 요청하며, 프로젝트 전체 원문을 반환하지 않는다. 최대 240개 id를 받는다. ```bash curl -s https://api.affinitybubble.com/v1/personas/$ID/evidence \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"utteranceIds":["U1-001","U1-003"]}' ``` ```jsonc { "evidences": [/* §9.10과 같은 원문 객체 */], "missingIds": [] // 요청했지만 이 프로젝트에서 찾지 못한 id } ``` 중복 `utteranceId`의 `ambiguous` 처리와 프로젝트 소유권 검사는 단건 조회와 같다. `evidences`는 요청 id 순서로 반환하며, 없는 id는 `missingIds`에만 담긴다. ## 11. 경고 코드 경고는 실패가 아니다 — 요청은 성공하고, 결과를 어떻게 읽어야 하는지 알린다. | 코드 | 나오는 곳 | 의미 | |---|---|---| | `low_evidence_users` | 생성 · 발화 추가 | `ok` 발화 5건 미만 사용자. 다수 변수가 `null`이 된다. `userIds` | | `duplicate_rows` | 생성 · 발화 추가 | 중복으로 판정된 행 수. `count` | | `empty_rows` | 생성 · 발화 추가 | 빈 발화 행 수. `count` | | `autorun_failed` | 생성(`autorun: true`) | 발화는 저장됐지만 워크플로 시작에 실패. `POST /:id/run`으로 시작 | | `existing_users_appended` | 발화 추가 | 기존 사용자에게 덧붙였다 → 다음 실행에서 다시 채점된다. `userIds` | | `unknown_variable_ids` | `PATCH /variables` | 없는 변수 id라 무시했다. `variableIds` | | `anchors_unfilled` | `PATCH /variables` | 앵커가 빈 변수가 있다 → 그 변수는 전부 `null`로 채점된다. `variableIds` | | `unknown_axis_ids` | `PATCH /axes` | 없는 축 id라 무시했다 | | `k_clamped` | `PATCH /clusters` | 요청한 k가 커서 줄였다 | | `variable_scores_missing` | `PATCH /clusters` | 채점된 변수가 하나도 없어 군집에서 뺀 사용자가 있다. `count` | ## 12. 에러 | HTTP | type | 의미 | |---|---|---| | 400 | `invalid_request` | 본문 검증 실패(`issues`에 Zod 상세) · 분석 가능한 발화 없음 · 변수 전체 삭제 · 발화 추가 시 `utteranceId` 충돌 | | 402 | `insufficient_credits` | 크레딧 잔액 부족. `required`·`balance` 동봉 | | 400 | `limit_exceeded` | 플랜 행 상한(생성은 요청당, 발화 추가는 **프로젝트 총량**) 또는 사용자 200명 상한(양쪽 다 프로젝트 총량) 초과 | | 401 | `unauthorized` | 키 누락·형식 오류·무효·만료 | | 403 | `plan_required` | `basic` 키 — 이 계열은 `business` 전용 | | 403 | `forbidden` | 다른 키가 만든 프로젝트 | | 404 | `not_found` | 프로젝트 · 단계(아직 생성 전) · 발화 · 잡(만료 24시간) | | 409 | `stage_stale` | 낡은 단계를 편집하려 함 → 먼저 재실행 | | 409 | `upstream_missing` | `from` 위쪽 단계 결과가 없음 · 군집 편집인데 축 좌표가 없음 | | 409 | `project_busy` | 실행 중에는 편집·발화 추가 불가 | | 429 | `quota_exceeded` | 계약 `monthly_quota`가 설정된 키에서 월 한도 초과 | | 500 | `storage_failed` | 발화 저장 실패(부분 삽입은 되돌린다) | | 500 | `workflow_start_failed` | 워크플로 시작 실패 | | 502 | `autocomplete_failed` | 추가 변수의 코드북 자동 완성 실패 → 편집 전체 취소 | 실행 중 **단계가 실패**하면 HTTP가 아니라 잡의 `error`에 사유가 실린다. 논리적으로 진행할 수 없는 경우는 전용 메시지다. | 잡 `error` | 뜻 | |---|---| | `no_micro_codes: … (배치 N개 중 실패 M개, 그중 예산 초과로 잘림 K개)` | 발화에서 행동코드를 한 건도 뽑지 못했다. 배치 집계를 메시지에 함께 실어 "추출이 실패했나 / 뽑을 것이 없었나"를 구분할 수 있게 한다 | | `no_variables_generated` | 코드북 후보가 정제 후 한 개도 남지 않았다 | | `all_axes_dropped` | 축이 전부 탈락했다 | | `clustering_infeasible` | 군집을 만들 수 없다 | 이미 커밋된 상류 단계는 그대로 남으므로 파라미터를 바꿔 그 지점부터 다시 돌린다. ## 13. 한도·비용·주의 - **플랜**: 이 리소스 계열 전체가 `business` 전용이다. `basic` 키는 모든 경로에서 `403 plan_required`. 요청 1건이 LLM 을 많이 쓰므로 버블과 같은 한도로 묶지 않는다. - `rows`: 플랜별 상한(`basic` 100 / `business` 5000). 발화 추가는 프로젝트 누적 기준. - **사용자 수**: 프로젝트 하나당 **200명**. 생성·발화 추가 양쪽에서 프로젝트 총량 기준으로 막는다(`400 limit_exceeded`). 실행을 시작한 뒤가 아니라 **업로드 시점에** 막으므로, 큰 데이터를 올리기 전에 나눌 단위를 정하면 된다. 더 큰 규모는 문의. - **작업별로 한도가 분리돼 있다.** `personas.run`(월 한도 = 키의 `monthly_quota`), `personas.dna`(30), `personas.ask`(300)를 각각 센다 — 한 한도를 공유하면 대화 몇 번에 분석 예산이 사라진다. - **크레딧**: 사용량 이벤트마다 `credits` 가 기록되고 `GET /v1/usage/me` 가 잔액과 합계를 돌려준다. 잔액이 모자라면 `402 insufficient_credits` 가 `required`·`balance` 와 함께 온다. ``` 크레딧 = (기본요금 5 + ceil(초과 텍스트 / 100)) × 제품 배수 초과 텍스트 = max(0, 텍스트 수 - 기본 포함 500건) ``` | 제품 | 배수 | 최소(500건까지) | 1,000건 | 5,000건 | |---|---:|---:|---:|---:| | 어피니티버블 | ×1 | 5 | 10 | 50 | | 세분화 | ×2 | 10 | 20 | 100 | | 속성분석 | ×3 | 15 | 30 | 150 | | 퍼소나 도구 | ×5 | 25 | 50 | 250 | 퍼소나는 **응답자 1명당 1크레딧**이 하한이다 — 채점이 1인 1콜이라, 사람이 많고 발화가 짧은 입력에서는 텍스트 기준보다 사람 수가 크다. 요청당 고정: 퍼소나 DNA 5 · 카드 아이콘 1(장당) · 퍼소나 대화 1(답변 5개당). 컬럼 추천은 무료. 실패로 끝난 실행은 과금하지 않는다. - **개인정보는 업로드 전에 지우거나 가릴 것.** 발화 원문은 근거 추적의 뿌리라서 그대로 저장되고 기간 제한 없이 남는다. 지우는 방법은 `DELETE /v1/personas/:id` 하나뿐이며 프로젝트의 발화·단계·편집 이력을 모두 지운다. 실행 중에는 `409 project_busy` 로 막히니 실행이 끝난 뒤 다시 호출한다. - 표본이 작으면(사용자 100명 미만 또는 변수당 5명 미만) 축은 합성축 경로로 가고 `diagnostics.exploratoryOnly: true` 가 선다. **탐색적 결과로 읽을 것.** ## 15. 퍼소나와 대화 — `POST /:id/ask` DNA(§9.14)를 만든 뒤 그 퍼소나들에게 질문한다. **상태를 저장하지 않는다** — 이력은 호출자가 보내고 서버는 기록하지 않는다(발화 원문이 이미 저장돼 있는데 대화까지 저장하면 보존·삭제 정책이 하나 더 늘어난다). ```bash curl -X POST https://api.affinitybubble.com/v1/personas/$ID/ask \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{ "question": "세제는 얼마나 넣으세요?", "targets": [{ "clusterId": "c0" }, { "clusterId": "c1", "speakerUserId": "U7" }], "history": [{ "q": "…", "a": "…" }], "react": false }' ``` | 필드 | 설명 | |---|---| | `question` | 최대 500자 | | `targets` | 생략하면 전 퍼소나(상한까지). `speakerUserId` 생략 시 **medoid**(군집 중심에 가장 가까운 응답자) | | `history` | 최근 **8턴**만 쓴다. 넘으면 잘린다 | | `react` | `true` 면 **반응 라운드** 추가 — 콜 수와 크레딧이 2배 | ```jsonc { "rounds": 1, "answers": [{ "clusterId": "c1", "speakerUserId": "U4", "basis": "stated", // stated | inferred | unknown "answer": "기사분이 조금만 넣으라고 해서 기준은 없고 절반 정도만 넣어.", "evidenceIds": ["U4-079", "U4-096"], "evidence": [{ "utteranceId": "U4-079", "userId": "U4", "text": "…원문…" }], "support": { "members": 1, "of": 4 }, // 이 방향을 군집 몇 명이 뒷받침하나 "round": 1 }], "credits": 6, "limits": { "maxPanel": 4, "maxHistoryTurns": 8, "monthlyUsed": 1, "monthlyLimit": 300 } } ``` **설계 규칙** — 근거 없는 대화는 이 도구의 존재 이유를 무너뜨리므로 코드로 강제한다. - **`basis` 3단계.** 근거를 못 댄 `stated` 는 `unknown` 으로 **내려간다**(답을 버리지 않고 표시를 내린다 — 버리면 화면이 비어 회피형 대화가 된다). 근거는 **그 응답자 본인의 발화**만 유효하다 - **`unknown` 은 회피가 아니라 산출물이다.** ① 그 얘기를 하지 않았다는 사실 ② 인접한 근거 ③ `suggestedQuestion`(다음 인터뷰에서 물어볼 질문). 실증 연구에서 회피형 답변("wishy-washy")이 이런 도구의 대표적 실패였다 - **말투는 그 사람의 실제 발화 few-shot** 으로 재현한다(DNA `members[].sampleUtteranceIds`). 퍼소나 평균으로 말하면 세 퍼소나가 같은 문체로 다른 내용을 말하는 "골만 다른 로봇"이 된다 - **반응 라운드는 순차가 아니라 동시다.** 전원이 라운드 1 전체를 같이 보고 각자 반응한다 — 순차로 하면 첫 발언자가 프레임을 정하는 FGI 의 알려진 편향을 재현한다. 그리고 **다른 참가자의 답변은 근거가 될 수 없다**(동의하든 반대하든 자기 발화로 근거를 대야 한다) - 발화 5건 미만인 응답자는 대화 상대로 세우지 않는다(`409 no_speaker` 또는 그 사람 제외) **비용 상한** — 요청 하나의 최대 콜 수가 상수다: 퍼소나 최대 4 × 라운드 최대 2 = **8콜**. 프롬프트도 근거 발화 12개 + 말투 예시 3개 + 이력 8턴으로 묶여 있어 발화가 500건이어도 커지지 않는다. 월 한도는 `personas.ask` 로 **분리**돼 있다(기본 300회). --- ## 14. 대화용 DNA — `POST /:id/dna` · `GET /:id/dna` 퍼소나와 **대화**하기 위한 프로필. 5단계와 분리된 **6번째 옵션 단계**다. | | | |---|---| | 왜 별도 단계인가 | 대화는 질문마다 도는 요청이고 DNA 는 그 앞에 한 번 만들어 캐시하는 것이다. 매 질문에 발화 전량을 넣으면 대화가 잦아질수록 비용이 선형으로 커진다 | | 왜 `run` 에 넣지 않았나 | (1) `status='ready'` 는 5단계 기준이라 넣으면 **기존 프로젝트가 전부 `partial`** 로 강등된다 (2) 기본 `run {}` 이 묻지 않은 LLM 비용을 태운다 | | 낡음 | `cards`·`clusters` 를 다시 커밋하면 `dna` 가 낡는다. 낡은 카드 위에서는 생성을 거부한다(`409 stage_stale`) — 다른 군집 기준의 프로필로 대화하면 그게 곧 거짓이다 | | 비용 | LLM **1콜**. 월 한도는 실행과 **분리**돼 있다(기본 30회, `action: 'personas.dna'`). 초과 시 `429 quota_exceeded` | ```bash curl -X POST https://api.affinitybubble.com/v1/personas/$ID/dna -H "Authorization: Bearer $KEY" ``` ```jsonc { "revision": 1, "personas": [{ "clusterId": "c1", "attitudes": [{ "text": "특수한 오염은 사람 손을 거쳐야 완벽하다고 믿음", "evidenceIds": ["U3-055"] }], "behaviors": [{ "text": "세제량을 눈대중으로 조절함", "evidenceIds": ["U4-079","U7-148"] }], "contexts": [{ "text": "아기가 생긴 이후 주기적으로 통세척함", "evidenceIds": ["U7-146"] }], "decisionRules": [{ "when": "세탁물이 많을 때", "then": "'내 마음' 코스로 시간을 직접 조절한다", "evidenceIds": ["U3-060"] }], "unknowns": ["공유·추천 성향", "선호하는 세제 브랜드", "세탁기 구매 기준"], "members": [{ "userId": "U3", "utteranceCount": 25, "avgLength": 41, "endings": { "polite": 0, "formal": 0, "casual": 21, "other": 4 }, "register": "casual", "distinctiveTerms": ["매일","코스","손으로"], "sampleUtteranceIds": ["U3-052","U3-062","U3-070"], "isMedoid": false, "borderline": false, "eligible": true }] }], "narration": { "failed": false, "truncated": false, "missingClusters": [] }, "usage": { "textCount": 187, "llmInputTokens": 12043, "llmOutputTokens": 5210 }, "monthlyUsed": 1, "monthlyLimit": 30 } ``` **불변식** — 카드와 같은 게이트다. - `attitudes`·`behaviors`·`contexts`·`decisionRules` 의 모든 항목은 **그 군집 멤버의 발화**를 근거로 대야 한다. 못 대면 항목이 **제거**된다(다른 퍼소나의 발화를 근거로 단 것도 제거) - 카드는 입력에 없는 속성을 만들지 않는다는 규칙을 그대로 따른다 — **성격 유형은 넣지 않는다** - `unknowns` 는 두 출처를 합친다: 계산(군집 전원 `null` 이라 측정되지 않은 변수) + LLM(발화에서 다루지 않은 주제). 대화에서 "그건 이야기하지 않았다"고 답하는 근거이자 다음 인터뷰 질문의 씨앗이다 - `members[]` 는 **LLM 이 만들지 않는다** — 어미·길이·어휘는 발화에서 센 값이다. `sampleUtteranceIds` 는 대화 프롬프트에 그대로 넣을 few-shot 이고, 발화가 5건 미만이면 `eligible: false` 와 이유가 붙는다(말투도 근거도 부족한 사람을 대화 상대로 세우지 않는다) --- ## 16. 컬럼 추천 — `POST /suggest-mapping` 업로드 직후, 표본 행으로 **발화·사용자 컬럼과 분석 제목**을 제안한다. 프로젝트 생성 전에 쓰므로 `:id` 가 없고 아무것도 저장하지 않는다. 페이지의 키워드 휴리스틱을 대체하지 않고 그 위에 얹는다 — 이 호출이 실패해도 화면은 휴리스틱으로 동작해야 한다. ```json // 요청 — columns 최대 30개, rows 최대 20행 (파일 전체에서 고르게 표본) { "columns": ["참가자", "응답"], "rows": [{ "참가자": "U1", "응답": "..." }] } // 응답 { "textColumn": "응답", // 발화 컬럼. 판정 불가면 "" "userColumn": "참가자", // 사용자 구분. 1인 1행 데이터면 "" "confident": true, // false = 애매 — 화면은 reason 을 보여주고 직접 고르게 한다 "reason": "…", // 화면에 그대로 표시하는 한두 문장 "title": "세탁기 사용 인터뷰" // 내용 기반 분석 제목 (≤60자) } ``` - 모델이 존재하지 않는 컬럼을 답하면 서버가 비우고, `textColumn` 이 무효면 `confident` 를 강등한다 — 화면 select 에 없는 값이 들어가지 않는다 - 프롬프트 크기는 상수로 묶인다: 행 ≤ 20 · 셀 ≤ 200자 · **컬럼당 누적 ≤ 1,000자** (문장형 판정은 앞의 몇 셀이면 되고, 식별자의 값 반복 판정은 전 행이 필요하지만 짧다 — 컬럼당 예산이 이 배분을 저절로 만든다) - 월 한도 `personas.map` 300회(429 `quota_exceeded`), 크레딧 1/콜. 모델은 최저가 등급 --- ## 17. 카드 이미지 — `POST /:id/cards/images` 카드의 시각적 상징 아이콘을 생성해 **data URL(base64)** 로 저장한다. 파이프라인 단계가 아니다 — 낡음 전파·revision· 수정 이력에 관여하지 않고, `cards`·`clusters` 가 **최신일 때만** 실행된다(`409 stage_stale`). ``` POST /v1/personas/:id/cards/images { "action": "generate" | "regenerate" | "restore", // 생략 시 generate "cardId": "p0", // regenerate·restore 필수 "colors": { "c1": "#3b82f6", … } } // clusterId → 강조색 (선택) ``` | action | 동작 | LLM | |---|---|---| | `generate` | **이미지 없는(또는 실패한) 카드만** 생성. 전부 있으면 `{ upToDate: true }` — 반복 호출해도 비용이 늘지 않는다. 대상이 2개 이상이면 **4개씩 한 이미지의 2×2 격자**로 그린다(1콜에 최대 4카드, 스타일 자동 통일) — 카드들은 원본 하나를 공유하고 각자 `quadrant`(tl·tr·bl·br)로 자기 사분면을 가리킨다. 화면이 CSS로 그 사분면만 보여준다(서버는 자르지 않는다) | 4카드당 1콜 | | `regenerate` | 그 카드의 현재 이미지를 이전(prev)으로 밀고 **단독 512** 새로 생성(`quadrant: null`) | 1콜 | | `restore` | 직전 이미지 복구(current↔prev 스왑). 한도·과금 없음 | 0콜 | 응답: `{ generated: ["p0",…], failed: [{cardId, error}], images: { p0: {…}, … }, monthlyUsed, monthlyLimit }` - **생성 실패는 실행 전체의 실패가 아니다** — 그 카드만 `status:'failed'` + `error` 로 남고, 다음 `generate` 가 재시도한다 - `GET /:id/cards` 응답의 각 카드에 `image` 가 병합된다: `{ url, mediaType, status: 'ready'|'failed', error?, hasPrev, updatedAt, quadrant } | null`. `url` 은 `data:image/png;base64,…` 그대로 `` 에 넣는다. `null`/실패면 화면은 자체 fallback(군집색 마크)을 그린다 - 이미지는 만들 당시의 **cards revision 에 묶인다.** 재실행으로 카드가 다시 커밋되면 (카드 id 는 `p0,p1…` 로 재사용된다) 기존 이미지는 응답에서 빠지고 다음 `generate` 가 새로 만든다 — 군집 의미가 바뀌었는데 옛 그림이 남는 것을 막는다 - 호출은 프록시(미국 리전) 우선, 프록시에 이미지 라우트가 없으면 OpenRouter 직접 폴백 - 이미지당 data URL 길이 상한 1.5M자 — 초과는 `failed(image too large)` 로 표면화 - 실행 중(`running`) `409 project_busy` · 월 한도 `personas.image` 60회(429 `quota_exceeded`, restore 제외) · 크레딧 1/장 ---