api

API リファレンス (Studio /v1)

Studio API の公開面。すべて /v1 prefix を持ちます。エラーは RFC 9457 (application/problem+json) envelope。SSE は text/event-stream。認証は OIDC Cookie + サーバ側 JWT (15 分短命 + refresh)。

API の全体規約

共通の HTTP 規約とエラー形式。すべての API は /v1 配下です。

認証

  • OIDC (Google Workspace / Auth0 / Keycloak) → Cookie セッション (HttpOnly Secure SameSite=Lax)
  • サーバ側 JWT: 15 分短命 + refresh
  • 未認証時は 401 + WWW-Authenticate: Bearer
  • RBAC: rbac.matrix_json 設定。違反時は 403

エラー envelope (RFC 9457)

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://errors.suntory-nedo.io/bundle-not-ready",
  "title": "Bundle not ready",
  "status": 409,
  "detail": "Bundle bdl_01H... is still building (phase=rpeak).",
  "instance": "/v1/bundles/bdl_01H.../manifest",
  "trace_id": "abc123..."
}

レート制限

未指定。テナント単位の cost cap (bq.daily_tenant_cap_usd) と user 単位の cost cap (bq.daily_user_cap_usd) が事実上のレート制御となる。超過時は 402 Payment Required

idempotency

書き込み系 (POST /bundles, /jobs/*, /drafts) は Idempotency-Key ヘッダで重複防止可能。Bundle 作成は加えて canonical_hash でサーバ側 idempotency を持つ (詳細: studio § canonical_hash)。

1. Health & Catalog

GET /v1/healthz

API の死活確認。認証不要。

Response 200

{ "status": "ok", "version": "0.1.0", "uptime_sec": 3600 }
GET /v1/catalog/sources

非表示でない catalog source を返す。include_hidden=true で隠しソースも取得。

Query

  • include_hidden (boolean, optional, default false) — `is_hidden=true` のソースも含める (admin)
  • signal_family (string, optional) — `ecg` / `eeg` / `ppg` / `acc` / `vital` / `meta` のいずれかでフィルタ

Response 200

[
  {
    "source_key": "xhro_04.eeg",
    "dataset": "xhro_04",
    "table_name": "eeg",
    "signal_family": "ecg",
    "display_name": "XHRO-04 ECG (ch1-ch2 差動)",
    "sample_rate_hz": 250,
    "is_hidden": false
  },
  { "source_key": "xhro_04.acc", "signal_family": "acc", "sample_rate_hz": 25, ... }
]
GET /v1/catalog/subjects

被験者検索。raw UID / email は返さない (PII redaction)。

Query

  • q (string, optional) — `hid` または `public_id` の prefix
  • cohort (string, optional) — `cohort_tags` 内の一致
  • limit (int, default 50, max 200)
  • offset (int, default 0)

Response 200

{
  "items": [
    {
      "public_id": "sub_01HZMX9ABCDE00000000XH015",
      "expt_id": "XHRO-04",
      "hid": "XH015",
      "cohort_tags": ["XHRO-04", "XH015"]
    }
  ],
  "total": 12,
  "limit": 50,
  "offset": 0
}

2. Recordings & Availability

GET /v1/subjects/{public_id}/recordings

指定 subject の Recording 一覧を返す。Postgres mirror から読む (BQ には触らない、FR-X1)。

Path

  • public_id — `sub_` prefix ULID

Response 200

[
  {
    "id": "rec_01HZMX...",
    "source_key": "xhro_04.eeg",
    "expt_id": "XHRO-04",
    "start_ms": 1699578000000,
    "end_ms":   1699839878000,
    "duration_hours": 72.8,
    "signal_families": ["ecg", "acc", "opt", "vital"]
  }
]
GET /v1/recordings/{id}/availability

10 秒 bucket の family 別データ密度を返す。`start_ms` / `end_ms` は必須。

Query

  • start_ms (int, required) — 区間開始 epoch ms
  • end_ms (int, required) — 区間終了 epoch ms (`end_ms - start_ms ≤ 7 日`)
  • families (csv, optional) — `ecg,acc,opt,vital` の絞り込み

Response 200

{
  "recording_id": "rec_01HZMX...",
  "bucket_ms": 10000,
  "buckets": [
    {
      "ts_ms": 1699578000000,
      "ecg":   { "rows": 2500, "leadoff_ratio": 0.0, "motion_high": false },
      "acc":   { "rows": 250 },
      "opt":   { "rows": 500 },
      "vital": { "rows": 10, "wearing_ratio": 1.0 }
    },
    { "ts_ms": 1699578010000, ... }
  ]
}

3. Preview & Estimate

GET /v1/recordings/{id}/overview-waveform

pyramid L3 (10 秒集約) の概観波形を返す。未生成時は空 rows で graceful placeholder (404 にはしない)。

Query

  • start_ms / end_ms (int, required)
  • lead (string, optional, default `ch1_minus_ch2`)

Response 200

{
  "recording_id": "rec_01HZMX...",
  "lead": "ch1_minus_ch2",
  "rows": [
    { "ts_ms": 1699578000000, "min": -120.4, "max": 145.2, "mean": 3.1 },
    { "ts_ms": 1699578010000, "min": -118.0, "max": 142.5, "mean": 2.8 }
  ]
}
POST /v1/estimates

Window と family から所要時間・コスト・予算状態の見積もりを返す。BQ dry-run と人間語サマリの両方を返す。3 秒以内目標 (FR-X5)。

Request

{
  "source_key": "xhro_04.eeg",
  "subject_public_id": "sub_01HZMX9ABCDE00000000XH015",
  "windows": [
    { "start_ms": 1699578000000, "end_ms": 1699578300000 },
    { "start_ms": 1699664400000, "end_ms": 1699664700000 }
  ],
  "attached_signals": ["acc"],
  "preset_id": "xhro-multiday"
}

Response 200

{
  "human_summary": {
    "duration_text": "約 30 秒で出来上がります",
    "cost_text":     "コスト目安: ¥80 以内",
    "cap_status":    "予算内"
  },
  "details": {
    "bytes": 12400000,
    "rows": 1500000,
    "cost_usd": 0.0023,
    "windows_count": 2,
    "caps": {
      "ui_preview":     { "limit_usd": 0.05, "used_usd": 0.0023, "ok": true },
      "preview_window": { "limit_usd": 0.50, "used_usd": 0.0023, "ok": true },
      "windows_per_bundle_max": { "limit": 12, "used": 2, "ok": true }
    },
    "dry_run_id": "dr_01HZMX..."
  }
}

4. Bundles

POST /v1/bundles

Bundle build を開始。canonical_hash が既存と一致する場合は 200 OK + 既存 ID を返し、新規 Build は走らない (FR-X6 idempotency)。新規 Build の場合は 202 Accepted + job_id。

Request

{
  "source_key": "xhro_04.eeg",
  "subject_public_id": "sub_01HZMX9ABCDE00000000XH015",
  "windows": [
    { "start_ms": 1699578000000, "end_ms": 1699578300000, "label": "rest-1" },
    { "start_ms": 1699664400000, "end_ms": 1699664700000, "label": "rest-2" }
  ],
  "attached_signals": ["acc"],
  "preset_id": "xhro-multiday",
  "ecg_params": {
    "detector": "pan_tompkins_v1",
    "rr_min_ms": 200,
    "rr_max_ms": 2000,
    "hrv_window_sec": 300,
    "leads": ["ch1", "ch2"]
  },
  "schema_version": "1.0",
  "client_idempotency_key": "a1b2c3d4-..."
}

Response 200 (既存 Bundle 一致)

{
  "bundle_id": "bdl_01HZMX9P2QR3S4T5U6V7W8X9Y",
  "status": "ready",
  "canonical_hash": "0123456789ab...",
  "manifest_uri": "gs://sn-bundles/bundles/bdl_01HZMX.../manifest.json",
  "reused": true
}

Response 202 (新規 Build)

{
  "bundle_id": null,
  "job_id": "job_01HZMXJOB...",
  "canonical_hash": "0123456789ab...",
  "events_url": "/v1/jobs/job_01HZMXJOB.../events",
  "estimated_seconds": 32
}
GET /v1/bundles

Bundle 一覧。`subject_public_id`, `status`, `limit`, `offset` で絞り込み。

Query

  • subject_public_id (optional)
  • status (optional, csv) — `ready` / `building` / `failed` / `archived`
  • parent_bundle_id (optional) — 派生子のみ
  • limit / offset

Response 200

{
  "items": [
    {
      "bundle_id": "bdl_01HZMX9P2QR3S4T5U6V7W8X9Y",
      "status": "ready",
      "subject_public_id": "sub_01HZMX9ABCDE00000000XH015",
      "signal_family": "ecg",
      "windows_count": 2,
      "bytes_total": 12345678,
      "cost_usd": 0.0023,
      "parent_bundle_id": null,
      "derive_kind": null,
      "built_at": "2026-05-25T12:34:56.789Z"
    }
  ],
  "total": 5,
  "limit": 50,
  "offset": 0
}
GET /v1/bundles/{id}

Bundle summary を取得。

Response 200

{
  "bundle_id": "bdl_01HZMX9P2QR3S4T5U6V7W8X9Y",
  "status": "ready",
  "canonical_hash": "0123456789ab...",
  "schema_version": "1.0",
  "subject_public_id": "sub_01HZMX9ABCDE00000000XH015",
  "signal_family": "ecg",
  "sample_rate_hz": 250,
  "leads": ["ch1", "ch2"],
  "preset_id": "xhro-multiday",
  "attached_signals": ["acc"],
  "windows": [
    { "ordinal": 0, "label": "rest-1", "start_ms": 1699578000000, "end_ms": 1699578300000 },
    { "ordinal": 1, "label": "rest-2", "start_ms": 1699664400000, "end_ms": 1699664700000 }
  ],
  "lineage": { "parent_bundle_id": null, "derive_kind": null, "...": "..." },
  "built_at": "2026-05-25T12:34:56.789Z"
}
GET /v1/bundles/{id}/manifest

ready Bundle の manifest を返す。未 ready は 409 Conflict

Response 200

application/jsonbundle § manifest 完全例 と同じ構造を返す。

Response 409 (未 ready)

{
  "type": "https://errors.suntory-nedo.io/bundle-not-ready",
  "title": "Bundle not ready",
  "status": 409,
  "detail": "Bundle bdl_01H... is still building (phase=rpeak)."
}
GET /v1/bundles/{id}/files/{path}

Bundle ファイルの署名付き URL へ 302 redirect。ブラウザは Studio API を経由せず GCS から直接ダウンロードする。署名 URL の TTL は storage.signed_url_ttl_sec (既定 900 秒)。

Path

  • path — `manifest.json` / `windows/win_0001.parquet` / `derived/r_peaks.parquet` などの相対パス

Response 302

HTTP/1.1 302 Found
Location: https://storage.googleapis.com/sn-bundles/bundles/bdl_01H.../windows/win_0001.parquet?X-Goog-Signature=...&X-Goog-Expires=900
Cache-Control: private, max-age=900

5. Jobs (Build 制御 + SSE)

GET /v1/jobs/{id}

ジョブ状態を取得。

Response 200

{
  "id": "job_01HZMXJOB...",
  "kind": "bundle_build",
  "status": "running",
  "phase": "detect_rpeaks",
  "progress": 0.62,
  "canonical_hash": "0123456789ab...",
  "bundle_id": null,
  "checkpoint_uri": "gs://sn-bundles/_staging/job_01HZMXJOB.../rpeak/",
  "created_at": "2026-05-25T12:34:00.000Z",
  "started_at": "2026-05-25T12:34:01.123Z"
}
SSE /v1/jobs/{id}/events

Job 進捗を Server-Sent Events で配信。`Accept: text/event-stream` で接続。reconnect は最新 phase からリプレイされる。

イベント形式

event: progress
data: {"phase":"detect_rpeaks","fraction":0.62,"label":"R ピークを探しています…"}

event: progress
data: {"phase":"pyramid","fraction":0.85,"label":"概観波形を作っています…"}

event: done
data: {"bundle_id":"bdl_01HZMX9P2QR3S4T5U6V7W8X9Y","cost_usd":0.0023,"duration_sec":31.4}

event: error
data: {"code":"bq_cost_cap_exceeded","message":"BQ scan exceeded ui_preview cap","retry_after_sec":3600}

phase の値

  • extract — BQ → GCS Parquet
  • clean — HPF / notch / 体動 / lead-off
  • detect_rpeaks — Pan-Tompkins v1
  • hrv — SDNN / RMSSD / pNN50 / LF/HF
  • pyramid — L0..L3 生成
  • attach — 付帯信号同期
  • manifest — integrity hash + manifest 組み立て
  • register — GCS commit + Postgres + `_READY`

Locale 翻訳: label は API 側で `Accept-Language` から `ja` / `en` を選択して翻訳済みで送る。フロントは `fraction` でバー描画。

POST /v1/jobs/{id}/pause

次の phase boundary で一時停止を要求。status: paused へ遷移。

Response 200

{ "status": "paused", "pause_at_phase": "pyramid" }
POST /v1/jobs/{id}/resume

checkpoint から再開。新しい job_id は作らない (同 job の続行)。同 canonical_hash の Bundle が既に ready なら再実行せず done を返す。

Response 200

{ "status": "running", "resumed_from_phase": "rpeak" }
POST /v1/jobs/{id}/cancel

queued は即時 cancel、running は canceling にして boundary で停止。GCS の中間ファイルは TTL ライフサイクル (7 日) で自動削除。

Response 200

{ "status": "canceled", "canceled_at_phase": "pyramid" }

6. Drafts & Recipes

POST /v1/drafts

Wizard 中断状態を保存。24h TTL (FR-X13)。`PUT /v1/drafts/{id}` で上書き。

Request

{
  "payload": {
    "step": 3,
    "source_key": "xhro_04.eeg",
    "subject_public_id": "sub_01HZMX9...",
    "windows": [{ "start_ms": ..., "end_ms": ..., "label": "rest-1" }],
    "attached_signals": ["acc"],
    "preset_id": "xhro-multiday"
  }
}

Response 200

{
  "id": "drf_01HZMX...",
  "expires_at": "2026-05-26T12:34:00.000Z",
  "updated_at": "2026-05-25T12:34:00.000Z"
}
GET / POST / PUT / DELETE /v1/recipes

Recipe の一覧 / 作成 / 取得 / 更新 / 削除 / 適用 (FR-X11)。Apply は build ではなく draft 反映 (Recipe を draft に展開 → ユーザーが Build ボタンを押す)。

Recipe スキーマ

{
  "id": "rcp_01HZMX...",
  "scope": "project",
  "scope_id": "prj_01HZMX...",
  "display_name": "夜間 5 分 × 3 (Holter)",
  "description": "00:00-04:00 から lead-off 少ない 5 分 × 3 を自動抽出",
  "spec": {
    "applies_to": { "signal_family": "ecg", "min_duration_hours": 6 },
    "window_picker": {
      "strategy": "auto_quality_top_k",
      "k": 3,
      "duration_sec": 300,
      "time_range": "00:00-04:00",
      "exclude": ["lead_off", "motion_high"]
    },
    "attached": ["acc"],
    "preset_id": "holter-overnight"
  },
  "created_at": "2026-05-25T12:34:00.000Z"
}

Apply

POST /v1/recipes/{id}/apply
{
  "draft_id": "drf_01HZMX...",
  "subject_public_id": "sub_01HZMX...",
  "recording_id": "rec_01HZMX..."
}

→ 200 { "draft_id": "drf_01HZMX...", "applied_windows_count": 3 }

7. Settings

GET /v1/settings/registry

DefaultRegistry の全エントリを返す (フロントが自動フォームを描く元データ)。

Response 200

{
  "entries": [
    {
      "key": "bq.cost_cap_usd.ui_preview",
      "type": "float",
      "min": 0, "max": 100,
      "default": 0.05,
      "scope_max": "tenant",
      "overridable_at": ["tenant"],
      "hot_reload": true,
      "secret": false,
      "description": "UI プレビュー dry-run 1 回あたりの USD 上限",
      "affects": ["Studio Step4", "BigQueryAccessService.dry_run"],
      "ui": { "component": "money", "currency": "USD" }
    },
    { "key": "llm.api_key", "type": "secret", "scope_max": "tenant", "secret": true, "...": "..." }
  ]
}
GET /v1/settings/{scope}/{scope_id?}

指定 scope の全設定値を取得。継承解決後の値 (effective value)。

Response 200

{
  "scope": "tenant",
  "scope_id": "tnt_01HZMX...",
  "values": {
    "bq.cost_cap_usd.ui_preview":     { "value": 0.05, "source": "tenant" },
    "bq.cost_cap_usd.preview_window": { "value": 0.50, "source": "default" },
    "llm.api_key":                    { "value": "sk-•••••abc", "source": "tenant", "secret": true }
  }
}
PUT /v1/settings/{scope}/{scope_id?}/{key}

設定値の更新。schema validation を通す。機密値 (secret=true) は KMS 暗号化付きで保存。`settings_audit` に who / when / old_hash / new_hash が残る (機密は hash のみ、非機密は raw)。Hot reload 対象は Redis `settings:changed` チャネルへ即配信。

Request

{ "value": 0.10 }   # 非機密
{ "value": "sk-ant-..." }  # 機密 (secret=true のキー)

Response 200

{
  "scope": "tenant",
  "key": "bq.cost_cap_usd.ui_preview",
  "old_value": 0.05,
  "new_value": 0.10,
  "hot_reload": true,
  "audit_id": 12345
}
SSE /v1/settings/events

Settings 変更通知の購読。他者が編集した値に即追従するため。

イベント形式

event: changed
data: {"scope":"tenant","scope_id":"tnt_01HZMX...","key":"bq.cost_cap_usd.ui_preview","new_value":0.10,"changed_by":"usr_01HZMX..."}

event: secret_changed
data: {"scope":"tenant","scope_id":"tnt_01HZMX...","key":"llm.api_key","new_hash":"abc123..."}

8. Copilot

POST /v1/copilot/messages

Studio Copilot の会話 1 ターン。Phase 1 は in-memory thread (永続化なし)。

Request

{
  "thread_id": "thr_01HZMX...",
  "message": "夜間で lead-off が 5% 未満の 5 分 Window を 3 つ選んで",
  "context": {
    "recording_id": "rec_01HZMX...",
    "subject_public_id": "sub_01HZMX..."
  }
}

Response 200

{
  "thread_id": "thr_01HZMX...",
  "messages": [
    { "role": "assistant", "content": "夜間 (00:00-04:00 JST) の Window を 3 件提案します。" }
  ],
  "tool_calls": [
    {
      "tool": "studio.suggest_windows",
      "input": { "recording_id": "rec_01HZMX...", "intent": "夜間 5 分 × 3", "constraints": {...} },
      "output": { "windows": [{...}, {...}, {...}] }
    }
  ],
  "cost_usd": 0.0042,
  "tokens_used": 1842
}
POST /v1/copilot/tools/propose-{tool_name}

Window / Recipe / Preset 候補を返す。`build-bundle` は 常に 501 Not Implemented (Agent からの直接 Build を禁止、人間承認必須)。

使えるツール

  • propose-windows — 自然言語 + recording から Window 候補
  • propose-recipe — 直近操作から Recipe 候補
  • propose-preset — Window 特性から preset 候補
  • propose-build-bundle501 を返す (HUMAN_ONLY)

Response 501 (build-bundle のみ)

{
  "type": "https://errors.suntory-nedo.io/human-approval-required",
  "title": "Human approval required",
  "status": 501,
  "detail": "Bundle build cannot be invoked from Copilot. Use the Studio UI [エクスポート] button."
}

OpenAPI スペック

正本の OpenAPI は packages/api-client/openapi.json に生成されます (Pydantic v2 起点)。TS クライアントは openapi-typescript で同 package に生成。CLI / E2E テストでも共有可能です。

# OpenAPI 取得
curl http://localhost:8080/openapi.json > apps/studio-api/openapi.json

# TS 型生成
pnpm --filter @suntory-nedo/api-client gen

# 利用例 (Next.js)
import createClient from "openapi-fetch";
import type { paths } from "@suntory-nedo/api-client";

const api = createClient<paths>({ baseUrl: "/api" });
const { data, error } = await api.GET("/v1/bundles", { params: { query: { status: "ready" } } });