# IDEA-MVP API — 연동 가이드

> 이 문서는 사람과 AI 코딩 에이전트가 그대로 따라 연동할 수 있도록 쓴 원문입니다.
> 받기: `curl -s https://idea.socialproject.net/developers.md`
> 사람용 화면: https://idea.socialproject.net/developers

아이디어 한 줄(prompt)을 보내면 트렌드 수집 → 기술 분석 → 다관점 정제 → MVP 제안서까지 만들어
돌려준다. 결과는 JSON · HTML · PDF 로 받는다. **작업형(비동기) API** 다 — 시작하면 `id` 를 받고,
상태를 조회하다가 `done` 이 되면 결과를 가져간다. 분석은 보통 **5~15분**, 길면 30분 넘게 걸린다.

- Base URL: `https://idea.socialproject.net`
- 인증: OAuth 2.0 `client_credentials` → `Authorization: Bearer <access_token>`
- 요청·응답: JSON (UTF-8). 시간: ISO 8601.

---

## 0. 자격 (사람이 준비)

서비스에 로그인 → 오른쪽 위 **⚙ 설정 › API 클라이언트** → 이름 입력 → **발급**.
`client_id`(`idc_…`)와 `client_secret`(`ids_…`)이 나온다. secret 은 **발급 때 한 번만** 보인다.

코드에는 넣지 말고 환경변수로 받는다:

```
IDEA_CLIENT_ID=idc_...
IDEA_CLIENT_SECRET=ids_...
```

scope: `read`(조회) · `write`(분석·생성 실행). 연동에는 **둘 다** 필요하다.

---

## 1. 토큰 받기

```
POST /oauth/token
Content-Type: application/json

{"grant_type": "client_credentials", "client_id": "idc_...", "client_secret": "ids_..."}
```

`application/x-www-form-urlencoded` 와 HTTP Basic(`client_id:client_secret`)도 받는다.
선택 필드 `scope`(공백 구분)로 권한을 줄일 수 있다.

응답 200:

```json
{"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "scope": "read write"}
```

- 토큰은 `expires_in`초(기본 3600) 유효. **캐시해서 쓰고** 만료 60초 전쯤 다시 받는다.
- API 가 401 `token_expired` / `invalid_token` 을 주면 토큰을 새로 받아 **한 번만** 재시도.
- 이 엔드포인트의 오류는 OAuth 표준 모양: `{"error": "invalid_client", "error_description": "..."}`
  (`invalid_request` 400 · `unsupported_grant_type` 400 · `invalid_scope` 400 · `invalid_client` 401)

---

## 2. 분석 시작

```
POST /api/v1/jobs          scope: write
Authorization: Bearer <token>
Content-Type: application/json

{"prompt": "동네 소상공인이 재고와 단골 고객을 함께 관리하는 AI 도우미"}
```

| 필드 | 필수 | 설명 |
|---|---|---|
| `prompt` | 예 | 만들고 싶은 서비스·관심 기술·풀 문제. 자유 문장, 4000자 이하. 구체적일수록 주제를 벗어나지 않는다 |
| `title` | | MVP 제목. 없으면 결과 제안서 제목으로 채워진다 |
| `domain` | | 도메인 힌트 (예: `헬스케어`) |

응답 **202**:

```json
{
  "id": "a1b2c3d4",
  "status": "queued",
  "query": "inventory management small business AI assistant",
  "progress": null,
  "error": null,
  "created_at": "2026-09-23T02:10:00",
  "links": {
    "job":    "https://idea.socialproject.net/api/v1/jobs/a1b2c3d4",
    "result": "https://idea.socialproject.net/api/v1/mvps/a1b2c3d4",
    "html":   "https://idea.socialproject.net/api/v1/mvps/a1b2c3d4/export?format=html",
    "pdf":    "https://idea.socialproject.net/api/v1/mvps/a1b2c3d4/export?format=pdf"
  }
}
```

`id` 는 작업 ID 이자 결과(MVP) ID 다. `created_at` 처럼 시간대 표기가 없는 시간은 **UTC** 다.

> ⚠️ **이 요청은 멱등하지 않다.** 같은 요청을 두 번 보내면 분석이 두 번 돌고 LLM 비용과 하루 한도도
> 두 번 쓴다. 네트워크 타임아웃으로 응답을 못 받았다면 무작정 재시도하지 말고, 먼저
> `GET /api/v1/mvps?status=generating` 으로 방금 만든 작업이 있는지 확인한다.

---

## 3. 진행 확인 (폴링)

```
GET /api/v1/jobs/{id}      scope: read
```

**15~30초 간격**으로 부른다. 더 자주 불러도 빨라지지 않는다.

| status | 뜻 | 할 일 |
|---|---|---|
| `queued` | 대기 중 | 계속 폴링 |
| `running` | 진행 중. `progress` 가 채워진다 | 계속 폴링 |
| `done` | 완료 | 4번으로 결과 받기 |
| `failed` | 실패. `error` 에 이유 | 멈춤. 필요하면 새 작업으로 다시 시작 |

`running` 일 때 `progress`:

```json
{"percent": 45, "phase": "tech_discovery", "phase_label": "혁신 패턴 발견",
 "message": "...", "estimated_remaining": "약 6분 12초 남음"}
```

- `phase` 순서: `search` → `ingest` → `tech_discovery` → `tech_linkage` → `dag` → `idea_generation`
  (`dag` 단계 중에는 `positive` · `critical` · `scoring` 이 나올 수 있다. 사람에게 보일 때는 `phase_label` 을 쓴다)
- `queued`/`running` 상태로 **3시간**이 지나면 서버가 `failed` 로 정리한다. 클라이언트 쪽 타임아웃은 60분 정도가 적당하다.

---

## 4. 결과 받기

```
GET /api/v1/mvps/{id}              scope: read
GET /api/v1/mvps/{id}?include=raw  (내부 파이프라인 원본까지 — 수백 KB)
```

끝나기 전에 부르면 **409**:

```json
{"error": {"code": "not_ready", "message": "아직 결과가 없습니다 (status=running)",
           "state": "running", "job": "https://.../api/v1/jobs/a1b2c3d4"}}
```

완료 응답 200:

```json
{
  "id": "a1b2c3d4",
  "title": "단골노트 — 소상공인 재고·고객 AI 도우미",
  "status": "done",
  "query": "...",
  "created_at": "...", "updated_at": "...",
  "proposal": {
    "title": "...", "problem": "...", "solution": "...",
    "target_market": "...", "revenue_model": "...", "competitive_advantage": "...",
    "mvp_features": ["..."], "tech_stack": ["..."], "biz_architecture": {}
  },
  "score": {"innovation": 8, "feasibility": 7, "overall_score": 7.6},
  "ideas": [{"title": "...", "problem": "...", "solution": "..."}],
  "has_mockup": false,
  "has_patent": false,
  "links": {}
}
```

- `proposal`: 최종 제안서. **필드 구성은 결과마다 조금씩 다르다** — `title` 외에는 있을 수도 없을 수도 있다고 가정하고 읽는다.
- `score`: 다관점 정제를 거친 경우에만 있다. 없으면 `null`.
- `ideas`: 도출된 아이디어 전체 (첫 번째가 보통 `proposal` 의 기반).

---

## 5. JSON · HTML · PDF 로 내보내기

```
GET /api/v1/mvps/{id}/export?format={json|html|pdf}&doc={proposal|patent|mockup}    scope: read
```

| doc | json | html | pdf |
|---|---|---|---|
| `proposal` (기본) 최종 제안서 | 4번과 같은 구조 | 인쇄용 문서 | A4 |
| `patent` 특허 명세서 초안 | `{"id","doc","html"}` | ✓ | A4 |
| `mockup` 화면 목업 | `{"id","doc","html"}` | ✓ 인터랙티브 | ✗ 400 |

- PDF: `Content-Type: application/pdf`, `Content-Disposition: attachment; filename="mvp-{id}-{doc}.pdf"` — **바이너리로 저장**한다.
- HTML: `text/html; charset=utf-8`.
- `patent`·`mockup` 은 6번으로 먼저 만들어야 한다. 없으면 404.

---

## 6. 화면 목업 · 특허 초안 만들기 (동기, 느림)

```
POST /api/v1/mvps/{id}/mockup     scope: write   — 20~60초
POST /api/v1/mvps/{id}/patent     scope: write   — 30~90초
```

- 응답이 올 때까지 기다린다. **HTTP 타임아웃을 150초 이상**으로 둔다.
- 이미 만든 게 있으면 LLM 을 다시 부르지 않고 그대로 돌려준다(`"generated": false`, 한도에 안 셈).
  새로 만들려면 본문 `{"regenerate": true}` 또는 `?regenerate=true`.
- 분석이 `done` 이 아니면 409 `not_ready`.

```json
{"id": "a1b2c3d4", "doc": "patent", "generated": true, "html": "<!doctype html>...",
 "links": {"html": "https://.../export?format=html&doc=patent"}}
```

특허 초안은 AI 초안이다. 실제 출원 전 변리사 검토·선행기술조사가 필요하다.

---

## 7. 목록 · 내 정보

```
GET /api/v1/mvps?limit=20&offset=0&status=done     scope: read
```

이 계정의 MVP (웹 화면에서 만든 것 포함), 최신순. `limit` 1~100(기본 20).
`status`: `generating`(분석 중) · `done` · `failed` · `draft`.

```json
{"items": [{"id": "...", "title": "...", "status": "done", "query": "...", "topic": "...",
            "domain": "...", "favorite": false, "has_proposal": true, "has_mockup": false,
            "has_patent": false, "created_at": "...", "updated_at": "...", "links": {}}],
 "total": 12, "has_more": false}
```

```
GET /api/v1/me      (토큰만 있으면 됨)
```

```json
{"client_id": "idc_...", "name": "...", "owner": "me@example.com", "scopes": ["read", "write"],
 "limits": {"daily_llm_jobs": 30, "used_today": 3, "max_concurrent_jobs": 2}}
```

---

## 8. 오류와 한도

`/api/v1/*` 오류는 모두 `{"error": {"code": "...", "message": "...", ...}}` 모양이다.

| HTTP | code | 재시도? | 할 일 |
|---|---|---|---|
| 400 | `invalid_request` | 아니오 | 요청을 고친다 |
| 401 | `unauthorized` · `token_expired` · `invalid_token` | 토큰 새로 받아 1회 | 계속 401 이면 클라이언트가 비활성화됐거나 secret 이 재발급된 것 |
| 403 | `insufficient_scope` | 아니오 | 클라이언트에 `write`/`read` scope 추가 |
| 404 | `not_found` | 아니오 | ID 확인 (다른 계정의 MVP 는 안 보인다) |
| 409 | `not_ready` | 폴링 계속 | 분석이 끝날 때까지 3번 |
| 429 | `too_many_jobs` | 몇 분 뒤 | 동시 실행 한도. 진행 중인 작업이 끝난 뒤 |
| 429 | `rate_limited` | 다음 날 | 하루 한도 소진. UTC 자정(한국 09:00)에 초기화 |
| 502·503 | `generation_failed` · `server_error` | 예, 간격을 두고 2~3회 | LLM 공급자 문제일 수 있다 |

클라이언트당 한도 (기본값):

- 동시 실행 분석: **2건**
- 하루 LLM 작업(분석 시작 + 목업 생성 + 특허 생성 합산): **30건** — 시작한 분석은 실패해도 센다
- 조회·목록·내보내기, 이미 만든 목업·특허 다시 받기: 한도에 안 센다

---

## 9. 예제 — Python (폴링)

```python
import os, time, requests

BASE = "https://idea.socialproject.net"
CID, SECRET = os.environ["IDEA_CLIENT_ID"], os.environ["IDEA_CLIENT_SECRET"]

class Idea:
    def __init__(self):
        self._tok, self._exp = None, 0

    def _token(self, force=False):
        if force or not self._tok or time.time() > self._exp - 60:
            r = requests.post(f"{BASE}/oauth/token", timeout=15, json={
                "grant_type": "client_credentials", "client_id": CID, "client_secret": SECRET})
            r.raise_for_status()
            body = r.json()
            self._tok, self._exp = body["access_token"], time.time() + body["expires_in"]
        return self._tok

    def call(self, method, path, timeout=30, **kw):
        for attempt in range(2):
            r = requests.request(method, BASE + path, timeout=timeout,
                                 headers={"Authorization": f"Bearer {self._token(force=attempt == 1)}"}, **kw)
            if r.status_code != 401:
                return r
        return r

api = Idea()

job = api.call("POST", "/api/v1/jobs", json={"prompt": "동네 소상공인이 재고와 단골 고객을 함께 관리하는 AI 도우미"})
job.raise_for_status()
job_id = job.json()["id"]

deadline = time.time() + 60 * 60
while True:
    j = api.call("GET", f"/api/v1/jobs/{job_id}").json()
    if j["status"] == "done":
        break
    if j["status"] == "failed":
        raise RuntimeError(j["error"])
    if time.time() > deadline:
        raise TimeoutError(job_id)
    time.sleep(20)

mvp = api.call("GET", f"/api/v1/mvps/{job_id}").json()
print(mvp["proposal"].get("title"))

pdf = api.call("GET", f"/api/v1/mvps/{job_id}/export", params={"format": "pdf"}, timeout=120)
open(f"mvp-{job_id}.pdf", "wb").write(pdf.content)
```

---

## 10. 예제 — Temporal (Python SDK)

외부 호출은 활동(activity)에서, 기다림은 워크플로우의 durable timer(`asyncio.sleep`)로 한다.
타이머는 기다리는 동안 자원을 거의 안 쓰므로 30분 폴링도 부담이 없다.

```python
import asyncio
from datetime import timedelta
from temporalio import activity, workflow
from temporalio.common import RetryPolicy
from temporalio.exceptions import ApplicationError

with workflow.unsafe.imports_passed_through():
    from idea_client import api          # 9번의 Idea 클래스 인스턴스

@activity.defn
async def start_job(prompt: str) -> str:
    r = await asyncio.to_thread(api.call, "POST", "/api/v1/jobs", json={"prompt": prompt})
    if r.status_code == 429:
        raise ApplicationError(r.json()["error"]["message"])            # 재시도 (몇 분 뒤)
    if r.status_code >= 400:
        raise ApplicationError(r.text, non_retryable=True)
    return r.json()["id"]

@activity.defn
async def get_job(job_id: str) -> dict:
    r = await asyncio.to_thread(api.call, "GET", f"/api/v1/jobs/{job_id}")
    r.raise_for_status()
    return r.json()

@activity.defn
async def get_result(job_id: str) -> dict:
    r = await asyncio.to_thread(api.call, "GET", f"/api/v1/mvps/{job_id}")
    r.raise_for_status()
    return r.json()

@workflow.defn
class IdeaMvpWorkflow:
    @workflow.run
    async def run(self, prompt: str) -> dict:
        # 시작은 멱등하지 않다 — 타임아웃 재시도로 분석이 두 번 돌지 않게 짧게, 적게
        job_id = await workflow.execute_activity(
            start_job, prompt, start_to_close_timeout=timedelta(seconds=60),
            retry_policy=RetryPolicy(maximum_attempts=3, initial_interval=timedelta(minutes=2)))

        poll = dict(start_to_close_timeout=timedelta(seconds=30),
                    retry_policy=RetryPolicy(maximum_attempts=5))
        deadline = workflow.now() + timedelta(minutes=60)
        while True:
            job = await workflow.execute_activity(get_job, job_id, **poll)
            if job["status"] == "done":
                break
            if job["status"] == "failed":
                raise ApplicationError(job["error"] or "failed", non_retryable=True)
            if workflow.now() > deadline:
                raise ApplicationError(f"timeout: {job_id}", non_retryable=True)
            await asyncio.sleep(20)      # durable timer

        return await workflow.execute_activity(get_result, job_id, **poll)
```

---

## 요약 (에이전트용 체크리스트)

1. 토큰: `POST /oauth/token` (JSON, client_credentials) → 캐시, 401 이면 새로 받아 1회 재시도
2. 시작: `POST /api/v1/jobs {"prompt"}` → 202 `id` — **멱등하지 않음, 함부로 재시도 금지**
3. 폴링: `GET /api/v1/jobs/{id}` 15~30초 간격, `done`/`failed` 까지, 60분 타임아웃
4. 결과: `GET /api/v1/mvps/{id}` → `proposal`(필드 가변), `ideas`, `score`(nullable)
5. 파일: `GET /api/v1/mvps/{id}/export?format=pdf|html|json&doc=proposal|patent|mockup`
6. 선택: `POST /api/v1/mvps/{id}/mockup|patent` (동기 20~90초, 타임아웃 150초+)
7. 오류: `{"error":{"code","message"}}` — 400/403/404 재시도 금지, 409 폴링 계속, 429·502·503 간격 두고 재시도
