# 소개 > EduTAP 개발자 문서 안내 Source: https://developers.edutap.ai/docs EduTAP은 AI Tutor와 EduAlign 두 제품을 제공해요. 연동하려는 제품의 가이드에서 시작하세요. ## AI Tutor 강의 영상 옆에서 학습자의 질문에 답하는 AI 튜터예요. SDK로 화면에 붙이고 강의 데이터를 등록하면 사용할 수 있어요. - [Chatbot SDK 연동](/docs/sdk): TapKit를 프로젝트에 통합하는 방법 - [강의 데이터 등록](/docs/integration): 강의 데이터를 등록해 AI 튜터를 사용하는 방법 ## EduAlign EduTAP에 쌓인 학습 데이터를 외부 시스템과 연동하는 API예요. - [EduAlign API 연동 가이드](/docs/opsapi): EduTAP 데이터를 외부 시스템과 연동하는 EduAlign API 사용 가이드 ## 자주 묻는 질문 **Q. AI Tutor를 붙이려면 무엇부터 해야 하나요?** 두 가지가 필요해요. [Chatbot SDK 연동](/docs/sdk)으로 화면에 튜터를 띄우고, [강의 데이터 등록](/docs/integration)으로 튜터가 답할 강의 데이터를 올려요. 순서는 상관없지만 둘 다 끝나야 튜터가 답변해요. **Q. SDK만 연동하면 튜터가 동작하나요?** 채팅 UI는 표시되지만, 강의 데이터를 등록하고 전처리가 끝나야 강의 내용에 기반한 답변을 받을 수 있어요. **Q. API 키는 어디서 받나요?** 콕스웨이브 담당자에게 발급받아요. 강의 데이터, 시험 첨삭, 사용자 관리 API는 모든 요청에 `TAP-API-KEY` 헤더로 넣고, EduAlign API는 로그인으로 받은 Bearer 토큰을 써요. --- # EduAlign API 연동 가이드 > EduTAP 데이터를 외부 시스템과 연동하는 EduAlign API 사용 가이드 Source: https://developers.edutap.ai/docs/opsapi ## 시작하기 **EduAlign API**(Ops API)는 파트너사가 자사 시스템에서 EduTAP 데이터를 조회하기 위한 백엔드용 REST API예요. 학습자 세션, 메시지, 사용량 집계를 자사 BI나 데이터 레이크에 적재할 때 사용해요. 1. [로그인](/docs/api/ops/signin): client member 계정으로 access token과 refresh token 발급 2. [데이터 조회](/docs/api/ops/common): Authorization 헤더에 access token을 넣어 조회 API 호출 3. [토큰 갱신](/docs/api/ops/refresh): 401 응답을 받으면 refresh token으로 새 토큰 쌍 발급 ## 제공 API | 분류 | API | 내용 | | --- | --- | --- | | Auth | `POST /ops/v1/auth/signin` | 로그인, 토큰 발급 | | Auth | `POST /ops/v1/auth/refresh` | 토큰 갱신 (rotation) | | Session | `GET /ops/v1/sessions` | 세션 목록 (페이지네이션) | | Session | `GET /ops/v1/sessions/{session_id}/messages` | 세션 메시지 (페이지네이션) | | Session | `GET /ops/v1/sessions/dumps` | 기간 내 세션 일괄 덤프 | | Session | `GET /ops/v1/sessions/dumps/since-last` | 직전 호출 이후 세션 증분 덤프 | | Session | `GET /ops/v1/sessions/messages/dumps` | 기간 내 메시지 일괄 덤프 | | Session | `GET /ops/v1/sessions/messages/dumps/since-last` | 직전 호출 이후 메시지 증분 덤프 | | Dashboard | `GET /ops/v1/dashboard/user-count` | 기간 내 사용자 수 집계 | | Dashboard | `GET /ops/v1/dashboard/message-count` | 기간 내 메시지 수 집계 | | Users | `GET /ops/v1/users` | 학습자별 활동 요약 | | Client | `GET /ops/v1/clients/children` | 하위 고객사 목록 | Base URL은 `https://opsapi.edutap.ai`예요. 페이지네이션, 날짜 형식 같은 공통 규약은 [공통 사항](/docs/api/ops/common)에서 확인할 수 있어요. ## 인증 로그인과 토큰 갱신을 제외한 모든 API는 `Authorization: Bearer {access_token}` 헤더가 필요해요. * client member 계정만 로그인할 수 있어요. * 토큰 갱신은 rotation 방식이에요. 갱신하면 이전 access token과 refresh token이 모두 무효화되므로, 응답으로 받은 새 토큰 쌍을 저장해 주세요. * 갱신 시 401 `InvalidRefreshToken`을 받으면 자동 갱신이 불가능해요. 다시 로그인해 주세요. > `access_token`은 `signin` 응답에서 받은 값을 **그대로** 사용하세요. 토큰 문자열에 `|` 등 추가 접미사를 붙이면 401로 거부돼요. ## 하위 고객사 데이터 조회 고객사에 하위 고객사가 있다면, 모든 조회 API에서 `client_identity`와 `include_descendants` 파라미터로 조회 범위를 지정할 수 있어요. | 요청 | 조회 범위 | | --- | --- | | `(파라미터 생략)` | 로그인 고객사만 | | `include_descendants=true` | 로그인 고객사 + 모든 하위 고객사 | | `client_identity=acme_kr` | acme_kr 고객사만 | | `client_identity=acme_kr&include_descendants=true` | acme_kr 고객사 + acme_kr의 모든 하위 고객사 | 1. [하위 고객사 목록 조회](/docs/api/ops/clients-children)로 지정할 수 있는 `client_identity`를 확인해요. 2. 조회 API에 `client_identity`, `include_descendants`를 전달해요. 3. 여러 고객사를 함께 조회하면 응답 항목의 `client_identity`로 소속 고객사를 구분해요. 집계 API는 합계를 반환해요. > 로그인 고객사 하위가 아니거나 존재하지 않는 `client_identity`를 지정하면 403 `OuterClientScopeForbidden`을 받아요. 증분 덤프의 마지막 호출 시점은 `client_identity`·`include_descendants` 조합별로 따로 관리돼요. ## 연동 시나리오 ### 일일 사용량 동기화 1. [로그인](/docs/api/ops/signin)으로 토큰을 발급받아요. 2. [사용자 수 집계](/docs/api/ops/user-count), [메시지 수 집계](/docs/api/ops/message-count)에 `start_date`, `end_date`를 넣어 기간 집계를 조회해요. 3. 결과를 자사 BI 시스템에 적재해요. ### 세션·메시지 데이터 적재 1. 최초 적재나 재처리는 [세션 덤프](/docs/api/ops/session-dumps), [메시지 덤프](/docs/api/ops/message-dumps)로 기간을 지정해 받아요. 2. 이후에는 [세션 증분 덤프](/docs/api/ops/session-dumps-since-last), [메시지 증분 덤프](/docs/api/ops/message-dumps-since-last)로 직전 호출 이후 데이터만 받아요. 3. 적재에 실패하면 `retry=true`로 같은 구간을 다시 요청해요. ### 사용자 활동 요약 조회 1. [사용자 활동 목록](/docs/api/ops/users)으로 학습자별 세션 수, 질문 수, 마지막 질문 일시를 조회해요. 2. `user_id` 부분 일치로 특정 학습자를 검색할 수 있어요. 3. `start_date`, `end_date`를 **둘 다** 지정하면 해당 기간 내 활동만 집계해요. ## 자주 묻는 질문 **Q. 하위 고객사 데이터가 조회되지 않아요.** `include_descendants`의 기본값은 `false`예요. 하위 고객사 데이터까지 받으려면 `include_descendants=true`를 함께 보내 주세요. **Q. 증분 덤프가 빈 배열로 와요.** 직전 호출 이후 새로 생성된 데이터가 없으면 빈 배열이 반환돼요. 직전 호출 결과를 다시 받으려면 `retry=true`로 요청해 주세요. **Q. 덤프 응답이 너무 커요.** 덤프 API는 페이지네이션 없이 일괄 반환해요. 기간을 짧게 나눠 호출하거나 증분 덤프를 사용해 주세요. ## 더 알아보기 - [공통 사항](/docs/api/ops/common): 인증, 고객사 조회 범위, 페이지네이션, 날짜 형식 규약 - [API 레퍼런스](/docs/api/ops/signin): 전체 엔드포인트 명세와 요청·응답 예시 - [에러 코드](/docs/api/errors): HTTP 상태별 에러 코드와 해결 방법 --- # Components > Components Source: https://developers.edutap.ai/docs/test ## Code Block ```js console.log('Hello World'); ``` ## Cards - [Learn more about Next.js](https://nextjs.org/docs) - [Learn more about Fumadocs](https://fumadocs.vercel.app) --- # 수강생 관리 API > EduTap 수강생 데이터 등록 및 관리 Source: https://developers.edutap.ai/docs/client-user ## 개요 수강생 관리 API를 통해 AI 튜터를 이용하는 수강생 정보를 등록하고 관리할 수 있습니다. 등록된 수강생 데이터는 맞춤형 학습 경험 제공에 활용됩니다. ## 주요 기능 단일 수강생 관리 개별 수강생 생성 및 삭제 대량 수강생 관리 CSV/JSON을 통한 일괄 등록 및 삭제 ## 더 알아보기 - [API 레퍼런스](/docs/api/client-users/create): 수강생 관리 REST API 전체 명세 --- # 에러 코드 > 요청이 실패했을 때 돌아오는 응답 형식과 API별 에러 코드 Source: https://developers.edutap.ai/docs/api/errors 요청이 실패하면 HTTP 상태 코드와 함께 아래 형식의 JSON 본문이 돌아와요. 에러 코드는 API 그룹마다 달라요. ```json { "error_code": "COURSE_NOT_FOUND", "message": "존재하지 않는 강의입니다.", "status_code": 404 } ``` ## AI Tutor API | HTTP | Error Code | Description | Solution | | --- | --- | --- | --- | | 400 | `INVALID_COURSE_DATA` | 유효하지 않은 강의 데이터 | 요청 데이터 형식 확인 | | 400 | `InvalidFileFormat` | 유효하지 않은 파일 형식 | 지원 파일 형식 확인 | | 400 | `InvalidJsonFormat` | 유효하지 않은 JSON 형식 | JSON 형식 확인 | | 400 | `ValidationError` | 데이터 검증 실패 | 필수 필드 및 형식 확인 | | 401 | `NotAuthorized` | 인증 실패 | API 키 확인 | | 401 | `InvalidAuthHeader` | 잘못된 인증 정보 | API 키 형식 확인 | | 404 | `COURSE_NOT_FOUND` | 존재하지 않는 강의 | course_id 확인 | | 404 | `SECTION_NOT_FOUND` | 존재하지 않는 섹션 | section_id 확인 | | 404 | `CLIP_NOT_FOUND` | 존재하지 않는 클립 | clip_id 확인 | | 404 | `MATERIAL_NOT_FOUND` | 존재하지 않는 자료 | material_id 확인 | | 422 | `InvalidParameter` | 유효하지 않은 파라미터 | 쿼리/경로 파라미터 확인 | | 500 | `InternalServerError` | 서버 내부 에러 | 담당자 문의 | ## EduAlign OpsAPI | HTTP | Error Code | Description | Solution | | --- | --- | --- | --- | | 400 | `NotExistMember` | 존재하지 않는 client member 이메일 | 이메일 확인 | | 400 | `MismatchedPassword` | 비밀번호 불일치 | 비밀번호 확인 | | 400 | `NotActivatedClientMember` | 비활성 멤버 또는 비활성 client | 담당자 문의 | | 400 | `SigninFailed` | 로그인 처리 중 일시 장애 | 동일 요청으로 재시도 | | 400 | `RefreshFailed` | 토큰 갱신 처리 중 일시 장애 | 동일 요청으로 재시도 | | 400 | `NotExistClient` | 존재하지 않는 client | client 식별자 확인 | | 400 | `InvalidClient` | 로그인 계정의 client를 찾을 수 없음 | 담당자 문의 | | 401 | `NotAuthorized` | Bearer 토큰 누락, 만료 또는 무효화 | 토큰 갱신 후 재시도 | | 401 | `InvalidAuthHeader` | `Authorization` 헤더가 `Bearer `로 시작하지 않음 | `Authorization: Bearer {access_token}` 형식 확인 | | 401 | `InvalidRefreshToken` | refresh token이 무효이거나 만료 | 재로그인 후 새 토큰 발급 | | 403 | `OuterClientScopeForbidden` | 로그인 client 하위가 아니거나 존재하지 않는 `client_identity` | 하위 고객사 목록 조회로 `client_identity` 확인 | | 404 | `NotExistSession` | 존재하지 않거나 다른 client 소유의 세션 | session_id 확인 | | 422 | `RequestValidationError` | 쿼리 파라미터 형식 오류 | 날짜 형식(YYYY-MM-DD)과 파라미터 확인 | --- # 소개 > Base URL, 인증, 에러 응답 등 모든 API에 공통으로 적용되는 내용 Source: https://developers.edutap.ai/docs/api EduTAP API는 REST 원칙을 따르고 JSON으로 요청과 응답을 주고받아요. 서비스마다 Base URL과 인증 방식이 달라요. ## Base URL | API | Base URL | 인증 | | -------------------- | ------------------------------------------------- | -------------------------- | | 강의 정보, 강의 데이터, 시험 첨삭 | `https://tap-file-upload-production.coxwave.link` | `TAP-API-KEY` 헤더 | | 수강생 | `https://tapapi.coxwave.link` | `TAP-API-KEY` 헤더 | | EduAlign OpsAPI | `https://opsapi.edutap.ai` | `Authorization: Bearer` 토큰 | ## 인증 ### API 키 강의 정보, 강의 데이터, 시험 첨삭, 수강생 API는 모든 요청에 `TAP-API-KEY` 헤더가 필요해요. 키는 콕스웨이브 담당자에게 발급받아요. ```bash curl "https://tap-file-upload-production.coxwave.link/api/v1/courses" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` ### Bearer 토큰 EduAlign OpsAPI는 [로그인](/docs/api/ops/signin)으로 발급받은 access token을 `Authorization` 헤더에 넣어요. 만료되면 [토큰 갱신](/docs/api/ops/refresh)으로 새 토큰을 받아요. ```bash curl "https://opsapi.edutap.ai/ops/v1/sessions" \ -H "Authorization: Bearer {ACCESS_TOKEN}" ``` --- # SDK > TapKit의 전체 API 레퍼런스 Source: https://developers.edutap.ai/docs/api/sdk ## TapKitElement `` Web Component 요소입니다. ### API 요약 **라이프사이클** | API | 설명 | | --------------- | ------------------- | | `ready` | 초기화 완료 Promise | | `isInitialized` | 초기화 완료 여부 (boolean) | **UI 제어** | API | 설명 | | -------- | ------------------ | | `show()` | 채팅 컨테이너 표시 | | `hide()` | 채팅 컨테이너 숨김 | | `isOpen` | 채팅 열림 상태 (boolean) | **코스 정보** | API | 설명 | | --------------------------------------------------------------------- | ------------------------------------- | | `setCourse({ courseId, clipId, sectionId?, userId?, clipPlayHead? })` | 코스 정보 업데이트 | | `setExternalReferenceId(id?)` | 외부 참조 ID 동적 변경 (`undefined` 전달 시 해제) | | `setExternalReferenceData(data?)` | 외부 참조 데이터 동적 변경 (`undefined` 전달 시 해제) | **호스트 컨텍스트** | API | 설명 | | ----------------------------------- | ---------------------- | | `sendContext(contextType, payload)` | 호스트 앱에서 AI 튜터로 컨텍스트 전달 | **이벤트 (kit.events)** | API | 설명 | | ---------------------------------------- | ----------------------------------- | | `seekTimeline({ clipPlayHead, clipId })` | 타임라인 이동 요청 | | `onTimelineSeek(callback)` | 타임라인 seek 이벤트 구독, unsubscribe 함수 반환 | | `onAlarmFadeIn(handler)` | 알람 표시 이벤트 구독, unsubscribe 함수 반환 | **비디오 (kit.video)** (deprecated - `videoTarget` 권장) | API | 설명 | | ----------------------- | ----------------------- | | `bind(config, clipId?)` | 비디오 플레이어 바인딩 (타임라인 동기화) | | `unbind()` | 비디오 바인딩 해제 | *** ### Properties #### `apiKey: string` (write-only) > **보안** > > > **Property로만 설정 가능**합니다. HTML attribute로 설정하지 마세요. ```javascript kit.apiKey = 'your-api-key'; // O // 사용 금지 ``` #### `userId: string` ```javascript kit.userId = 'user-123'; // 또는 ``` #### `courseId: string` ```javascript kit.courseId = 'course-456'; ``` #### `clipId: string` ```javascript kit.clipId = 'clip-789'; ``` #### `sectionId?: string` 섹션 ID(코스 내 섹션 식별자)입니다. 선택 값입니다. ```javascript kit.sectionId = 'section-456'; // 또는 ``` #### `clipPlayHead: number` 재생 위치 (초 단위)입니다. ```javascript kit.clipPlayHead = 100; ``` #### `externalReferenceId?: string` 외부 시스템(LMS 등)에서 부여한 참조 식별자입니다. 채팅 스트림 요청 바디에 함께 전달됩니다. ```javascript kit.externalReferenceId = 'ext-abc-123'; // 또는 ``` #### `userInputCode?: string` 외부 시스템(LMS 등)에서 부여한 참조 데이터입니다. 채팅 스트림 요청 바디에 함께 전달됩니다. ```javascript kit.userInputCode = 'ext-data-123'; // 또는 ``` #### `rowDataForCodeEditor?: CodingRowData` 호스트 LMS/플랫폼이 보유한 코딩 실습 문제 메타데이터입니다. SDK가 5개 필수 필드를 추출해 채팅 스트림 요청 바디에 자동으로 첨부합니다. 객체의 추가 필드는 무시됩니다. 속성 전용입니다 (HTML 속성이 아닌 JS property로 설정). | 원본 필드 | → 채팅 스트림 바디 필드 | | ---------------- | -------------------- | | `language_id_nm` | `coding_language` | | `d_test_case` | `coding_testcase` | | `content` | `coding_content` | | `io_example` | `coding_example` | | `restriction` | `coding_restriction` | `rowDataForCodeEditor`를 전달했다면 5개 필수 필드는 모두 비어있지 않은 문자열이어야 합니다 — 그렇지 않으면 SDK가 초기화 시점에 예외를 발생시킵니다. **HTML 엔티티 자동 디코딩**: chat stream 빌드 단계에서 5개 필드 모두에 entity 디코딩이 적용됩니다 (`<p>` → ``). 이미 raw HTML인 입력에도 idempotent하므로 호스트는 입력 형태와 무관하게 그대로 전달하면 됩니다. ```javascript kit.rowDataForCodeEditor = { language_id_nm: 'C++ (GCC 9.2.0)', d_test_case: '

...

', content: '문제 설명...', io_example: 'input: 1234 / output: 4S 0B', restriction: '1s / 256MB', // 추가 필드(grcode, sol_code 등)는 무시됨 }; ``` 자세한 호스트 측 데이터 흐름 및 fetch 패턴은 [코드 에디터 연동 - 호스트 API에서 JSON 가져오기](/docs/sdk/features/code-editor#호스트-api에서-json-가져오기) 참조. #### `language: "ko" | "en" | "ja"` 언어 설정입니다. 기본값은 `"ko"`입니다. ```javascript kit.language = 'en'; // 또는 ``` #### `mode: "inline" | "floating" | "sidebar"` 표시 모드입니다. 기본값은 `"inline"`입니다. | 값 | 설명 | | ---------- | ------------------ | | `inline` | 페이지 콘텐츠에 임베드 (기본값) | | `floating` | 화면에 떠있는 컴팩트 위젯 | | `sidebar` | 화면 우측 전체 높이 사이드바 | ```javascript kit.mode = 'floating'; // 또는 ``` #### `allowLayoutToggle: boolean` `floating` ↔ `sidebar` 모드 간 전환 버튼 표시 여부입니다. 기본값은 `true`입니다. ```javascript kit.allowLayoutToggle = false; // 레이아웃 토글 버튼 숨김 ``` #### `videoTarget: HTMLVideoElement | VideoPlayerAdapter` 비디오 플레이어 동기화를 위한 선언적 바인딩입니다. `video.bind()` 대신 권장됩니다. ```javascript // HTMLVideoElement kit.videoTarget = document.querySelector('video'); // VideoPlayerAdapter (YouTube, Vimeo 등) kit.videoTarget = { getCurrentTime: () => player.getCurrentTime(), setCurrentTime: (time, clipId) => player.seekTo(time), // clipId는 cross-clip seeking용 (optional) }; ``` > `videoTarget`은 라이프사이클 변경(mode 변경, 재연결)에도 유지됩니다. #### `buttonId: string` 커스텀 버튼 요소 ID입니다. #### `debug: boolean` 디버그 로깅 활성화 여부입니다. ### Static Properties #### `TapKit.version: string` (read-only) 현재 SDK 버전을 반환합니다. ```javascript console.log(TapKit.version); // "2.1.0" ``` #### `TapKit.buildEnv: string` (read-only) 현재 빌드 환경을 반환합니다. (`"prod"`, `"staging"`, `"dev"`, `"demo"`) ```javascript console.log(TapKit.buildEnv); // "prod" ``` ### Advanced Properties > 아래 속성들은 **테스트/개발 환경용**입니다. 프로덕션에서는 일반적으로 사용하지 않습니다. #### `apiUrl: string` 백엔드 API URL입니다. Staging/Demo 환경 테스트 시 사용합니다. ```javascript kit.apiUrl = 'https://tapapistaging.coxwave.link'; ``` #### `tapUrl: string` iframe URL입니다. 내부 테스트용이며 일반적으로 수정하지 마세요. #### `environment: "dev" | "prod" | "staging" | "demo"` 환경 설정입니다. 로깅 레벨 및 기본 URL에 영향을 줍니다. ```javascript kit.environment = 'staging'; ``` #### `root: HTMLElement` (deprecated) > **Deprecated** > > > 이 속성은 더 이상 위치 지정에 영향을 주지 않습니다. `floating`/`sidebar` 모드에서 컨테이너는 항상 `document.body`에 렌더링됩니다. ```javascript // ❌ 더 이상 사용하지 마세요 kit.root = document.getElementById('chat-container'); ``` ### Read-only Properties #### `isOpen: boolean` (read-only) 채팅이 현재 열려있는지 확인합니다. #### `isInitialized: boolean` (read-only) SDK 초기화가 완료되었는지 확인합니다. #### `ready: Promise` (read-only) SDK가 준비될 때까지 대기하는 Promise입니다. ```javascript await kit.ready; kit.show(); ``` ### Methods #### `show(): void` 채팅 창을 표시합니다. #### `hide(): void` 채팅 창을 숨깁니다. #### `setCourse(course): void` 강의 정보를 업데이트합니다. ```typescript interface SetCourseParams { userId?: string; courseId: string; // 필수 clipId: string; // 필수 sectionId?: string; // 선택 clipPlayHead?: number; } ``` ```javascript kit.setCourse({ courseId: 'course-999', clipId: 'clip-001', clipPlayHead: 50 }); ``` #### `setExternalReferenceId(id?): void` 외부 참조 ID를 런타임에 동적으로 변경합니다. `undefined` 전달 시 값이 해제됩니다. ```javascript kit.setExternalReferenceId('ext-abc-123'); kit.setExternalReferenceId(undefined); // 해제 ``` #### `setExternalReferenceData(data?): void` 외부 참조 데이터를 런타임에 동적으로 변경합니다. `undefined` 전달 시 값이 해제됩니다. ```javascript kit.setExternalReferenceData('ext-data-123'); kit.setExternalReferenceData(undefined); // 해제 ``` #### `sendContext(contextType, payload): void` 호스트 앱에서 AI 튜터로 컨텍스트 정보를 전달합니다. 퀴즈 답안, 읽기 진도 등 다양한 종류의 컨텍스트 데이터를 전달할 수 있는 범용 API입니다. | Parameter | Type | Description | | ------------- | ------------------------- | ------------------------------------------ | | `contextType` | `string` | 컨텍스트 유형 식별자 (예: `'passage:answer:submit'`) | | `payload` | `Record` | 컨텍스트 데이터 객체 | ```javascript kit.sendContext('passage:answer:submit', { question: '이 지문의 주요 아이디어는 무엇입니까?', selectedOptionId: 'A', correctAnswer: 'B', isCorrect: false, }); ``` ### Events #### `tap-kit:ready` SDK 초기화가 완료되면 발생합니다. ```javascript kit.addEventListener('tap-kit:ready', () => { console.log('TapKit이 준비되었습니다!'); }); ``` #### `tap-kit:error` 초기화 중 오류가 발생하면 발생합니다. ```javascript kit.addEventListener('tap-kit:error', (e) => { console.error('TapKit 오류:', e.detail.error); }); ``` #### `tap-kit:iframe-retry` iframe 로드 실패 후 재시도할 때 발생합니다. ```typescript interface IframeRetryEvent { attempt: number; // 현재 재시도 횟수 error: Error; // 발생한 에러 nextDelay: number; // 다음 재시도까지 대기 시간 (ms) } ``` ```javascript kit.addEventListener('tap-kit:iframe-retry', (e) => { console.log(`iframe 재시도 중... (${e.detail.attempt}번째, ${e.detail.nextDelay}ms 후 재시도)`); console.error('에러:', e.detail.error.message); }); ``` ### kit.events 이벤트 관련 API입니다. #### `kit.events.seekTimeline(params): void` 타임라인을 특정 위치로 이동합니다. ```typescript interface SeekTimelineParams { clipPlayHead: number; // 이동할 위치 (초 단위) clipId: string; } ``` ```javascript kit.events.seekTimeline({ clipPlayHead: 100, clipId: 'clip-789' }); ``` #### `kit.events.onTimelineSeek(handler): () => void` 타임라인 이동 이벤트를 리스닝합니다. Unsubscribe 함수를 반환합니다. ```javascript const unsubscribe = kit.events.onTimelineSeek((playHead, clipId) => { console.log(`${playHead}초로 이동: ${clipId}`); videoElement.currentTime = playHead; }); // 구독 해제 unsubscribe(); ``` #### `kit.events.onAlarmFadeIn(handler): () => void` 알림이 도착할 때 발생하는 이벤트를 리스닝합니다. ```javascript const unsubscribe = kit.events.onAlarmFadeIn((alarm) => { console.log('새 알림:', alarm.message); }); ``` ### kit.video > **Deprecated** > > > `kit.video.bind()` API는 deprecated되었습니다. 대신 `videoTarget` 속성을 사용하세요. > `videoTarget`은 라이프사이클 변경에도 자동으로 유지되어 더 안정적입니다. 비디오 플레이어 동기화 API입니다. > **왜 video.bind가 중요한가요?** > > > `video.bind()`를 사용하면: > > * AI 튜터가 **현재 재생 시점**을 인식하여 정확한 답변 제공 > * 채팅에서 **특정 구간 클릭 시 비디오 자동 이동** > * `setCourse()` 호출 시 **자동으로 새 clipId 추적** #### `kit.video.bind(config, clipId?): void` 비디오 플레이어를 TapKit과 동기화합니다. | Parameter | Type | Required | Description | | --------- | ---------------------------------------- | -------- | ------------------- | | `config` | `HTMLVideoElement \| VideoPlayerAdapter` | O | 비디오 요소 또는 커스텀 어댑터 | | `clipId` | `string` | | 생략 시 SDK 설정에서 자동 추적 | ```typescript interface VideoPlayerAdapter { getCurrentTime: () => number; setCurrentTime: (time: number, clipId?: string) => void; // clipId는 cross-clip seeking용 } ``` **자동 추적 (권장):** ```javascript kit.video.bind(videoElement); // 나중에 강의 변경 시 자동으로 새 clipId 사용 kit.setCourse({ courseId: 'new-course', clipId: 'new-clip' }); ``` **커스텀 어댑터 (YouTube 등):** ```javascript kit.video.bind({ getCurrentTime: () => player.getCurrentTime(), // clipId는 optional - cross-clip seeking 시 활용 setCurrentTime: (time, clipId) => player.seekTo(time, true), }); ``` #### `kit.video.unbind(): void` 비디오 플레이어 동기화를 해제합니다. ```javascript kit.video.unbind(); ``` *** ## createTapKit() `` Web Component 요소를 프로그래밍 방식으로 생성하고 제어할 수 있는 Factory API입니다. > **npm 패키지와 CDN 모두에서 사용 가능합니다.** > > * **npm**: `import { createTapKit } from '@coxwave/tap-kit'` > * **CDN**: `window.createTapKit(...)` > > React 프로젝트에서는 [useTapKit Hook](/docs/sdk/guides/react)을 권장합니다. ### Syntax ```typescript // npm 패키지 import { createTapKit } from '@coxwave/tap-kit'; const tapkit = createTapKit(options); tapkit.mount(); // DOM에 추가 (필수) await tapkit.ready; // 초기화 완료 대기 tapkit.show(); // 채팅 창 표시 ``` ### Parameters | Property | Type | Required | Description | | ---------------------- | ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `apiKey` | `string` | O | TapKit API 키 | | `userId` | `string` | | 사용자 ID | | `courseId` | `string` | | 강의 ID | | `clipId` | `string` | | 클립 ID | | `sectionId` | `string` | | 섹션 ID (코스 내 섹션 식별자) | | `clipPlayHead` | `number` | | 초기 재생 위치 (초 단위) | | `externalReferenceId` | `string` | | 외부 시스템에서 부여한 참조 식별자 (채팅 스트림 바디에 전달) | | `userInputCode` | `string` | | 외부 시스템에서 부여한 참조 데이터 (채팅 스트림 바디에 전달) | | `rowDataForCodeEditor` | `CodingRowData` | | 코딩 실습 메타데이터. 5개 필수 필드(`language_id_nm`, `d_test_case`, `content`, `io_example`, `restriction`)가 채팅 스트림 바디에 `coding_*`로 자동 첨부됨. 필드 누락 시 초기화 실패. | | `language` | `'ko' \| 'en' \| 'ja'` | | 언어 설정 | | `mode` | `'inline' \| 'floating' \| 'sidebar'` | | 표시 모드 (기본값: `'floating'`) | | `allowLayoutToggle` | `boolean` | | floating↔sidebar 토글 허용 (기본값: `true`) | | `videoTarget` | `HTMLVideoElement \| VideoPlayerAdapter` | | 비디오 플레이어 바인딩 | | `buttonId` | `string` | | 커스텀 버튼 요소 ID | | `debug` | `boolean` | | 디버그 로깅 활성화 | **Advanced Options (테스트/개발용):** | Property | Type | Description | | ------------- | ---------------------------------------- | ------------------------------------------------------- | | `apiUrl` | `string` | 백엔드 API URL (예: `'https://tapapistaging.coxwave.link'`) | | `tapUrl` | `string` | iframe URL (내부 테스트용) | | `environment` | `'dev' \| 'prod' \| 'staging' \| 'demo'` | 환경 설정 | **Event Handlers:** | Property | Type | Description | | ---------------- | ---------------------------------------- | --------------- | | `onReady` | `() => void` | SDK 초기화 완료 시 호출 | | `onError` | `(error: Error) => void` | 오류 발생 시 호출 | | `onTimelineSeek` | `(time: number, clipId: string) => void` | 타임라인 이동 이벤트 | | `onAlarmFadeIn` | `(info: unknown) => void` | 알람 표시 이벤트 | ### Return Value: TapKitFactoryControl | Property/Method | Type | Description | | ----------------------------------- | ----------------------- | ------------------- | | `ready` | `Promise` | 초기화 완료 Promise | | `isReady` | `boolean` | 초기화 완료 여부 | | `isOpen` | `boolean` | 채팅 열림 상태 | | `isMounted` | `boolean` | DOM에 마운트 여부 | | `mount(container?)` | `void` | DOM에 추가 (필수) | | `unmount()` | `void` | DOM에서 제거 | | `show()` | `void` | 채팅 창 표시 | | `hide()` | `void` | 채팅 창 숨김 | | `setCourse(course)` | `void` | 코스 정보 업데이트 | | `setExternalReferenceId(id?)` | `void` | 외부 참조 ID 동적 변경 | | `setExternalReferenceData(data?)` | `void` | 외부 참조 데이터 동적 변경 | | `sendContext(contextType, payload)` | `void` | 호스트 컨텍스트를 AI 튜터로 전달 | | `events` | `EventManager` | 이벤트 API | | `video` | `VideoController` | 비디오 API | | `element` | `TapKitElement \| null` | 내부 요소 접근 | | `destroy()` | `void` | 인스턴스 정리 | ### Example (npm 패키지) ```typescript import { createTapKit } from '@coxwave/tap-kit'; const tapkit = createTapKit({ apiKey: 'your-api-key', userId: 'user-123', courseId: 'course-456', clipId: 'clip-789', // 환경 설정 (테스트용) apiUrl: 'https://tapapistaging.coxwave.link', environment: 'staging', // 이벤트 핸들러 onReady: () => console.log('Ready!'), onTimelineSeek: (time, clipId) => { videoElement.currentTime = time; }, }); // DOM에 추가 (필수) tapkit.mount(); // 또는 특정 컨테이너에 추가 tapkit.mount(document.getElementById('chat-container')); // 초기화 완료 대기 await tapkit.ready; // 채팅 창 표시 tapkit.show(); // 정리 tapkit.destroy(); ``` ### Example (CDN) ```html ``` *** ## CSS Variables UI 스타일링을 위한 CSS 변수는 [스타일링 가이드](/docs/sdk/features/styling#css-variables)를 참조하세요. *** ## TypeScript Types ### TapKitConfig Legacy Class API (`new TapKit()`) 생성자용 타입입니다. ```typescript interface TapKitConfig { apiKey: string; } ``` ### CreateTapKitOptions `createTapKit()` 및 `useTapKit()` API용 설정 타입입니다. ```typescript interface CreateTapKitOptions { // Required apiKey: string; // Course Information userId?: string; courseId?: string; clipId?: string; sectionId?: string; clipPlayHead?: number; externalReferenceId?: string; userInputCode?: string; rowDataForCodeEditor?: CodingRowData; // Display Options language?: 'ko' | 'en' | 'ja'; mode?: 'inline' | 'floating' | 'sidebar'; allowLayoutToggle?: boolean; videoTarget?: HTMLVideoElement | VideoPlayerAdapter; buttonId?: string; debug?: boolean; // Advanced Options (테스트/개발용) apiUrl?: string; tapUrl?: string; environment?: 'dev' | 'prod' | 'staging' | 'demo'; // Event Handlers onReady?: () => void; onError?: (error: Error) => void; onTimelineSeek?: (clipPlayHead: number, clipId: string) => void; onAlarmFadeIn?: (messageInfo: AlarmMessageInstanceType) => void; } ``` ### TapKitElement `` Web Component 요소 타입입니다. ```typescript interface TapKitElement extends HTMLElement { // Properties apiKey: string; userId?: string; courseId?: string; clipId?: string; sectionId?: string; clipPlayHead?: number; externalReferenceId?: string; userInputCode?: string; rowDataForCodeEditor?: CodingRowData; language?: 'ko' | 'en' | 'ja'; mode?: 'inline' | 'floating' | 'sidebar'; allowLayoutToggle: boolean; videoTarget?: HTMLVideoElement | VideoPlayerAdapter; buttonId?: string; debug: boolean; // Advanced Properties apiUrl?: string; tapUrl?: string; environment?: 'dev' | 'prod' | 'staging' | 'demo'; /** @deprecated This property no longer affects positioning */ root?: HTMLElement; // Read-only readonly isOpen: boolean; readonly isInitialized: boolean; readonly ready: Promise; // Methods show(): void; hide(): void; setCourse(params: SetCourseParams): void; setExternalReferenceId(id?: string): void; setExternalReferenceData(data?: string): void; sendContext(contextType: string, payload: Record): void; // Sub-APIs readonly events: { seekTimeline(params: { clipPlayHead: number; clipId: string }): void; onTimelineSeek(handler: (playHead: number, clipId: string) => void): () => void; onAlarmFadeIn(handler: (messageInfo: AlarmMessageInstanceType) => void): () => void; }; /** @deprecated Use videoTarget property instead */ readonly video: { bind(config: HTMLVideoElement | VideoPlayerAdapter, clipId?: string): void; unbind(): void; }; } ``` ### CodingRowData `rowDataForCodeEditor` 속성 타입입니다. 호스트 애플리케이션이 LMS/플랫폼 raw JSON을 통째로 넘기면, SDK는 아래 5개 필수 필드만 추출하고 그 외 필드는 무시합니다. ```typescript interface CodingRowData { language_id_nm: string; // → coding_language d_test_case: string; // → coding_testcase content: string; // → coding_content io_example: string; // → coding_example restriction: string; // → coding_restriction [key: string]: unknown; // 추가 필드 허용 (SDK가 무시) } ``` ### AlarmMessageInstanceType 알람 메시지 정보 타입입니다. ```typescript interface AlarmMessageInstanceType { type: 'basic:welcome' | 'basic:default' | 'feat:quiz' | 'feat:guide' | 'feat:progress' | 'custom:cheer'; content: AlarmElement; } interface AlarmElement { tag: string; props?: Record; children?: (AlarmElement | string)[]; payload?: { type: 'startChat'; message: string; pingMessageId?: string; }; } ``` ### VideoPlayerAdapter 커스텀 비디오 플레이어 (YouTube, Vimeo, Video.js 등) 연동용 인터페이스입니다. ```typescript interface VideoPlayerAdapter { /** 현재 재생 시간 (초) */ getCurrentTime: () => number; /** * 재생 시간 설정 * @param time - 이동할 시간 (초) * @param clipId - cross-clip seeking 시 대상 클립 ID (optional) */ setCurrentTime: (time: number, clipId?: string) => void; } /** HTMLVideoElement 또는 커스텀 어댑터 */ type VideoPlayerConfig = HTMLVideoElement | VideoPlayerAdapter; ``` *** ## TapButtonElement `` Web Component는 AI 튜터 채팅을 열기 위한 플로팅 버튼입니다. ### 기본 사용법 ```html ``` > ``은 ``과 같은 페이지에 있으면 **자동으로 연결**되어 클릭 시 채팅창이 토글됩니다. ### Attributes #### `position: string` 버튼의 화면 위치를 지정합니다. 기본값은 `"bottom-right"`입니다. | 값 | 설명 | | -------------- | ---------------------------- | | `bottom-right` | 화면 우측 하단 (기본값) | | `bottom-left` | 화면 좌측 하단 | | `top-right` | 화면 우측 상단 | | `top-left` | 화면 좌측 상단 | | `custom` | 사용자 정의 위치 (inline style로 지정) | ```html ``` #### `size: string` 버튼 크기를 지정합니다. 기본값은 `"large"`입니다. | 값 | 크기 | | -------- | ---------- | | `small` | 48px | | `medium` | 56px | | `large` | 64px (기본값) | ```html ``` #### `floating: string` 포지셔닝 모드를 설정합니다. 기본값은 플로팅(fixed) 모드입니다. | 값 | 설명 | | --------- | ------------------------ | | (생략) | 플로팅 모드 (position: fixed) | | `"false"` | 정적 모드 (일반 문서 흐름) | ```html ``` #### `language: string` 툴팁 언어를 설정합니다. 기본값은 `"ko"`입니다. ```html ``` #### `tutor-name: string` 툴팁에 표시할 튜터 이름입니다. 설정하지 않으면 기본 문구가 표시됩니다. ```html ``` ### 커스텀 아이콘 슬롯을 사용하여 버튼 내용을 커스터마이징할 수 있습니다. ```html AI 튜터 ``` ### CSS Shadow Parts 외부 스타일링을 위한 CSS Shadow Parts를 제공합니다. ```css tap-button::part(button) { background: linear-gradient(135deg, #6366f1, #8b5cf6); border-radius: 50%; } tap-button::part(tooltip) { background: #1f2937; color: white; } ``` ### Events #### `tap-button:click` 버튼 클릭 시 발생합니다. ``이 있으면 자동으로 채팅이 토글됩니다. ```javascript const button = document.querySelector('tap-button'); button.addEventListener('tap-button:click', (e) => { console.log('버튼 클릭됨', e.detail); }); ``` *** ## 더 알아보기 - [React 가이드](/docs/sdk/guides/react): React 앱에서 TapKit 사용하기 - [스타일링](/docs/sdk/features/styling): tap-kit, tap-button 커스터마이징 --- # 시험 첨삭 연동 가이드 > 응시 결과를 제출해 AI 첨삭과 취약점 진단 받기 Source: https://developers.edutap.ai/docs/exam-grading ## 시작하기 응시자가 시험을 마치고 채점이 끝나면, 그 답안을 보내 문항별 AI 첨삭과 회차별 취약점 진단을 받습니다. ## 전체 흐름 1단계: 응시 결과 제출 Writing은 JSON, Speaking은 음성 파일과 함께 2단계: 첨삭 생성 음성은 전사 후 첨삭. 문항 수에 따라 수 분 소요 3단계: 결과 조회 접수 응답의 job_id로 문항별 결과 확인 ## 한 회차를 보내는 순서 한 응시자의 한 회차는 섹션별로 보냅니다. | 순서 | API | 내용 | | --- | --- | --- | | 1 | `POST /api/v1/courses/exam-gradings/writing` | Writing 12문항의 문항 정보와 답안 | | 2 | `POST /api/v1/courses/exam-gradings/speaking` | Speaking 11문항의 문항 정보와 답변 | | 3 | `GET /api/v1/courses/exam-gradings/{job_id}` | 문항별 첨삭 결과 조회 | items에 담은 문항 수만큼 job이 생성됩니다. ## 더 알아보기 - [API 레퍼런스](/docs/api/exam-gradings/writing): 시험 첨삭 요청 및 결과 조회 REST API 사용법 - [SDK 연동](/docs/exam-grading/sdk): 첨삭 결과를 학습자에게 보여주는 Chatbot SDK 설치 (EXAM 모드) ## 패키지 상품 연동 패키지 상품은 구매 1건당 최초 시험 제출을 접수한 달에 한 번 과금합니다. 같은 구매의 나머지 시험을 다음 달에 제출해도 패키지 상품 요금은 다시 부과하지 않습니다. 시험을 제출하지 않은 구매는 집계되지 않습니다. `product_category=PACKAGE`일 때 다음 두 ID를 보내세요. * `package_id`: 패키지 상품 ID. 같은 상품을 재구매해도 유지합니다. * `package_purchase_id`: 고객사 내에서 구매마다 고유한 ID. 재구매하거나 한 주문에 여러 패키지가 있으면 각 구매에 다른 값을 사용합니다. 같은 구매의 모든 시험과 Writing/Speaking 요청에는 두 ID를 동일하게 전달합니다. 분할 제출과 재전송에도 같은 값을 사용합니다. ID 발급과 상품·사용자 연결은 고객사가 관리합니다. 단품은 두 필드를 생략합니다. `product_category`를 생략하거나 null로 보내면 SINGLE로 처리합니다. 두 ID는 선택 항목이며, 생략해도 채점 요청은 접수됩니다. 패키지 구매 집계에는 두 ID가 모두 필요하며, 누락되거나 비어 있으면 구매 기록을 생성하지 않습니다. 각 ID는 최대 200자입니다. `exam_id`에는 기존 시험 COURSE\_ID를 사용합니다. `is_final`은 해당 시험 섹션의 제출 완료 여부입니다. ```json { "product_category": "PACKAGE", "package_id": "complete-vol-1-6", "package_purchase_id": "purchase-2026-0001" } ``` --- # SDK 연동 > 첨삭 결과를 학습자에게 보여주는 Chatbot SDK를 EXAM 모드로 설치하기 Source: https://developers.edutap.ai/docs/exam-grading/sdk 첨삭 API로 만든 결과는 Chatbot SDK를 통해 학습자에게 보여집니다. 설치 방법은 [Chatbot SDK 연동](/docs/sdk)과 같습니다. **`chat-mode="EXAM"` 한 가지만 추가**하면 첨삭 화면으로 열립니다. ## 일반 설치와 다른 점 | 항목 | 일반 학습 | 시험 첨삭 | | --- | --- | --- | | 설치 방법 | CDN 또는 npm | 동일 | | 필수 값 | apiKey, userId, courseId | 동일 (+ 문항을 가리키는 clipId) | | chat-mode | 지정하지 않음 | "EXAM" 필수 | | 첫 화면 | 학습 홈 (출석, 퀴즈, 학습 노트) | 첨삭 홈 (문제 해설, 힌트, 취약점 진단) | | 비디오 연동 | videoTarget으로 강의 영상 동기화 | 사용하지 않음 | > `chat-mode`를 빠뜨려도 설치는 성공합니다. 대신 일반 학습 화면이 떠서 첨삭 결과가 보이지 않습니다. > "설치는 됐는데 첨삭이 안 보인다"면 이 값을 가장 먼저 확인하세요. ## 값 맞추기 SDK에 넣는 세 값은 첨삭을 요청할 때 쓴 값과 **정확히 같아야** 합니다. 이 조합으로 첨삭 결과를 찾습니다. | 첨삭 API 필드 | CDN 속성 | npm 옵션 | 값 | | --- | --- | --- | --- | | `exam_id` | `course-id` | `courseId` | 시험 ID | | `items[].problem_id` | `clip-id` | `clipId` | 학습자가 지금 보고 있는 문항의 문제 ID | | `user_id` | `user-id` | `userId` | 응시자 ID | > 값이 하나라도 다르거나 해당 문항의 첨삭이 아직 `DONE`이 아니면 첨삭 화면이 비어 보입니다. ## 설치 ### CDN (HTML) ```html ``` > API 키는 `api-key` 속성이 아니라 **프로퍼티로만** 넣습니다. 속성으로 넣으면 HTML에 그대로 노출됩니다. ### npm (React) ```bash npm install @coxwave/tap-kit@^4.3.3 ``` ```tsx 'use client'; import { TapKit, useTapKit } from '@coxwave/tap-kit/react'; function ExamReviewPanel({ examId, problemId, userId }) { const tapkit = useTapKit({ apiKey: 'your-api-key', chatMode: 'EXAM', // 첨삭 화면으로 열기 userId, // 첨삭 요청의 user_id courseId: examId, // 첨삭 요청의 exam_id clipId: problemId, // 첨삭 요청의 problem_id onReady: () => console.log('Ready!'), }); return ; } ``` > `chatMode` 옵션은 `@coxwave/tap-kit` **4.3.3부터** 엘리먼트로 전달됩니다. > 4.3.2 이하에서는 옵션이 조용히 무시되어 일반 학습 화면이 뜨므로 버전을 올려주세요. > CDN 방식은 로더가 항상 최신 SDK를 받아오므로 해당되지 않습니다. ### npm (Non-React) ```typescript import { createTapKit } from '@coxwave/tap-kit'; const tapkit = createTapKit({ apiKey: 'your-api-key', chatMode: 'EXAM', userId: 'u-1001', courseId: 'I100000760_1', clipId: 'w11', }); tapkit.mount(); await tapkit.ready; ``` ## 문항을 이동할 때 학습자가 다른 문항으로 옮기면 **문제 ID만 갱신**합니다. `chat-mode`는 그대로 두세요. ```js // CDN / Non-React document.querySelector('tap-kit').clipId = 'w12'; ``` ```tsx // React — 상태로 넘기면 자동 반영됩니다 const [problemId, setProblemId] = useState('w11'); const tapkit = useTapKit({ /* ... */ chatMode: 'EXAM', clipId: problemId }); ``` ## EXAM 모드의 화면 구성 | 화면 | 내용 | | --- | --- | | 홈 | 문제 해설·힌트 요청 버튼과 AI 맞춤 질문 | | 채팅 | 현재 문항의 첨삭 맥락을 유지한 대화 | | 취약점 진단 | 회차별 취약점과 누적 횟수 | 퀴즈, 학습 노트, 채팅 기록, 설정 등 일반 학습 기능은 이 모드에 존재하지 않습니다. ## 기능 활성화 요청 `chat-mode="EXAM"`만으로는 첨삭 화면이 열리지 않습니다. 발급받은 **API 키에 시험 첨삭 기능이 켜져 있어야** 합니다. 기본값은 꺼짐이므로 담당자에게 활성화를 요청해 주세요. ## 연동 확인 체크리스트 | 확인 항목 | 확인 방법 | | --- | --- | | SDK가 로드됐는가 | loader.js 요청이 200인지 확인 | | EXAM 모드로 열렸는가 | 홈에 문제 해설·힌트 버튼이 보이는지 확인 | | 값이 일치하는가 | course-id / clip-id / user-id가 첨삭 요청 값과 같은지 확인 | | 첨삭이 끝났는가 | 결과 조회 API에서 해당 문항 status가 DONE인지 확인 | ## 더 알아보기 - [API 레퍼런스](/docs/api/exam-gradings/writing): 시험 첨삭 요청 및 결과 조회 REST API 사용법 - [Chatbot SDK 연동](/docs/sdk): SDK 전체 설치 방법과 공통 옵션 - [SDK](/docs/api/sdk): tap-kit 속성과 메서드 전체 목록 --- # 강의 정보 등록하기 > 강의 제목, 설명, 커리큘럼 같은 강의 구조 데이터를 등록하고 관리하기 Source: https://developers.edutap.ai/docs/integration/course-information ## 강의 구조 강의는 course > section (optional) > clip 구조로 구성돼요. 강의 영상이 있는 단계를 clip으로 이해하시면 돼요. 강의 데이터 중 가장 먼저 강의 정보를 등록해야 해요. 강의 정보는 JSON으로 구성하여 REST API를 통해 업로드하실 수 있어요. ## 더 알아보기 - [강의 정보 등록 API](/docs/api/courses/register): 요청 형식과 필드 설명, 언어별 예시 --- # 시작하기 > 강의 데이터를 등록해 AI 튜터를 사용하는 방법과 AI 도구(MCP)로 등록하는 방법 Source: https://developers.edutap.ai/docs/integration ## AI 튜터 이해하기 AI 튜터는 강의 데이터를 기반으로 서비스를 운영해요. 먼저 데이터를 튜터에 업로드해 주세요. 1. [강의 정보 등록](/docs/integration/course-information): 필수. 강의, 섹션, 클립 구조를 JSON으로 등록 2. [강의 자막 등록](/docs/integration/subtitles): 필수. 자막 파일 또는 영상 파일 업로드 3. [강의 자료 등록](/docs/integration/materials): 권장. PDF, PPT 같은 학습 자료 업로드 업로드하신 데이터가 전처리된 이후 튜터를 사용하실 수 있어요. | 주요 기능 | 설명 | | ----- | ------------------------------------ | | 채팅 | 강의 내용을 질문하고 강의 데이터에 기반한 답변을 받을 수 있어요 | | 추천 질문 | 다른 사용자들이 주로 궁금해했던 내용으로 질문할 수 있어요 | | 학습 퀴즈 | 문제를 풀어보면서 강의 내용을 학습할 수 있어요 | | 플래시카드 | 강의 핵심 키워드를 빠르게 익힐 수 있어요 | | 학습 노트 | 강의 내용을 노트 한 장으로 빠르게 훑을 수 있어요 | ## AI 도구로 등록하기 Claude, Codex 같은 AI 도구에 EduTAP MCP 서버를 연결하면 API를 직접 호출하지 않고 대화로 강의 데이터를 등록할 수 있어요. 커리큘럼 파일을 주면 강의 정보를 만들어 등록하고, 내 컴퓨터에 있는 자막과 자료 파일도 올려줘요. ### 설치하기 Node.js 22 이상과 EduTAP API 키가 필요해요. 사용하는 AI 도구에 맞는 명령을 터미널에서 실행해요. ``는 발급받은 키로 바꿔 주세요. ```sh # Claude Code claude mcp add edutap -e TAP_API_KEY= -- npx -y @coxwave/tap-mcp # Codex codex mcp add edutap --env TAP_API_KEY= -- npx -y @coxwave/tap-mcp # Gemini CLI gemini mcp add -e TAP_API_KEY= edutap npx -y @coxwave/tap-mcp ``` Claude Desktop, Cursor 같은 도구는 MCP 설정 파일에 아래 내용을 추가해요. ```json { "mcpServers": { "edutap": { "command": "npx", "args": ["-y", "@coxwave/tap-mcp"], "env": { "TAP_API_KEY": "" } } } } ``` ### 제공 도구 | 도구 | 용도 | | -------------------------------- | --------------------------------------------------------- | | `edutap_build_course_info` | 커리큘럼으로 강의 정보 JSON을 만들고 검증해요. | | `edutap_register_course_info` | 강의 정보를 등록해요. | | `edutap_upload_course_materials` | 파일 경로를 알려주면 내 컴퓨터의 자막, 영상, 자료 파일을 클립에 올려요. | | `edutap_list_courses` | 등록된 강의를 강의 ID나 제목으로 찾아요. 일치하는 강의가 없으면 전체 목록을 보여줘요. | | `edutap_get_course_status` | 강의의 처리 상태를 요약해요. 준비되지 않은 클립, 자막이 없는 클립, 처리에 실패한 자료를 알려줘요. | ### 요청 예시 | 하고 싶은 일 | 요청 예시 | | ---------- | ------------------------------------------------------------------------ | | 강의 정보 등록 | 첨부한 커리큘럼 파일로 강의 정보를 만들어서 등록해줘 | | 강의 데이터 업로드 | \~/Downloads의 PYB01-0101.srt와 PYB01-0101.pdf를 파이썬 기초 완성 강의의 첫 번째 클립에 올려줘 | | 강의 찾기 | 파이썬 강의 찾아줘 | | 처리 상태 확인 | 파이썬 기초 완성 강의의 처리 상태를 알려줘 | ## 자주 묻는 질문 **Q. 데이터를 등록했어요. 언제부터 사용 가능한가요?** 매일 KST 01:00(UTC 16:00)에 전처리가 시작돼요. 전처리가 완료되면 튜터를 사용하실 수 있어요. **Q. 강의 자막은 없는데 강의 영상으로도 연동할 수 있나요?** 네. 자막 대신 영상 다운로드 URL을 등록하면 튜터 서버에서 자막을 추출해요. 지원 형식은 [강의 자막 등록하기](/docs/integration/subtitles)에서 확인할 수 있어요. **Q. 강의 자료를 등록하지 않아도 튜터를 사용할 수 있나요?** 네. 하지만 자료가 더 다양한 컨텍스트로 서비스를 제공할 수 있어 등록을 권장드려요. ## 더 알아보기 - [강의 정보 등록하기](/docs/integration/course-information): 강의 구조 데이터를 JSON으로 등록하기 - [강의 자막 등록하기](/docs/integration/subtitles): 자막 파일을 올리거나 영상 파일로 자막 추출하기 - [강의 자료 등록하기](/docs/integration/materials): PDF, PPT 같은 학습 자료 등록하기 --- # 강의 자료 등록하기 > PDF, PPT 같은 학습 자료를 등록해 AI 튜터의 답변 품질 높이기 Source: https://developers.edutap.ai/docs/integration/materials ## 강의 자료 강의 자료는 course > section (optional) > clip 어느 단계에나 등록할 수 있어요. 필수는 아니지만, 자료가 있으면 튜터가 더 다양한 컨텍스트로 답할 수 있어요. ## 지원 포맷 | 구분 | type | 형식 | | -- | --------- | ---------------------------------------------------- | | 자료 | `content` | PDF, PPT, PPTX, DOC, DOCX, HTML, PY, IPYNB, TXT, CSV | ## 더 알아보기 - [강의 데이터 등록 API](/docs/api/materials/upload): 요청 형식과 필드 설명, 언어별 예시 --- # 강의 자막 등록하기 > 자막 파일을 올리거나 영상 파일로 자막을 추출하기 Source: https://developers.edutap.ai/docs/integration/subtitles ## 강의 자막 강의 자막은 clip 단위로 등록해요. 자막 파일이 없더라도 영상 파일을 올리면 튜터 서버에서 자막을 추출해요. ## 지원 포맷 | 구분 | type | 형식 | | -- | -------- | ------------------------------------------------- | | 자막 | `script` | SRT, VTT | | 영상 | `video` | MP4, AVI, MOV, MKV, WMV, FLV, WEBM, M4V, 3GP, OGV | ## 더 알아보기 - [강의 데이터 등록 API](/docs/api/materials/upload): 요청 형식과 필드 설명, 언어별 예시 --- # Blackboard에서 EduTap LTI 1.3 설정하기 > Blackboard 시스템 관리자를 위한 EduTap LTI 1.3 도구 제공자 설정 가이드 Source: https://developers.edutap.ai/docs/lti/blackboard 이 가이드는 Blackboard Learn Ultra 시스템 관리자가 EduTap을 LTI 1.3 도구 제공자로 등록하고 설정하는 과정을 안내합니다. ## 사전 요구 사항 * **Blackboard Learn Ultra** (Blackboard Learn Original에서는 LTI 1.3이 지원되지 않음) * Blackboard 인스턴스의 **시스템 관리자** 권한 * EduTap 계정 및 API 키 (EduTap 담당자에게 문의) > LTI 1.3은 OAuth 1.0 기반의 LTI 1.1을 OAuth 2.0 + JWT 기반 인증으로 대체하여, 더 강력한 보안과 풍부한 기능을 제공합니다. *** ## EduTap LTI 제품 정보 | 항목 | 값 | | --- | --- | | Client ID | `86a58b15-29b1-429c-9bec-9814cde94866` | | Name | `EduTAP` | | Description | `AI Tutor chatbot for online courses` | | Tool Launch URL | `https://tapapi.coxwave.link/api/v1/lti/launch` | | OIDC Login Initiation URL | `https://tapapi.coxwave.link/api/v1/lti/login` | | Public Keyset URL | `https://tapapi.coxwave.link/api/v1/lti/jwks` | | Redirect URIs | `https://tapapi.coxwave.link/api/v1/lti/launch` | *** ## Step 1: Blackboard에 LTI 도구 등록 1. **시스템 관리자**로 Blackboard 인스턴스에 로그인합니다. 2. **System Admin** > **Integrations** > **LTI Tool Providers**로 이동합니다. 3. **Register LTI 1.3/Advantage Tool**을 클릭합니다. 4. **Client ID** 필드에 아래 값을 입력합니다: ``` 86a58b15-29b1-429c-9bec-9814cde94866 ``` 5. **Submit**을 클릭합니다. > Client ID를 입력하고 Submit을 누르면, Blackboard가 EduTap의 등록 정보를 자동으로 조회하여 **Tool Launch URL**, **OIDC Login URL**, **JWKS URL**, **Redirect URIs**, **Domain** 등의 필드를 자동으로 채웁니다. 6. 자동으로 채워진 값들이 아래와 일치하는지 확인합니다: | 항목 | 자동 채워지는 값 | | --- | --- | | Initiate Login URL | `https://tapapi.coxwave.link/api/v1/lti/login` | | Tool Redirect URL(s) | `https://tapapi.coxwave.link/api/v1/lti/launch` | | JWKS URL | `https://tapapi.coxwave.link/api/v1/lti/jwks` | 7. **Deployment ID**가 생성됩니다. 이 값을 기록해 두세요 — Step 4에서 EduTap 팀에 전달해야 합니다. *** ## Step 2: 도구 설정 구성 도구 등록 후 설정을 구성합니다: 1. LTI Tool Providers 목록에서 **EduTAP**을 찾아 **Edit**을 클릭합니다. 2. 아래 설정을 확인하거나 업데이트합니다: | 설정 | 권장 값 | | --- | --- | | Tool Status | Approved | | User Fields to Send | Role in Course, Name, Email Address | | Allow Grade Service Access | Yes | | Allow Membership Service Access | Yes | > **Allow Grade Service Access**를 반드시 **Yes**로 설정하세요. 이 설정이 없으면 성적 연동이 동작하지 않습니다. 3. **Submit**을 클릭하여 변경 사항을 저장합니다. *** ## Step 3: Placement 생성 Placement는 도구가 코스 내에서 어떻게 표시될지 정의합니다. 1. LTI Tool Providers 페이지에서 **EduTAP** 옆의 드롭다운 화살표를 클릭합니다. 2. **Manage Placements** > **Create Placement**를 선택합니다. 3. 아래 내용을 입력합니다: | 항목 | 값 | | --- | --- | | Label | EduTap AI Tutor | | Handle | edutap-ai-tutor | | Availability | Yes | | Type | Deep Linking content tool | | Launch in New Window | No (iframe 경험을 위해 권장) | | Target Link URI | https://tapapi.coxwave.link/api/v1/lti/launch | 4. **Submit**을 클릭합니다. *** ## Step 4: EduTap에 Deployment ID 전달 아래 정보를 EduTap 통합 팀(**[support@edutap.ai](mailto:support@edutap.ai)**)에 전달해 주세요: 1. **Deployment ID** (Step 1에서 생성된 값) 2. **Blackboard 인스턴스 URL** (예: `https://your-university.blackboard.com`) EduTap 팀이 서버 측에서 이 값들을 설정하고, 테스트 준비가 완료되면 확인해 드립니다. *** ## Step 5: 통합 검증 EduTap 팀이 설정 완료를 확인한 후: 1. 강사 권한으로 Blackboard의 **코스**에 접속합니다. 2. **Content** > **Build Content**를 클릭하거나 Placement가 추가된 콘텐츠 영역을 사용합니다. 3. 도구 목록에서 **EduTap AI Tutor**를 선택합니다. 4. 코스 내에서 EduTap 인터페이스가 올바르게 로드되는지 확인합니다. ### 문제 해결 | 문제 | 해결 방법 | | --- | --- | | "Tool is not configured" 오류 | Deployment ID가 EduTap에 전달되고 확인되었는지 확인하세요. | | 실행 시 빈 화면 | Tool Launch URL이 올바른지, 도구 상태가 Approved인지 확인하세요. | | 사용자 역할이 인식되지 않음 | User Fields to Send에서 'Role in Course'가 활성화되어 있는지 확인하세요. | | OIDC 로그인 실패 | 자동 채워진 OIDC Login Initiation URL이 올바른지 확인하세요. | | JWKS 검증 오류 | 자동 채워진 Public Keyset URL에 접근 가능한지 확인하세요. | | 성적이 연동되지 않음 | Allow Grade Service Access가 Yes로 설정되어 있는지 확인하세요. | > 문제가 지속되면 Blackboard 인스턴스 URL과 오류 상세 정보를 포함하여 EduTap 지원팀에 문의하세요. *** ## 참고: EduTap LTI 엔드포인트 | 엔드포인트 | URL | | --- | --- | | Client ID | `86a58b15-29b1-429c-9bec-9814cde94866` | | Tool Launch URL | `https://tapapi.coxwave.link/api/v1/lti/launch` | | OIDC Login URL | `https://tapapi.coxwave.link/api/v1/lti/login` | | Public Keyset URL | `https://tapapi.coxwave.link/api/v1/lti/jwks` | | Redirect URIs | `https://tapapi.coxwave.link/api/v1/lti/launch` | *** ## 더 알아보기 - [SDK 연동](/ko/docs/sdk): JavaScript SDK로 EduTap 임베드하기 - [데이터 연동](/ko/docs/integration): AI 튜터를 위한 코스 데이터 업로드 --- # 시작하기 > 프로젝트에 AI 튜터를 설치하고 첫 화면을 확인하는 방법 Source: https://developers.edutap.ai/docs/sdk ## 5분 빠른 시작 React와 Next.js에서는 npm 패키지 사용을 권장합니다. ### 1. 패키지 설치 ```bash npm install @coxwave/tap-kit ``` ### 2. Chatbot 렌더링 ```tsx 'use client'; import { TapKit, useTapKit } from '@coxwave/tap-kit/react'; export function CourseChatbot() { const tapkit = useTapKit({ apiKey: 'your-api-key', userId: 'user-123', courseId: 'course-456', clipId: 'clip-789', mode: 'inline', }); return ; } ``` > **Next.js App Router** > > > `useTapKit`은 브라우저 API를 사용합니다. 컴포넌트 파일 상단에 반드시 `'use client'`를 추가하세요. ### 3. 설치 확인 다음 항목이 보이면 기본 설치가 완료된 것입니다. * 페이지에 Chatbot 영역이 표시됩니다. * 질문 입력창이 활성화됩니다. * 브라우저 콘솔에 TapKit 초기화 오류가 없습니다. * 강의 데이터 전처리가 끝났다면 강의 내용에 대한 답변을 받을 수 있습니다. ## 자주 발생하는 문제 | 증상 | 먼저 확인할 것 | | ------------------- | -------------------------------------- | | Chatbot이 표시되지 않음 | API 키와 Chatbot 컨테이너 높이 | | UI는 보이지만 답변이 나오지 않음 | 강의 데이터 등록·전처리 상태와 `courseId`, `clipId` | | Next.js에서 초기화되지 않음 | `'use client'` 선언 여부 | | CDN 스크립트를 불러오지 못함 | CSP의 `script-src`, 네트워크 방화벽 | [전체 문제 해결 가이드 보기 →](/docs/sdk/reference/troubleshooting) ## 다음 단계 - [강의 데이터 등록](/docs/integration): AI 튜터가 답변할 강의 정보와 자막 등록 - [비디오 연동](/docs/sdk/features/video): 재생 시점과 Chatbot 타임라인 동기화 - [스타일링](/docs/sdk/features/styling): 표시 모드, 크기와 위치 설정 - [SDK](/docs/api/sdk): 옵션, 메서드와 이벤트 전체 목록 --- # 대량 수강생 등록 (CSV) > CSV 파일로 다수의 수강생 정보를 일괄 등록합니다. Source: https://developers.edutap.ai/docs/api/client-users/bulk-csv **POST** `/api/v1/client-user/bulk/csv` ## Request Body (multipart/form-data) | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `file` | File | 예 | CSV 파일 (UTF-8 인코딩, BOM 지원) | #### CSV 형식 | Column | Required | Description | | --- | --- | --- | | `user_id` | O | 수강생 고유 식별자 | | `age` | - | 나이 (숫자) | | `gender` | - | 성별 | | `job_category` | - | 직군 코드 | | `years_of_experience` | - | 경력 연수 (숫자) | | `school_year` | - | 학년 (숫자) | | `department` | - | 학과/부서 코드 | | `extra_properties` | - | 추가 속성 (JSON 문자열) | #### CSV Example ```csv user_id,age,gender,job_category,years_of_experience,school_year,department,extra_properties user001,25,M,ITSW,3,,ENGINEERING, user002,30,F,FINANCE,5,,ECONOMIC,"{""team"":""backend""}" user003,22,M,,,2,NATURAL_SCIENCE, ``` ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `total_rows` | Number | 예 | 처리된 전체 행 수 | | `inserted_count` | Number | 예 | 신규 등록된 사용자 수 | | `skipped_count` | Number | 예 | 중복으로 건너뛴 사용자 수 | | `failed_count` | Number | 예 | 검증 실패로 등록되지 않은 수 | **cURL** ```bash curl -X POST "https://tapapi.coxwave.link/api/v1/client-user/bulk/csv" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -F "file=@users.csv" ``` **Python** ```python import requests res = requests.post( "https://tapapi.coxwave.link/api/v1/client-user/bulk/csv", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, files=[("file", open("users.csv", "rb"))], ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "mime/multipart" "net/http" "os" ) func main() { var buf bytes.Buffer w := multipart.NewWriter(&buf) f, _ := os.Open("users.csv") part, _ := w.CreateFormFile("file", "users.csv") io.Copy(part, f) f.Close() w.Close() req, _ := http.NewRequest("POST", "https://tapapi.coxwave.link/api/v1/client-user/bulk/csv", &buf) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") req.Header.Set("Content-Type", w.FormDataContentType()) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import okhttp3.*; import java.io.File; public class Main { public static void main(String[] args) throws Exception { OkHttpClient client = new OkHttpClient(); MultipartBody.Builder builder = new MultipartBody.Builder().setType(MultipartBody.FORM); builder.addFormDataPart("file", "users.csv", RequestBody.create(new File("users.csv"), MediaType.get("application/octet-stream"))); Request.Builder rb = new Request.Builder() .url("https://tapapi.coxwave.link/api/v1/client-user/bulk/csv") .header("TAP-API-KEY", "{YOUR_API_KEY}") .post(builder.build()); try (Response res = client.newCall(rb.build()).execute()) { System.out.println(res.body().string()); } } } ``` **Node.js** ```javascript import { readFile } from "node:fs/promises"; const form = new FormData(); form.append("file", new Blob([await readFile("users.csv")]), "users.csv"); const res = await fetch("https://tapapi.coxwave.link/api/v1/client-user/bulk/csv", { method: "POST", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, body: form, }); console.log(await res.json()); ``` ```json { "total_rows": 100, "inserted_count": 95, "skipped_count": 3, "failed_count": 2 } ``` --- # 대량 수강생 등록 (JSON) > JSON 배열로 다수의 수강생 정보를 일괄 등록합니다. Source: https://developers.edutap.ai/docs/api/client-users/bulk-json **PATCH** `/api/v1/client-user/bulk/json` ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `user_items` | Array of objects | 예 | 수강생 정보 배열 | | `user_items[].external_user_id` | String | 예 | 수강생 고유 식별자 | | `user_items[].age` | Number | 아니요 | 나이 | | `user_items[].gender` | String | 아니요 | 성별 | | `user_items[].job_category` | String | 아니요 | 직군 코드 | | `user_items[].years_of_experience` | Number | 아니요 | 경력 연수 | | `user_items[].school_year` | Number | 아니요 | 학년 | | `user_items[].department` | String | 아니요 | 학과/부서 코드 | | `user_items[].extra_properties` | Object | 아니요 | 추가 속성 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `total_rows` | Number | 예 | 처리된 전체 수강생 수 | | `inserted_count` | Number | 예 | 신규 등록된 수강생 수 | | `skipped_count` | Number | 예 | 중복으로 건너뛴 수강생 수 | | `failed_count` | Number | 예 | 등록 실패 수 (JSON의 경우 항상 0) | **cURL** ```bash curl -X PATCH "https://tapapi.coxwave.link/api/v1/client-user/bulk/json" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "user_items": [ { "external_user_id": "user001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }, { "external_user_id": "user002", "age": 30, "gender": "F", "job_category": "FINANCE", "years_of_experience": 5, "department": "ECONOMIC" } ] }' ``` **Python** ```python import requests res = requests.patch( "https://tapapi.coxwave.link/api/v1/client-user/bulk/json", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "user_items": [ { "external_user_id": "user001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }, { "external_user_id": "user002", "age": 30, "gender": "F", "job_category": "FINANCE", "years_of_experience": 5, "department": "ECONOMIC" } ] }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "user_items": [ { "external_user_id": "user001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }, { "external_user_id": "user002", "age": 30, "gender": "F", "job_category": "FINANCE", "years_of_experience": 5, "department": "ECONOMIC" } ] }`) req, _ := http.NewRequest("PATCH", "https://tapapi.coxwave.link/api/v1/client-user/bulk/json", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "user_items": [ { "external_user_id": "user001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }, { "external_user_id": "user002", "age": 30, "gender": "F", "job_category": "FINANCE", "years_of_experience": 5, "department": "ECONOMIC" } ] } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tapapi.coxwave.link/api/v1/client-user/bulk/json")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tapapi.coxwave.link/api/v1/client-user/bulk/json", { method: "PATCH", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "user_items": [ { "external_user_id": "user001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }, { "external_user_id": "user002", "age": 30, "gender": "F", "job_category": "FINANCE", "years_of_experience": 5, "department": "ECONOMIC" } ] }), }); console.log(await res.json()); ``` ```json { "total_rows": 50, "inserted_count": 48, "skipped_count": 2, "failed_count": 0 } ``` --- # 대량 수강생 등록 삭제 > 다수의 수강생 정보를 일괄 삭제합니다. (Soft Delete) Source: https://developers.edutap.ai/docs/api/client-users/bulk **PATCH** `/api/v1/client-user/bulk` ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `user_ids` | String | 예 | 삭제할 수강생 ID 목록 (쉼표로 구분) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `total_count` | Number | 예 | 요청된 전체 수강생 수 | | `deleted_count` | Number | 예 | 삭제된 수강생 수 | | `not_found_count` | Number | 예 | 존재하지 않거나 이미 삭제된 수강생 수 | **cURL** ```bash curl -X PATCH "https://tapapi.coxwave.link/api/v1/client-user/bulk" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "user_ids": "user001,user002,user003" }' ``` **Python** ```python import requests res = requests.patch( "https://tapapi.coxwave.link/api/v1/client-user/bulk", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "user_ids": "user001,user002,user003" }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "user_ids": "user001,user002,user003" }`) req, _ := http.NewRequest("PATCH", "https://tapapi.coxwave.link/api/v1/client-user/bulk", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "user_ids": "user001,user002,user003" } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tapapi.coxwave.link/api/v1/client-user/bulk")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tapapi.coxwave.link/api/v1/client-user/bulk", { method: "PATCH", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "user_ids": "user001,user002,user003" }), }); console.log(await res.json()); ``` ```json { "total_count": 3, "deleted_count": 2, "not_found_count": 1 } ``` --- # 수강생 등록 > 단일 수강생을 등록합니다. Source: https://developers.edutap.ai/docs/api/client-users/create **POST** `/api/v1/client-user` ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `user_id` | String | 예 | 수강생 고유 식별자 | | `age` | Number | 아니요 | 나이, (0 < age < 100) | | `gender` | String | 아니요 | 성별 (남: M, 여: F) | | `job_category` | String | 아니요 | 직무 | | `years_of_experience` | Number | 아니요 | 경력 연수, (0 < age < 100) | | `school_year` | Number | 아니요 | 학년, (0 < school_year < 15) | | `department` | String | 아니요 | 학과/부서 | | `extra_properties` | Object | 아니요 | 추가 속성 (자유 형식) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `result` | Boolean | 예 | 성공 여부 | **cURL** ```bash curl -X POST "https://tapapi.coxwave.link/api/v1/client-user" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "user_id": "user-001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }' ``` **Python** ```python import requests res = requests.post( "https://tapapi.coxwave.link/api/v1/client-user", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "user_id": "user-001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "user_id": "user-001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }`) req, _ := http.NewRequest("POST", "https://tapapi.coxwave.link/api/v1/client-user", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "user_id": "user-001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tapapi.coxwave.link/api/v1/client-user")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tapapi.coxwave.link/api/v1/client-user", { method: "POST", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "user_id": "user-001", "age": 25, "gender": "M", "job_category": "ITSW", "years_of_experience": 3, "department": "ENGINEERING" }), }); console.log(await res.json()); ``` ```json { "result": true } ``` --- # 수강생 등록 삭제 > 단일 수강생 등록을 삭제합니다. (Soft Delete) Source: https://developers.edutap.ai/docs/api/client-users/update **PATCH** `/api/v1/client-user` > 수강생 등록 삭제는 소프트 삭제(Soft Delete)로 처리됩니다. 데이터는 DB에 유지되며 `is_deleted` 플래그가 설정됩니다. ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `user_id` | String | 예 | 삭제할 수강생 ID | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `result` | Boolean | 예 | 성공 여부 | **cURL** ```bash curl -X PATCH "https://tapapi.coxwave.link/api/v1/client-user" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "user_id": "user-001" }' ``` **Python** ```python import requests res = requests.patch( "https://tapapi.coxwave.link/api/v1/client-user", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "user_id": "user-001" }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "user_id": "user-001" }`) req, _ := http.NewRequest("PATCH", "https://tapapi.coxwave.link/api/v1/client-user", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "user_id": "user-001" } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tapapi.coxwave.link/api/v1/client-user")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tapapi.coxwave.link/api/v1/client-user", { method: "PATCH", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "user_id": "user-001" }), }); console.log(await res.json()); ``` ```json { "result": true } ``` --- # 클립 상태 조회 > 특정 클립의 처리 상태를 조회합니다. Source: https://developers.edutap.ai/docs/api/courses/clip-status **GET** `/api/v1/courses/{course_id}/clips/{clip_id}/status` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | | `clip_id` | String | 예 | 클립 ID | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `clip_id` | String | 예 | 클립 ID | | `status` | enum | 예 | 클립의 처리 상태 (`PENDING`: 클립을 준비하고 있음, `PROCESSING`: 클립을 처리하고 있음, `READY`: 클립을 사용할 수 있음) | | `status_description` | String | 예 | 상태에 대한 설명 메시지 | **cURL** ```bash curl -X GET "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/clips/1000_1_1_1/status" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.get( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/clips/1000_1_1_1/status", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/clips/1000_1_1_1/status", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/clips/1000_1_1_1/status")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/clips/1000_1_1_1/status", { method: "GET", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "clip_id": "1000_1_1_1", "status": "READY", "status_description": "클립을 사용할 수 있습니다" } ``` --- # 강의 정보 삭제 > 특정 강의의 정보를 삭제합니다. Source: https://developers.edutap.ai/docs/api/courses/delete **DELETE** `/api/v1/courses/{course_id}/information` > **주의**: 강의 정보가 삭제되면 해당 강의와 연관된 모든 데이터 및 기능이 지원되지 않습니다. ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 삭제할 강의 ID | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `message` | String | 예 | 서버 메시지 | **cURL** ```bash curl -X DELETE "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.delete( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("DELETE", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .DELETE() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", { method: "DELETE", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "message": "Course information deleted successfully" } ``` --- # 강의 정보 조회 > 특정 강의의 상세 정보를 조회합니다. Source: https://developers.edutap.ai/docs/api/courses/get **GET** `/api/v1/courses/{course_id}/information` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_information` | Object | 예 | 강의 정보 객체 | | `course_information.material_id` | String | 예 | 자료 ID | | `course_information.client_identity` | String | 예 | 고객사명 | | `course_information.course_id` | String | 예 | 강의 ID | | `course_information.course_type` | String | 아니요 | 강의 유형 | | `course_information.course_title` | String | 예 | 강의 제목 | | `course_information.course_description` | String | 아니요 | 강의 설명 | | `course_information.course_categories` | Array of objects | 아니요 | 카테고리 목록 | | `course_information.total_play_time` | Number | 예 | 총 재생 시간 (초) | | `course_information.total_clip_count` | Number | 예 | 총 클립 수 | | `course_information.instructors` | Array of objects | 아니요 | 강사 목록 | | `course_information.course_curriculum` | Object | 예 | 커리큘럼 | | `course_information.thumbnail_url` | String | 아니요 | 강의 썸네일 이미지 URL | | `course_information.course_url` | String | 아니요 | 강의 상세 페이지 URL | | `course_information.price` | Number | 아니요 | 강의 가격 | | `course_information.created_at` | String | 예 | 생성일시 | | `course_information.updated_at` | String | 아니요 | 수정일시 | **cURL** ```bash curl -X GET "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.get( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", { method: "GET", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "course_information": { "material_id": "mat_123", "client_identity": "Coxwave", "course_id": "1000", "course_type": "internet", "course_title": "파이썬 기초 프로그래밍", "course_description": "파이썬 프로그래밍의 기초를 배우는 강의입니다.", "course_categories": [ { "site": "coxwave", "course_category_id": "1", "course_category_parent_id": "", "depth": 0, "course_category_title": "Programming" } ], "total_play_time": 1800, "total_clip_count": 4, "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_order": 1, "section_title": "Part 1. 파이썬 기초", "depth": 1, "total_play_time": 900, "total_clip_count": 2, "clips": [ { "clip_id": "1000_1_1", "clip_order": 1, "clip_title": "변수 선언", "clip_play_time": 450 } ] } ] }, "created_at": "2025-01-15T10:00:00", "updated_at": null } } ``` --- # 강의 목록 조회 > 등록된 강의 목록을 페이지네이션으로 조회합니다. Source: https://developers.edutap.ai/docs/api/courses/list **GET** `/api/v1/courses` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `page` | Number | 아니요 | 페이지 번호 (기본값: 1, 최소: 1) | | `size` | Number | 아니요 | 페이지당 항목 수 (기본값: 10, 범위: 1~100) | | `organization` | String | 아니요 | 조직 식별자 (미지정 시 전체 조회) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `pagination` | Object | 예 | 페이지네이션 정보 | | `pagination.current_page` | Number | | 현재 페이지 | | `pagination.page_size` | Number | | 페이지당 항목 수 | | `pagination.total_count` | Number | | 전체 항목 수 | | `pagination.total_pages` | Number | | 전체 페이지 수 | | `pagination.has_next` | Boolean | | 다음 페이지 존재 여부 | | `pagination.has_previous` | Boolean | | 이전 페이지 존재 여부 | | `courses` | Array of objects | 예 | 강의 요약 정보 배열 | | `courses[].course_id` | String | | 강의 ID | | `courses[].course_title` | String | | 강의명 | | `courses[].course_category` | String | | 강의 카테고리 | | `courses[].clip_count` | Number | | 클립 수 | | `courses[].total_play_time` | Number | | 총 재생 시간 (초) | | `courses[].upload_date` | String | | 업로드 일자 (YYYY.MM.DD) | | `courses[].script_count` | Number | | 강의자막 개수 | | `courses[].content_count` | Number | | 강의자료 개수 | | `courses[].has_sections` | Boolean | | 섹션 존재 여부 | **cURL** ```bash curl -X GET "https://tap-file-upload-production.coxwave.link/api/v1/courses?page=1&size=10&organization=A" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.get( "https://tap-file-upload-production.coxwave.link/api/v1/courses?page=1&size=10&organization=A", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://tap-file-upload-production.coxwave.link/api/v1/courses?page=1&size=10&organization=A", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses?page=1&size=10&organization=A")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses?page=1&size=10&organization=A", { method: "GET", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "pagination": { "current_page": 1, "page_size": 10, "total_count": 60, "total_pages": 6, "has_next": true, "has_previous": false }, "courses": [ { "course_id": "1000", "course_title": "파이썬 기초 프로그래밍", "course_category": "Programming", "clip_count": 4, "total_play_time": 1800, "upload_date": "2025.01.15", "script_count": 3, "content_count": 8, "has_sections": true } ] } ``` --- # 강의 정보 등록 > 여러 강의의 정보를 한 번에 업로드합니다. Source: https://developers.edutap.ai/docs/api/courses/register **POST** `/api/v1/courses/information` ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `courses` | Array of objects | 예 | 강의 정보 배열 | | `courses[].organization` | String | 아니요 | 조직 식별자 | | `courses[].course_id` | String | 예 | 강의 고유 식별자 | | `courses[].course_type` | String | 아니요 | 강의 유형 (예: internet) | | `courses[].course_title` | String | 예 | 강의 제목 | | `courses[].course_description` | String | 아니요 | 강의 설명 | | `courses[].course_categories` | Array of objects | 아니요 | 카테고리 정보 배열 | | `courses[].course_categories[].site` | String | 예 | 사이트명 | | `courses[].course_categories[].course_category_id` | String | 예 | 카테고리 ID | | `courses[].course_categories[].course_category_parent_id` | String | 아니요 | 부모 카테고리 ID | | `courses[].course_categories[].depth` | Number | 예 | 카테고리 깊이 (0부터 시작) | | `courses[].course_categories[].course_category_title` | String | 예 | 카테고리명 | | `courses[].instructors` | Array of objects | 아니요 | 강사 정보 배열 | | `courses[].instructors[].instructor` | String | 예 | 강사명 | | `courses[].instructors[].instructor_number` | String | 아니요 | 강사 번호 | | `courses[].course_curriculum` | Object | 예 | 커리큘럼. sections 또는 clips를 가질 수 있으며 둘 다 선택입니다. 빈 객체면 클립 없이 강의 단위로만 구성된 강의(course-only)로 등록됩니다. | | `courses[].course_curriculum.sections` | Array of objects | 아니요 | 섹션 배열. 강의를 섹션 단위로 나눌 때 사용합니다. | | `courses[].course_curriculum.sections[].section_id` | String | 예 | 섹션 고유 식별자 | | `courses[].course_curriculum.sections[].section_title` | String | 예 | 섹션 제목 | | `courses[].course_curriculum.sections[].section_description` | String | 아니요 | 섹션 설명 | | `courses[].course_curriculum.sections[].sections` | Array of objects | 아니요 | 하위 섹션 (중첩 가능) | | `courses[].course_curriculum.sections[].clips` | Array of objects | 아니요 | 클립 배열 | | `courses[].course_curriculum.sections[].preprocessing_required` | Boolean | 아니요 | 섹션 전처리 필수 여부. 생략 시 true. false면 전처리 없이 바로 사용 가능. | | `courses[].course_curriculum.clips` | Array of objects | 아니요 | 클립 배열. 섹션 없이 강의 바로 아래에 클립을 둘 때 사용합니다. | | `courses[].course_curriculum.clips[].clip_id` | String | 예 | 클립 고유 식별자 | | `courses[].course_curriculum.clips[].clip_title` | String | 예 | 클립 제목 | | `courses[].course_curriculum.clips[].clip_description` | String | 아니요 | 클립 설명 | | `courses[].course_curriculum.clips[].clip_play_time` | Number | 아니요 | 재생 시간 (초). 미입력 시 0 | | `courses[].course_curriculum.clips[].clip_level` | Number | 아니요 | 난이도 (1: 쉬움, 2: 보통, 3: 어려움) | | `courses[].course_curriculum.clips[].preprocessing_required` | Boolean | 아니요 | 클립 전처리 필수 여부. 생략 시 true. false면 전처리 없이 바로 사용 가능. | | `courses[].preprocessing_required` | Boolean | 아니요 | 코스 레벨 전처리 필수 여부. 생략 시 true(전처리 완료돼야 AI 튜터에서 사용 가능). false면 전처리 없이 바로 사용 가능. | | `courses[].thumbnail_url` | String | 아니요 | 강의 썸네일 이미지 URL. http/https 형식만 허용, 빈 문자열 불가 | | `courses[].course_url` | String | 아니요 | 강의 상세 페이지 URL. http/https 형식만 허용, 빈 문자열 불가 | | `courses[].price` | Number | 아니요 | 강의 가격. 0 이상 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `success_courses` | Array of strings | 예 | 업로드 성공한 강의 ID 목록 | | `failed_courses` | Array of strings | 예 | 업로드 실패한 강의 ID 목록 | **cURL** ```bash curl -X POST "https://tap-file-upload-production.coxwave.link/api/v1/courses/information" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "courses": [ { "organization": "A연수원", "course_id": "1000", "course_type": "internet", "course_title": "파이썬 기초", "course_description": "파이썬 프로그래밍 기초 강의", "preprocessing_required": false, "thumbnail_url": "https://cdn.example.com/courses/1000/thumbnail.png", "course_url": "https://example.com/courses/1000", "price": 49000, "instructors": [{ "instructor": "콕웨", "instructor_number": "001" }], "course_categories": [ { "site": "coxwave", "course_category_id": "1", "course_category_parent_id": "", "depth": 0, "course_category_title": "프로그래밍" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "section_description": "파이썬의 기본 문법과 자료형을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_description": "파이썬의 변수 선언 방법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_description": "기본 자료형(int, str, list, dict)", "clip_play_time": 450, "preprocessing_required": true } ] }, { "section_id": "1000_2", "section_title": "Part 2. 제어문", "section_description": "조건문과 반복문을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_2_1", "clip_title": "조건문", "clip_description": "if, elif, else 사용법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_2_2", "clip_title": "반복문", "clip_description": "for, while 사용법", "clip_play_time": 450, "preprocessing_required": true } ] } ] } } ] }' ``` **Python** ```python import requests res = requests.post( "https://tap-file-upload-production.coxwave.link/api/v1/courses/information", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "courses": [ { "organization": "A연수원", "course_id": "1000", "course_type": "internet", "course_title": "파이썬 기초", "course_description": "파이썬 프로그래밍 기초 강의", "preprocessing_required": False, "thumbnail_url": "https://cdn.example.com/courses/1000/thumbnail.png", "course_url": "https://example.com/courses/1000", "price": 49000, "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_categories": [ { "site": "coxwave", "course_category_id": "1", "course_category_parent_id": "", "depth": 0, "course_category_title": "프로그래밍" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "section_description": "파이썬의 기본 문법과 자료형을 배웁니다", "preprocessing_required": False, "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_description": "파이썬의 변수 선언 방법", "clip_play_time": 450, "preprocessing_required": True }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_description": "기본 자료형(int, str, list, dict)", "clip_play_time": 450, "preprocessing_required": True } ] }, { "section_id": "1000_2", "section_title": "Part 2. 제어문", "section_description": "조건문과 반복문을 배웁니다", "preprocessing_required": False, "clips": [ { "clip_id": "1000_2_1", "clip_title": "조건문", "clip_description": "if, elif, else 사용법", "clip_play_time": 450, "preprocessing_required": True }, { "clip_id": "1000_2_2", "clip_title": "반복문", "clip_description": "for, while 사용법", "clip_play_time": 450, "preprocessing_required": True } ] } ] } } ] }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "courses": [ { "organization": "A연수원", "course_id": "1000", "course_type": "internet", "course_title": "파이썬 기초", "course_description": "파이썬 프로그래밍 기초 강의", "preprocessing_required": false, "thumbnail_url": "https://cdn.example.com/courses/1000/thumbnail.png", "course_url": "https://example.com/courses/1000", "price": 49000, "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_categories": [ { "site": "coxwave", "course_category_id": "1", "course_category_parent_id": "", "depth": 0, "course_category_title": "프로그래밍" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "section_description": "파이썬의 기본 문법과 자료형을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_description": "파이썬의 변수 선언 방법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_description": "기본 자료형(int, str, list, dict)", "clip_play_time": 450, "preprocessing_required": true } ] }, { "section_id": "1000_2", "section_title": "Part 2. 제어문", "section_description": "조건문과 반복문을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_2_1", "clip_title": "조건문", "clip_description": "if, elif, else 사용법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_2_2", "clip_title": "반복문", "clip_description": "for, while 사용법", "clip_play_time": 450, "preprocessing_required": true } ] } ] } } ] }`) req, _ := http.NewRequest("POST", "https://tap-file-upload-production.coxwave.link/api/v1/courses/information", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "courses": [ { "organization": "A연수원", "course_id": "1000", "course_type": "internet", "course_title": "파이썬 기초", "course_description": "파이썬 프로그래밍 기초 강의", "preprocessing_required": false, "thumbnail_url": "https://cdn.example.com/courses/1000/thumbnail.png", "course_url": "https://example.com/courses/1000", "price": 49000, "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_categories": [ { "site": "coxwave", "course_category_id": "1", "course_category_parent_id": "", "depth": 0, "course_category_title": "프로그래밍" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "section_description": "파이썬의 기본 문법과 자료형을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_description": "파이썬의 변수 선언 방법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_description": "기본 자료형(int, str, list, dict)", "clip_play_time": 450, "preprocessing_required": true } ] }, { "section_id": "1000_2", "section_title": "Part 2. 제어문", "section_description": "조건문과 반복문을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_2_1", "clip_title": "조건문", "clip_description": "if, elif, else 사용법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_2_2", "clip_title": "반복문", "clip_description": "for, while 사용법", "clip_play_time": 450, "preprocessing_required": true } ] } ] } } ] } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/information")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/information", { method: "POST", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "courses": [ { "organization": "A연수원", "course_id": "1000", "course_type": "internet", "course_title": "파이썬 기초", "course_description": "파이썬 프로그래밍 기초 강의", "preprocessing_required": false, "thumbnail_url": "https://cdn.example.com/courses/1000/thumbnail.png", "course_url": "https://example.com/courses/1000", "price": 49000, "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_categories": [ { "site": "coxwave", "course_category_id": "1", "course_category_parent_id": "", "depth": 0, "course_category_title": "프로그래밍" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "section_description": "파이썬의 기본 문법과 자료형을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_description": "파이썬의 변수 선언 방법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_description": "기본 자료형(int, str, list, dict)", "clip_play_time": 450, "preprocessing_required": true } ] }, { "section_id": "1000_2", "section_title": "Part 2. 제어문", "section_description": "조건문과 반복문을 배웁니다", "preprocessing_required": false, "clips": [ { "clip_id": "1000_2_1", "clip_title": "조건문", "clip_description": "if, elif, else 사용법", "clip_play_time": 450, "preprocessing_required": true }, { "clip_id": "1000_2_2", "clip_title": "반복문", "clip_description": "for, while 사용법", "clip_play_time": 450, "preprocessing_required": true } ] } ] } } ] }), }); console.log(await res.json()); ``` ```json { "success_courses": ["1000"], "failed_courses": [] } ``` --- # 강의 정보 수정 > 특정 강의의 정보를 수정합니다. Source: https://developers.edutap.ai/docs/api/courses/update **PUT** `/api/v1/courses/{course_id}/information` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 수정할 강의 ID | ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_title` | String | 예 | 강의 제목 | | `course_type` | String | 아니요 | 강의 유형 | | `course_description` | String | 아니요 | 강의 설명 | | `course_categories` | Array of objects | 아니요 | 카테고리 정보 배열 | | `instructors` | Array of objects | 아니요 | 강사 정보 배열 | | `course_curriculum` | Object | 예 | 커리큘럼. sections 또는 clips를 가질 수 있으며 둘 다 선택입니다. 빈 객체면 클립 없이 강의 단위로만 구성된 강의(course-only)로 등록됩니다. | | `preprocessing_required` | Boolean | 아니요 | 코스 레벨 전처리 필수 여부. 생략 시 true. 수정은 전체 교체이므로, 값을 보내지 않으면 true로 초기화됩니다. | | `thumbnail_url` | String | 아니요 | 강의 썸네일 이미지 URL. http/https 형식만 허용, 빈 문자열 불가 | | `course_url` | String | 아니요 | 강의 상세 페이지 URL. http/https 형식만 허용, 빈 문자열 불가 | | `price` | Number | 아니요 | 강의 가격. 0 이상 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_information` | Object | 예 | 수정된 강의 정보 객체. 강의 정보 조회 응답과 동일한 구조 | **cURL** ```bash curl -X PUT "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "course_title": "파이썬 기초 (개정판)", "course_description": "파이썬 프로그래밍 기초 강의 - 2026년 개정", "instructors": [{ "instructor": "콕웨", "instructor_number": "001" }], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_play_time": 400 }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_play_time": 400 }, { "clip_id": "1000_1_3", "clip_title": "연산자", "clip_play_time": 400 } ] } ] } }' ``` **Python** ```python import requests res = requests.put( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "course_title": "파이썬 기초 (개정판)", "course_description": "파이썬 프로그래밍 기초 강의 - 2026년 개정", "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_play_time": 400 }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_play_time": 400 }, { "clip_id": "1000_1_3", "clip_title": "연산자", "clip_play_time": 400 } ] } ] } }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "course_title": "파이썬 기초 (개정판)", "course_description": "파이썬 프로그래밍 기초 강의 - 2026년 개정", "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_play_time": 400 }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_play_time": 400 }, { "clip_id": "1000_1_3", "clip_title": "연산자", "clip_play_time": 400 } ] } ] } }`) req, _ := http.NewRequest("PUT", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "course_title": "파이썬 기초 (개정판)", "course_description": "파이썬 프로그래밍 기초 강의 - 2026년 개정", "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_play_time": 400 }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_play_time": 400 }, { "clip_id": "1000_1_3", "clip_title": "연산자", "clip_play_time": 400 } ] } ] } } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .PUT(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/information", { method: "PUT", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "course_title": "파이썬 기초 (개정판)", "course_description": "파이썬 프로그래밍 기초 강의 - 2026년 개정", "instructors": [ { "instructor": "콕웨", "instructor_number": "001" } ], "course_curriculum": { "sections": [ { "section_id": "1000_1", "section_title": "Part 1. 파이썬 기초", "clips": [ { "clip_id": "1000_1_1", "clip_title": "변수 선언", "clip_play_time": 400 }, { "clip_id": "1000_1_2", "clip_title": "자료형 소개", "clip_play_time": 400 }, { "clip_id": "1000_1_3", "clip_title": "연산자", "clip_play_time": 400 } ] } ] } }), }); console.log(await res.json()); ``` ```json { "course_information": { "material_id": "mat_123", "client_identity": "Coxwave", "course_id": "1000", "course_title": "파이썬 기초 (개정판)", "course_description": "파이썬 프로그래밍 기초 강의 - 2026년 개정", "total_play_time": 2400, "total_clip_count": 6, "course_curriculum": { "..." : "..." }, "created_at": "2025-01-15T10:00:00", "updated_at": "2026-02-24T14:30:00" } } ``` --- # 첨삭 결과 조회 > 문항 하나의 첨삭 결과를 조회합니다. Source: https://developers.edutap.ai/docs/api/exam-gradings/get **GET** `/api/v1/courses/exam-gradings/{job_id}` **Path Parameters** | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `job_id` | String | 예 | 접수 응답으로 받은 첨삭 job ID | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `job_id` | String | 예 | 첨삭 job ID | | `status` | String | 예 | 첨삭 진행 상태 (`PENDING`: 접수됨. 첨삭 대기 중, `PROCESSING`: 첨삭 진행 중, `DONE`: 첨삭 완료. result에 결과가 담깁니다, `FAILED`: 첨삭 실패) | | `result` | Object | 아니요 | 첨삭 결과. 아직 첨삭 전이면 null | | `result.exam_id` | String | 예 | 시험 ID | | `result.exam_name` | String | 예 | 상품명. 요청에 담아 보내주신 값을 그대로 돌려드립니다 | | `result.product_category` | String | 예 | 상품 구분 (`SINGLE`: 단품, `PACKAGE`: 패키지) | | `result.package_id` | String | 아니요 | 요청에서 전달한 패키지 식별자. 이전 결과와 단품은 null입니다. | | `result.package_purchase_id` | String | 아니요 | 요청에서 전달한 패키지 식별자. 이전 결과와 단품은 null입니다. | | `result.product_type` | String | 아니요 | 상품 유형. 요청에 없었으면 비어 있습니다 (`COMPLETE`: TPO Complete, `HALF`: TPO Half (W/S)) | | `result.problem_id` | String | 예 | 문제 ID | | `result.question_number` | Number | 아니요 | 문항 번호 | | `result.problem_type` | String | 예 | 문항 유형 (`MSOM`: Build a Sentence, `WDS`: Write an Email, Write for an Academic Discussion, `LISTENREPEAT`: Listen and Repeat, `INTERVIEW`: Take an Interview) | | `result.grade_type` | String | 예 | 시험 영역 (`WRITING`: Writing, `SPEAKING`: Speaking) | | `result.score` | Number | 예 | 요청에 담아 보내주신 문항 점수를 그대로 돌려드립니다 | | `result.score_base` | Number | 예 | 문항 만점. Build a Sentence는 1, 나머지는 5 | | `result.section_score` | Number | 예 | 섹션 점수 (1.0~6.0, 0.5 단위) | | `result.explanation` | String | 예 | 첨삭. 점수 산출 근거 | | `result.user_answer` | String | 예 | 응시자 답안. Speaking은 전사된 텍스트 | | `result.correct_answer` | String | 예 | 모범답안 또는 정답 | | `result.assessment_results` | Object | 예 | 평가 결과 | | `result.assessment_results.strength` | Array of strings | 예 | 잘된 점 | | `result.assessment_results.weakness` | Array of objects | 예 | 취약점 | | `result.assessment_results.weakness[].tag` | String | 예 | 개선 항목 분류 | | `result.assessment_results.weakness[].description` | String | 예 | 개선 제안 본문 | | `result.tips` | Array of objects | 예 | 풀이 팁 | | `result.tips[].description` | String | 예 | 팁 본문 | | `result.tips[].example` | String | 아니요 | 예문. Speaking은 비어 있을 수 있습니다 | | `result.idea` | Array of objects | 예 | 아이디어 제안 | | `result.idea[].keyword` | String | 예 | 아이디어를 한마디로 요약한 말 | | `result.idea[].description` | String | 예 | 아이디어 본문 | | `weakness_report` | String | 예 | 취약점 진단 준비 상태. 미제출 문항이 있어도 그때까지 채점된 문항으로 진단을 만들며, 나눠 보내신 문항이 뒤늦게 도착하면 다시 만들어 갱신합니다 (`PENDING`: 진단 준비 중, `READY`: 진단 생성 완료) | **cURL** ```bash curl "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/7a577745-8fb2-453d-aa57-b17fc80e0e39" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.get( "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/7a577745-8fb2-453d-aa57-b17fc80e0e39", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/7a577745-8fb2-453d-aa57-b17fc80e0e39", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/7a577745-8fb2-453d-aa57-b17fc80e0e39")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/7a577745-8fb2-453d-aa57-b17fc80e0e39", { method: "GET", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "job_id": "7a577745-8fb2-453d-aa57-b17fc80e0e39", "status": "DONE", "result": { "exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "package_id": null, "package_purchase_id": null, "product_type": "COMPLETE", "problem_id": "s1", "question_number": 1, "problem_type": "LISTENREPEAT", "grade_type": "SPEAKING", "score": 3.0, "score_base": 5.0, "section_score": 2.0, "explanation": "제시된 문장의 핵심 어휘는 전달했으나 중간에 끊김이 있어 3점입니다.", "user_answer": "The ticket counter is over there.", "correct_answer": "The ticket counter is over there.", "assessment_results": { "strength": ["핵심 어휘를 정확히 발화했습니다."], "weakness": [ { "tag": "답변 마무리", "description": "문장 끝을 흐리지 않고 끝까지 발화하세요." } ] }, "tips": [ { "description": "문장을 한 호흡에 말하는 연습을 해보세요.", "example": "The ticket counter / is over there." } ], "idea": [] }, "weakness_report": "READY" } ``` --- # Speaking 첨삭 요청 > Speaking 응시 결과를 제출해 첨삭을 요청합니다. Source: https://developers.edutap.ai/docs/api/exam-gradings/speaking **POST** `/api/v1/courses/exam-gradings/speaking` ## 패키지 상품 연동 패키지 상품은 구매 1건당 최초 시험 제출을 접수한 달에 한 번 과금합니다. 같은 구매의 나머지 시험을 다음 달에 제출해도 패키지 상품 요금은 다시 부과하지 않습니다. 시험을 제출하지 않은 구매는 집계되지 않습니다. `product_category=PACKAGE`일 때 다음 두 ID를 보내세요. * `package_id`: 패키지 상품 ID. 같은 상품을 재구매해도 유지합니다. * `package_purchase_id`: 고객사 내에서 구매마다 고유한 ID. 재구매하거나 한 주문에 여러 패키지가 있으면 각 구매에 다른 값을 사용합니다. 같은 구매의 모든 시험과 Writing/Speaking 요청에는 두 ID를 동일하게 전달합니다. 분할 제출과 재전송에도 같은 값을 사용합니다. ID 발급과 상품·사용자 연결은 고객사가 관리합니다. 단품은 두 필드를 생략합니다. `product_category`를 생략하거나 null로 보내면 SINGLE로 처리합니다. 두 ID는 선택 항목이며, 생략해도 채점 요청은 접수됩니다. 패키지 구매 집계에는 두 ID가 모두 필요하며, 누락되거나 비어 있으면 구매 기록을 생성하지 않습니다. 각 ID는 최대 200자입니다. `exam_id`에는 기존 시험 COURSE\_ID를 사용합니다. `is_final`은 해당 시험 섹션의 제출 완료 여부입니다. ## Request Body (multipart/form-data) | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `exam_id` | String | 예 | 시험 ID | | `exam_name` | String | 예 | 상품명 | | `product_category` | String | 아니요 | 상품 구분 (`SINGLE`: 단품, `PACKAGE`: 패키지; 기본값: `SINGLE`) | | `package_id` | String | 아니요 | 선택 항목. 패키지 상품 종류를 식별하는 고객사 ID. package_purchase_id와 함께 전달합니다. | | `package_purchase_id` | String | 아니요 | 선택 항목. 고객사 내에서 고유한 패키지 구매 ID. 동일 구매의 모든 시험과 Writing/Speaking 요청에 같은 값을 전달합니다. | | `product_type` | String | 아니요 | 상품 유형 (`COMPLETE`: TPO Complete, `HALF`: TPO Half (W/S)) | | `is_final` | Boolean | 아니요 | 이번 요청으로 이 응시의 전송이 끝나는지. false 인 요청은 취약점 진단을 만들지 않고 다음 요청을 기다립니다. 보내지 않으면 true 로 보고, 그때까지 채점된 문항으로 진단을 만듭니다 | | `user_id` | String | 예 | 응시자 ID | | `section_score` | Number | 예 | 섹션 점수 (1.0~6.0, 0.5 단위) | | `items` | String | 예 | 문항 목록 JSON 배열을 문자열로 | | `items.problem_id` | String | 예 | 문제 ID. 한 요청 안에서 중복될 수 없습니다 | | `items.problem_type` | String | 예 | 문항 유형 (`LISTENREPEAT`: Listen and Repeat (1~7번), `INTERVIEW`: Take an Interview (8~11번)) | | `items.question_number` | Number | 예 | 문항 번호. 없으면 422로 거부됩니다 | | `items.score` | Number | 예 | 문항 점수. Build a Sentence는 0 또는 1, 나머지는 -0.4998~5.4998입니다 | | `items.audio_file_name` | String | 예 | 이 문항의 음성 파일 이름. audios에 올린 파일 이름과 정확히 일치해야 합니다 | | `audios` | Array of files | 예 | 답변 음성 파일 목록. items의 audio_file_name과 이름이 일치해야 합니다 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `jobs` | Array of objects | 예 | 생성된 첨삭 job 목록. 문항 수와 같습니다 | | `jobs[].job_id` | String | 예 | 첨삭 job ID. 결과 조회에 사용합니다 | | `jobs[].problem_id` | String | 예 | 문제 ID | | `jobs[].status` | String | 예 | 접수 시점 상태 (PENDING) | **cURL** ```bash curl -X POST "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/speaking" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -F "exam_id=I100000760_1" \ -F "exam_name=Complete Practice Test Volume 02" \ -F "product_category=SINGLE" \ -F "product_type=COMPLETE" \ -F "is_final=true" \ -F "user_id=u-1001" \ -F "section_score=2.0" \ -F 'items=[ { "problem_id": "s1", "question_number": 1, "problem_type": "LISTENREPEAT", "score": 4.7498, "audio_file_name": "q01.mp3" }, { "problem_id": "s8", "question_number": 8, "problem_type": "INTERVIEW", "score": 2.8752, "audio_file_name": "q08.mp3" } ]' \ -F "audios=@q01.mp3;type=audio/mpeg" \ -F "audios=@q08.mp3;type=audio/mpeg" ``` **Python** ```python import json import requests res = requests.post( "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/speaking", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, data={"exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "product_type": "COMPLETE", "is_final": json.dumps(True), "user_id": "u-1001", "section_score": json.dumps(2.0), "items": json.dumps([ { "problem_id": "s1", "question_number": 1, "problem_type": "LISTENREPEAT", "score": 4.7498, "audio_file_name": "q01.mp3" }, { "problem_id": "s8", "question_number": 8, "problem_type": "INTERVIEW", "score": 2.8752, "audio_file_name": "q08.mp3" } ])}, files=[("audios", open("q01.mp3;type=audio/mpeg", "rb")), ("audios", open("q08.mp3;type=audio/mpeg", "rb"))], ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "mime/multipart" "net/http" "os" ) func main() { var buf bytes.Buffer w := multipart.NewWriter(&buf) w.WriteField("exam_id", `I100000760_1`) w.WriteField("exam_name", `Complete Practice Test Volume 02`) w.WriteField("product_category", `SINGLE`) w.WriteField("product_type", `COMPLETE`) w.WriteField("is_final", `true`) w.WriteField("user_id", `u-1001`) w.WriteField("section_score", `2.0`) w.WriteField("items", `[ { "problem_id": "s1", "question_number": 1, "problem_type": "LISTENREPEAT", "score": 4.7498, "audio_file_name": "q01.mp3" }, { "problem_id": "s8", "question_number": 8, "problem_type": "INTERVIEW", "score": 2.8752, "audio_file_name": "q08.mp3" } ]`) f, _ := os.Open("q01.mp3;type=audio/mpeg") part, _ := w.CreateFormFile("audios", "q01.mp3;type=audio/mpeg") io.Copy(part, f) f.Close() f, _ := os.Open("q08.mp3;type=audio/mpeg") part, _ := w.CreateFormFile("audios", "q08.mp3;type=audio/mpeg") io.Copy(part, f) f.Close() w.Close() req, _ := http.NewRequest("POST", "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/speaking", &buf) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") req.Header.Set("Content-Type", w.FormDataContentType()) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import okhttp3.*; import java.io.File; public class Main { public static void main(String[] args) throws Exception { OkHttpClient client = new OkHttpClient(); MultipartBody.Builder builder = new MultipartBody.Builder().setType(MultipartBody.FORM); builder.addFormDataPart("exam_id", "I100000760_1"); builder.addFormDataPart("exam_name", "Complete Practice Test Volume 02"); builder.addFormDataPart("product_category", "SINGLE"); builder.addFormDataPart("product_type", "COMPLETE"); builder.addFormDataPart("is_final", "true"); builder.addFormDataPart("user_id", "u-1001"); builder.addFormDataPart("section_score", "2.0"); builder.addFormDataPart("items", """ [ { "problem_id": "s1", "question_number": 1, "problem_type": "LISTENREPEAT", "score": 4.7498, "audio_file_name": "q01.mp3" }, { "problem_id": "s8", "question_number": 8, "problem_type": "INTERVIEW", "score": 2.8752, "audio_file_name": "q08.mp3" } ] """); builder.addFormDataPart("audios", "q01.mp3;type=audio/mpeg", RequestBody.create(new File("q01.mp3;type=audio/mpeg"), MediaType.get("application/octet-stream"))); builder.addFormDataPart("audios", "q08.mp3;type=audio/mpeg", RequestBody.create(new File("q08.mp3;type=audio/mpeg"), MediaType.get("application/octet-stream"))); Request.Builder rb = new Request.Builder() .url("https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/speaking") .header("TAP-API-KEY", "{YOUR_API_KEY}") .post(builder.build()); try (Response res = client.newCall(rb.build()).execute()) { System.out.println(res.body().string()); } } } ``` **Node.js** ```javascript import { readFile } from "node:fs/promises"; const form = new FormData(); form.append("exam_id", "I100000760_1"); form.append("exam_name", "Complete Practice Test Volume 02"); form.append("product_category", "SINGLE"); form.append("product_type", "COMPLETE"); form.append("is_final", "true"); form.append("user_id", "u-1001"); form.append("section_score", "2.0"); form.append("items", JSON.stringify([ { "problem_id": "s1", "question_number": 1, "problem_type": "LISTENREPEAT", "score": 4.7498, "audio_file_name": "q01.mp3" }, { "problem_id": "s8", "question_number": 8, "problem_type": "INTERVIEW", "score": 2.8752, "audio_file_name": "q08.mp3" } ])); form.append("audios", new Blob([await readFile("q01.mp3;type=audio/mpeg")]), "q01.mp3;type=audio/mpeg"); form.append("audios", new Blob([await readFile("q08.mp3;type=audio/mpeg")]), "q08.mp3;type=audio/mpeg"); const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/speaking", { method: "POST", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, body: form, }); console.log(await res.json()); ``` ```json { "jobs": [ { "job_id": "7a577745-...", "problem_id": "s1", "status": "PENDING" }, { "job_id": "b2c3d4e5-...", "problem_id": "s8", "status": "PENDING" } ] } ``` --- # Writing 첨삭 요청 > Writing 응시 결과를 제출해 첨삭을 요청합니다. Source: https://developers.edutap.ai/docs/api/exam-gradings/writing **POST** `/api/v1/courses/exam-gradings/writing` ## 패키지 상품 연동 패키지 상품은 구매 1건당 최초 시험 제출을 접수한 달에 한 번 과금합니다. 같은 구매의 나머지 시험을 다음 달에 제출해도 패키지 상품 요금은 다시 부과하지 않습니다. 시험을 제출하지 않은 구매는 집계되지 않습니다. `product_category=PACKAGE`일 때 다음 두 ID를 보내세요. * `package_id`: 패키지 상품 ID. 같은 상품을 재구매해도 유지합니다. * `package_purchase_id`: 고객사 내에서 구매마다 고유한 ID. 재구매하거나 한 주문에 여러 패키지가 있으면 각 구매에 다른 값을 사용합니다. 같은 구매의 모든 시험과 Writing/Speaking 요청에는 두 ID를 동일하게 전달합니다. 분할 제출과 재전송에도 같은 값을 사용합니다. ID 발급과 상품·사용자 연결은 고객사가 관리합니다. 단품은 두 필드를 생략합니다. `product_category`를 생략하거나 null로 보내면 SINGLE로 처리합니다. 두 ID는 선택 항목이며, 생략해도 채점 요청은 접수됩니다. 패키지 구매 집계에는 두 ID가 모두 필요하며, 누락되거나 비어 있으면 구매 기록을 생성하지 않습니다. 각 ID는 최대 200자입니다. `exam_id`에는 기존 시험 COURSE\_ID를 사용합니다. `is_final`은 해당 시험 섹션의 제출 완료 여부입니다. ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `exam_id` | String | 예 | 시험 ID | | `exam_name` | String | 예 | 상품명 | | `product_category` | String | 아니요 | 상품 구분 (`SINGLE`: 단품, `PACKAGE`: 패키지; 기본값: `SINGLE`) | | `package_id` | String | 아니요 | 선택 항목. 패키지 상품 종류를 식별하는 고객사 ID. package_purchase_id와 함께 전달합니다. | | `package_purchase_id` | String | 아니요 | 선택 항목. 고객사 내에서 고유한 패키지 구매 ID. 동일 구매의 모든 시험과 Writing/Speaking 요청에 같은 값을 전달합니다. | | `product_type` | String | 아니요 | 상품 유형 (`COMPLETE`: TPO Complete, `HALF`: TPO Half (W/S)) | | `is_final` | Boolean | 아니요 | 이번 요청으로 이 응시의 전송이 끝나는지. 문항을 나눠 보내신다면 마지막이 아닌 요청에 false 를 담아 주세요. 보내지 않으면 true 로 봅니다 | | `user_id` | String | 예 | 응시자 ID | | `section_score` | Number | 예 | 섹션 점수 (1.0~6.0, 0.5 단위) | | `items` | Array of objects | 예 | 문항 목록. 최소 1개 | | `items[].problem_id` | String | 예 | 문제 ID. 한 요청 안에서 중복될 수 없습니다 | | `items[].problem_type` | String | 예 | 문항 유형 (`MSOM`: Build a Sentence (1~10번), `WDS`: Write an Email (11번), Write for an Academic Discussion (12번)) | | `items[].question_number` | Number | 예 | 문항 번호. WDS는 11(Write an Email)과 12(Academic Discussion)의 첨삭 기준을 가르는 값입니다. 없으면 422로 거부됩니다 | | `items[].score` | Number | 예 | 문항 점수. Build a Sentence는 0 또는 1, 나머지는 -0.4998~5.4998입니다 | | `items[].user_answer` | String | 예 | 응시자 답안 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `jobs` | Array of objects | 예 | 생성된 첨삭 job 목록. 문항 수와 같습니다 | | `jobs[].job_id` | String | 예 | 첨삭 job ID. 결과 조회에 사용합니다 | | `jobs[].problem_id` | String | 예 | 문제 ID | | `jobs[].status` | String | 예 | 접수 시점 상태 (PENDING) | **cURL** ```bash curl -X POST "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/writing" \ -H "Content-Type: application/json" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -d '{ "exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "product_type": "COMPLETE", "is_final": true, "user_id": "u-1001", "section_score": 4.0, "items": [ { "problem_id": "w1", "question_number": 1, "problem_type": "MSOM", "score": 0, "user_answer": "I wonder where did she find the book." }, { "problem_id": "w11", "question_number": 11, "problem_type": "WDS", "score": 4.1252, "user_answer": "Dear Professor Kim, thank you for your email about the group project..." }, { "problem_id": "w12", "question_number": 12, "problem_type": "WDS", "score": 3.4998, "user_answer": "I agree with Andrew that online shopping has changed local businesses..." } ] }' ``` **Python** ```python import requests res = requests.post( "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/writing", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, json={ "exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "product_type": "COMPLETE", "is_final": True, "user_id": "u-1001", "section_score": 4.0, "items": [ { "problem_id": "w1", "question_number": 1, "problem_type": "MSOM", "score": 0, "user_answer": "I wonder where did she find the book." }, { "problem_id": "w11", "question_number": 11, "problem_type": "WDS", "score": 4.1252, "user_answer": "Dear Professor Kim, thank you for your email about the group project..." }, { "problem_id": "w12", "question_number": 12, "problem_type": "WDS", "score": 3.4998, "user_answer": "I agree with Andrew that online shopping has changed local businesses..." } ] }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "product_type": "COMPLETE", "is_final": true, "user_id": "u-1001", "section_score": 4.0, "items": [ { "problem_id": "w1", "question_number": 1, "problem_type": "MSOM", "score": 0, "user_answer": "I wonder where did she find the book." }, { "problem_id": "w11", "question_number": 11, "problem_type": "WDS", "score": 4.1252, "user_answer": "Dear Professor Kim, thank you for your email about the group project..." }, { "problem_id": "w12", "question_number": 12, "problem_type": "WDS", "score": 3.4998, "user_answer": "I agree with Andrew that online shopping has changed local businesses..." } ] }`) req, _ := http.NewRequest("POST", "https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/writing", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "product_type": "COMPLETE", "is_final": true, "user_id": "u-1001", "section_score": 4.0, "items": [ { "problem_id": "w1", "question_number": 1, "problem_type": "MSOM", "score": 0, "user_answer": "I wonder where did she find the book." }, { "problem_id": "w11", "question_number": 11, "problem_type": "WDS", "score": 4.1252, "user_answer": "Dear Professor Kim, thank you for your email about the group project..." }, { "problem_id": "w12", "question_number": 12, "problem_type": "WDS", "score": 3.4998, "user_answer": "I agree with Andrew that online shopping has changed local businesses..." } ] } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/writing")) .header("Content-Type", "application/json") .header("TAP-API-KEY", "{YOUR_API_KEY}") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/exam-gradings/writing", { method: "POST", headers: {"Content-Type": "application/json", "TAP-API-KEY": "{YOUR_API_KEY}"}, body: JSON.stringify({ "exam_id": "I100000760_1", "exam_name": "Complete Practice Test Volume 02", "product_category": "SINGLE", "product_type": "COMPLETE", "is_final": true, "user_id": "u-1001", "section_score": 4.0, "items": [ { "problem_id": "w1", "question_number": 1, "problem_type": "MSOM", "score": 0, "user_answer": "I wonder where did she find the book." }, { "problem_id": "w11", "question_number": 11, "problem_type": "WDS", "score": 4.1252, "user_answer": "Dear Professor Kim, thank you for your email about the group project..." }, { "problem_id": "w12", "question_number": 12, "problem_type": "WDS", "score": 3.4998, "user_answer": "I agree with Andrew that online shopping has changed local businesses..." } ] }), }); console.log(await res.json()); ``` ```json { "jobs": [ { "job_id": "6c4e8c77-...", "problem_id": "w1", "status": "PENDING" }, { "job_id": "5e0b1c2f-...", "problem_id": "w11", "status": "PENDING" }, { "job_id": "a1b2c3d4-...", "problem_id": "w12", "status": "PENDING" } ] } ``` --- # 강의 데이터 삭제 > 등록된 강의 데이터를 삭제합니다. Source: https://developers.edutap.ai/docs/api/materials/delete **DELETE** `/api/v1/courses/{course_id}/materials/{material_id}` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | | `material_id` | String | 예 | 삭제할 자료 ID | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `message` | String | 예 | 서버 메시지 | **cURL** ```bash curl -X DELETE "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials/mat_123" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.delete( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials/mat_123", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("DELETE", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials/mat_123", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials/mat_123")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .DELETE() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials/mat_123", { method: "DELETE", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "message": "Material deleted successfully" } ``` --- # 강의 데이터 조회 > 강의에 등록된 자막, 영상, 자료 목록과 처리 상태를 조회합니다. Source: https://developers.edutap.ai/docs/api/materials/list **GET** `/api/v1/courses/{course_id}/materials` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `clip_id` | String | 아니요 | 특정 클립의 데이터만 필터링 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | | `course_title` | String | 예 | 강의명 | | `materials` | Array of objects | 예 | 업로드된 자료 목록 | | `materials[].client_identity` | String | | 고객사명 | | `materials[].material_id` | String | | 자료 고유 ID | | `materials[].section_id` | String | | 섹션 ID | | `materials[].clip_id` | String | | 클립 ID | | `materials[].type` | String | | 데이터 타입 | | `materials[].file_name` | String | | 파일명 | | `materials[].size` | Number | | 파일 크기 (bytes) | | `materials[].url` | String | | 파일 URL | | `materials[].status` | String | | 처리 상태 | | `materials[].summary` | String | | 콘텐츠 요약 | | `materials[].description` | String | | 콘텐츠 설명 | | `materials[].is_generated_from_video` | Boolean | | 비디오 URL에서 생성된 자료 여부 | ## 처리 상태 강의 데이터를 등록하면 전처리 과정을 거쳐요. 조회 API 응답의 `status` 필드로 진행 상황을 확인할 수 있고, `ALL_GENERATED`가 되면 그 클립의 모든 기능을 쓸 수 있어요. | Status | Description | | --- | --- | | `UPLOADING` | 파일 업로드 진행 중 | | `UPLOADED` | 업로드 완료 | | `UPLOAD_FAILED` | 업로드 실패 | | `SUMMARIZING` | 내용 요약 진행 중 | | `SUMMARIZED` | 요약 완료 | | `SUMMARIZE_FAILED` | 요약 실패 | | `EMBEDDING` | 벡터 임베딩 진행 중 | | `EMBEDDED` | 임베딩 완료 | | `EMBED_FAILED` | 임베딩 실패 | | `QUIZ_GENERATING` | 퀴즈 생성 진행 중 | | `QUIZ_GENERATED` | 퀴즈 생성 완료 | | `QUIZ_GENERATE_FAILED` | 퀴즈 생성 실패 | | `PING_MESSAGE_GENERATING` | 추천질문 생성 진행 중 | | `PING_MESSAGE_GENERATED` | 추천질문 생성 완료 | | `PING_MESSAGE_GENERATE_FAILED` | 추천질문 생성 실패 | | `FLASHCARD_GENERATING` | 플래시카드 생성 진행 중 | | `FLASHCARD_GENERATED` | 플래시카드 생성 완료 | | `FLASHCARD_GENERATE_FAILED` | 플래시카드 생성 실패 | | `ALL_GENERATED` | 모든 콘텐츠 생성 완료 (전체 기능 사용 가능) | **cURL** ```bash curl -X GET "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials" \ -H "TAP-API-KEY: {YOUR_API_KEY}" ``` **Python** ```python import requests res = requests.get( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials", nil) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials")) .header("TAP-API-KEY", "{YOUR_API_KEY}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials", { method: "GET", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, }); console.log(await res.json()); ``` ```json { "course_id": "1000", "course_title": "파이썬 기초 프로그래밍", "materials": [ { "client_identity": "Coxwave", "material_id": "mat_123", "section_id": null, "clip_id": "1000_1", "type": "script", "file_name": "lecture1.srt", "size": 12345, "url": null, "status": "EMBEDDED", "summary": "파이썬 변수 선언에 대한 강의 내용", "description": "파이썬 기초 문법 중 변수 선언 방법을 설명합니다.", "is_generated_from_video": false } ] } ``` --- # 강의 데이터 등록 > 강의에 자막(script), 영상(video), 자료(content)를 등록합니다. Source: https://developers.edutap.ai/docs/api/materials/upload **POST** `/api/v1/courses/{course_id}/materials` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | ## Request Body (multipart/form-data) | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `metadata` | Array of objects | 예 | 파일 메타데이터 JSON 배열 | | `metadata[].clip_id` | String | 아니요 | 클립 ID. script, video 타입은 필수이며, content 타입은 클립 자료일 때만 지정합니다. | | `metadata[].section_id` | String | 아니요 | 섹션 ID. content 타입을 섹션 자료로 등록할 때 지정합니다. clip_id와 함께 쓸 수 없습니다. | | `metadata[].type` | String | 예 | 데이터 타입 (`script`: 자막 파일, `video`: 영상 파일 또는 영상 URL, `content`: 강의 자료. clip_id와 section_id를 모두 생략하면 강의 자료로 등록됩니다.) | | `metadata[].file_name` | String | 예 | 파일명 또는 영상 URL | | `files` | Array of files | 아니요 | 업로드할 파일 목록. 영상 URL로 등록하는 video 타입은 파일이 필요 없습니다. | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String | 예 | 강의 ID | | `upload_results` | Array of objects | 예 | 업로드 결과 배열 | | `upload_results[].client_identity` | String | | 클라이언트 식별자 | | `upload_results[].material_id` | String | | 자료 고유 ID | | `upload_results[].section_id` | String | | 섹션 ID (섹션 레벨 자료인 경우) | | `upload_results[].clip_id` | String | | 클립 ID (클립 레벨 자료인 경우) | | `upload_results[].file_name` | String | | 파일명 | | `upload_results[].size` | Number | | 파일 크기 (bytes) | | `upload_results[].status` | String | | 처리 상태 | | `upload_results[].file_log_id` | String | | 파일 로그 ID | | `upload_results[].description` | String | | 설명 또는 에러 메시지 | | `upload_results[].is_generated_from_video` | Boolean | | 비디오 URL에서 생성된 자료 여부 | **cURL** ```bash curl -X POST "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials" \ -H "TAP-API-KEY: {YOUR_API_KEY}" \ -F 'metadata=[ {"clip_id": "1000_1", "type": "script", "file_name": "script.vtt"}, {"clip_id": "1000_2", "type": "video", "file_name": "https://example.com/video.mp4"}, {"type": "content", "file_name": "syllabus.pdf"} ]' \ -F 'files=@script.vtt' \ -F 'files=@syllabus.pdf' ``` **Python** ```python import json import requests res = requests.post( "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials", headers={"TAP-API-KEY": "{YOUR_API_KEY}"}, data={"metadata": json.dumps([ { "clip_id": "1000_1", "type": "script", "file_name": "script.vtt" }, { "clip_id": "1000_2", "type": "video", "file_name": "https://example.com/video.mp4" }, { "type": "content", "file_name": "syllabus.pdf" } ])}, files=[("files", open("script.vtt", "rb")), ("files", open("syllabus.pdf", "rb"))], ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "mime/multipart" "net/http" "os" ) func main() { var buf bytes.Buffer w := multipart.NewWriter(&buf) w.WriteField("metadata", `[ {"clip_id": "1000_1", "type": "script", "file_name": "script.vtt"}, {"clip_id": "1000_2", "type": "video", "file_name": "https://example.com/video.mp4"}, {"type": "content", "file_name": "syllabus.pdf"} ]`) f, _ := os.Open("script.vtt") part, _ := w.CreateFormFile("files", "script.vtt") io.Copy(part, f) f.Close() f2, _ := os.Open("syllabus.pdf") part2, _ := w.CreateFormFile("files", "syllabus.pdf") io.Copy(part2, f2) f2.Close() w.Close() req, _ := http.NewRequest("POST", "https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials", &buf) req.Header.Set("TAP-API-KEY", "{YOUR_API_KEY}") req.Header.Set("Content-Type", w.FormDataContentType()) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import okhttp3.*; import java.io.File; public class Main { public static void main(String[] args) throws Exception { OkHttpClient client = new OkHttpClient(); MultipartBody.Builder builder = new MultipartBody.Builder().setType(MultipartBody.FORM); builder.addFormDataPart("metadata", """ [ { "clip_id": "1000_1", "type": "script", "file_name": "script.vtt" }, { "clip_id": "1000_2", "type": "video", "file_name": "https://example.com/video.mp4" }, { "type": "content", "file_name": "syllabus.pdf" } ] """); builder.addFormDataPart("files", "script.vtt", RequestBody.create(new File("script.vtt"), MediaType.get("application/octet-stream"))); builder.addFormDataPart("files", "syllabus.pdf", RequestBody.create(new File("syllabus.pdf"), MediaType.get("application/pdf"))); Request.Builder rb = new Request.Builder() .url("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials") .header("TAP-API-KEY", "{YOUR_API_KEY}") .post(builder.build()); try (Response res = client.newCall(rb.build()).execute()) { System.out.println(res.body().string()); } } } ``` **Node.js** ```javascript import { readFile } from "node:fs/promises"; const form = new FormData(); form.append("metadata", JSON.stringify([ { "clip_id": "1000_1", "type": "script", "file_name": "script.vtt" }, { "clip_id": "1000_2", "type": "video", "file_name": "https://example.com/video.mp4" }, { "type": "content", "file_name": "syllabus.pdf" } ])); form.append("files", new Blob([await readFile("script.vtt")]), "script.vtt"); form.append("files", new Blob([await readFile("syllabus.pdf")]), "syllabus.pdf"); const res = await fetch("https://tap-file-upload-production.coxwave.link/api/v1/courses/1000/materials", { method: "POST", headers: {"TAP-API-KEY": "{YOUR_API_KEY}"}, body: form, }); console.log(await res.json()); ``` ```json { "course_id": "1000", "upload_results": [ { "client_identity": "Coxwave", "material_id": "40d2f656-0ea8-41e0-8ea5-fd90da314178", "section_id": null, "clip_id": "1000_1", "file_name": "script.vtt", "size": 173, "status": "UPLOADING", "file_log_id": "6836cf0f767332a07065455a", "description": null, "is_generated_from_video": false }, { "client_identity": "Coxwave", "material_id": "8c1e2a44-5b7d-4c11-9f0e-2a6d3b7c9e10", "section_id": null, "clip_id": "1000_2", "file_name": "https://example.com/video.mp4", "size": 0, "status": "UPLOADING", "file_log_id": "6836cf0f767332a07065455b", "description": null, "is_generated_from_video": true }, { "client_identity": "Coxwave", "material_id": "f3a9c0d1-7e42-4b8a-a5c6-1d2e3f4a5b6c", "section_id": null, "clip_id": null, "file_name": "syllabus.pdf", "size": 48213, "status": "UPLOADING", "file_log_id": "6836cf0f767332a07065455c", "description": null, "is_generated_from_video": false } ] } ``` --- # 하위 고객사 목록 조회 > 기준 고객사의 모든 하위(자손) 고객사를 평면 목록으로 조회합니다. Source: https://developers.edutap.ai/docs/api/ops/clients-children **GET** `/ops/v1/clients/children` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `client_identity` | String | 예 | 조회 기준 고객사의 `client_identity` | | `children` | Array of objects | 예 | 기준 고객사 자신을 제외한 모든 하위 고객사 (너비 우선 순서) | | `children[].client_identity` | String | | 하위 고객사의 `client_identity` | | `children[].client_name` | String | | 하위 고객사명 | | `children[].parent_client_identity` | String | | 부모 고객사의 `client_identity`. 이 값으로 트리를 재구성할 수 있습니다 | | `children[].depth` | Number | | 기준 고객사로부터의 단계 (직계 자식 = 1) | > 하위 고객사가 없으면 `children`은 빈 배열로 반환됩니다. 삭제된 고객사는 포함되지 않습니다. 여기서 확인한 `client_identity`를 다른 조회 API의 `client_identity` 파라미터로 전달하면 해당 고객사 기준으로 데이터를 조회할 수 있습니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/clients/children" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/clients/children", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/clients/children", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/clients/children")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/clients/children", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "client_identity": "acme", "children": [ { "client_identity": "acme_kr", "client_name": "ACME KR", "parent_client_identity": "acme", "depth": 1 }, { "client_identity": "acme_kr_seoul", "client_name": "ACME 서울", "parent_client_identity": "acme_kr", "depth": 2 } ] } ``` --- # 공통 사항 > EduAlign OpsAPI의 인증, 고객사 조회 범위, 페이지네이션, 날짜 형식 규약 Source: https://developers.edutap.ai/docs/api/ops/common ## 인증 `POST /ops/v1/auth/signin`과 `POST /ops/v1/auth/refresh`를 제외한 모든 엔드포인트는 `Authorization: Bearer {access_token}` 헤더가 필요해요. ## 고객사 조회 범위 로그인한 client member가 속한 고객사에 하위 고객사가 있다면, 조회 API에서 어느 고객사의 데이터를 볼지 지정할 수 있습니다. 인증 API를 제외한 모든 조회 API가 다음 쿼리 파라미터를 공통으로 사용합니다. **Query Parameters** | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다 | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | * `client_identity`에는 로그인 고객사 또는 그 하위 고객사만 지정할 수 있습니다. 범위 밖이거나 존재하지 않는 값은 구분 없이 403 `OuterClientScopeForbidden`을 반환합니다. * 지정할 수 있는 하위 고객사 목록은 [하위 고객사 목록 조회](/docs/api/ops/clients-children)로 확인합니다. * `include_descendants=true`로 조회하면 응답 항목의 `client_identity`로 어느 고객사의 데이터인지 구분합니다. 집계 API(`user-count`, `message-count`)는 기준 고객사와 하위 고객사의 합계를 반환합니다. * 두 파라미터를 모두 생략하면 로그인 고객사의 데이터만 조회합니다. ## 페이지네이션 목록 조회 엔드포인트는 다음 쿼리 파라미터를 공통으로 사용합니다. **Query Parameters** | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `page` | Number | 아니요 | 페이지 번호 (1-base, 기본 1) | | `page_size` | Number | 아니요 | 페이지당 항목 수 (기본 10, 최대 100) | 응답 본문의 `pagination` 객체 구조: | Field | Type | Description | | --- | --- | --- | | `current_page` | Number | 현재 페이지 | | `total_pages` | Number | 전체 페이지 수 | | `page_size` | Number | 페이지당 항목 수 | | `total_items` | Number | 전체 항목 수 | | `is_next` | Boolean | 다음 페이지 존재 여부 | | `is_prev` | Boolean | 이전 페이지 존재 여부 | ## 날짜·시간 포맷 * 모든 일시 응답 필드(`created_at`, `first_message_at`, `last_message_at`, `last_question_at` 등): **UTC ISO 8601** (예: `2026-06-25T09:00:00Z`) * 기간 쿼리 파라미터(`start_date`, `end_date`): `YYYY-MM-DD` 포맷, **KST(Asia/Seoul) 기준**으로 해석됩니다. 서버에서 KST 00:00:00 \~ 23:59:59 구간으로 변환한 뒤 UTC 로 조회합니다. --- # 메시지 수 집계 조회 > 지정한 기간 내의 학습자-AI 튜터 간 메시지 총량을 일(daily) 단위로 집계해 반환합니다. Source: https://developers.edutap.ai/docs/api/ops/message-count **GET** `/ops/v1/dashboard/message-count` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `start_date` | String | 예 | 시작 날짜. `YYYY-MM-DD` (KST 기준) | | `end_date` | String | 예 | 종료 날짜. `YYYY-MM-DD` (KST 기준) | | `course_id` | String | 아니요 | `course_id` 정확 일치 필터 (case-sensitive). 미지정 시 필터 미적용 | | `clip_id` | String | 아니요 | `clip_id` 정확 일치 필터 (case-sensitive). 미지정 시 필터 미적용 | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields 사용자 수 집계 조회와 동일 구조 (`OuterCountResponse`). > 메시지 카운트는 system 메시지를 제외한 사용자/어시스턴트 메시지 합계입니다. > `course_id`/`clip_id` 필터는 exact match, case-sensitive 로 적용됩니다. 값은 앞뒤 공백을 strip 후 사용하며, 빈 문자열 또는 공백만 있는 값은 필터 미적용(`null` echo)으로 처리됩니다. 둘 다 지정하면 AND 조건입니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/dashboard/message-count?start_date=2026-06-01&end_date=2026-06-25" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" # course_id/clip_id (옵셔널 필터, 예: &course_id=course_abc&clip_id=clip_xyz) ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/dashboard/message-count?start_date=2026-06-01&end_date=2026-06-25", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/dashboard/message-count?start_date=2026-06-01&end_date=2026-06-25", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/dashboard/message-count?start_date=2026-06-01&end_date=2026-06-25")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/dashboard/message-count?start_date=2026-06-01&end_date=2026-06-25", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "period_start_date": "2026-06-01", "period_end_date": "2026-06-25", "client_identity": "acme", "course_id": null, "clip_id": null, "count": 38420 } ``` --- # 세션 메시지 증분 덤프 조회 > 직전 호출 시점 이후 신규 생성된 세션 메시지만 반환합니다. 증분 동기화에 사용합니다. Source: https://developers.edutap.ai/docs/api/ops/message-dumps-since-last **GET** `/ops/v1/sessions/messages/dumps/since-last` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `retry` | Boolean | 아니요 | 기본값 false. true이면 직전 덤프 window를 그대로 재반환합니다 (재시도용) | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields 세션 메시지 덤프 조회(기간 지정)와 동일 구조: `messages` 배열 (페이지네이션 없음). > 호출자별 마지막 호출 시점은 서버에서 client\_member 단위로 관리되며, `client_identity`·`include_descendants` 조합별로 따로 관리됩니다. 조합을 바꿔 호출하면 해당 조합의 첫 호출로 처리됩니다. 적재 실패 시 `retry=true`로 같은 window를 재요청할 수 있습니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" # 재시도 (직전 window 재반환) curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last?retry=true" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last?retry=true", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last?retry=true", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last?retry=true")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last?retry=true", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "messages": [ { "message_id": "msg_01HXEFGH1234ABCD5678", "chat_session_id": "sess_01HXABCD1234EFGH5678", "client_identity": "acme", "role": "user", "message": "이차방정식 x^2 - 5x + 6 = 0 풀이를 설명해 주세요.", "created_at": "2026-06-20T03:21:30Z", "answer_style": null, "clip_id": "clip_01", "course_title": null, "user_id": null, "clip_play_head": 0, "is_floating": false, "is_retry": false, "is_preemptive_question": false, "is_follow_up_question": false, "is_fixed_guide_message": false, "references": [], "pdf_highlight_texts": [], "message_generation_time_seconds": null, "files": [], "question_message_id": null } ] } ``` --- # 세션 메시지 덤프 조회 (기간 지정) > 지정한 기간 범위 내의 세션 메시지를 일괄 반환합니다. 데이터 레이크 적재 용도입니다. Source: https://developers.edutap.ai/docs/api/ops/message-dumps **GET** `/ops/v1/sessions/messages/dumps` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `start_date` | String (ISO 8601) | 아니요 | 시작 일자. 미지정 시 end_date 기준 7일 전 | | `end_date` | String (ISO 8601) | 아니요 | 종료 일자. 미지정 시 현재 시각 | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `messages` | Array of objects | 예 | 메시지 목록 (페이지네이션 없음). 세션 메시지 조회의 messages item 과 동일 구조 | > 페이지네이션 없이 일괄 반환하므로 큰 기간 지정 시 응답이 매우 클 수 있습니다. 가능한 짧은 기간으로 분할 호출하거나 증분 덤프(`/messages/dumps/since-last`)를 사용하세요. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps?start_date=2026-06-24&end_date=2026-06-25" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps?start_date=2026-06-24&end_date=2026-06-25", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps?start_date=2026-06-24&end_date=2026-06-25", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps?start_date=2026-06-24&end_date=2026-06-25")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps?start_date=2026-06-24&end_date=2026-06-25", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "messages": [ { "message_id": "msg_01HXEFGH1234ABCD5678", "chat_session_id": "sess_01HXABCD1234EFGH5678", "client_identity": "acme", "role": "user", "message": "이차방정식 x^2 - 5x + 6 = 0 풀이를 설명해 주세요.", "created_at": "2026-06-20T03:21:30Z", "answer_style": null, "clip_id": "clip_01", "course_title": null, "user_id": null, "clip_play_head": 0, "is_floating": false, "is_retry": false, "is_preemptive_question": false, "is_follow_up_question": false, "is_fixed_guide_message": false, "references": [], "pdf_highlight_texts": [], "message_generation_time_seconds": null, "files": [], "question_message_id": null } ] } ``` --- # Access token 갱신 > signin 으로 발급된 refresh_token 을 사용해 새 access/refresh 토큰 쌍을 발급받습니다 (rotation). Source: https://developers.edutap.ai/docs/api/ops/refresh **POST** `/ops/v1/auth/refresh` ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `refresh_token` | String | 예 | signin 으로 발급된 refresh token. 이 호출 직후 무효화되며 새 값으로 교체됩니다. | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `access_token` | String | 예 | 새로 발급된 Bearer access token. | | `refresh_token` | String | 예 | rotation 으로 교체된 새 refresh token. 기존 값은 무효화되었습니다. | > `refresh_token` 이 무효이거나 만료된 경우 401 `InvalidRefreshToken` 응답을 받습니다. 이때는 사용자에게 재로그인을 요청해야 합니다. rotation 이므로 응답의 새 `refresh_token` 을 반드시 저장하세요. **cURL** ```bash curl -X POST "https://opsapi.edutap.ai/ops/v1/auth/refresh" \ -H "Content-Type: application/json" \ -d '{ "refresh_token": "eyJhbGciOi...refresh-payload..." }' ``` **Python** ```python import requests res = requests.post( "https://opsapi.edutap.ai/ops/v1/auth/refresh", json={ "refresh_token": "eyJhbGciOi...refresh-payload..." }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "refresh_token": "eyJhbGciOi...refresh-payload..." }`) req, _ := http.NewRequest("POST", "https://opsapi.edutap.ai/ops/v1/auth/refresh", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "refresh_token": "eyJhbGciOi...refresh-payload..." } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/auth/refresh")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/auth/refresh", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ "refresh_token": "eyJhbGciOi...refresh-payload..." }), }); console.log(await res.json()); ``` ```json { "access_token": "eyJhbGciOi...new-payload...", "refresh_token": "eyJhbGciOi...new-refresh-payload..." } ``` --- # 세션 증분 덤프 조회 > 직전 호출 시점 이후 신규 생성/갱신된 세션만 반환합니다. 증분 동기화에 사용합니다. Source: https://developers.edutap.ai/docs/api/ops/session-dumps-since-last **GET** `/ops/v1/sessions/dumps/since-last` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `retry` | Boolean | 아니요 | 기본값 false. true이면 직전 덤프 window를 그대로 재반환합니다 (재시도용) | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields 세션 덤프 조회(기간 지정)와 동일 구조: `sessions` 배열 (페이지네이션 없음). > 호출자별 마지막 호출 시점은 서버에서 client\_member 단위로 관리되며, `client_identity`·`include_descendants` 조합별로 따로 관리됩니다. 조합을 바꿔 호출하면 해당 조합의 첫 호출로 처리됩니다. 적재 실패 시 `retry=true`로 같은 window를 재요청할 수 있습니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" # 재시도 (직전 window 재반환) curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last?retry=true" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last?retry=true", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last?retry=true", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last?retry=true")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last?retry=true", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "sessions": [ { "chat_session_id": "sess_01HXABCD1234EFGH5678", "client_id": "client_acme", "client_identity": "acme", "user_id": "user_abc123", "course_id": "course_math_101", "clip_id": "clip_01", "title": "이차방정식 풀이", "course_name": "고등 수학 I", "course_category": "수학", "course_sub_category": "대수", "status": "ACTIVE", "created_at": "2026-06-20T03:21:00Z", "first_message_at": "2026-06-20T03:21:30Z", "last_message_at": "2026-06-20T03:45:10Z", "message_count": 14 } ] } ``` --- # 세션 덤프 조회 (기간 지정) > 지정한 기간 범위 내의 세션 전체를 일괄 반환합니다. 데이터 레이크 적재 등 배치 동기화 용도입니다. Source: https://developers.edutap.ai/docs/api/ops/session-dumps **GET** `/ops/v1/sessions/dumps` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `start_date` | String (ISO 8601) | 아니요 | 시작 일자. 미지정 시 end_date 기준 7일 전 | | `end_date` | String (ISO 8601) | 아니요 | 종료 일자. 미지정 시 현재 시각 | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `sessions` | Array of objects | 예 | 세션 목록 (페이지네이션 없음). 세션 목록 조회의 sessions item 과 동일 구조 | > 페이지네이션 없이 일괄 반환하므로 큰 기간 지정 시 응답이 매우 클 수 있습니다. 가능한 짧은 기간으로 분할 호출하거나 증분 덤프(`/dumps/since-last`)를 사용하세요. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/dumps?start_date=2026-06-24&end_date=2026-06-25" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/sessions/dumps?start_date=2026-06-24&end_date=2026-06-25", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/sessions/dumps?start_date=2026-06-24&end_date=2026-06-25", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/sessions/dumps?start_date=2026-06-24&end_date=2026-06-25")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/sessions/dumps?start_date=2026-06-24&end_date=2026-06-25", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "sessions": [ { "chat_session_id": "sess_01HXABCD1234EFGH5678", "client_id": "client_acme", "client_identity": "acme", "user_id": "user_abc123", "course_id": "course_math_101", "clip_id": "clip_01", "title": "이차방정식 풀이", "course_name": "고등 수학 I", "course_category": "수학", "course_sub_category": "대수", "status": "ACTIVE", "created_at": "2026-06-20T03:21:00Z", "first_message_at": "2026-06-20T03:21:30Z", "last_message_at": "2026-06-20T03:45:10Z", "message_count": 14 } ] } ``` --- # 세션 메시지 조회 > 특정 세션 ID의 채팅 메시지를 페이지 단위로 조회합니다. Source: https://developers.edutap.ai/docs/api/ops/session-messages **GET** `/ops/v1/sessions/{session_id}/messages` ## Path Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `session_id` | String | 예 | 조회할 세션 ID | ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `page` | Number | 아니요 | 페이지 번호 (1-base, 기본 1) | | `page_size` | Number | 아니요 | 페이지당 항목 수 (기본 10, 최대 100) | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `messages` | Array of objects | 예 | 메시지 목록 | | `messages[].message_id` | String | | 메시지 고유 ID | | `messages[].chat_session_id` | String | | 메시지가 속한 세션 ID | | `messages[].client_identity` | String \| null | | 메시지가 속한 고객사의 `client_identity` | | `messages[].role` | String | | 메시지 발화자 역할 (`user` / `assistant` / `system`) | | `messages[].message` | String | | 메시지 본문 (자연어 텍스트) | | `messages[].created_at` | String (ISO 8601) | | 메시지 생성 시각 (UTC) | | `messages[].answer_style` | String \| null | | 답변 스타일 식별자 (assistant 메시지에만 의미) | | `messages[].clip_id` | String \| null | | 메시지가 연관된 클립 ID | | `messages[].course_title` | String \| null | | 메시지가 발생한 과정 제목 (현재 응답에서는 항상 null) | | `messages[].user_id` | String \| null | | 메시지 발화 학습자 ID (현재 응답에서는 항상 null) | | `messages[].clip_play_head` | Number \| null | | 메시지 시점의 클립 재생 위치 (초) | | `messages[].is_floating` | Boolean | | 플로팅(독립) 메시지 여부 | | `messages[].is_retry` | Boolean | | 재시도로 생성된 메시지 여부 | | `messages[].is_preemptive_question` | Boolean | | 선제 질문 여부 (시스템이 제안한 첫 질문) | | `messages[].is_follow_up_question` | Boolean | | 후속 질문 여부 | | `messages[].is_fixed_guide_message` | Boolean | | 고정 가이드 메시지 여부 (function_call 기반) | | `messages[].references` | Array \| null | | 참조 자료 목록 | | `messages[].pdf_highlight_texts` | Array \| null | | PDF 하이라이트 텍스트 목록 | | `messages[].message_generation_time_seconds` | Number \| null | | 메시지 생성 소요 시간 (초, assistant 메시지에 한해 채워짐) | | `messages[].files` | Array \| null | | 첨부 파일 목록 | | `messages[].question_message_id` | String \| null | | 이 메시지가 답변한 직전 user 질문의 `message_id` (assistant 메시지에 한해 채워지며, 그 외는 `null`) | | `pagination` | Object | 예 | 페이지네이션 정보. | > `session_id`가 존재하지 않거나 조회 범위(기준 고객사, `include_descendants=true`이면 하위 고객사 포함) 밖의 세션이라면 404 `NotExistSession` 응답을 받습니다 (cross-tenant 거부). 응답은 `created_at` 오름차순으로 정렬되며, 메시지에 첨부 파일이 있는 경우 단건 조회 시 presigned URL 로 변환되어 반환됩니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/sess_01HXABCD1234EFGH5678/messages?page=1&page_size=10" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/sessions/sess_01HXABCD1234EFGH5678/messages?page=1&page_size=10", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/sessions/sess_01HXABCD1234EFGH5678/messages?page=1&page_size=10", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/sessions/sess_01HXABCD1234EFGH5678/messages?page=1&page_size=10")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/sessions/sess_01HXABCD1234EFGH5678/messages?page=1&page_size=10", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "messages": [ { "message_id": "msg_01HXEFGH1234ABCD5678", "chat_session_id": "sess_01HXABCD1234EFGH5678", "client_identity": "acme", "role": "user", "message": "이차방정식 x^2 - 5x + 6 = 0 풀이를 설명해 주세요.", "created_at": "2026-06-20T03:21:30Z", "answer_style": null, "clip_id": "clip_01", "course_title": null, "user_id": null, "clip_play_head": 0, "is_floating": false, "is_retry": false, "is_preemptive_question": false, "is_follow_up_question": false, "is_fixed_guide_message": false, "references": [], "pdf_highlight_texts": [], "message_generation_time_seconds": null, "files": [], "question_message_id": null } ], "pagination": { "current_page": 1, "total_pages": 2, "page_size": 10, "total_items": 14, "is_next": true, "is_prev": false } } ``` --- # 세션 목록 조회 > 필터 조건에 부합하는 학습자 채팅 세션 목록을 페이지 단위로 조회합니다. Source: https://developers.edutap.ai/docs/api/ops/sessions **GET** `/ops/v1/sessions` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `page` | Number | 아니요 | 페이지 번호 (1-base, 기본 1) | | `page_size` | Number | 아니요 | 페이지당 항목 수 (기본 10, 최대 100) | | `first_message_at_start` | String (ISO 8601) | 아니요 | 세션 시작 시간 조회 시작일 | | `first_message_at_end` | String (ISO 8601) | 아니요 | 세션 시작 시간 조회 종료일 | | `last_message_at_start` | String (ISO 8601) | 아니요 | 최근 활동 시간 조회 시작일 | | `last_message_at_end` | String (ISO 8601) | 아니요 | 최근 활동 시간 조회 종료일 | | `message_count_gte` | Number | 아니요 | 사용자 메시지 수 이상 필터 (0 이상) | | `message_count_lte` | Number | 아니요 | 사용자 메시지 수 이하 필터 (0 이상) | | `status` | String | 아니요 | 세션 상태 필터 | | `external_user_id` | String | 아니요 | 사용자 ID 부분 일치 필터 | | `session_title` | String | 아니요 | 세션 제목 부분 일치 필터 | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `sessions` | Array of objects | 예 | 세션 목록 | | `sessions[].chat_session_id` | String | | 채팅 세션 고유 ID | | `sessions[].client_id` | String | | 고객사 ID | | `sessions[].client_identity` | String \| null | | 세션이 속한 고객사의 `client_identity` | | `sessions[].user_id` | String | | 세션 소유 학습자 ID | | `sessions[].course_id` | String \| null | | 과정 ID. 자유 채팅 세션은 null | | `sessions[].clip_id` | String \| null | | 클립(콘텐츠 단위) ID | | `sessions[].title` | String \| null | | 세션 제목 (AI 자동 부여 또는 사용자 지정) | | `sessions[].course_name` | String \| null | | 과정명 | | `sessions[].course_category` | String \| null | | 과정 카테고리 | | `sessions[].course_sub_category` | String \| null | | 과정 서브 카테고리 | | `sessions[].status` | String | | 세션 상태 | | `sessions[].created_at` | String (ISO 8601) | | 세션 생성 시각 (UTC) | | `sessions[].first_message_at` | String (ISO 8601) \| null | | 첫 메시지 시각 (UTC) | | `sessions[].last_message_at` | String (ISO 8601) \| null | | 마지막 메시지 시각 (UTC) | | `sessions[].message_count` | Number | | 세션 내 누적 메시지 수 | | `pagination` | Object | 예 | 페이지네이션 정보. | > 응답은 `created_at` 내림차순으로 정렬됩니다. 모든 일시 쿼리 파라미터는 ISO 8601 형식으로 전달하며, 응답 일시 필드는 UTC 입니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions?page=1&page_size=10&external_user_id=user_abc" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/sessions?page=1&page_size=10&external_user_id=user_abc", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/sessions?page=1&page_size=10&external_user_id=user_abc", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/sessions?page=1&page_size=10&external_user_id=user_abc")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/sessions?page=1&page_size=10&external_user_id=user_abc", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "sessions": [ { "chat_session_id": "sess_01HXABCD1234EFGH5678", "client_id": "client_acme", "client_identity": "acme", "user_id": "user_abc123", "course_id": "course_math_101", "clip_id": "clip_01", "title": "이차방정식 풀이", "course_name": "고등 수학 I", "course_category": "수학", "course_sub_category": "대수", "status": "ACTIVE", "created_at": "2026-06-20T03:21:00Z", "first_message_at": "2026-06-20T03:21:30Z", "last_message_at": "2026-06-20T03:45:10Z", "message_count": 14 } ], "pagination": { "current_page": 1, "total_pages": 14, "page_size": 10, "total_items": 132, "is_next": true, "is_prev": false } } ``` --- # Client member 로그인 > Client member 자격 증명으로 로그인하여 Bearer 토큰을 발급받습니다. Source: https://developers.edutap.ai/docs/api/ops/signin **POST** `/ops/v1/auth/signin` ## Request Body | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `client_member_email` | String | 예 | Client member 이메일 주소 | | `client_member_password` | String | 예 | Client member 비밀번호 | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `access_token` | String | 예 | Bearer access token. 보호 API 호출 시 `Authorization: Bearer {access_token}` 형태로 그대로 사용합니다. | | `refresh_token` | String | 예 | Refresh token. 만료된 access token 갱신에 사용합니다. | > client\_member 자격만 허용됩니다. 내부 staff/superadmin 자격은 거부됩니다. 주요 에러: 400 `NotExistMember` (이메일 미존재), 400 `MismatchedPassword` (비밀번호 불일치), 400 `NotActivatedClientMember` (비활성 멤버 또는 비활성 client). **cURL** ```bash curl -X POST "https://opsapi.edutap.ai/ops/v1/auth/signin" \ -H "Content-Type: application/json" \ -d '{ "client_member_email": "partner-admin@example.com", "client_member_password": "S3cure!Pass" }' ``` **Python** ```python import requests res = requests.post( "https://opsapi.edutap.ai/ops/v1/auth/signin", json={ "client_member_email": "partner-admin@example.com", "client_member_password": "S3cure!Pass" }, ) print(res.json()) ``` **Go** ```go package main import ( "bytes" "fmt" "io" "net/http" ) func main() { body := []byte(`{ "client_member_email": "partner-admin@example.com", "client_member_password": "S3cure!Pass" }`) req, _ := http.NewRequest("POST", "https://opsapi.edutap.ai/ops/v1/auth/signin", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { String body = """ { "client_member_email": "partner-admin@example.com", "client_member_password": "S3cure!Pass" } """; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/auth/signin")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/auth/signin", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ "client_member_email": "partner-admin@example.com", "client_member_password": "S3cure!Pass" }), }); console.log(await res.json()); ``` ```json { "access_token": "eyJhbGciOi...payload...", "refresh_token": "eyJhbGciOi...refresh-payload..." } ``` --- # 사용자 수 집계 조회 > 지정한 기간 내의 학습자 수를 일(daily) 단위로 집계해 반환합니다. Source: https://developers.edutap.ai/docs/api/ops/user-count **GET** `/ops/v1/dashboard/user-count` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `start_date` | String | 예 | 시작 날짜. `YYYY-MM-DD` (KST 기준) | | `end_date` | String | 예 | 종료 날짜. `YYYY-MM-DD` (KST 기준) | | `course_id` | String | 아니요 | `course_id` 정확 일치 필터 (case-sensitive). 미지정 시 필터 미적용 | | `clip_id` | String | 아니요 | `clip_id` 정확 일치 필터 (case-sensitive). 미지정 시 필터 미적용 | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `period_start_date` | String | 예 | 요청한 시작 일자 (`YYYY-MM-DD`, KST) | | `period_end_date` | String | 예 | 요청한 종료 일자 (`YYYY-MM-DD`, KST) | | `client_identity` | String \| null | 아니요 | 조회 기준 고객사의 `client_identity` | | `course_id` | String \| null | 아니요 | 요청 파라미터 `course_id`를 strip 정규화 후 echo. 미지정 또는 빈 값이면 `null` | | `clip_id` | String \| null | 아니요 | 요청 파라미터 `clip_id`를 strip 정규화 후 echo. 미지정 또는 빈 값이면 `null` | | `count` | Number \| null | 아니요 | 집계 결과 카운트. 등록된 course 가 1개 미만이면 `null` 로 반환됩니다. | > 서버 내부적으로 일(daily) 단위 집계만 지원합니다. 잘못된 날짜 포맷은 422 `RequestValidationError`, 클라이언트 미존재는 400 `NotExistClient` 응답을 받습니다. > `course_id`/`clip_id` 필터는 exact match, case-sensitive 로 적용됩니다. 값은 앞뒤 공백을 strip 후 사용하며, 빈 문자열 또는 공백만 있는 값은 필터 미적용(`null` echo)으로 처리됩니다. 둘 다 지정하면 AND 조건입니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/dashboard/user-count?start_date=2026-06-01&end_date=2026-06-25" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" # course_id/clip_id (옵셔널 필터, 예: &course_id=course_abc&clip_id=clip_xyz) ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/dashboard/user-count?start_date=2026-06-01&end_date=2026-06-25", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/dashboard/user-count?start_date=2026-06-01&end_date=2026-06-25", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/dashboard/user-count?start_date=2026-06-01&end_date=2026-06-25")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/dashboard/user-count?start_date=2026-06-01&end_date=2026-06-25", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "period_start_date": "2026-06-01", "period_end_date": "2026-06-25", "client_identity": "acme", "course_id": null, "clip_id": null, "count": 1284 } ``` --- # 사용자 활동 목록 조회 > 학습자별 활동 요약을 일괄 조회합니다 (페이지네이션 없음). Source: https://developers.edutap.ai/docs/api/ops/users **GET** `/ops/v1/users` ## Query Parameters | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `user_id` | String | 아니요 | user_id 부분 일치 검색 (case-insensitive) | | `start_date` | String | 아니요 | 기간 필터 시작일. `YYYY-MM-DD` (KST 기준). `end_date` 와 **둘 다** 지정해야 기간 필터가 적용됩니다. | | `end_date` | String | 아니요 | 기간 필터 종료일. `YYYY-MM-DD` (KST 기준). `start_date` 와 **둘 다** 지정해야 기간 필터가 적용됩니다. | | `course_id` | String | 아니요 | `course_id` 정확 일치 필터 (case-sensitive). 미지정 시 필터 미적용 | | `clip_id` | String | 아니요 | `clip_id` 정확 일치 필터 (case-sensitive). 미지정 시 필터 미적용 | | `client_identity` | String | 아니요 | 조회 기준 고객사의 `client_identity`. 미지정 시 로그인 계정이 속한 고객사 기준입니다. 로그인 고객사 또는 그 하위 고객사만 지정할 수 있으며, 그 외 값은 403 `OuterClientScopeForbidden` | | `include_descendants` | Boolean | 아니요 | `true`이면 기준 고객사와 모든 하위 고객사의 데이터를 함께 조회합니다. `false`이면 기준 고객사의 데이터만 조회합니다 (기본값: `아니요`) | ## Response Fields | 이름 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `course_id` | String \| null | 아니요 | 요청 파라미터 `course_id`를 strip 정규화 후 echo. 미지정 또는 빈 값이면 `null` | | `clip_id` | String \| null | 아니요 | 요청 파라미터 `clip_id`를 strip 정규화 후 echo. 미지정 또는 빈 값이면 `null` | | `users` | Array of objects | 예 | 사용자 활동 목록 (페이지네이션 없음, 일괄 반환) | | `users[].user_id` | String | | 학습자 식별자 | | `users[].client_identity` | String \| null | | 학습자가 속한 고객사의 `client_identity`. 같은 `user_id`라도 고객사가 다르면 별개 행으로 반환됩니다 | | `users[].session_count` | Number | | 해당 학습자의 누적 세션 수 | | `users[].question_count` | Number | | 해당 학습자의 누적 질문 수 (role='user' 메시지 수) | | `users[].last_question_at` | String (ISO 8601) \| null | | 마지막 질문 일시 (role='user' 메시지의 최신 created_at, UTC) | > 페이지네이션 없이 일괄 반환됩니다. `start_date`/`end_date` 를 둘 다 지정한 경우, 해당 기간에 `role='user'` 메시지가 없는 학습자는 결과에서 제외됩니다. > `course_id`/`clip_id` 필터는 exact match, case-sensitive 로 적용됩니다. 값은 앞뒤 공백을 strip 후 사용하며, 빈 문자열 또는 공백만 있는 값은 필터 미적용(`null` echo)으로 처리됩니다. 둘 다 지정하면 AND 조건입니다. **cURL** ```bash curl -X GET "https://opsapi.edutap.ai/ops/v1/users?user_id=user_abc&start_date=2026-06-01&end_date=2026-06-25" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" # course_id/clip_id (옵셔널 필터, 예: &course_id=course_abc&clip_id=clip_xyz) ``` **Python** ```python import requests res = requests.get( "https://opsapi.edutap.ai/ops/v1/users?user_id=user_abc&start_date=2026-06-01&end_date=2026-06-25", headers={"Authorization": "Bearer ${ACCESS_TOKEN}"}, ) print(res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://opsapi.edutap.ai/ops/v1/users?user_id=user_abc&start_date=2026-06-01&end_date=2026-06-25", nil) req.Header.Set("Authorization", "Bearer ${ACCESS_TOKEN}") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) fmt.Println(string(b)) } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Main { public static void main(String[] args) throws Exception { HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://opsapi.edutap.ai/ops/v1/users?user_id=user_abc&start_date=2026-06-01&end_date=2026-06-25")) .header("Authorization", "Bearer ${ACCESS_TOKEN}") .GET() .build(); HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } } ``` **Node.js** ```javascript const res = await fetch("https://opsapi.edutap.ai/ops/v1/users?user_id=user_abc&start_date=2026-06-01&end_date=2026-06-25", { method: "GET", headers: {"Authorization": "Bearer ${ACCESS_TOKEN}"}, }); console.log(await res.json()); ``` ```json { "course_id": null, "clip_id": null, "users": [ { "user_id": "user_abc123", "client_identity": "acme", "session_count": 12, "question_count": 84, "last_question_at": "2026-06-24T15:42:00Z" } ] } ``` --- # 코드 에디터 연동 > TapKit과 코드 에디터 양방향 동기화 Source: https://developers.edutap.ai/docs/sdk/features/code-editor # 코드 에디터 연동 ## 초기 설정 `rowDataForCodeEditor`는 정적 문제 메타데이터, `userInputCodeTarget`은 학습자의 현재 코드를 전달합니다. 코딩 실습 시나리오에서는 두 옵션을 함께 등록하는 것이 권장 구성입니다. ```tsx const tapkit = useTapKit({ apiKey, userId, courseId, rowDataForCodeEditor: rowData, // 정적 문제 메타 userInputCodeTarget: { getCode: () => editor.getValue() }, // 학습자 코드 }); ``` 각 옵션의 상세 사용법은 아래 [어댑터 패턴](#어댑터-패턴)과 [코드 컨텍스트 데이터](#코드-컨텍스트-데이터-rowdataforcodeeditor) 섹션을 참조하세요. *** ## 어댑터 패턴 `videoTarget`과 동일한 양방향 어댑터 패턴입니다. 호스트는 `getCode`/`setCode` 두 함수를 가진 객체만 등록하면, SDK가 알아서 폴링·세팅을 처리합니다. ```typescript interface UserInputCodeAdapter { getCode: () => string; setCode?: (code: string) => void; } ``` | 함수 | 호출 시점 | 필수 여부 | | --------- | ----------------------------------------- | ----- | | `getCode` | SDK가 500ms 주기로 폴링 → 변경 감지 시 chat 컨텍스트에 반영 | 필수 | | `setCode` | 채팅 "에디터에 적용" 모달에서 사용자가 모드 선택 시 | 선택 | `setCode`를 생략하면 read-only 모드 — 코드를 SDK에 보내기만 하고 채팅에서 코드를 받아오는 동작은 비활성화됩니다. > 비디오가 `