# heymoa-ai가 heymoa-server에 제공할 내부 REST. **계획 문서** — 구현이 이 계약을 따른다.
# 생성물이 아니라 수기 작성이며, 계약이 먼저이고 구현이 따라온다.
openapi: "3.1.0"
info:
  title: "Heymoa AI Internal API"
  description: |
    heymoa-server → heymoa-ai 내부 API 계약이다.
    heymoa-ai는 내부 전용 서비스로, 이 API는 heymoa-server만 호출한다.
    브라우저·외부 트래픽은 절대 도달하지 않는다 (public edge/CORS 없음).

    원칙:
    - 진실의 원본은 heymoa-server. heymoa-ai는 파생 데이터(임베딩, checkpoint)만 소유한다.
    - 데이터는 오직 API payload로만 이동한다 (cross-database 접근 금지).
    - toolCredentials는 요청 스코프에서만 존재한다. 저장·로깅 금지 (아래 각 필드 설명 참조).

    ## 호출자가 거는 타임아웃 (heymoa-server 실측값)

    이 값들이 응답 지연의 상한이다. 넘기면 server가 연결을 끊고 유저에게 오류가 간다.

    | 경로 | connect | read |
    |---|---|---|
    | `POST /internal/v1/agent-chats/{chatId}/messages` (SSE) | 5초 | **60초 유휴** — 행 간격 기준 |
    | `POST /internal/v1/agent-chats/{chatId}/approvals/{approvalId}` | 3초 | **10초** |

    SSE의 60초는 전체 소요가 아니라 **입력 행 사이 간격**이다. 승인 대기·도구 실행처럼 이벤트가
    없는 구간은 keepalive comment(15초 이하)로 채운다 — asyncapi-server-ai.yml 참조.

    승인 재개는 10초 안에 응답해야 한다. 도구 실행 완료를 기다리고 응답하면 넘긴다 —
    **재개 신호만 주고 즉시 204**를 내고, 실행 결과는 열려 있는 SSE 스트림으로 보낸다.

    ## 인증 — 이 방향은 비어 있다

    server → ai 요청에는 **인증 헤더가 없다.** 네트워크 격리가 유일한 경계다.
    반대 방향(ai → server의 `/internal/**`, 아래 분석 callback과 컨텍스트 조회)은
    `X-Internal-Token` 공유 시크릿을 요구한다(APP-122).

    비대칭이 의도된 것인지 미결이다. 대칭으로 가려면 server의 두 클라이언트
    (`AgentChatSseClient`, `AgentChatApprovalClient`)와 분석 클라이언트가 헤더를 실어야 하므로,
    **먼저 정한 뒤 양쪽을 같은 변경으로 맞춘다** — 여기만 고치면 모든 호출이 401이 된다.
  version: "1.1.0"
servers:
  - url: "/"
    description: "내부 네트워크의 heymoa-ai 인스턴스"
tags:
  - name: "Analyses"
    description: "회의 종료 후 비동기 분석 (202 + callback 패턴)"
  - name: "AgentChats"
    description: "agent 채팅 (SSE 스트리밍)"
paths:
  /internal/v1/analyses:
    post:
      tags:
        - "Analyses"
      summary: "분석 잡 접수"
      description: |
        회의 종료 시 노트의 전사 전체를 받아 OVERVIEW / ACTION_ITEM / DECISION 세 섹션의
        항목을 뽑는다. 항목마다 그 항목이 나온 전사 세그먼트 id를 근거로 단다(APP-392).
        id(analysisId, noteId, segmentId)는 heymoa-server가 발급하는 13자 TSID 문자열이다.
        검증 후 즉시 202를 반환하고 비동기로 처리하며, 완료·실패 시
        heymoa-server의 callback endpoint로 결과를 POST한다.

        멱등성: 같은 analysisId 재요청 시 진행 중이든 완료됐든 동일하게 202를
        반환한다. 이미 완료된 잡이면 callback을 재발송한다. 새 작업을 만들지 않는다.
      operationId: "createAnalysis"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAnalysisRequest"
      responses:
        "202":
          description: "접수됨. 결과는 callback으로 전달된다."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalysisAcceptedResponse"
        "400":
          description: "payload 검증 실패"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
      callbacks:
        analysisResult:
          "{$request.body#/callbackUrl}":
            post:
              summary: "분석 결과 callback"
              description: |
                분석 완료·실패 시 heymoa-ai가 callbackUrl로 결과를 POST한다.
                callbackUrl은 heymoa-server의
                POST /internal/v1/callbacks/analyses/{analysisId} 이다.
                heymoa-server는 이미 완료 처리된 잡에 대한 중복 callback을 무시한다(멱등).

                **X-Internal-Token 공유 시크릿을 실어야 한다 (APP-122 확정).**
                heymoa-server는 /internal 하위 전 경로를 이 헤더로 차단한다 —
                헤더가 없으면 401이고 결과가 저장되지 않는다.
              parameters:
                - name: "X-Internal-Token"
                  in: "header"
                  required: true
                  description: "heymoa-server와 공유하는 내부 시크릿"
                  schema:
                    type: "string"
              requestBody:
                required: true
                content:
                  application/json:
                    schema:
                      $ref: "#/components/schemas/AnalysisResultCallback"
              responses:
                "204":
                  description: "수신 완료"
                "401":
                  description: "X-Internal-Token 없음 또는 불일치"
  /internal/v1/agent-chats/{chatId}/messages:
    post:
      tags:
        - "AgentChats"
      summary: "agent 채팅 메시지 전송 (SSE 스트림 응답)"
      description: |
        유저 메시지를 받아 LangGraph agent 루프를 실행하고,
        응답을 text/event-stream(SSE)으로 스트리밍한다.
        이벤트 스키마는 asyncapi-server-ai.yml 계약을 따른다
        (message_start / token / tool_call_start / tool_approval_request /
        tool_approval_resolved / tool_call_result / message_end / error).

        스트림 규약 세 가지는 어기면 조용히 깨진다:
        - **반드시 message_end 또는 error로 끝낸다.** 종료 이벤트 없이 연결이 닫히면 server는
          실패로 처리하고 응답을 저장하지 않는다.
        - **message_end.content는 비어 있지 않은 문자열이어야 한다.** 아니면 server가 스트림을
          실패시킨다 (저장을 건너뛰지 않는다).
        - **approvalId·toolCallId는 이벤트 사이에서 짝이 맞아야 한다.** approvalId는 13자 TSID다 —
          아니면 승인 API가 404가 되어 카드가 죽는다.

        대화 상태는 chatId 기준으로 heymoa-ai의 LangGraph checkpointer가 유지한다
        (멀티턴 지원 — heymoa-server는 히스토리를 다시 보낼 필요 없음).

        기획 v2:
        - **(chatKind, scope) 조합이 agent profile을 정한다.** scope만으로는 갈리지 않는다 —
          공유 챗봇과 노트 스코프 개인 챗봇이 둘 다 scope=note로 온다.
        - scope가 컨텍스트를 정한다. **컨텍스트 데이터는 heymoa-ai가 heymoa-server의
          내부 조회 API로 당겨간다** (요청 payload에 전사를 싣지 않는다) —
          note면 GET /internal/v1/notes/{noteId}/context(전사+요약 통째)를 주입하고,
          workspace면 GET /internal/v1/workspaces/{workspaceId}/notes를 인덱스로
          agentic 검색 도구를 쓴다 (openapi3-server.yml InternalAgentContext 참조).
          **heymoa-server의 /internal 하위 전 경로가 X-Internal-Token 공유 시크릿을 요구한다**
          (APP-122) — 이 두 조회 경로와 위 분석 결과 callback 모두 heymoa-ai가 헤더를 실어야 한다.
        - 컨텍스트는 **매 턴 다시 당겨간다**. 회의 진행 중이면 그 시점까지 확정된 전사가
          반영되고(기획 v2 §3.2 "답변은 질문 시점의 스냅숏"), 대화 히스토리에는 전사를
          쌓지 않는다 — checkpoint에는 메시지만 남는다.
        - 쓰기(write) 도구는 실행 전 tool_approval_request 이벤트를 발행하고
          LangGraph interrupt로 대기한다. 승인 재개는 resolveToolApproval 참조.
          조회(read) 도구는 자동 실행한다.
      operationId: "sendAgentChatMessage"
      parameters:
        - name: "chatId"
          in: "path"
          required: true
          description: "heymoa-server가 발급한 채팅 세션 id"
          schema:
            type: "string"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentChatMessageRequest"
      responses:
        "200":
          description: |
            SSE 스트림. 각 이벤트는 `event: <type>` + `data: <json>` 형식이며
            payload 스키마는 asyncapi-server-ai.yml에 정의한다.
          content:
            text/event-stream:
              schema:
                type: "string"
                description: "asyncapi-server-ai.yml 계약을 따르는 SSE 이벤트 스트림"
        "400":
          description: "payload 검증 실패"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /internal/v1/agent-chats/{chatId}/approvals/{approvalId}:
    post:
      tags:
        - "AgentChats"
      summary: "쓰기 도구 승인/거절 (interrupt 재개)"
      description: |
        tool_approval_request로 대기 중인 LangGraph interrupt를 재개한다.
        heymoa-server가 입력자 검증을 마친 뒤 호출한다 (heymoa-ai는 권한을 재검증하지 않는다).
        APPROVED면 도구를 실행하고, REJECTED면 도구 없이 agent가 거절을 반영해 응답을 이어간다.
        어느 쪽이든 열려 있는 SSE 스트림으로 tool_approval_resolved가 발행된다.
        승인 대기 타임아웃이 지난 approvalId면 404를 반환한다.

        **도구 실행을 기다리지 말고 응답한다.** 호출자의 read timeout은 10초이고, 결과는
        SSE 스트림으로 가므로 이 응답이 실행 완료를 뜻할 필요가 없다.

        **상태 코드는 셋만 의미가 있다.** server는 2xx를 성공으로, 404를 "대기 중인 승인 없음"
        (유저에게 404)으로 해석하고, **그 외 모든 코드를 500으로 바꾼다** — 400을 돌려줘도
        유저는 500을 본다. 재개가 불가능한 상황은 반드시 404로 표현한다.

        멱등성: 이미 확정된 approvalId 재호출은 새 실행을 만들지 않는다. 대기 중이 아니면 404다.
      operationId: "resolveToolApproval"
      parameters:
        - name: "chatId"
          in: "path"
          required: true
          schema:
            type: "string"
        - name: "approvalId"
          in: "path"
          required: true
          schema:
            type: "string"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResolveToolApprovalRequest"
      responses:
        "204":
          description: "재개 처리됨"
        "404":
          description: "대기 중인 승인 요청 없음 (타임아웃 만료 포함)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "400":
          description: "payload 검증 실패"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  schemas:
    CreateAnalysisRequest:
      type: "object"
      required:
        - "analysisId"
        - "noteId"
        - "callbackUrl"
        - "segments"
      properties:
        analysisId:
          type: "string"
          minLength: 1
          maxLength: 32
          description: "heymoa-server가 발급한 잡 id (13자 TSID). 멱등성 키."
        noteId:
          type: "string"
          minLength: 1
          maxLength: 32
        callbackUrl:
          type: "string"
          format: "uri"
          description: "결과를 POST할 heymoa-server callback URL (/internal/v1/callbacks/analyses/{analysisId})"
        language:
          type: "string"
          description: "전사 언어 코드 (예: ko, en)"
        participants:
          type: "array"
          items:
            type: "string"
          description: "참석자 표시 이름 목록"
        segments:
          type: "array"
          minItems: 1
          description: |
            전사 세그먼트 전체 (시간순). **비어 있으면 보내지 않는다** — 분석할 것이
            없는 요청이고, heymoa-ai가 400으로 거절한다.
            server가 이미 빈 전사를 사전 차단한다(APP-196). 이 제약은 그 사실을
            계약에 적은 것이지 새 규칙이 아니다(APP-234).
          items:
            $ref: "#/components/schemas/TranscriptSegment"
    TranscriptSegment:
      type: "object"
      required:
        - "segmentId"
        - "text"
        - "startMs"
        - "endMs"
      properties:
        segmentId:
          type: "string"
          minLength: 1
          maxLength: 32
          description: |
            전사 세그먼트 id (13자 TSID). **근거(evidence)가 가리키는 대상이다** — 이것이
            없으면 분석 항목이 어느 발화에서 나왔는지 되짚을 수 없다(APP-392).
            heymoa-ai는 이 id를 프롬프트에 넣지 않는다. 줄마다 정수 인덱스를 매겨 모델에
            보이고, 돌아온 인덱스를 이 id로 되돌린 뒤 callback에 싣는다.
        text:
          type: "string"
        startMs:
          type: "integer"
          minimum: 0
          description: "세션 시작 기준 offset (밀리초)"
        endMs:
          type: "integer"
          minimum: 0
        speaker:
          type: "string"
          description: "화자 식별자 (없으면 생략)"
    AnalysisAcceptedResponse:
      type: "object"
      required:
        - "analysisId"
        - "status"
      properties:
        analysisId:
          type: "string"
        status:
          type: "string"
          enum: ["ACCEPTED"]
    AnalysisResultCallback:
      oneOf:
        - $ref: "#/components/schemas/AnalysisSucceededCallback"
        - $ref: "#/components/schemas/AnalysisFailedCallback"
      discriminator:
        propertyName: "status"
        mapping:
          SUCCEEDED: "#/components/schemas/AnalysisSucceededCallback"
          FAILED: "#/components/schemas/AnalysisFailedCallback"
    AnalysisSucceededCallback:
      type: "object"
      required:
        - "analysisId"
        - "status"
        - "sections"
      properties:
        analysisId:
          type: "string"
        status:
          type: "string"
          enum: ["SUCCEEDED"]
        sections:
          type: "array"
          description: |
            섹션마다 항목 목록. **섹션이 발신 단위다** — 섹션별 부분 발신(APP-198)이
            오면 이 배열의 원소 하나씩 보내면 된다.
            세 kind가 모두 있어야 한다. 뽑을 것이 없었던 섹션은 `items`가 빈 배열이고,
            kind 자체가 빠진 것은 분석 실패다.
          items:
            $ref: "#/components/schemas/AnalysisSection"
    AnalysisSection:
      type: "object"
      required:
        - "kind"
        - "items"
      properties:
        kind:
          $ref: "#/components/schemas/MeetingItemKind"
        items:
          type: "array"
          items:
            $ref: "#/components/schemas/MeetingItem"
    MeetingItemKind:
      type: "string"
      enum: ["OVERVIEW", "ACTION_ITEM", "DECISION"]
      description: |
        OVERVIEW = 회의 개요 핵심 포인트 · ACTION_ITEM = 앞으로 누가 할 일 ·
        DECISION = 회의에서 정해진 것과 그 기준.
    MeetingItem:
      type: "object"
      required:
        - "content"
        - "evidence"
      properties:
        content:
          type: "string"
          minLength: 1
          description: "항목 본문 한 줄. 인라인 markdown만 쓴다 — 목록 기호나 제목을 붙이지 않는다."
        evidence:
          type: "array"
          maxItems: 3
          description: |
            이 항목이 나온 전사 세그먼트 id. 요청의 `segments[].segmentId` 중에서만 고른다.
            **비어 있어도 된다** — 근거를 특정하지 못한 항목을 버리는 대신 근거 없이 둔다.
            server가 앞 3개만 저장하고, 이 노트 소속이 아닌 id는 버린다.
          items:
            type: "string"
            minLength: 1
            maxLength: 32
    AnalysisFailedCallback:
      type: "object"
      required:
        - "analysisId"
        - "status"
        - "error"
      properties:
        analysisId:
          type: "string"
        status:
          type: "string"
          enum: ["FAILED"]
        error:
          $ref: "#/components/schemas/AnalysisError"
    AnalysisError:
      type: "object"
      required:
        - "code"
        - "message"
      properties:
        code:
          type: "string"
          description: "기계 판독용 에러 코드 (예: LLM_PROVIDER_ERROR, INVALID_TRANSCRIPT, TIMEOUT)"
        message:
          type: "string"
    AgentChatMessageRequest:
      type: "object"
      required:
        - "message"
        - "scope"
      properties:
        message:
          type: "string"
          minLength: 1
          description: "유저 메시지. 빈 문자열은 물어본 것이 없으므로 거절한다."
        scope:
          type: "string"
          enum: ["workspace", "note"]
          description: |
            컨텍스트 범위. note면 heymoa-ai가 server 내부 API(GET /internal/v1/notes/{noteId}/context)로
            전사+요약을 당겨와 통째로 주입하고, workspace면 노트 목록 내부 API를 인덱스로
            agentic 검색 도구를 사용한다.
        chatKind:
          type: "string"
          enum: ["personal", "shared"]
          default: "personal"
          description: |
            이 채팅이 개인 챗봇인가 노트 공유 챗봇인가. **scope만으로는 갈리지 않는다** —
            공유 챗봇과 노트 스코프 개인 챗봇이 둘 다 scope=note로 오고,
            기획 v2 §3.2가 "진행 중인 회의에 대한 개인 챗봇 질문도 가능하다"고 정해서
            meetingStatus로 대신 가를 수도 없다.

            heymoa-ai는 (chatKind, scope) 조합으로 agent profile을 고른다:
            (shared, note)=회의 진행 보조 / (personal, note)=노트 조사 / (personal, workspace)=워크스페이스 조사.
            profile 이름은 heymoa-ai 내부 개념이라 server가 알 필요 없다 — server는
            자기가 아는 사실(공유인가 개인인가)만 보낸다.

            optional이고 기본값은 personal이다. 노트 공유 챗봇(APP-105)이 붙기 전까지
            개인 챗봇만 오므로, 필수로 하면 지금 도는 요청이 깨진다.

            **현재 heymoa-server는 이 필드를 보내지 않는다** (요청 DTO에 없다). 공유 챗봇이
            AI에 붙는 시점에 server가 함께 실어야 하며, 그 전까지 heymoa-ai가 받는 값은 항상
            기본값 personal이다.
        workspaceId:
          type: "string"
          description: "scope=workspace일 때 필수. 공유 챗봇(scope=note)에서도 도구 자격 증명의 소속 확인용으로 항상 포함한다."
        noteId:
          type: "string"
          nullable: true
          description: "scope=note일 때 필수. 채팅 컨텍스트가 되는 note. scope=workspace면 null로 직렬화된다."
        requestedBy:
          type: "string"
          description: "메시지 입력자 표시 이름 (도구 실행 기록·승인 카드 표시용)"
        toolCredentials:
          type: "object"
          description: |
            워크스페이스에 연동된 외부 도구의 단기 access token (기획 v2: 개인 아닌 워크스페이스 자산).
            heymoa-ai는 이 값을 요청 스코프 메모리에서만 사용한다 — LangGraph **runtime context**
            (`context=` 인자)로만 주입한다. graph state는 물론 **`configurable`에 넣어도 안 된다**:
            `get_checkpoint_metadata`가 configurable의 스칼라를 전부 checkpoint metadata로 복사해서
            DB에 영속화한다. DB·파일 저장 금지, 로그 마스킹 필수.
            양쪽 서비스 모두 요청 로깅 시 이 필드를 마스킹한다.
          properties:
            linear:
              type: "string"
            github:
              type: "string"
            slack:
              type: "string"
          additionalProperties:
            type: "string"
    ResolveToolApprovalRequest:
      type: "object"
      required:
        - "decision"
      properties:
        decision:
          type: "string"
          enum: ["APPROVED", "REJECTED"]
    ErrorResponse:
      type: "object"
      required:
        - "code"
        - "message"
      properties:
        code:
          type: "string"
        message:
          type: "string"
