Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/api-contract-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---

name: api-contract-guard
description: detect-images の応答形・エラーコード・HTTP ステータス・エラー優先順位・部分成功不変条件に後方互換を壊す変更が無いかを検査する。PR 前や API 周辺を変更した後に使う。
disable-model-invocation: true
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/native-deps-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---

name: native-deps-guard
description: onnxruntime-node / Node / Docker ベースイメージなどネイティブ ML スタック依存を上げる前に、glibc・Docker runtime の Node22 との整合を確認する。依存のバージョンを上げる時に使う。
disable-model-invocation: true
Expand Down
19 changes: 10 additions & 9 deletions .claude/skills/preflight/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---

name: preflight
description: PR・コミット前に CI 相当のローカル関門(typecheck / lint / unit / integration / docker build)を順に通し、落ちた所だけ報告する。CI が未整備の間の関門であり、のちの CI 仕様でもある。
disable-model-invocation: true
Expand All @@ -10,9 +11,9 @@ allowed-tools: Bash, Read
リポジトリルートで以下を順に実行する。**途中で落ちても残りは続行**し、最後に
「どこが緑/赤か」を一覧にする。すべて緑なら「PR 可」と明言する。

1. `pnpm run typecheck` — core を build してから各 package を `tsc --noEmit`。
2. `pnpm run lint` — `biome check .`。
3. `pnpm run test:unit` — 純粋ロジック。
1. `pnpm run typecheck` — core を build してから各 package を `tsc --noEmit`。
2. `pnpm run lint` — `biome check .`。
3. `pnpm run test:unit` — 純粋ロジック。
4. `pnpm run test:integration` — 実モデルロード+実 classify。
**モデル不在なら自動 skip** される(その場合は「skip」と明示し、緑とは区別する)。
CPU/arch 非対応(x64 / arm64 以外)は skip ではなく失敗として扱う。
Expand All @@ -22,12 +23,12 @@ allowed-tools: Bash, Read

## 報告フォーマット

| step | 結果 |
| --- | --- |
| typecheck | ✅ / ❌ |
| lint | ✅ / ❌ |
| test:unit | ✅ / ❌ |
| step | 結果 |
| ---------------- | ---------------- |
| typecheck | ✅ / ❌ |
| lint | ✅ / ❌ |
| test:unit | ✅ / ❌ |
| test:integration | ✅ / ❌ / ⏭ skip |
| docker build | ✅ / ❌ |
| docker build | ✅ / ❌ |

赤があれば、そのコマンドの末尾出力から原因行を引用し、最小の修正方針を添える。
3 changes: 2 additions & 1 deletion .claude/skills/scope-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---

name: scope-guard
description: sensitive-detector に機能を足す時の判断軸と、生予測値サービスとしての不変条件。コードを書く・レビューする・新機能を検討する時に常に適用する。
user-invocable: false
Expand All @@ -15,7 +16,7 @@ HTTP サイドカーに切り出したもの。切り出して嬉しいのは **
新機能・新エンドポイントを検討する時は、まず次を問う:

1. **ネイティブ ML スタックの隔離に必要か?** 必要なら足してよい(例: 将来の detect-file 構想、
モデル情報の公開など)。「`/v1/detect-images` 1 本のみ」は *現状の数* であって固定ルールではない。
モデル情報の公開など)。「`/v1/detect-images` 1 本のみ」は _現状の数_ であって固定ルールではない。
2. **「正規化済み入力 → 推論 → 生予測値」に徹しているか?**
3. **正規化・しきい値・集約を持ち込んでいないか?**

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Setup pnpm, Node.js 22, install dependencies, and build
runs:
using: composite
steps:
- uses: pnpm/action-setup@v6.0.9
- uses: pnpm/action-setup@v6.1.0
- uses: actions/setup-node@v7.1.0
with:
node-version: 22
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ Misskey 本体の `AiService.detectSensitive`(NSFW 推論)を切り出した
複数の正規化済み画像を一括推論する。詳しい入出力仕様、型、エラー、実装例は
[docs/api-detect-images.md](docs/api-detect-images.md) を参照。

| | |
| --- | --- |
| Content-Type | `multipart/form-data` |
| Authorization | `Bearer <token>`(`config.apiKey` 設定時のみ要求) |
| Body | 各パートに画像バイナリ(フィールド名は任意、順序を保持)。パートの Content-Type は `image/png` |
| | |
| ------------- | ---------------------------------------------------------------------------------------------- |
| Content-Type | `multipart/form-data` |
| Authorization | `Bearer <token>`(`config.apiKey` 設定時のみ要求) |
| Body | 各パートに画像バイナリ(フィールド名は任意、順序を保持)。パートの Content-Type は `image/png` |

成功(200):

Expand Down Expand Up @@ -45,13 +45,13 @@ Misskey 本体の `AiService.detectSensitive`(NSFW 推論)を切り出した

全体失敗(4xx/5xx)はリクエスト全体に問題がある場合のみ:

| code | status | 意味 |
| --- | --- | --- |
| `AUTHENTICATION_REQUIRED` | 401 | トークン欠落 / 不一致 |
| `INVALID_REQUEST` | 400 | パーツなし / multipart パース失敗 |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | Content-Type が `multipart/form-data` 以外 |
| `REQUEST_TOO_LARGE` | 413 | リクエストボディが `maxBodySize` 超過、またはパーツ数が `maxParts` 超過 |
| `DETECTION_FAILED` | 500 | リクエスト処理パイプラインの想定外エラー |
| code | status | 意味 |
| ------------------------- | ------ | ----------------------------------------------------------------------- |
| `AUTHENTICATION_REQUIRED` | 401 | トークン欠落 / 不一致 |
| `INVALID_REQUEST` | 400 | パーツなし / multipart パース失敗 |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | Content-Type が `multipart/form-data` 以外 |
| `REQUEST_TOO_LARGE` | 413 | リクエストボディが `maxBodySize` 超過、またはパーツ数が `maxParts` 超過 |
| `DETECTION_FAILED` | 500 | リクエスト処理パイプラインの想定外エラー |

パーツ個別の失敗(画像 dimensions 上限超過による `REQUEST_TOO_LARGE`、`IMAGE_DECODE_FAILED` 等)は
`results[i].error.code` に入り、HTTP は 200。
Expand Down
4 changes: 2 additions & 2 deletions biome.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand All @@ -19,7 +19,7 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"preset": "recommended"
}
},
"javascript": {
Expand Down
94 changes: 44 additions & 50 deletions docs/api-detect-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,32 @@ Authorization: Bearer <token>

### ヘッダー

| Header | 型 | 必須 | 説明 |
| --- | --- | --- | --- |
| `Content-Type` | `multipart/form-data` | はい | JSON や単一画像バイナリではなく、必ず multipart で送る。`boundary` は HTTP クライアントに生成させる。 |
| `Authorization` | `Bearer <string>` | 条件付き | `config.apiKey` 設定時のみ必要。欠落または不一致の場合は 401。 |
| `X-Request-Id` | `string` | いいえ | ログ相関用。英数字、`-`、`_`、`.` の有効な値ならレスポンスにも同じ値が返る。不正または未指定ならサーバーが生成する。 |
| Header | 型 | 必須 | 説明 |
| --------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `Content-Type` | `multipart/form-data` | はい | JSON や単一画像バイナリではなく、必ず multipart で送る。`boundary` は HTTP クライアントに生成させる。 |
| `Authorization` | `Bearer <string>` | 条件付き | `config.apiKey` 設定時のみ必要。欠落または不一致の場合は 401。 |
| `X-Request-Id` | `string` | いいえ | ログ相関用。英数字、`-`、`_`、`.` の有効な値ならレスポンスにも同じ値が返る。不正または未指定ならサーバーが生成する。 |

### multipart body

各パートに画像ファイルを入れる。フィールド名は任意で、レスポンスの `results` はリクエストパートの順序に対応する。

| 項目 | 型 | 必須 | 説明 |
| --- | --- | --- | --- |
| field name | `string` | はい | 任意。例: `image0`、`image1`。同じ名前を繰り返してもよいが、呼び出し元では順序で対応付ける。 |
| filename | `string` | 任意 | API の判定には使わない。 |
| part `Content-Type` | `image/png` \| `image/jpeg` \| `image/gif` \| `image/bmp` | はい | これ以外はそのパートだけ `UNSUPPORTED_MEDIA_TYPE` になる。 |
| part body | binary | はい | 正規化済み画像バイト。空ボディはそのパートだけ `INVALID_REQUEST` になる。 |
| 項目 | 型 | 必須 | 説明 |
| ------------------- | --------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------- |
| field name | `string` | はい | 任意。例: `image0`、`image1`。同じ名前を繰り返してもよいが、呼び出し元では順序で対応付ける。 |
| filename | `string` | 任意 | API の判定には使わない。 |
| part `Content-Type` | `image/png` \| `image/jpeg` \| `image/gif` \| `image/bmp` | はい | これ以外はそのパートだけ `UNSUPPORTED_MEDIA_TYPE` になる。 |
| part body | binary | はい | 正規化済み画像バイト。空ボディはそのパートだけ `INVALID_REQUEST` になる。 |

### 入力制限

設定値は [config.example.mjs](../config.example.mjs) を参照。

| 制限 | 対象 | 超過時 |
| --- | --- | --- |
| `maxBodySize` | リクエスト全体 | HTTP 413。`success: false` の全体エラー。 |
| `maxParts` | multipart パート数 | HTTP 413。`success: false` の全体エラー。 |
| `maxBinarySize` | 各画像パートのバイト数 | HTTP 200 のまま、該当 `results[i]` が `REQUEST_TOO_LARGE`。 |
| 制限 | 対象 | 超過時 |
| ----------------------------------------------------- | --------------------------- | ----------------------------------------------------------- |
| `maxBodySize` | リクエスト全体 | HTTP 413。`success: false` の全体エラー。 |
| `maxParts` | multipart パート数 | HTTP 413。`success: false` の全体エラー。 |
| `maxBinarySize` | 各画像パートのバイト数 | HTTP 200 のまま、該当 `results[i]` が `REQUEST_TOO_LARGE`。 |
| `maxImageWidth` / `maxImageHeight` / `maxImagePixels` | デコード後の画像 dimensions | HTTP 200 のまま、該当 `results[i]` が `REQUEST_TOO_LARGE`。 |

既定値では 299x299 までの正規化済み画像を想定している。
Expand Down Expand Up @@ -96,31 +96,31 @@ type DetectErrorCode =

#### 成功パート

| Field | 型 | 説明 |
| --- | --- | --- |
| `success` | `true` | パート単位の推論に成功したことを表す。 |
| `predictions` | `Prediction[]` | 推論モデルの生出力。サービス側ではしきい値判定やクラスの集約をしない。 |
| `predictions[].className` | `string` | クラス名。現在同梱しているモデルでは `Drawing`、`Hentai`、`Neutral`、`Porn`、`Sexy`。API 型としては将来のモデル差し替えに備えて `string`。 |
| `predictions[].probability` | `number` | 確率。通常は `0` 以上 `1` 以下。 |
| Field | 型 | 説明 |
| --------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `success` | `true` | パート単位の推論に成功したことを表す。 |
| `predictions` | `Prediction[]` | 推論モデルの生出力。サービス側ではしきい値判定やクラスの集約をしない。 |
| `predictions[].className` | `string` | クラス名。現在同梱しているモデルでは `Drawing`、`Hentai`、`Neutral`、`Porn`、`Sexy`。API 型としては将来のモデル差し替えに備えて `string`。 |
| `predictions[].probability` | `number` | 確率。通常は `0` 以上 `1` 以下。 |

#### 失敗パート

| Field | 型 | 説明 |
| --- | --- | --- |
| `success` | `false` | そのパートだけ処理に失敗したことを表す。 |
| `error.code` | `DetectErrorCode` | 呼び出し元が分岐に使う安定したコード。 |
| `error.message` | `string` | 人間向けの診断テキスト。内容は API 契約として固定しない。 |
| Field | 型 | 説明 |
| --------------- | ----------------- | --------------------------------------------------------- |
| `success` | `false` | そのパートだけ処理に失敗したことを表す。 |
| `error.code` | `DetectErrorCode` | 呼び出し元が分岐に使う安定したコード。 |
| `error.message` | `string` | 人間向けの診断テキスト。内容は API 契約として固定しない。 |

パート単位で返り得る主な `error.code`:

| code | 意味 |
| --- | --- |
| `INVALID_REQUEST` | パートがファイルではない、または空ボディ。 |
| `UNSUPPORTED_MEDIA_TYPE` | パートの `Content-Type` が未対応。 |
| `REQUEST_TOO_LARGE` | パートサイズまたは画像 dimensions が上限超過。 |
| `IMAGE_DECODE_FAILED` | 画像バイトを読み取れない、または画像 dimensions を読めない。 |
| `MODEL_UNAVAILABLE` | モデルが利用できない。CPU 非対応、モデルロード失敗など。 |
| `DETECTION_FAILED` | 推論処理の失敗、タイムアウト、想定外エラー。 |
| code | 意味 |
| ------------------------ | ------------------------------------------------------------ |
| `INVALID_REQUEST` | パートがファイルではない、または空ボディ。 |
| `UNSUPPORTED_MEDIA_TYPE` | パートの `Content-Type` が未対応。 |
| `REQUEST_TOO_LARGE` | パートサイズまたは画像 dimensions が上限超過。 |
| `IMAGE_DECODE_FAILED` | 画像バイトを読み取れない、または画像 dimensions を読めない。 |
| `MODEL_UNAVAILABLE` | モデルが利用できない。CPU 非対応、モデルロード失敗など。 |
| `DETECTION_FAILED` | 推論処理の失敗、タイムアウト、想定外エラー。 |

### 全体エラー: HTTP 4xx / 5xx

Expand All @@ -136,13 +136,13 @@ type ErrorResponse = {
};
```

| status | code | 主な原因 |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | multipart パース失敗、パートなし。 |
| 401 | `AUTHENTICATION_REQUIRED` | `config.apiKey` 設定時に Bearer token が欠落または不一致。 |
| 413 | `REQUEST_TOO_LARGE` | リクエスト全体の `maxBodySize` 超過、または `maxParts` 超過。 |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | リクエストの `Content-Type` が `multipart/form-data` ではない。 |
| 500 | `DETECTION_FAILED` | リクエスト処理パイプラインの想定外エラー。 |
| status | code | 主な原因 |
| ------ | ------------------------- | --------------------------------------------------------------- |
| 400 | `INVALID_REQUEST` | multipart パース失敗、パートなし。 |
| 401 | `AUTHENTICATION_REQUIRED` | `config.apiKey` 設定時に Bearer token が欠落または不一致。 |
| 413 | `REQUEST_TOO_LARGE` | リクエスト全体の `maxBodySize` 超過、または `maxParts` 超過。 |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | リクエストの `Content-Type` が `multipart/form-data` ではない。 |
| 500 | `DETECTION_FAILED` | リクエスト処理パイプラインの想定外エラー。 |

このエンドポイントの通常処理では、画像デコード失敗の `IMAGE_DECODE_FAILED` とモデル利用不可の `MODEL_UNAVAILABLE` は全体エラーではなく、HTTP 200 の `results[i].error.code` に入る。

Expand All @@ -168,14 +168,8 @@ import { readFile } from 'node:fs/promises';

const form = new FormData();

form.append(
'image0',
new File([await readFile('frame1.png')], 'frame1.png', { type: 'image/png' }),
);
form.append(
'image1',
new File([await readFile('frame2.png')], 'frame2.png', { type: 'image/png' }),
);
form.append('image0', new File([await readFile('frame1.png')], 'frame1.png', { type: 'image/png' }));
form.append('image1', new File([await readFile('frame2.png')], 'frame2.png', { type: 'image/png' }));

const res = await fetch('http://127.0.0.1:3000/v1/detect-images', {
method: 'POST',
Expand Down
Loading
Loading