# @things-factory/integration-label-studio

Things-Factory와 Label Studio Custom을 완전 통합하는 엔터프라이즈급 모듈입니다.

[![Label Studio Version](https://img.shields.io/badge/Label%20Studio-1.20.0--sso.32-blue)](https://github.com/aidoop/label-studio-custom)
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Faidoop%2Flabel--studio--custom-blue)](https://github.com/aidoop/label-studio-custom/pkgs/container/label-studio-custom)

## 지원 버전

- **Label Studio Custom**: `ghcr.io/aidoop/label-studio-custom:1.20.0-sso.38`
- **label-studio-sso**: `v6.0.8` (내장)
- **Things-Factory**: `9.1.0+`

## 주요 기능

### ✅ 이미 구현된 기능

- **iframe 통합** - Label Studio를 Things-Factory 화면에 임베드
- **SSO 인증** - JWT 토큰 기반 자동 로그인 (label-studio-sso v6.0.8 내장)
- **사용자 동기화** - Things-Factory ↔ Label Studio 사용자 배치 동기화
- **권한 매핑** - Things-Factory 권한 → Label Studio 역할 자동 매핑
- **자동 Organization 할당** - SSO 로그인 시 자동으로 Organization 멤버 추가

### 🆕 새로 추가된 기능

- **프로젝트 관리** - 프로젝트 생성, 조회, 수정, 삭제 (CRUD)
- **태스크 관리** - 태스크 임포트, 익스포트, 어노테이션 조회
- **웹훅 연동** - Label Studio 이벤트 실시간 수신
- **ML Backend 통합** - 자동 예측 및 모델 학습 지원
- **통계 대시보드** - 프로젝트 진행률, 사용자 생산성 통계
- **UI 컴포넌트** - 프로젝트 목록, 생성 폼, 통계 위젯

## 아키텍처

### 전체 구조

```
┌─────────────────────────────────────────────────────────┐
│                   Things-Factory                        │
│                                                          │
│  ┌─────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │   GraphQL   │  │   Webhook    │  │ UI Components │  │
│  │   API       │  │   Handler    │  │               │  │
│  └──────┬──────┘  └──────┬───────┘  └───────┬───────┘  │
│         │                │                  │           │
│         └────────────────┴──────────────────┘           │
│                          │                              │
│                  ┌───────▼────────┐                     │
│                  │  API Client    │                     │
│                  └───────┬────────┘                     │
└──────────────────────────┼──────────────────────────────┘
                           │
                  ┌────────▼─────────┐
                  │  Label Studio    │
                  │  REST API        │
                  └──────────────────┘
```

### 유연한 확장 아키텍처

이 모듈은 **플러그인 기반의 유연한 아키텍처**를 제공합니다:

```
┌─────────────────────────────────────────────────────────┐
│              Application Layer                          │
│                                                          │
│  • registerWebhookHandler()    - 웹훅 핸들러 등록       │
│  • registerExportFormat()      - 익스포트 포맷 등록     │
│  • LabelConfigBuilder          - 프로젝트 설정 생성     │
│  • TaskTransformer             - 데이터 변환            │
└─────────────────────────────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────┐
│         integration-label-studio (Base Layer)           │
│                                                          │
│  • LabelConfigBuilder      - 선언적 설정 생성           │
│  • TaskTransformer         - 유연한 데이터 변환         │
│  • AnnotationExporter      - 플러그인형 익스포트        │
│  • Webhook Extension       - 확장 가능한 웹훅           │
│  • GraphQL API             - 유연한 입력 지원           │
└─────────────────────────────────────────────────────────┘
```

## 빠른 시작

### 1. Label Studio Custom Docker 실행

```bash
# Docker Compose로 Label Studio Custom 실행
cd /path/to/label-studio-custom

# .env 파일 설정
cat > .env << 'EOF'
POSTGRES_DB=labelstudio
POSTGRES_USER=postgres
POSTGRES_PASSWORD=dev_postgres_2024

# Label Studio 접속 URL
LABEL_STUDIO_HOST=http://label.hatiolab.localhost:8080

# 서브도메인 간 쿠키 공유 (SSO 필수)
COOKIE_DOMAIN=.hatiolab.localhost
SESSION_COOKIE_DOMAIN=.hatiolab.localhost
CSRF_COOKIE_DOMAIN=.hatiolab.localhost

# iframe 임베딩 보안 헤더
CSP_FRAME_ANCESTORS='self' http://localhost:3000 http://hatiolab.localhost:3000

# API 토큰 (초기 사용자 생성 후 설정)
LABEL_STUDIO_API_TOKEN=your-api-token-here
EOF

# Docker Compose 실행
docker compose up -d

# 로그 확인
docker compose logs -f labelstudio

# 초기 Admin 사용자 생성 (최초 1회)
docker compose exec labelstudio python manage.py createsuperuser

# API 토큰 발급
# → http://label.hatiolab.localhost:8080 로그인
# → Account Settings → Access Token → Create new token
# → 생성된 토큰을 .env 파일의 LABEL_STUDIO_API_TOKEN에 설정
```

### 2. Things-Factory 설정

**packages/integration-label-studio/config/label-studio.config.js:**

```javascript
module.exports = {
  labelStudio: {
    serverUrl: process.env.LABEL_STUDIO_URL || 'http://label.hatiolab.localhost:8080',
    apiToken: process.env.LABEL_STUDIO_API_TOKEN || '',
    cookieDomain: process.env.LABEL_STUDIO_COOKIE_DOMAIN || '.hatiolab.localhost',
    interfaces: 'panel,controls,annotations:menu'
  }
}
```

**Things-Factory .env (Development):**

```bash
LABEL_STUDIO_URL=http://label.hatiolab.localhost:8080
LABEL_STUDIO_API_TOKEN=your-api-token-here
LABEL_STUDIO_COOKIE_DOMAIN=.hatiolab.localhost
```

**Things-Factory .env (Production):**

```bash
LABEL_STUDIO_URL=https://label.example.com
LABEL_STUDIO_API_TOKEN=your-api-token-here
LABEL_STUDIO_COOKIE_DOMAIN=.example.com
```

### 3. Things-Factory 빌드 및 실행

```bash
cd /path/to/things-factory

# integration-label-studio 모듈 빌드
cd packages/integration-label-studio
yarn build

# 루트로 돌아가서 실행
cd ../..
yarn workspace @things-factory/operato-mms run serve:dev
```

### 4. 접속

- **Things Factory**: http://hatiolab.localhost:3000 → 메뉴 → Label Studio
- **Label Studio**: http://label.hatiolab.localhost:8080 (직접 접속)
- **GraphQL Playground**: http://hatiolab.localhost:3000/graphql

**참고**: `*.localhost` 도메인은 브라우저가 자동으로 `127.0.0.1`로 해석하므로 `/etc/hosts` 수정이 필요 없습니다.

## 유연한 확장 기능

### 1. LabelConfigBuilder - 선언적 프로젝트 설정

XML을 직접 작성하지 않고, **선언적 사양**으로 Label Studio 설정을 생성:

```typescript
import { LabelConfigBuilder, LabelConfigTemplates } from '@things-factory/integration-label-studio'

// 방법 1: 템플릿 사용
const config = LabelConfigTemplates.imageRankN(3, ['Normal', 'Defect A', 'Defect B', 'Defect C'])
const xml = LabelConfigBuilder.build(config)

// 방법 2: 커스텀 설정
const customConfig = LabelConfigBuilder.build({
  dataType: 'image',
  controls: [
    {
      type: 'choices',
      name: 'rank1',
      toName: 'data',
      config: { choices: ['Normal', 'Defect'], choice: 'single', required: true }
    },
    {
      type: 'rating',
      name: 'confidence',
      toName: 'data',
      config: { maxRating: 5, icon: 'star' }
    }
  ]
})
```

### 2. TaskTransformer - 유연한 데이터 변환

임의의 소스 데이터를 Label Studio 태스크로 변환:

```typescript
import { TaskTransformer, TaskTransformTemplates } from '@things-factory/integration-label-studio'

// WBM 디바이스 데이터 → Label Studio 태스크
const sourceData = [
  {
    image_url: 'http://device/image1.jpg',
    device_id: 'WBM-001',
    wafer_id: 'W123',
    timestamp: '2025-01-01T10:00:00Z',
    ai_prediction: { rank1: 'Normal', rank2: 'Defect A', rank3: 'Defect B' },
    confidence: 0.95
  }
]

// 변환 규칙
const rule = TaskTransformTemplates.wbmImageClassification(true)

// 변환 실행
const lsTasks = TaskTransformer.transform(sourceData, rule)
// 결과: Label Studio 태스크 (data, predictions, meta)
```

**커스텀 변환 규칙:**

```typescript
const customRule = {
  dataFields: {
    image: 'images[0].url', // 중첩 배열 지원
    date: 'metadata.captured_at', // 중첩 객체 지원
    device: 'device.id'
  },
  predictions: {
    enabled: true,
    resultPath: 'ai.classification',
    scorePath: 'ai.confidence',
    modelVersion: 'v1.0.0',
    resultTransform: result => {
      // AI 결과를 Label Studio 포맷으로 변환
      return [{ from_name: 'label', to_name: 'data', type: 'choices', value: { choices: [result] } }]
    }
  },
  meta: {
    lot: 'production.lot_id',
    line: 'production.line_number'
  }
}
```

### 3. Webhook Handler 확장

웹훅 이벤트에 커스텀 로직 추가:

```typescript
import { registerWebhookHandler, WebhookAction } from '@things-factory/integration-label-studio'

// 어노테이션 완료 시 자동 처리
registerWebhookHandler(WebhookAction.ANNOTATION_CREATED, async (payload, ctx) => {
  console.log('New annotation:', payload.annotation.id)

  // 1. Things-Factory DB에 저장
  await saveAnnotationToDatabase(payload.annotation)

  // 2. 품질 시스템에 전송
  await sendToQualitySystem(payload.annotation)

  // 3. 임계값 초과 시 알림
  if (payload.annotation.result.includes('Defect')) {
    await sendAlertToManager(payload.project.id)
  }
})

// 여러 핸들러 등록 가능 (순차 실행)
registerWebhookHandler(WebhookAction.ANNOTATION_CREATED, async (payload, ctx) => {
  // 통계 업데이트
  await updateProjectStats(payload.project.id)
})
```

### 4. AnnotationExporter - 플러그인형 익스포트

커스텀 익스포트 포맷 추가:

```typescript
import { registerExportFormat, exportAnnotations } from '@things-factory/integration-label-studio'

// 커스텀 포맷 등록
registerExportFormat('wbm-report', (annotations, context) => {
  const report = {
    project: context.projectTitle,
    exported_at: context.exportedAt,
    summary: {
      total: annotations.length,
      defect_count: annotations.filter(a => a.result.includes('Defect')).length
    },
    details: annotations.map(a => ({
      task_id: a.task,
      wafer_id: a.meta?.wafer_id,
      classification: a.result[0].value.choices[0],
      annotator: a.completed_by.email,
      time: a.lead_time
    }))
  }

  return JSON.stringify(report, null, 2)
})

// 사용
const data = await exportAnnotations(annotations, 'wbm-report', { projectId: 1, exportedAt: new Date().toISOString() })
```

**기본 제공 포맷:**

- `json` - Label Studio 기본 JSON
- `jsonl` - JSON Lines (한 줄에 하나씩)
- `csv` - 이미지 분류 CSV
- `rank-csv` - Rank N 분류 CSV
- `coco` - COCO object detection
- `yolo` - YOLO object detection
- `ner-json` - Named Entity Recognition

## GraphQL API

### 기본 프로젝트 관리

```graphql
# 프로젝트 목록 조회
query {
  labelStudioProjects {
    id
    title
    description
    taskCount
    completedTaskCount
    completionRate
  }
}

# 프로젝트 생성 (XML 직접 작성)
mutation {
  createLabelStudioProject(
    input: {
      title: "Image Classification"
      description: "Classify product images"
      labelConfig: """
      <View>
        <Image name="image" value="$image"/>
        <Choices name="category" toName="image">
          <Choice value="Electronics"/>
          <Choice value="Clothing"/>
          <Choice value="Food"/>
        </Choices>
      </View>
      """
    }
  ) {
    id
    title
  }
}

# 🆕 프로젝트 생성 (선언적 사양 사용)
mutation {
  createLabelStudioProjectWithSpec(
    input: {
      title: "WBM Rank 3 Classification"
      description: "Wafer defect classification"
      labelConfigSpec: {
        dataType: "image"
        controls: """
        [
          {
            "type": "choices",
            "name": "rank1",
            "toName": "data",
            "config": {
              "choices": ["Normal", "Defect A", "Defect B", "Defect C"],
              "choice": "single",
              "required": true
            }
          },
          {
            "type": "choices",
            "name": "rank2",
            "toName": "data",
            "config": {
              "choices": ["Normal", "Defect A", "Defect B", "Defect C"],
              "choice": "single"
            }
          },
          {
            "type": "choices",
            "name": "rank3",
            "toName": "data",
            "config": {
              "choices": ["Normal", "Defect A", "Defect B", "Defect C"],
              "choice": "single"
            }
          }
        ]
        """
      }
    }
  ) {
    id
    labelConfig
  }
}

# 프로젝트 통계
query {
  labelStudioProjectMetrics(projectId: 1) {
    totalTasks
    completedTasks
    completionRate
    avgTimePerTask
    annotatorStats {
      email
      annotationCount
      avgTime
    }
  }
}

# 프로젝트 삭제
mutation {
  deleteLabelStudioProject(projectId: 1)
}
```

### 태스크 관리

```graphql
# 태스크 조회
query {
  labelStudioTasks(projectId: 1, page: 1, pageSize: 100) {
    id
    data
    annotationCount
    isCompleted
  }
}

# 태스크 임포트 (기본)
mutation {
  importTasksToLabelStudio(
    projectId: 1
    tasks: [
      { data: "{\"image\": \"https://example.com/image1.jpg\"}" }
      { data: "{\"image\": \"https://example.com/image2.jpg\"}" }
    ]
  ) {
    imported
    failed
    taskIds
    errors
  }
}

# 🆕 태스크 임포트 (데이터 변환 사용)
mutation {
  importTasksWithTransform(
    projectId: 1
    input: {
      sourceData: """
      [
        {
          "image_url": "http://device/img1.jpg",
          "device_id": "WBM-001",
          "wafer_id": "W123",
          "lot_id": "LOT-001",
          "timestamp": "2025-01-01T10:00:00Z",
          "ai_prediction": {
            "rank1": "Normal",
            "rank2": "Defect A",
            "rank3": "Defect B"
          },
          "confidence": 0.95
        }
      ]
      """
      transformRule: {
        dataFields: """
        {
          "image": "image_url",
          "date": "timestamp",
          "device_id": "device_id"
        }
        """
        predictions: """
        {
          "enabled": true,
          "resultPath": "ai_prediction",
          "scorePath": "confidence",
          "modelVersion": "v1.0"
        }
        """
        meta: """
        {
          "wafer_id": "wafer_id",
          "lot_id": "lot_id"
        }
        """
      }
    }
  ) {
    imported
    taskIds
  }
}

# 어노테이션 조회
query {
  labelStudioTaskAnnotations(taskId: 1) {
    id
    result
    completedBy
    leadTime
    createdAt
  }
}

# 어노테이션 익스포트 (기본)
mutation {
  exportLabelStudioAnnotations(
    projectId: 1
    format: "JSON" # JSON, CSV, COCO, YOLO 등
  ) {
    exportPath
    annotationCount
    format
  }
}

# 🆕 어노테이션 익스포트 (커스텀 포맷)
mutation {
  exportAnnotationsWithFormat(
    projectId: 1
    input: {
      format: "rank-csv" # json, jsonl, csv, rank-csv, coco, yolo, ner-json, 또는 커스텀
      taskIds: "[1, 2, 3]" # 선택적 필터
    }
  ) {
    data
    count
    format
  }
}

# Things-Factory DB에 동기화
mutation {
  syncAnnotationsToDatabase(projectId: 1)
}
```

### 웹훅 관리

```graphql
# 웹훅 등록 (실시간 이벤트 수신)
mutation {
  registerLabelStudioWebhook(projectId: 1) {
    id
    url
    isActive
  }
}

# 웹훅 조회
query {
  labelStudioWebhooks(projectId: 1) {
    id
    url
    isActive
  }
}

# 웹훅 삭제
mutation {
  deleteLabelStudioWebhook(webhookId: 1)
}
```

### ML Backend 연동

```graphql
# ML Backend 조회
query {
  labelStudioMLBackends(projectId: 1) {
    id
    url
    title
    isInteractive
    modelVersion
  }
}

# ML Backend 추가
mutation {
  addMLBackendToProject(
    projectId: 1
    input: { url: "http://ml-backend:9090", title: "Auto-labeling Model", isInteractive: true }
  ) {
    id
    url
  }
}

# 예측 실행
mutation {
  triggerLabelStudioPredictions(
    projectId: 1
    taskIds: [1, 2, 3] # null이면 전체
  )
}

# 모델 학습
mutation {
  trainLabelStudioModel(mlBackendId: 1)
}
```

### 사용자 동기화

```graphql
# 내 계정 동기화
mutation {
  syncMyUserToLabelStudio {
    success
    email
    action
    lsUserId
    lsPermissions
  }
}

# 전체 사용자 동기화 (관리자만)
mutation {
  syncAllUsersToLabelStudio {
    total
    created
    updated
    deactivated
    skipped
    errors
    results {
      email
      action
      lsPermissions
    }
  }
}
```

## UI 컴포넌트

### 프로젝트 목록

```
메뉴 → Label Studio → Projects
```

- 프로젝트 카드 그리드 뷰
- 완료율 프로그레스 바
- 통계 표시 (태스크 수, 완료 수)
- 프로젝트 클릭 → Label Studio 오픈

### 프로젝트 생성

```
메뉴 → Label Studio → Projects → New Project
```

- 템플릿 지원 (Text Classification, Image Classification, NER)
- Label Config XML 편집기
- 실시간 검증

### Label Studio Viewer

```
프로젝트 카드 클릭 → iframe으로 Label Studio 오픈
```

- SSO 자동 로그인
- 최소화 UI (panel, controls, annotations:menu)

## 웹훅 이벤트

Things-Factory가 수신하는 Label Studio 이벤트:

| 이벤트               | 설명              | 핸들러                      |
| -------------------- | ----------------- | --------------------------- |
| `ANNOTATION_CREATED` | 어노테이션 생성   | `handleAnnotationCreated()` |
| `ANNOTATION_UPDATED` | 어노테이션 수정   | `handleAnnotationUpdated()` |
| `ANNOTATION_DELETED` | 어노테이션 삭제   | `handleAnnotationDeleted()` |
| `TASK_CREATED`       | 태스크 생성       | `handleTaskCreated()`       |
| `PROJECT_UPDATED`    | 프로젝트 업데이트 | `handleProjectUpdated()`    |

**웹훅 엔드포인트:**

```
POST https://your-things-factory.com/label-studio/webhook
```

**커스터마이징:**
`server/route/webhook.ts`의 핸들러 함수를 수정하여 비즈니스 로직 구현

## 권한 매핑

| Things-Factory         | Label Studio               |
| ---------------------- | -------------------------- |
| Owner + label-studio   | Admin (is_superuser=true)  |
| label-studio privilege | Staff (is_superuser=false) |
| No label-studio        | Inactive (is_active=false) |

## 사용 예제

### 1. 이미지 분류 프로젝트

```graphql
# 1. 프로젝트 생성
mutation {
  createLabelStudioProject(
    input: {
      title: "Product Image Classification"
      labelConfig: """
      <View>
        <Image name="image" value="$image"/>
        <Choices name="category" toName="image" choice="single">
          <Choice value="Electronics"/>
          <Choice value="Clothing"/>
          <Choice value="Food"/>
        </Choices>
      </View>
      """
    }
  ) {
    id
  }
}

# 2. 이미지 임포트
mutation {
  importTasksToLabelStudio(projectId: 1, tasks: [{ data: "{\"image\": \"https://cdn.example.com/product1.jpg\"}" }]) {
    imported
  }
}

# 3. 웹훅 등록
mutation {
  registerLabelStudioWebhook(projectId: 1) {
    id
  }
}

# 4. 완료 후 익스포트
mutation {
  exportLabelStudioAnnotations(projectId: 1, format: "JSON") {
    exportPath
  }
}
```

### 2. 텍스트 감성 분석

```graphql
mutation {
  createLabelStudioProject(
    input: {
      title: "Customer Review Sentiment"
      labelConfig: """
      <View>
        <Text name="text" value="$text"/>
        <Choices name="sentiment" toName="text" choice="single">
          <Choice value="Positive"/>
          <Choice value="Negative"/>
          <Choice value="Neutral"/>
        </Choices>
      </View>
      """
    }
  ) {
    id
  }
}
```

### 3. Named Entity Recognition

```graphql
mutation {
  createLabelStudioProject(
    input: {
      title: "Document NER"
      labelConfig: """
      <View>
        <Text name="text" value="$text"/>
        <Labels name="label" toName="text">
          <Label value="Person" background="red"/>
          <Label value="Organization" background="blue"/>
          <Label value="Location" background="green"/>
        </Labels>
      </View>
      """
    }
  ) {
    id
  }
}
```

## 파일 구조

```
integration-label-studio/
├── server/
│   ├── types/
│   │   ├── label-studio-types.ts      # GraphQL 타입 정의
│   │   └── global.d.ts                # 전역 타입
│   ├── utils/
│   │   ├── label-studio-api-client.ts # REST API 클라이언트
│   │   ├── label-config-builder.ts    # 🆕 선언적 설정 생성
│   │   ├── task-transformer.ts        # 🆕 유연한 데이터 변환
│   │   └── annotation-exporter.ts     # 🆕 플러그인형 익스포트
│   ├── service/
│   │   ├── project/
│   │   │   └── project-management.ts  # 프로젝트 CRUD + 유연한 생성
│   │   ├── task/
│   │   │   └── task-management.ts     # 태스크 관리 + 변환 임포트
│   │   ├── webhook/
│   │   │   └── webhook-management.ts  # 웹훅 관리
│   │   ├── ml/
│   │   │   └── ml-backend-service.ts  # ML Backend
│   │   └── user-provisioning/
│   │       └── user-sync-mutation.ts  # 사용자 동기화
│   ├── route/
│   │   └── webhook.ts                 # 웹훅 라우터 + 확장 포인트
│   ├── route.ts                       # 메뉴 등록
│   └── index.ts                       # 모든 유틸리티 export
├── client/
│   ├── label-studio-wrapper.ts        # iframe 래퍼
│   ├── label-studio-project-list.ts   # 프로젝트 목록
│   ├── label-studio-project-create.ts # 프로젝트 생성
│   └── index.ts
├── README.md
├── SETUP_GUIDE.md
├── IMPLEMENTATION_GUIDE.md
└── package.json
```

## 애플리케이션 레벨 커스터마이징 예제

### 1. server/index.ts에서 초기화

```typescript
import {
  registerWebhookHandler,
  registerExportFormat,
  WebhookAction,
  LabelConfigTemplates,
  TaskTransformTemplates
} from '@things-factory/integration-label-studio'

// 웹훅 핸들러 등록
registerWebhookHandler(WebhookAction.ANNOTATION_CREATED, async (payload, ctx) => {
  // WBM 품질 시스템에 어노테이션 전송
  await sendToWBMQualitySystem(payload.annotation)
})

// 커스텀 익스포트 포맷 등록
registerExportFormat('wbm-report', (annotations, context) => {
  // WBM 전용 리포트 포맷
  return generateWBMReport(annotations)
})
```

### 2. GraphQL Resolver에서 사용

```typescript
import { LabelConfigBuilder, TaskTransformer } from '@things-factory/integration-label-studio'

@Mutation(returns => LabelStudioProject)
async createWBMProject(@Ctx() context: ResolverContext) {
  // 템플릿으로 설정 생성
  const config = LabelConfigTemplates.imageRankN(3, getWBMDefectTypes())
  const xml = LabelConfigBuilder.build(config)

  // Label Studio 프로젝트 생성
  const project = await labelStudioApi.createProject({
    title: 'WBM Defect Classification',
    label_config: xml
  })

  return project
}

@Mutation(returns => TaskImportResult)
async importWBMImages(@Arg('deviceData') deviceData: string) {
  const sourceData = JSON.parse(deviceData)

  // WBM 디바이스 데이터를 Label Studio 태스크로 변환
  const rule = TaskTransformTemplates.wbmImageClassification(true)
  const lsTasks = TaskTransformer.transform(sourceData, rule)

  // Label Studio에 임포트
  return await labelStudioApi.importTasks(projectId, lsTasks)
}
```

### 3. 클라이언트에서 사용

```typescript
// 프로젝트 생성 (선언적 사양 사용)
const result = await client.mutate({
  mutation: gql`
    mutation CreateWBMProject {
      createLabelStudioProjectWithSpec(
        input: { title: "WBM Line 1", labelConfigSpec: { dataType: "image", controls: "[...]" } }
      ) {
        id
      }
    }
  `
})

// 디바이스 데이터 임포트 (변환 사용)
const importResult = await client.mutate({
  mutation: gql`
    mutation ImportWBMData($input: ImportTasksWithTransformInput!) {
      importTasksWithTransform(projectId: 1, input: $input) {
        imported
        taskIds
      }
    }
  `,
  variables: {
    input: {
      sourceData: JSON.stringify(wbmDeviceData),
      transformRule: wbmTransformRule
    }
  }
})
```

## 트러블슈팅

### 웹훅이 수신되지 않음

**원인:** Label Studio가 Things-Factory에 접근 불가

**해결:**

```bash
# Label Studio에서 Things-Factory URL 접근 가능한지 확인
curl -X POST https://your-things-factory.com/label-studio/webhook \
  -H "Content-Type: application/json" \
  -d '{"action":"TEST"}'
```

### 프로젝트 생성 실패

**원인:** Label Studio API 토큰 없음

**해결:**

```bash
# Label Studio에서 API 토큰 발급
# Settings → Account → Access Token 복사
export LABEL_STUDIO_API_TOKEN=your-token
```

### CORS 에러

**원인:** Label Studio CORS 설정 누락

**해결:**
Label Studio settings에 추가:

```python
CORS_ALLOWED_ORIGINS = [
    'https://your-things-factory.com',
]
```

## 추가 문서

- [설치 및 설정 가이드](./SETUP_GUIDE.md)
- [구현 가이드](./IMPLEMENTATION_GUIDE.md)
- [UI 커스터마이징](./UI_CUSTOMIZATION.md)
- [사용자 동기화](./USER_SYNC_GUIDE.md)

## 라이선스

MIT

## 기여

버그 리포트 및 기능 제안은 GitHub Issues로 부탁드립니다.
