{
  "openapi": "3.0.3",
  "info": {
    "title": "Copilot 도입 지표 API",
    "version": "0.1.0",
    "description": "교육청·학교의 Microsoft 365 Copilot 도입 지표를 돌려주는 읽기 전용 API입니다. Graph 사용량 보고서를 가명처리(HMAC)한 뒤 학교 명단과 결합해 학교·교육청 단위로만 집계합니다. 라이선스 사용자가 k명 미만인 학교는 비율을 숨깁니다(suppressed). 개인 단위 값은 어떤 응답에도 없습니다. 비율은 퍼센트(0~100), 변화량은 퍼센트포인트입니다. dataMode가 synthetic이면 실제 학교·교사 데이터가 아닌 합성 데이터입니다."
  },
  "servers": [
    {
      "url": "https://adoption.cmhong95.com",
      "description": "공개 데모 주소(계획, 아직 배포 안 함)"
    },
    {
      "url": "http://127.0.0.1:8097",
      "description": "로컬 개발"
    }
  ],
  "paths": {
    "/api/v1/summary": {
      "get": {
        "operationId": "getSummary",
        "summary": "교육청 전체 도입 요약",
        "description": "교육청 전체의 라이선스 사용자, 활성률, 습관화율, 활성 사용자당 프롬프트 수, 전주 대비 변화를 돌려줍니다.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "description": "집계 기간. D7·D28·D90·D180(Copilot 사용 보고서 v2 기준). M365 앱·Teams 보고서는 D28 대신 D30 창을 씁니다.",
            "schema": {
              "type": "string",
              "enum": [
                "D7",
                "D28",
                "D90",
                "D180"
              ],
              "default": "D28"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "교육청 요약",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Summary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/NoData"
          }
        }
      }
    },
    "/api/v1/schools": {
      "get": {
        "operationId": "listSchools",
        "summary": "학교별 도입 지표 목록",
        "description": "학교급·상태로 거르고 정렬한 학교 목록을 돌려줍니다. 숨김 학교는 비율 값이 null입니다.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "description": "집계 기간. D7·D28·D90·D180(Copilot 사용 보고서 v2 기준). M365 앱·Teams 보고서는 D28 대신 D30 창을 씁니다.",
            "schema": {
              "type": "string",
              "enum": [
                "D7",
                "D28",
                "D90",
                "D180"
              ],
              "default": "D28"
            }
          },
          {
            "name": "level",
            "in": "query",
            "required": false,
            "description": "학교급 필터. all(전체), elementary(초), middle(중), high(고).",
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "elementary",
                "middle",
                "high"
              ],
              "default": "all"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "상태 필터. all이면 숨김(suppressed) 학교도 포함합니다. 특정 상태를 고르면 숨김 학교는 빠집니다.",
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "leading",
                "growing",
                "stalled",
                "not_started"
              ],
              "default": "all"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "정렬 기준. activeRate, habitualRate, trendDelta, name. 값이 없는 학교는 정렬 방향과 관계없이 뒤에 옵니다.",
            "schema": {
              "type": "string",
              "enum": [
                "activeRate",
                "habitualRate",
                "trendDelta",
                "name"
              ],
              "default": "activeRate"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "정렬 방향. asc(오름차순) 또는 desc(내림차순).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "돌려줄 학교 수(1~50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "건너뛸 학교 수(페이지 넘김). total과 함께 써서 전체 목록을 받습니다.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100000,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "학교 목록",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SchoolList"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/Concealed"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/NoData"
          }
        }
      }
    },
    "/api/v1/schools/{schoolId}": {
      "get": {
        "operationId": "getSchool",
        "summary": "학교 한 곳의 상세 지표와 권고",
        "description": "학교 한 곳의 지표, 앱별 Copilot 사용 비율, 주간 활성률 추세, 한국어 권고를 돌려줍니다.",
        "parameters": [
          {
            "name": "schoolId",
            "in": "path",
            "required": true,
            "description": "학교 ID. /api/v1/schools 결과의 schoolId 값.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{1,32}$"
            }
          },
          {
            "name": "period",
            "in": "query",
            "required": false,
            "description": "집계 기간. D7·D28·D90·D180(Copilot 사용 보고서 v2 기준). M365 앱·Teams 보고서는 D28 대신 D30 창을 씁니다.",
            "schema": {
              "type": "string",
              "enum": [
                "D7",
                "D28",
                "D90",
                "D180"
              ],
              "default": "D28"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "학교 상세",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SchoolDetail"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Concealed"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/NoData"
          }
        }
      }
    },
    "/api/v1/insights": {
      "get": {
        "operationId": "listInsights",
        "summary": "도입 인사이트와 권장 조치",
        "description": "정체·챔피언 후보·연수 공백·미시작 학교 묶음과 한국어 제목, 설명, 권장 조치를 돌려줍니다.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "description": "집계 기간. D7·D28·D90·D180(Copilot 사용 보고서 v2 기준). M365 앱·Teams 보고서는 D28 대신 D30 창을 씁니다.",
            "schema": {
              "type": "string",
              "enum": [
                "D7",
                "D28",
                "D90",
                "D180"
              ],
              "default": "D28"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "돌려줄 인사이트 수(1~20). 종류(not_started, stalled, training_gap, champion)를 번갈아 담습니다.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 20,
              "default": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "인사이트 목록",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InsightList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "$ref": "#/components/responses/NoData"
          }
        }
      }
    },
    "/healthz": {
      "get": {
        "operationId": "getHealth",
        "summary": "상태 확인",
        "description": "서버가 응답하면 ok:true를 돌려줍니다.",
        "responses": {
          "200": {
            "description": "정상",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Summary": {
        "type": "object",
        "description": "교육청 전체 요약.",
        "properties": {
          "district": {
            "type": "string",
            "description": "교육청 이름.",
            "example": "울산(가상) 교육청 데모"
          },
          "period": {
            "type": "string",
            "enum": [
              "D7",
              "D28",
              "D90",
              "D180"
            ],
            "description": "집계 기간."
          },
          "generatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "집계를 만든 시각(UTC)."
          },
          "dataMode": {
            "type": "string",
            "enum": [
              "synthetic",
              "graph"
            ],
            "description": "synthetic: 합성 데이터, graph: Graph 보고서 실데이터."
          },
          "schoolCount": {
            "type": "integer",
            "description": "라이선스 사용자가 있는 학교 수(숨김 학교 포함)."
          },
          "licensedUsers": {
            "type": "integer",
            "description": "Copilot 라이선스 사용자 수."
          },
          "activeUsers": {
            "type": "integer",
            "nullable": true,
            "description": "기간 중 활성 사용자 수. 교육청 전체가 k명 미만이면 null."
          },
          "activeRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "활성률(%).",
            "example": 42.5
          },
          "habitualRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "습관화율(%). 최근 28일 중 8일 이상 쓰고 기간 창 안에서도 활성인 사용자 비율.",
            "example": 42.5
          },
          "promptsPerActiveUser": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "활성 사용자 1명당 기간 중 프롬프트 수."
          },
          "weekOverWeekDelta": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "전주 대비 활성률 변화(퍼센트포인트)."
          },
          "kAnonymity": {
            "type": "integer",
            "description": "숨김 기준 k(라이선스 사용자 수 최소값).",
            "example": 10
          }
        },
        "required": [
          "district",
          "period",
          "generatedAt",
          "dataMode",
          "schoolCount",
          "licensedUsers",
          "activeUsers",
          "activeRate",
          "habitualRate",
          "promptsPerActiveUser",
          "weekOverWeekDelta",
          "kAnonymity"
        ]
      },
      "SchoolItem": {
        "type": "object",
        "description": "학교 한 곳의 지표.",
        "properties": {
          "schoolId": {
            "type": "string",
            "description": "학교 ID(명단 CSV의 schoolId).",
            "example": "ES-004"
          },
          "name": {
            "type": "string",
            "description": "학교 이름(합성 데이터에서는 가상 이름).",
            "example": "가람누리초등학교"
          },
          "level": {
            "type": "string",
            "enum": [
              "elementary",
              "middle",
              "high"
            ],
            "description": "학교급: elementary(초), middle(중), high(고)."
          },
          "licensedUsers": {
            "type": "integer",
            "description": "Copilot 라이선스 사용자 수(이 학교 교직원).",
            "example": 33
          },
          "activeRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "활성률(%). 기간 창 안에 Copilot을 한 번 이상 쓴 라이선스 사용자 비율. suppressed면 null.",
            "example": 42.5
          },
          "habitualRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "습관화율(%). 최근 28일 중 8일 이상(주 2회) 쓰고 기간 창 안에서도 활성인 사용자 비율. suppressed면 null.",
            "example": 42.5
          },
          "promptsPerActiveUser": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "활성 사용자 1명당 기간 중 프롬프트 수(모든 앱). 숨김 학교이거나 활성 사용자가 k명 미만이면 null.",
            "example": 24.7
          },
          "trendDelta": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "전주 대비 활성률 변화(퍼센트포인트). 매주 같은 기간 창으로 다시 계산한 값의 차이. suppressed면 null.",
            "example": 1.8
          },
          "status": {
            "type": "string",
            "nullable": true,
            "enum": [
              "leading",
              "growing",
              "stalled",
              "not_started",
              null
            ],
            "description": "도입 상태. not_started(활성 5% 미만), stalled(2주 연속 하락 또는 습관화 15% 미만), leading(활성 60% 이상·습관화 35% 이상), growing(그 밖). 상태도 숨긴 비율에서 나오므로 suppressed면 null."
          },
          "suppressed": {
            "type": "boolean",
            "description": "true면 라이선스 사용자가 k명 미만(또는 차감 계산 방지용 보조 숨김)이라 비율 값을 숨겼습니다."
          }
        },
        "required": [
          "schoolId",
          "name",
          "level",
          "licensedUsers",
          "activeRate",
          "habitualRate",
          "promptsPerActiveUser",
          "trendDelta",
          "status",
          "suppressed"
        ]
      },
      "SchoolList": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SchoolItem"
            }
          },
          "total": {
            "type": "integer",
            "description": "필터에 맞는 전체 학교 수(limit·offset 적용 전)."
          }
        },
        "required": [
          "items",
          "total"
        ]
      },
      "AppBreadth": {
        "type": "object",
        "description": "활성 사용자 중 각 앱에서 Copilot을 쓴 비율(%). 활성 사용자가 k명 미만이거나 숨김 학교면 값이 null.",
        "properties": {
          "word": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "Word에서 Copilot을 쓴 활성 사용자 비율(%).",
            "example": 42.5
          },
          "excel": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "Excel에서 Copilot을 쓴 활성 사용자 비율(%).",
            "example": 42.5
          },
          "powerpoint": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "PowerPoint에서 Copilot을 쓴 활성 사용자 비율(%).",
            "example": 42.5
          },
          "outlook": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "Outlook에서 Copilot을 쓴 활성 사용자 비율(%).",
            "example": 42.5
          },
          "teams": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "Teams에서 Copilot을 쓴 활성 사용자 비율(%).",
            "example": 42.5
          },
          "onenote": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "OneNote에서 Copilot을 쓴 활성 사용자 비율(%).",
            "example": 42.5
          }
        },
        "required": [
          "word",
          "excel",
          "powerpoint",
          "outlook",
          "teams",
          "onenote"
        ]
      },
      "WeeklyPoint": {
        "type": "object",
        "properties": {
          "week": {
            "type": "string",
            "format": "date",
            "description": "주간 스냅숏의 보고서 새로 고침 날짜."
          },
          "activeRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "그 주에 같은 기간 창으로 계산한 활성률(%).",
            "example": 42.5
          }
        },
        "required": [
          "week",
          "activeRate"
        ]
      },
      "SchoolDetail": {
        "type": "object",
        "description": "학교 상세. SchoolItem의 모든 필드에 appBreadth, weekly, recommendations_ko가 더해집니다.",
        "properties": {
          "schoolId": {
            "type": "string",
            "description": "학교 ID(명단 CSV의 schoolId).",
            "example": "ES-004"
          },
          "name": {
            "type": "string",
            "description": "학교 이름(합성 데이터에서는 가상 이름).",
            "example": "가람누리초등학교"
          },
          "level": {
            "type": "string",
            "enum": [
              "elementary",
              "middle",
              "high"
            ],
            "description": "학교급: elementary(초), middle(중), high(고)."
          },
          "licensedUsers": {
            "type": "integer",
            "description": "Copilot 라이선스 사용자 수(이 학교 교직원).",
            "example": 33
          },
          "activeRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "활성률(%). 기간 창 안에 Copilot을 한 번 이상 쓴 라이선스 사용자 비율. suppressed면 null.",
            "example": 42.5
          },
          "habitualRate": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "습관화율(%). 최근 28일 중 8일 이상(주 2회) 쓰고 기간 창 안에서도 활성인 사용자 비율. suppressed면 null.",
            "example": 42.5
          },
          "promptsPerActiveUser": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "활성 사용자 1명당 기간 중 프롬프트 수(모든 앱). 숨김 학교이거나 활성 사용자가 k명 미만이면 null.",
            "example": 24.7
          },
          "trendDelta": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "전주 대비 활성률 변화(퍼센트포인트). 매주 같은 기간 창으로 다시 계산한 값의 차이. suppressed면 null.",
            "example": 1.8
          },
          "status": {
            "type": "string",
            "nullable": true,
            "enum": [
              "leading",
              "growing",
              "stalled",
              "not_started",
              null
            ],
            "description": "도입 상태. not_started(활성 5% 미만), stalled(2주 연속 하락 또는 습관화 15% 미만), leading(활성 60% 이상·습관화 35% 이상), growing(그 밖). 상태도 숨긴 비율에서 나오므로 suppressed면 null."
          },
          "suppressed": {
            "type": "boolean",
            "description": "true면 라이선스 사용자가 k명 미만(또는 차감 계산 방지용 보조 숨김)이라 비율 값을 숨겼습니다."
          },
          "appBreadth": {
            "$ref": "#/components/schemas/AppBreadth"
          },
          "weekly": {
            "type": "array",
            "description": "오래된 주부터 정렬한 주간 활성률.",
            "items": {
              "$ref": "#/components/schemas/WeeklyPoint"
            }
          },
          "recommendations_ko": {
            "type": "array",
            "description": "한국어 권고.",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "schoolId",
          "name",
          "level",
          "licensedUsers",
          "activeRate",
          "habitualRate",
          "promptsPerActiveUser",
          "trendDelta",
          "status",
          "suppressed",
          "appBreadth",
          "weekly",
          "recommendations_ko"
        ]
      },
      "Insight": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "stalled",
              "champion",
              "training_gap",
              "not_started"
            ],
            "description": "stalled(정체), champion(챔피언 후보), training_gap(앱은 쓰지만 Copilot은 덜 씀), not_started(미시작)."
          },
          "schoolIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "해당 학교 ID(최대 10개)."
          },
          "title_ko": {
            "type": "string",
            "description": "제목."
          },
          "detail_ko": {
            "type": "string",
            "description": "근거 설명."
          },
          "action_ko": {
            "type": "string",
            "description": "권장 조치."
          }
        },
        "required": [
          "type",
          "schoolIds",
          "title_ko",
          "detail_ko",
          "action_ko"
        ]
      },
      "InsightList": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Insight"
            }
          }
        },
        "required": [
          "items"
        ]
      },
      "Health": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ]
      },
      "Error": {
        "type": "object",
        "description": "오류. error는 한국어 메시지, code는 기계가 읽는 코드입니다.",
        "properties": {
          "error": {
            "type": "string",
            "example": "period 값은 D7, D28, D90, D180 중 하나여야 합니다."
          },
          "code": {
            "type": "string",
            "example": "invalid_parameter"
          }
        },
        "required": [
          "error",
          "code"
        ]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "요청 값이 잘못됨(code: invalid_parameter).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "학교를 찾을 수 없음(code: not_found).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Concealed": {
        "description": "보고서 사용자 이름이 숨김 처리되어 학교별 집계가 없음(code: school_breakdown_unavailable).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "요청이 너무 많음(code: rate_limited). Retry-After 헤더를 따르세요.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NoData": {
        "description": "아직 집계된 데이터가 없음(code: no_data).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
