diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 1066712..e781ea0 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -119,7 +119,6 @@ function v2DocsSidebar(lang: 'en' | 'ja'): DefaultTheme.SidebarItem[] { { text: 'CSV / Parquet', link: `${prefix}/data-sources/csv` }, { text: 'BigQuery', link: `${prefix}/data-sources/bigquery` }, { text: 'SQL', link: `${prefix}/data-sources/sql` }, - { text: 'GA4', link: `${prefix}/data-sources/ga4` }, { text: lang === 'ja' ? 'プラグイン' : 'Plugins', link: `${prefix}/data-sources/plugins` }, ], }, diff --git a/docs/data-sources/ga4.md b/docs/data-sources/ga4.md deleted file mode 100644 index e8de7ca..0000000 --- a/docs/data-sources/ga4.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: GA4 Source ---- - -# GA4 Source - -The `ga4` source trains a recommender directly from the [Google Analytics 4 Data API](https://developers.google.com/analytics/devguides/reporting/data/v1), skipping the BigQuery Export hop. Intended for properties that don't have BQ Export enabled. - -See `examples/ga4-data-api/` in the recotem repository for a working starting point. - -For properties that **do** have BQ Export enabled, the [BigQuery source](./bigquery) is usually a better fit — BigQuery scales further and exposes the full event payload. - -## Install - -```bash -pip install "recotem[ga4]" -``` - -Without this extra, `recotem train` exits with: - -``` -DataSourceError: google-analytics-data is required for GA4Source. Install with: pip install 'recotem[ga4]' -``` - -## Authentication - -Application Default Credentials (ADC) only. No credentials are embedded in recipes. Configure one of: - -```bash -# Local development -gcloud auth application-default login - -# GKE — Workload Identity binding the pod's service account -# to a Google service account. No env var setup needed. - -# Cloud Run / Cloud Functions -# --service-account=@.iam.gserviceaccount.com at deploy time - -# Service account key file (not recommended for production) -export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json -``` - -The service account needs `roles/analytics.viewer` on the GA4 property. - -## Recipe configuration - -```yaml -source: - type: ga4 - property_id: "123456789" # numeric, NOT the G-XXXX measurement ID - user_dimension: userPseudoId # userPseudoId or userId - item_dimension: itemId # itemId | itemName | itemCategory - time_dimension: date # date | dateHour | dateHourMinute - event_names: [purchase, view_item, add_to_cart] - # Pick exactly one of (lookback_days) OR (start_date + end_date): - lookback_days: 90 - # start_date: "2026-01-01" - # end_date: "2026-05-01" - max_rows: 1_000_000 # required - weight_column: event_count - api_timeout_seconds: 60 -``` - -| Field | Required | Default | Notes | -|---|---|---|---| -| `property_id` | yes | — | Numeric only (`^\d+$`). **Not** the `G-XXXX` measurement ID. | -| `user_dimension` | yes | — | `userId` requires the User-ID feature to be configured on the property; `userPseudoId` is the cookie-bound default. | -| `item_dimension` | no | `itemId` | Any GA4 item-scoped dimension. | -| `time_dimension` | no | `date` | Granularity of the time bucket. `date` / `dateHour` / `dateHourMinute`. | -| `event_names` | yes | — | 1–50 event names; each matches `^[A-Za-z_][A-Za-z0-9_]{0,39}$`. | -| `lookback_days` | XOR | — | 1–3650. Rolling window ending at `yesterday` (the previous complete day in the property's timezone). | -| `start_date` / `end_date` | XOR | — | ISO dates. Both required if either is set; `start_date <= end_date`. | -| `max_rows` | yes | — | Hard cap on rows returned. Valid range `[1, 50_000_000]`. Out-of-range raises `ValidationError`. | -| `weight_column` | no | `event_count` | Output DataFrame column name for the `eventCount` metric. Must match `schema.weight_column`. Validation rejects values that collide with `user_dimension`, `item_dimension`, `time_dimension`, or the literal `eventName` — a collision would silently overwrite a dimension in the per-row dict. | -| `api_timeout_seconds` | no | `60` | Valid range `[5, 600]`. Out-of-range raises `ValidationError`. | - -::: warning Pick exactly one date-range form -Set `lookback_days`, **or** set both `start_date` and `end_date`. Setting both forms — or neither — raises `ValidationError` at recipe load. `lookback_days` produces a rolling window ending at the previous complete day in the property's timezone (i.e. yesterday, not today). -::: - -## How rows reach the DataFrame - -A GA4 request asks for four dimensions and one metric: - -``` -dimensions = [, , , eventName] -metric = eventCount -``` - -The response is paginated (page size 100,000). Each row becomes a DataFrame row with the recipe-schema column names. The internal `eventName` column is dropped before `fetch()` returns, so multiple event types for the same `(user, item, time)` show up as multiple rows. - -::: warning Use `cleansing.dedup: none` for GA4 -The GA4 source returns one row per `(user, item, time, eventName)`. `keep_first` / `keep_last` would discard the weight from the other event types. irspack aggregates repeated `(user, item)` weights internally, so leaving them as separate rows is correct. -::: - -## Quotas, pagination, retries - -- Page size 100,000 (Data API hard maximum). -- The fetcher loops until `row_count` is drained, `max_rows` is hit, or `RECOTEM_GA4_MAX_PAGES` (default 500) is reached. -- `RESOURCE_EXHAUSTED` / `UNAVAILABLE` gRPC codes are retried via `google.api_core.retry.Retry` (initial 1 s, exponential backoff up to 30 s, total budget = 3 × `api_timeout_seconds`). -- `PERMISSION_DENIED` → immediate `DataSourceError` naming the role (`roles/analytics.viewer`) and the property ID. -- All other `GoogleAPICallError` subclasses (e.g. `NOT_FOUND`, `INVALID_ARGUMENT`) → immediate `DataSourceError` carrying the API error class name and message. -- A per-fetch wall-clock budget of `10 × api_timeout_seconds` bounds the entire paginated run. The deadline is checked **before** and **after** every `run_report` call, so an unlucky page that consumes the retry budget cannot overshoot by another full retry cycle. - -### Budget interaction - -The per-page `Retry(timeout=3 × api_timeout_seconds)` budget can in the worst case consume the full 3× wait before raising. Combined with the per-attempt `timeout=api_timeout_seconds`, a single page in worst-case retry can therefore burn ~`3 × api_timeout_seconds`. The outer 10× wall-clock budget is intentionally a circuit-breaker, not a generous cap: under sustained `RESOURCE_EXHAUSTED` back-pressure it will abort after roughly three exhausted pages rather than letting the run drift unboundedly. Tighten the query or raise `api_timeout_seconds` (which also raises the wall-clock budget linearly) if your workload legitimately requires more retried pages. - -## Environment variables - -| Variable | Default | Notes | -|---|---|---| -| `GOOGLE_APPLICATION_CREDENTIALS` | (unset) | ADC key file path. Empty = use the default chain (`gcloud` user creds → metadata server). | -| `RECOTEM_GA4_MAX_PAGES` | `500` | Hard ceiling on pagination loops. Clamp `[1, 10_000]`. | -| `RECOTEM_METRICS_ENABLED` | (unset) | Truthy emits `recotem_ga4_pages_fetched_total`, `recotem_ga4_rows_fetched_total`, and `recotem_ga4_quota_remaining` Prometheus metrics (requires `recotem[metrics]`). | - -## Troubleshooting - -| Error | Likely cause | Fix | -|---|---|---| -| `google-analytics-data is required for GA4Source. Install with: pip install 'recotem[ga4]'` | Missing extra. | `pip install "recotem[ga4]"` | -| `GA4 access denied for property ...` | Service account lacks the role. | Grant `roles/analytics.viewer` on the GA4 property. | -| `set exactly one of lookback_days OR (start_date + end_date)` | Both or neither set. | Pick one. | -| `GA4 result exceeds max_rows=...` | Genuinely huge result. | Narrow `event_names` or shorten the window. | -| `GA4 fetch reached max_pages= without seeing a short page; increase RECOTEM_GA4_MAX_PAGES or tighten the query` | Property is too large for default ceiling. | Raise `RECOTEM_GA4_MAX_PAGES` after confirming quota. | - -## Notes - -- `recotem validate recipes/my_recipe.yaml` validates the ADC chain and the recipe schema before training. It does **not** issue a real Data API request — the property's quota is preserved. -- Non-integer `eventCount` values surface as `DataSourceError` (exit 3) instead of a bare `ValueError`. -- `GOOGLE_*` and `GCP_*` env vars are blacklisted from recipe `${...}` expansion. Cloud credentials must come from ADC, not from the recipe file. diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 589f4b0..59ac820 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -69,7 +69,7 @@ services: healthcheck: test: - "CMD-SHELL" - - "python -c \"import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5).status == 200 else 1)\"" + - "python -c \"import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/v1/health', timeout=5).status == 200 else 1)\"" interval: 30s timeout: 10s retries: 3 @@ -122,7 +122,7 @@ Named Docker volumes (as in `compose.yaml`) are pre-created with the right owner ### Image-level HEALTHCHECK -The Dockerfile declares its own `HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3` that probes the public `/health` endpoint with `urllib.request.urlopen(f'http://127.0.0.1:{RECOTEM_PORT}/health', timeout=3)` (so it picks up an overridden `RECOTEM_PORT`). For one-shot `train` containers this fires after the process has already exited and causes no spurious failures. The Compose-level healthcheck shown in the annotated example also targets `/health` and overrides the image default for the `serve` service — orchestrators should rely on the HTTP 200 response from `/health`. +The Dockerfile declares its own `HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3` that probes `urllib.request.urlopen(f'http://127.0.0.1:{RECOTEM_PORT}/health', timeout=3)` (so it picks up an overridden `RECOTEM_PORT`). Note: the image-default probe targets `/health` (no `/v1` prefix); because the v1 router is mounted at `/v1` the public health endpoint is `/v1/health`. The Compose-level healthcheck shown in the annotated example overrides the image default for the `serve` service and targets `/v1/health`; orchestrators should rely on the HTTP 200 response from `/v1/health`. For one-shot `train` containers the image healthcheck fires after the process has already exited and causes no spurious failures. ### Reverse proxy binding @@ -177,18 +177,18 @@ See [cron / systemd Deployment](./cron-systemd) for host-based scheduling patter | `RECOTEM_ENV` | no | `""` | `--insecure-no-auth` permitted when set to `development`, `dev`, or `test`; `--dev-allow-unsigned` permitted only when set to `development`. | | `RECOTEM_ARTIFACT_ROOT` | no | `""` | If set, local `output.path` must resolve under this directory (symlink-escape guard) | | `RECOTEM_LOCK_DIR` | no | `""` | Override directory for per-recipe training lock files. Needed when `output.path` is a remote URI (lock files must be host-local). Falls back to a temp dir under the system temp directory. | -| `RECOTEM_METADATA_FIELD_DENY` | no | `""` | Comma-separated column names stripped from `/predict` responses after the metadata join | -| `RECOTEM_METRICS_ENABLED` | no | `""` | Set to `1`/`true`/`yes`/`on` to enable the Prometheus `/metrics` endpoint. Requires `recotem[metrics]` extra. | +| `RECOTEM_METADATA_FIELD_DENY` | no | `""` | Comma-separated column names stripped from `/v1/recipes/{name}:recommend` and `:recommend-related` responses after the metadata join | +| `RECOTEM_METRICS_ENABLED` | no | `""` | Set to `1`/`true`/`yes`/`on` to enable the Prometheus `/v1/metrics` endpoint. Requires `recotem[metrics]` extra. | | `RECOTEM_STARTUP_PARALLELISM` | no | `""` (auto) | Number of parallel threads used to load artifacts at startup. Default is `min(len(recipes), 8)`. Clamped 1–32. Set to `1` for sequential loading (useful for memory-constrained environments or debugging). | *`auto` switches to `console` for an interactive TTY and `json` otherwise. ## Health check -The `/health` endpoint is unauthenticated and safe for container probes: +The `/v1/health` endpoint is unauthenticated and safe for container probes: ```bash -curl http://localhost:8080/health +curl http://localhost:8080/v1/health ``` ```json @@ -201,4 +201,4 @@ curl http://localhost:8080/health `status` is `degraded` (HTTP 503) if any recipe failed to load. Use a Kubernetes readiness probe or Docker HEALTHCHECK targeting this endpoint — see [Serving API](../serving-api) for the full response contract. -For per-recipe detail including `kid`, `trained_at`, and `best_class`, use the authenticated `/health/details` endpoint. +For per-recipe detail including `kid`, `trained_at`, and `best_class`, use the authenticated `/v1/health/details` endpoint. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 70c8844..7586eca 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -72,9 +72,9 @@ These variables control the runtime environment, graceful shutdown, and log outp | Variable | Default | Scope | Clamping | Description | |---|---|---|---|---| -| `RECOTEM_ENV` | (empty) | serve | — | Deployment environment tag. `--insecure-no-auth` is permitted only when set to `development`, `dev`, or `test`. `--dev-allow-unsigned` is permitted only when set to `development`. When set to `production`, `prod`, or `staging`, the `/docs`, `/redoc`, and `/openapi.json` endpoints are disabled (requests return 404). | +| `RECOTEM_ENV` | (empty) | serve | — | Deployment environment tag. `--insecure-no-auth` is permitted only when set to `development`, `dev`, or `test`. `--dev-allow-unsigned` is permitted only when set to `development`. The `/docs`, `/redoc`, and `/openapi.json` endpoints are fail-secure: they are enabled only when this variable is one of `development`, `dev`, or `test`; for any other value (including unset, `production`, `prod`, `staging`, or a custom tag) those paths return 404. | | `RECOTEM_DRAIN_SECONDS` | `30` | serve | [1, 300] | SIGTERM graceful drain window in seconds. In-flight requests are given this window to complete before uvicorn closes remaining connections. For Kubernetes, set `terminationGracePeriodSeconds` to at least `RECOTEM_DRAIN_SECONDS + 5`. | -| `RECOTEM_LOG_FORMAT` | `auto` | both | — | Log output format. `auto` uses JSON when stdout is not a TTY, console otherwise. `json` forces structured JSON. `console` forces human-readable output. | +| `RECOTEM_LOG_FORMAT` | `auto` | both | — | Log output format. `auto` uses JSON when `stderr` is not a TTY, console otherwise. `json` forces structured JSON. `console` forces human-readable output. | ## Operational @@ -84,19 +84,18 @@ These variables configure storage paths, locking, metadata field filtering, and |---|---|---|---|---| | `RECOTEM_ARTIFACT_ROOT` | (empty) | train | — | If set, local `output.path` values in recipes must lie under this directory. Symlink escapes are rejected. Use this to confine where train processes can write artifacts on the host. | | `RECOTEM_LOCK_DIR` | (empty) | train | — | Override directory for per-recipe training lock files. Local `output.path` values always lock at `.lock`. Remote `output.path` values (`s3://`, `gs://`, etc.) require a host-local lock file; if `RECOTEM_LOCK_DIR` is unset they fall back to `/recotem-locks/`. Note: `flock` is host-local — for cross-host single-writer guarantees use scheduler-level mutex (Kubernetes `concurrencyPolicy: Forbid`, etc.). | -| `RECOTEM_METADATA_FIELD_DENY` | (empty) | serve | — | Comma-separated list of column names stripped from `/predict` responses after the item-metadata join. Matching is case-insensitive — `"Internal_ID"` in the metadata is stripped if `"internal_id"` is in the deny list. Use this to keep PII columns out of API responses. | -| `RECOTEM_METRICS_ENABLED` | (unset) | serve | — | Truthy values: `1`, `true`, `yes`, `on`. Enables the Prometheus `/metrics` endpoint. Requires the `recotem[metrics]` extra (`pip install "recotem[metrics]"`). The endpoint is opt-in and off by default. | +| `RECOTEM_METADATA_FIELD_DENY` | (empty) | serve | — | Comma-separated list of column names dropped from the item-metadata index at load time, so they never appear on any recommendation response (`:recommend`, `:recommend-related`, and `:batch-recommend*` when `include_metadata=true`). Matching is case-insensitive — `"Internal_ID"` in the metadata is stripped if `"internal_id"` is in the deny list. Use this to keep PII columns out of API responses. | +| `RECOTEM_METRICS_ENABLED` | (unset) | serve | — | Truthy values: `1`, `true`, `yes`, `on`. Enables the Prometheus `/v1/metrics` endpoint. Requires the `recotem[metrics]` extra (`pip install "recotem[metrics]"`). The endpoint is opt-in and off by default. | ## Data source -These variables tune behaviour of specific data sources. They are read only by `recotem train` and only when the corresponding source is used. See the [Data sources](./data-sources/) reference for full context. +These variables tune behaviour of specific data sources. They are read only by `recotem train` and only when the corresponding source is used. See the [Data sources](./recipe-reference#source) reference for full context. | Variable | Default | Scope | Clamping | Description | |---|---|---|---|---| | `RECOTEM_BQ_REQUIRE_STORAGE_API` | (unset) | train | — | Truthy values: `1`, `true`, `yes`, `on`. When set, the BigQuery source raises `DataSourceError` (exit 3) instead of silently falling back to the slower REST API when the BigQuery Storage Read API fails (e.g. missing `bigquery.readSessions.create` IAM permission). Use this to surface IAM gaps rather than accepting degraded throughput. | | `RECOTEM_MAX_SQL_ROWS` | `50_000_000` | train | [1_000, 500_000_000] | Hard cap on the number of rows returned by the SQL data source. Exceeding the cap raises `DataSourceError` (exit 3). Caps **row count**, not DataFrame resident memory — see [SQL source — memory bound caveat](./data-sources/sql#memory-bound-caveat). | | `RECOTEM_SQL_ALLOW_PRIVATE` | (unset) | train | — | Truthy values: `1`, `true`, `yes`, `on`. Opts the SQL source into accepting private/loopback DSN hosts (default deny, for SSRF). Covers every driver-routing form — netloc, `?host=`, `?hostaddr=`, `?service=`, `?unix_socket=`, absolute-path host, and network DSNs with no host info — all default-deny without this flag. Also disables the DNS-rebinding re-check before each probe/fetch — opting in means trusting the host end-to-end. | -| `RECOTEM_GA4_MAX_PAGES` | `500` | train | [1, 10_000] | Hard ceiling on GA4 Data API pagination loops. Reached when a property is too large for the default; raise after confirming quota. | ## Recipe expansion diff --git a/docs/index.md b/docs/index.md index 7609826..d902573 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ title: Architecture # Architecture -Recotem is a recipe-driven recommender system: a single YAML file (the _recipe_) defines the data source, training configuration, and artifact destination. One recipe produces one trained model and one `/predict/{name}` HTTP endpoint. +Recotem is a recipe-driven recommender system: a single YAML file (the _recipe_) defines the data source, training configuration, and artifact destination. One recipe produces one trained model and a set of `/v1/recipes/{name}:` HTTP endpoints. ## System overview @@ -26,10 +26,10 @@ Recotem is a recipe-driven recommender system: a single YAML file (the _recipe_) │ │ │ │ ├── HMAC verify │ │ ├── deserialize payload │ - │ └── FastAPI /predict/{name} │ - │ │ │ - │ ▼ │ - │ API client request │ + │ └── FastAPI /v1/recipes/{name}:recommend │ + │ │ │ + │ ▼ │ + │ API client request │ └─────────────────────────────────────┘ ``` @@ -40,11 +40,14 @@ Recotem is a recipe-driven recommender system: a single YAML file (the _recipe_) A recipe is the single source of truth for a model: ``` -1 recipe YAML → 1 trained artifact → 1 /predict/{name} endpoint +1 recipe YAML → 1 trained artifact → /v1/recipes/{name}:recommend + /v1/recipes/{name}:recommend-related + /v1/recipes/{name}:batch-recommend + /v1/recipes/{name}:batch-recommend-related ``` The recipe captures: -- **Where to get data** (`source` block — CSV, Parquet, BigQuery, SQL, GA4, or plugin) +- **Where to get data** (`source` block — CSV, Parquet, BigQuery, SQL, or plugin) - **How to map columns** (`schema` block — user ID, item ID, optional timestamp) - **Data quality gates** (`cleansing` block — null-drop, dedup, minimum thresholds) - **What to train** (`training` block — algorithms, Optuna budget, split scheme) @@ -75,8 +78,8 @@ magic | version | reserved | kid | hmac | header_json | payload |-------|------------------|-------------| | Operator | Recipe YAML, signing keys, env vars, `RECOTEM_SIGNING_KEYS` | Fully trusted | | Training host | Reads source data, writes signed artifact | Trusted (operator-controlled) | -| Serving host | Reads artifact directory, serves `/predict` | Trusted (operator-controlled) | -| API client | Sends `/predict` requests with an API key | Untrusted user input | +| Serving host | Reads artifact directory, serves `/v1/recipes/{name}:` | Trusted (operator-controlled) | +| API client | Sends `/v1/recipes/{name}:` requests with an API key | Untrusted user input | | Artifact file | Immutable signed binary; any tamper fails HMAC | Authenticated by HMAC | Recipes can reference environment variables for dynamic values (via `${RECOTEM_RECIPE_*}` expansion). The expansion mechanism is restricted to that prefix and never applied inside `source.query` or `source.query_parameters` to foreclose SQL injection. @@ -90,7 +93,7 @@ The serving process polls the recipes directory for artifact file changes. When 3. Atomically replace the in-memory model reference. 4. The previous model is evicted; all subsequent requests use the new model. -Hot-swap is **recipe-scoped**: updating artifact `A` does not affect the in-flight model for recipe `B`. The serving process never restarts. If HMAC verification or deserialization of the new artifact fails, the previous model continues serving and the failure is recorded in `/health` and in the `recotem_artifact_load_failures_total` Prometheus metric (when metrics are enabled). +Hot-swap is **recipe-scoped**: updating artifact `A` does not affect the in-flight model for recipe `B`. The serving process never restarts. If HMAC verification or deserialization of the new artifact fails, the previous model continues serving and the failure is recorded in `/v1/health` and in the `recotem_artifact_load_failures_total` Prometheus metric (when metrics are enabled). The watcher poll interval is configured by `RECOTEM_WATCH_INTERVAL` (default 5 s, clamped to 1–30 s). @@ -123,7 +126,7 @@ This separation means: | Command | Purpose | |---------|---------| | `recotem train ` | Fetch data, run Optuna search, train best model, sign artifact | -| `recotem serve --recipes ` | Start FastAPI `/predict` server with hot-swap | +| `recotem serve --recipes ` | Start FastAPI `/v1/recipes` server with hot-swap | | `recotem inspect ` | Read and verify artifact header (no payload deserialization) | | `recotem validate ` | Validate recipe schema and probe data-source connectivity | | `recotem schema` | Emit JSON Schema for the Recipe model (IDE integration) | @@ -149,7 +152,6 @@ This separation means: - [CSV / Parquet Source](./data-sources/csv) — local, object-storage, and HTTP source options - [BigQuery Source](./data-sources/bigquery) — authentication, parameter binding, GA4 patterns - [SQL Source](./data-sources/sql) — PostgreSQL / MySQL / MariaDB / SQLite via SQLAlchemy 2 -- [GA4 Source](./data-sources/ga4) — Google Analytics 4 Data API, skipping the BigQuery Export hop - [Plugin Data Sources](./data-sources/plugins) — extend `source.type` with custom plugins - Deployment guides — Docker, Kubernetes, cron scheduling - Operations — key rotation, recovery, sizing, troubleshooting diff --git a/docs/operations.md b/docs/operations.md index 0eb63c4..196644b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -54,16 +54,16 @@ Once all recipes have been retrained and hot-swapped, remove the old entry: RECOTEM_SIGNING_KEYS="prod-2026-q3:ddeeff..." ``` -Restart `recotem serve`. Any artifact still signed with the old kid will fail to load and will show up as `loaded: false` in `/health/details`. Retrain those recipes. +Restart `recotem serve`. Any artifact still signed with the old kid will fail to load and will show up as `loaded: false` in `/v1/health/details`. Retrain those recipes. -Confirm all recipes loaded successfully. Per-recipe state lives behind the authenticated `/health/details` endpoint — the public `/health` returns only `{status, total, loaded}` aggregates: +Confirm all recipes loaded successfully. Per-recipe state lives behind the authenticated `/v1/health/details` endpoint — the public `/v1/health` returns only `{status, total, loaded}` aggregates: ```bash # -f / --fail returns exit 22 on 4xx/5xx, which would mask a 503. # Use -w to capture the status code instead. HTTP_STATUS=$(curl -s -o /tmp/health.json -w "%{http_code}" \ -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ - http://localhost:8080/health/details) + http://localhost:8080/v1/health/details) echo "HTTP $HTTP_STATUS" jq '.recipes | to_entries[] | select(.value.loaded == false)' /tmp/health.json ``` @@ -132,7 +132,7 @@ If an artifact is corrupt (truncated write, disk error, storage-side corruption) The `kid` field reads `""` only when the artifact is too short to hold a full kid (truncated writes, zero-byte files). For a tampered or wrong-magic file of the expected length, the parsed kid string is shown verbatim instead. -The server continues running and returns 503 for that recipe's `/predict/{name}` endpoint. +The server continues running and returns 503 for that recipe's recommendation endpoints. **Recovery steps:** @@ -158,7 +158,7 @@ This writes a fresh, signed artifact. The server detects the new file at the nex ```bash curl -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ - http://localhost:8080/health/details | jq '.recipes.my_recipe' + http://localhost:8080/v1/health/details | jq '.recipes.my_recipe' # {"loaded": true, "best_class": "IALSRecommender", ...} ``` @@ -229,8 +229,10 @@ A successful training run emits these structured events in order. Use them as th | `train_done` | end | `name`, `run_id`, `exit_code`, `artifact`, `best_class`, `best_score`, `trials`, `n_orphaned`, `trained_at`, `kid`, `recipe_hash`, `n_rows`, `n_users`, `n_items` | | `train_error` | failure | `error`, `code` (`internal_error` for non-domain exceptions), `recipe`, `run_id`, `exit_code`, `trained_at`; additionally `n_rows`, `n_users`, `n_items`, `min_rows`, `min_users`, `min_items` when `code=min_data_violation` | | `recipe_lock_contended_skipping` | start | `recipe`, `run_id` (default `--fail-on-busy=False` exits 0) | -| `csv_source_redirect`, `csv_source_size_exceeded` | datasource | `path`, `status`, `cap` | -| `metadata_source_redirect`, `metadata_source_size_exceeded` | datasource | `path`, `status`, `cap` | +| `csv_source_redirect` | datasource | `from_`, `to`, `status` | +| `csv_source_size_exceeded` | datasource | `path`, `bytes_read`, `cap` | +| `metadata_source_redirect` | datasource | `from_`, `to`, `status` | +| `metadata_source_size_exceeded` | datasource | `path`, `bytes_read`, `cap` | Operators alerting on `csv_source_redirect` / `csv_source_size_exceeded` should add equivalent alerts for `metadata_source_redirect` / `metadata_source_size_exceeded`. Both event families fire when an HTTP/HTTPS fetch hits a redirect cap or byte cap. @@ -315,8 +317,8 @@ Recotem does not enforce SLOs internally. Recommended baseline targets for produ | Metric | Target | |--------|--------| -| `/predict/{name}` p99 latency | < 50 ms (pure recommender, no metadata join) | -| `/health` p99 latency | < 5 ms | +| Recommendation endpoints p99 latency | < 50 ms (pure recommender, no metadata join) | +| `/v1/health` p99 latency | < 5 ms | | Availability (per recipe) | Measure via `recotem_model_loaded{recipe}` Prometheus gauge | | Artifact hot-swap time | ≤ `RECOTEM_WATCH_INTERVAL` + model load time | | Train-to-serve lag | Schedule train; serve detects in ≤ `RECOTEM_WATCH_INTERVAL` seconds | @@ -327,7 +329,7 @@ Enable Prometheus metrics: pip install "recotem[metrics]" ``` -Set `RECOTEM_METRICS_ENABLED=1` to activate the `/metrics` endpoint. +Set `RECOTEM_METRICS_ENABLED=1` to activate the `/v1/metrics` endpoint. --- @@ -339,11 +341,11 @@ Set `RECOTEM_METRICS_ENABLED=1` to activate the `/metrics` endpoint. - On `recotem serve` shutdown (SIGTERM), `ArtifactWatcher.stop()` calls `executor.shutdown(wait=False, cancel_futures=True)` so queued-but-not-started futures are discarded immediately. - A change is detected from the artifact pointer's mtime/size (local FS) or ETag/VersionId (object stores). When the marker changes the watcher reads the full bytes once, computes sha256, and **only reloads if the sha256 also changed** — so replacing a file with identical content bumps mtime but does not trigger an unnecessary swap. - Recipes directory is rescanned each tick: new `*.yaml` files trigger `recipe_discovered` + an immediate forced load; removed files trigger `recipe_removed` and the entry is dropped from the registry. -- On any failure during reload (`artifact_load_failed`, `artifact_load_unexpected_error`), the existing entry remains served and its `last_load_error` field is set so `/health` shows the staleness while `/predict` continues to return the previous good model. +- On any failure during reload (`artifact_load_failed`, `artifact_load_unexpected_error`), the existing entry remains served and its `last_load_error` field is set so `/v1/health` shows the staleness while the recommendation endpoints continue to return the previous good model. ### Initial load failure -When an artifact fails to load at startup the recipe is still registered as a stub (`loaded=false`, `error=`). The server starts, `/health` reports `degraded`, and `/predict/{name}` returns 503. A partial outage is recoverable by retraining without restarting the process. +When an artifact fails to load at startup the recipe is still registered as a stub (`loaded=false`, `error=`). The server starts, `/v1/health` reports `degraded`, and the recipe's recommendation endpoints return 503. A partial outage is recoverable by retraining without restarting the process. The startup-only event variants are: @@ -380,8 +382,8 @@ The high-signal metrics for production alerting: | Artifact load failures since restart | `recotem_artifact_load_failures_total{recipe=...}` increase | warn | | Artifact stat failures (watcher poll) | `recotem_artifact_stat_failures_total{recipe=...}` increase | warn | | Watcher unhandled errors | `recotem_watcher_unhandled_errors_total` increase | warn | -| Predict error rate | `rate(recotem_predict_total{status="error"}[5m]) / rate(recotem_predict_total[5m])` | warn at 1%, page at 10% | -| Predict latency | `histogram_quantile(0.99, recotem_predict_latency_seconds_bucket)` | per-recipe SLO | +| Predict error rate | `rate(recotem_v1_requests_total{status="error"}[5m]) / rate(recotem_v1_requests_total[5m])` | warn at 1%, page at 10% | +| Predict latency | `histogram_quantile(0.99, recotem_v1_request_latency_seconds_bucket)` | per-recipe SLO | | Active recipes | `recotem_active_recipes` drop > 0 since last scrape | warn | | BigQuery Storage API fallback | `rate(recotem_bigquery_storage_fallback_total{reason="api_error"}[5m]) > 0` | warn | | Recipes-dir scan failures | `rate(recotem_recipes_dir_scan_failures_total[5m]) > 0` | warn | @@ -408,7 +410,7 @@ For zero-downtime upgrade of the serve fleet, deploy new pods with both the old ```bash curl -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ - http://localhost:8080/health/details | jq '.recipes' + http://localhost:8080/v1/health/details | jq '.recipes' ``` ```json @@ -462,16 +464,16 @@ All Optuna trials scored 0.0. Common causes: - The split produced an empty test set (too few users or interactions). Try `split.scheme: random` or lower `split.heldout_ratio`. - The data after cleansing has too few items for the cutoff. Lower `training.cutoff`. -### 401 on /predict +### 401 on recommendation endpoints - Trailing or leading whitespace in the `X-API-Key` header is treated as part of the key and will not match. Trim client-side. - Confirm the hash in `RECOTEM_API_KEYS` was produced by `recotem keygen --type api` for the plaintext you are sending. The wire prefix is `sha256:` but the digest is scrypt — a plain `sha256(plaintext)` will not match. -### 503 on /predict/{name} +### 503 on /v1/recipes/{name}:recommend (and related verbs) -The recipe is unhealthy (`loaded: false`). See `/health/details` for the error. Usually a signing mismatch or corrupt artifact. +The recipe is unhealthy (`loaded: false`). See `/v1/health/details` for the error. Usually a signing mismatch or corrupt artifact. -### 404 on /predict/{name} +### 404 UNKNOWN_USER on /v1/recipes/{name}:recommend The `user_id` in the request was not present in training data. This is expected for new users. Handle it in your application layer (fall back to popularity-based recommendations, for example). diff --git a/docs/recipe-reference.md b/docs/recipe-reference.md index 08f5cb1..f7c289a 100644 --- a/docs/recipe-reference.md +++ b/docs/recipe-reference.md @@ -4,14 +4,14 @@ title: Recipe Reference # Recipe Reference -A recipe is a YAML file that defines what data to fetch, how to train, and where to write the artifact. One recipe produces one model and one `/predict/{name}` endpoint. +A recipe is a YAML file that defines what data to fetch, how to train, and where to write the artifact. One recipe produces one model and one `/v1/recipes/{name}:recommend` (and related) endpoint. ## Top-level fields | Field | Type | Required | Description | |-------|------|----------|-------------| -| `name` | string | yes | Endpoint name. Pattern: `^[A-Za-z0-9_-]{1,64}$`. Becomes `/predict/{name}`. | -| `source` | object | yes | Data source config. `type` field is the discriminator (`csv`, `parquet`, `bigquery`, `sql`, `ga4`, or any plugin). Validated in two stages: the rest of the recipe is parsed first, then the source dict is dispatched to the plugin's `Config` class. As a result, errors in `source.*` surface *after* errors elsewhere in the recipe; an unknown `source.type` raises a `DataSourceError` listing all registered type names. | +| `name` | string | yes | Endpoint name. Pattern: `^[A-Za-z0-9_-]{1,64}$`. Used in endpoint paths such as `/v1/recipes/{name}:recommend`. | +| `source` | object | yes | Data source config. `type` field is the discriminator (`csv`, `parquet`, `bigquery`, `sql`, or any plugin). Validated in two stages: the rest of the recipe is parsed first, then the source dict is dispatched to the plugin's `Config` class. As a result, errors in `source.*` surface *after* errors elsewhere in the recipe; an unknown `source.type` raises a `DataSourceError` listing all registered type names. | | `schema` | object | yes | Column mapping. | | `cleansing` | object | no | Data quality gates. | | `item_metadata` | object | no | Metadata joined into predict responses. | @@ -100,37 +100,6 @@ source: Install one extra: `pip install "recotem[postgres]"`, `recotem[mysql]`, or `recotem[sqlite]`. Full reference: [SQL source](./data-sources/sql). -### `source.type: ga4` - -```yaml -source: - type: ga4 - property_id: "123456789" - user_dimension: userPseudoId - item_dimension: itemId - time_dimension: date - event_names: [purchase, view_item, add_to_cart] - lookback_days: 90 # XOR with start_date + end_date - max_rows: 1_000_000 - weight_column: event_count - api_timeout_seconds: 60 -``` - -| Field | Type | Default | Notes | -|-------|------|---------|-------| -| `property_id` | string | required | Numeric only (`^\d+$`). Not the `G-XXXX` measurement ID. | -| `user_dimension` | string | required | `userId` or `userPseudoId`. | -| `item_dimension` | string | `itemId` | Any GA4 item-scoped dimension. | -| `time_dimension` | string | `date` | `date` / `dateHour` / `dateHourMinute`. | -| `event_names` | list[string] | required | 1–50 names; each matches `^[A-Za-z_][A-Za-z0-9_]{0,39}$`. | -| `lookback_days` | int | XOR | 1–3650. Rolling window ending yesterday. | -| `start_date` / `end_date` | string (ISO) | XOR | Both required if either is set. | -| `max_rows` | int | required | Valid range `[1, 50_000_000]`. | -| `weight_column` | string | `event_count` | Must not collide with the dimension keys or the literal `eventName`. | -| `api_timeout_seconds` | int | `60` | Valid range `[5, 600]`. | - -Install the extra: `pip install "recotem[ga4]"`. Full reference: [GA4 source](./data-sources/ga4). - --- ## `schema` @@ -198,12 +167,12 @@ item_metadata: |-------|------|---------|-------| | `type` | string | required | `csv` or `parquet`. | | `path` | string | required | See [Path rules](#path-rules). | -| `fields` | list[string] | required | Non-empty. Only listed fields are returned in predict responses. | -| `on_field_missing` | string | `error` | What to do if a `fields` entry is absent in the file. `error` fails the model load (at startup the recipe registers as `loaded=false` with `last_load_error` set; on hot-swap the previous model keeps serving and the failure is surfaced via `/health` and the `recotem_artifact_load_failures_total` metric); `null` fills the column with `null`. | +| `fields` | list[string] | required | Non-empty. Only listed fields are returned in recommendation responses. | +| `on_field_missing` | string | `error` | What to do if a `fields` entry is absent in the file. `error` fails the model load (at startup the recipe registers as `loaded=false` with `last_load_error` set; on hot-swap the previous model keeps serving and the failure is surfaced via `/v1/health` and the `recotem_artifact_load_failures_total` metric); `null` fills the column with `null`. | | `sha256` | string | optional (required when `path` is `http://` or `https://`) | 64-char lowercase hex; verified against the fetched bytes; mismatch raises `DataSourceError` | | `item_id_column` | string | `"item_id"` | Column name in the metadata file that holds item identifiers. Override when your metadata file uses a different column name (e.g. `product_id`). Must be a non-empty, non-whitespace string. | -Server-side field suppression is also available via `RECOTEM_METADATA_FIELD_DENY` (comma-separated column names), applied as a post-join column drop. +Server-side field suppression is also available via `RECOTEM_METADATA_FIELD_DENY` (comma-separated column names). Listed columns are dropped from the metadata index at load time, so they never appear on any recommendation response. --- diff --git a/docs/security.md b/docs/security.md index 677b581..ec5edde 100644 --- a/docs/security.md +++ b/docs/security.md @@ -16,8 +16,16 @@ title: Security │ recotem serve │ │ binds to RECOTEM_HOST:RECOTEM_PORT │ API clients │ │ - (authenticated) ─────►│ POST /predict/{name} X-API-Key header │ - │ GET /health │ + (authenticated) ─────►│ POST /v1/recipes/{name}:recommend │ + │ POST /v1/recipes/{name}:recommend-related │ + │ POST /v1/recipes/{name}:batch-recommend │ + │ POST /v1/recipes/{name}:batch-recommend-related │ + │ GET /v1/recipes │ + │ GET /v1/recipes/{name} │ + │ GET /v1/health/details │ + │ GET /v1/metrics (opt-in; auth required) │ + │ GET /v1/health (no auth required) │ + │ X-API-Key header (all other endpoints) │ └──────────────┬────────────────────────────┘ │ reads (signed) ┌──────────────▼────────────────────────────┐ @@ -48,7 +56,7 @@ When `source.path` uses `s3://`, `gs://`, `az://`, or `abfs(s)://`, the Pod's am | Stat-then-read TOCTOU on artifact | Read-once protocol: bytes read into memory once, sha256 computed, then HMAC-verified from the same buffer | | Key material in logs | structlog redaction processor runs first in chain; unit test asserts no key material at any log level | | API key brute-force / timing attack | `hmac.compare_digest` constant-time compare; no logging of plaintext or hash | -| Credential injection via recipe env expansion | `RECOTEM_SIGNING_KEYS`, `RECOTEM_API_KEYS`, `*_SECRET*`, `*_PASSWORD*`, `*_TOKEN*`, `*_KEY*`, `AWS_*`, `GOOGLE_*`, `GCP_*` are blacklisted from `${...}` expansion | +| Credential injection via recipe env expansion | `RECOTEM_SIGNING_KEYS`, `RECOTEM_API_KEYS`, `*_SECRET*`, `*_PASSWORD*`, `*_TOKEN*`, `*_KEY*`, and cloud prefixes (`AWS_*`, `GCP_*`, `GOOGLE_*`, `AZURE_*`, `ALIYUN_*`, `ALICLOUD_*`, `OCI_*`, `IBM_*`, `DO_*`, `HCLOUD_*`, `DIGITALOCEAN_*`) are blacklisted from `${...}` expansion | | SQL injection via recipe | Env expansion never performed inside `source.query`; dynamic values must use `@param` BigQuery placeholders | | Path traversal via recipe | `name` validated with `^[A-Za-z0-9_-]{1,64}$` at load and before every filesystem use; artifact root confinement via `RECOTEM_ARTIFACT_ROOT` | | Tampered or rotated network-fetched data | `sha256` integrity pin is **mandatory** on `source.path` / `item_metadata.path` when the scheme is `http://` or `https://`; mismatch raises `DataSourceError` (exit 3) before the bytes reach the parser | @@ -318,7 +326,7 @@ Only variables matching `RECOTEM_RECIPE_*` are candidates for `${...}` expansion | Rule | Patterns (case-insensitive) | |------|----------------------------| | Exact match | `RECOTEM_SIGNING_KEYS`, `RECOTEM_API_KEYS` | -| Prefix match | `AWS_*`, `GCP_*`, `GOOGLE_*`, `AZURE_*` | +| Prefix match | `AWS_*`, `GCP_*`, `GOOGLE_*`, `AZURE_*`, `ALIYUN_*`, `ALICLOUD_*`, `OCI_*`, `IBM_*`, `DO_*`, `HCLOUD_*`, `DIGITALOCEAN_*` | | Substring match | `*SECRET*`, `*PASSWORD*`, `*PASSWD*`, `*TOKEN*`, `*KEY*`, `*AUTH*`, `*BEARER*`, `*CRED*`, `*PRIVATE*` | The `*KEY*` substring is intentionally broad. Any `RECOTEM_RECIPE_*` variable whose uppercased name contains the substring `KEY` (no underscore boundary required) is rejected — this includes `RECOTEM_RECIPE_PARTITION_KEY`, `RECOTEM_RECIPE_APIKEY`, and `RECOTEM_RECIPE_KEYBOARD`. Use a name that does not contain `KEY` (e.g. `RECOTEM_RECIPE_PARTITION_COLUMN`). A blacklisted reference raises `RecipeError` (exit 2) and the error message names the variable but never includes its value. @@ -353,7 +361,7 @@ Never commit signing keys, API key hashes, or API key plaintexts to version cont ## API key minimum length -Recotem enforces a 32-character minimum on the `X-API-Key` header value. Plaintext keys shorter than 32 chars are rejected with a 401 (`invalid_api_key`) before any digest comparison is attempted. The error message does not reveal the minimum threshold to the caller. +Recotem enforces a 32-character minimum on the `X-API-Key` header value. Plaintext keys shorter than 32 chars are rejected with a 401 (`INVALID_API_KEY`) before any digest comparison is attempted. The error message does not reveal the minimum threshold to the caller. The recommended workflow is `recotem keygen --type api`, which generates a 43-char base64url plaintext (32 raw bytes of `os.urandom`). Operator-chosen passphrases or passwords must be at least 32 chars; shorter values will silently fail authentication at runtime with no configuration error at startup. @@ -460,7 +468,7 @@ Two unsafe flags exist and are gated by `RECOTEM_ENV`: | `--dev-allow-unsigned` | `RECOTEM_ENV=development` AND `--i-understand-this-loads-arbitrary-code` | Skips HMAC verify; never use outside controlled testing | ::: warning OpenAPI schema in production -When `RECOTEM_ENV` is set to `production`, `prod`, or `staging`, the `/docs`, `/redoc`, and `/openapi.json` endpoints are disabled at app construction time. Requests to those paths return 404. Development and test environments keep the endpoints enabled for developer ergonomics. +The `/docs`, `/redoc`, and `/openapi.json` endpoints are fail-secure: they are enabled only when `RECOTEM_ENV` is one of `development`, `dev`, or `test`. Any other value — including an unset variable, `production`, `prod`, `staging`, or a custom tag — disables them at app construction time, and requests to those paths return 404. ::: Both flags are rejected at startup in any environment not matching the requirement, with an explicit error message. @@ -476,8 +484,8 @@ environment. | Event | Level | Trigger | Status | |-------|-------|---------|--------| -| `auth_missing_header` | WARN | Request with no `X-API-Key` header (and `RECOTEM_API_KEYS` is non-empty) | 401, code `missing_api_key` | -| `auth_invalid_key` | WARN | Header present but no kid hashes match | 401, code `invalid_api_key` | +| `auth_missing_header` | WARN | Request with no `X-API-Key` header (and `RECOTEM_API_KEYS` is non-empty) | 401, code `MISSING_API_KEY` | +| `auth_invalid_key` | WARN | Header present but no kid hashes match | 401, code `INVALID_API_KEY` | | `auth_anonymous_bypass` | DEBUG | Every request when `RECOTEM_API_KEYS` is empty (no-auth mode) | — | | `auth_anonymous_bypass_first_seen` | INFO | First request from a given `client_host` in no-auth mode | — | @@ -487,21 +495,21 @@ When `RECOTEM_API_KEYS` is empty, `auth_anonymous_bypass` fires on **every** req ## Predict response: information leakage -`POST /predict/{name}` returns: +`POST /v1/recipes/{name}:recommend` (and the related verb endpoints) returns: -- 503 (`recipe_unavailable`) — recipe stub or stale entry; visible without auth context only at `/health`. -- 404 (`user_not_found`) — `user_id` was not in training data. This response distinguishes "known user, no recommendations" from "unknown user". If user-existence is sensitive in your application, mask 404 responses at your reverse proxy and return a generic empty-recommendation body. +- 503 (`RECIPE_UNAVAILABLE`) — recipe stub or stale entry; aggregate status is visible without auth at `/v1/health`. +- 404 (`UNKNOWN_USER`) — `user_id` was not in training data. This response distinguishes "known user, no recommendations" from "unknown user". If user-existence is sensitive in your application, mask 404 responses at your reverse proxy and return a generic empty-recommendation body. - 200 — recommendations, optionally joined with item metadata. Field stripping is configured via `RECOTEM_METADATA_FIELD_DENY` (case-**insensitive** column names — `"Internal_ID"` in metadata is stripped if `"internal_id"` is in the deny list). Use this to keep PII columns out of API responses even when they are present in the metadata file. -`cutoff` is bounded at `[1, 1000]` by the request schema; oversized requests -receive a 422 from FastAPI before reaching the recommender. +`limit` is bounded at `[1, 1000]` by the request schema; oversized requests +receive a 422 (`VALIDATION_ERROR`) from FastAPI before reaching the recommender. ## Rate limiting and DoS Recotem itself does not implement request-rate limiting. Operators **must** front `recotem serve` with a reverse proxy (nginx `limit_req`, Caddy `rate_limit`, ALB / Cloud Armor) and apply per-IP or per-API-key quotas on -`/predict/*`. This is not optional in production. +`/v1/recipes/`. This is not optional in production. **Why the proxy layer is responsible — scrypt amplification.** Every authentication attempt (valid or not) runs a scrypt key-derivation check @@ -511,7 +519,7 @@ therefore trigger CPU-bound scrypt work on every failed authentication, at a rate bounded only by the network rather than by the application. Recotem does not implement its own rate limiter; that is the proxy's responsibility. -`/predict` is also CPU-bound for recommendation inference; sustained request +The recommendation endpoints (`/v1/recipes/`) are also CPU-bound for recommendation inference; sustained request rates above the recommender's inference throughput will queue under uvicorn and cause request latency to climb. Measure and cap at the proxy. @@ -524,7 +532,7 @@ limit_req_zone $binary_remote_addr zone=recotem_predict:10m rate=20r/s; server { # ... TLS and upstream configuration ... - location /predict/ { + location /v1/recipes/ { limit_req zone=recotem_predict burst=40 nodelay; limit_req_status 429; proxy_pass http://recotem_backend; diff --git a/docs/serving-api.md b/docs/serving-api.md index e0b849a..c7d4369 100644 --- a/docs/serving-api.md +++ b/docs/serving-api.md @@ -1,282 +1,552 @@ --- -title: Serving API +title: "Serving API" +description: "Complete reference for the recotem serving API — all endpoints, authentication, request/response shapes, error codes, and middleware." --- # Serving API -`recotem serve` exposes a FastAPI application over HTTP. All endpoints are documented here with their request/response shapes, authentication requirements, and error codes. +`recotem serve` exposes a FastAPI application over HTTP. All endpoints live under the `/v1` namespace. Custom verbs follow the [AIP-136](https://google.aip.dev/136) colon-verb convention — for example, `/v1/recipes/{name}:recommend`. ## Authentication -API key authentication uses the `X-API-Key` request header. Keys are configured via `RECOTEM_API_KEYS` as a comma-separated list of `:sha256:` entries. The server verifies the submitted plaintext against the stored scrypt hash. +All endpoints except `GET /v1/health` require the `X-API-Key` request header carrying a plaintext API key. + +Keys are configured via `RECOTEM_API_KEYS` as a comma-separated list of `:sha256:` entries. The server verifies the submitted plaintext against a scrypt-derived hash stored in the entry (scrypt parameters: N=2, r=8, p=1, salt=`recotem.api-key.v1`). Key length must be between 32 and 256 characters. + +Generate a valid API key with: + +```bash +recotem keygen --type api +``` + +This produces a 43-character base64url string ready to use as the plaintext key. The corresponding `sha256:` digest is printed for placement in `RECOTEM_API_KEYS`. + +When `RECOTEM_API_KEYS` is empty and `--insecure-no-auth` is not set: -When `RECOTEM_API_KEYS` is empty: - The server forces `127.0.0.1` as the bind host regardless of `RECOTEM_HOST`. -- All requests from `127.0.0.1` are accepted without a key. -- Use `--insecure-no-auth` with `RECOTEM_ENV` set to `development`, `dev`, or `test` to disable auth explicitly in local development. +- All requests are accepted without a key (the client is tagged as `kid=anonymous` in logs). ::: warning Trailing or leading whitespace in the `X-API-Key` header is treated as part of the key and will not match. Trim values client-side before sending. ::: -## Common headers +## Common Headers | Header | Direction | Description | -|--------|-----------|-------------| -| `X-API-Key` | Request | Authentication token (plaintext). Required on all authenticated endpoints. | -| `X-Request-ID` | Request (optional) | Client-supplied request identifier. Must match `[A-Za-z0-9_-]{1,64}`. Values that do not match are replaced with a freshly generated UUID4. | -| `X-Request-ID` | Response | Echo of the request ID used internally — either the validated client-supplied value or the generated UUID4. | -| `X-Recotem-Metadata-Degraded` | Response | Set to `1` when one or more items in the response had a metadata lookup failure (item was present in training but the metadata join failed for that item). The `items` list still includes those items with only `item_id` and `score`. | +|---|---|---| +| `X-API-Key` | Request | Authentication token (plaintext). Required on all endpoints except `GET /v1/health`. | +| `X-Request-ID` | Request / Response | Client-supplied request identifier. Must match `^[A-Za-z0-9_-]{1,128}$`. Values that do not match, or absent values, cause the server to generate a fresh 12-hex identifier. The value actually used is echoed in the response. | +| `X-Recotem-Model-Version` | Response | The model version hash (`sha256:<64-hex>`) of the recipe that served the request. Present on all recommendation responses. Mirrors the `model_version` field in the response body. | +| `X-Recotem-Items-Degraded` | Response | Single-recommendation endpoints only. Set to the total count of items whose metadata join produced a fallback or was dropped. Absent when the response is fully clean. Not sent on batch endpoints. | + +## Recipe Name Format + +Recipe names used as path parameters must match `^[A-Za-z0-9_-]{1,64}$`. Paths with a name that does not match are rejected by the router — depending on how the URL parses, the response is either `404 Not Found` or `422 Unprocessable Entity`. ## Endpoints -### POST /predict/{name} +### Recommendation + +#### POST /v1/recipes/{name}:recommend Get top-K recommendations for a single user. **Authentication:** Required (`X-API-Key`). -**Path parameters:** +**Path parameter:** `name` — recipe name matching `^[A-Za-z0-9_-]{1,64}$`. -| Parameter | Type | Constraints | Description | -|-----------|------|-------------|-------------| -| `name` | string | `[A-Za-z0-9_-]{1,64}` | Recipe name (stem of the recipe YAML filename). | +**Request body** (`extra` fields are forbidden): -**Request body:** +| Field | Type | Constraints | Default | Description | +|---|---|---|---|---| +| `user_id` | string | required, 1–256 chars | — | User identifier as seen in training data. | +| `limit` | integer | 1–1000 | `10` | Maximum number of items to return. | +| `exclude_items` | string[] \| null | optional, ≤1000 items | null | Item IDs to exclude from the result. | ```json { "user_id": "u1", - "cutoff": 10 + "limit": 10, + "exclude_items": ["item-99"] } ``` -| Field | Type | Constraints | Default | Description | -|-------|------|-------------|---------|-------------| -| `user_id` | string | required | — | User identifier as seen in training data. | -| `cutoff` | integer | 1–1000 | `10` | Number of items to return. | - **Response body (200 OK):** ```json { + "request_id": "a1b2c3d4e5f6", + "recipe": "purchase_log", + "model_version": "sha256:a3f2...e91d", "items": [ - { - "item_id": "item-42", - "score": 0.9812, - "title": "Example Item", - "category": "news" - }, - { - "item_id": "item-17", - "score": 0.8754 - } - ], - "model": { - "recipe": "news_articles", - "trained_at": "2026-05-07T01:23:45Z", - "best_class": "IALSRecommender", - "kid": "prod-2026-q2" - }, - "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + {"item_id": "item-42", "score": 0.91, "title": "Example Item", "category": "books"}, + {"item_id": "item-17", "score": 0.84} + ] } ``` -The `items` array is ordered by descending `score`. Each item always contains `item_id` and `score`; additional fields are joined from the item metadata configured in the recipe (`item_metadata` block). Fields listed in `RECOTEM_METADATA_FIELD_DENY` are stripped before the response is sent. A metadata column named `item_id` or `score` cannot shadow the trusted recommender values. +Items are ordered by descending `score`. The `score` field is always a finite number (NaN and Inf are rejected internally). Each item always contains `item_id` and `score`; additional fields are joined from the item metadata configured in the recipe's `item_metadata` block. Because `RecommendItem` permits extra fields, metadata-derived fields appear alongside `item_id` and `score`. **Status codes:** -| Code | Condition | Response body `code` field | -|------|-----------|---------------------------| +| Code | Condition | Error code | +|---|---|---| | 200 | Success | — | -| 401 | Missing or invalid `X-API-Key` | `missing_api_key` or `invalid_api_key` | -| 404 | `user_id` was not present in training data | `user_not_found` | -| 422 | Request body failed schema validation (missing `user_id`, `cutoff` out of range) | — (FastAPI default validation envelope) | -| 503 | Recipe is not loaded or unhealthy | `recipe_unavailable` | +| 401 | Missing `X-API-Key` | `MISSING_API_KEY` | +| 401 | Key does not match any entry | `INVALID_API_KEY` | +| 404 | `user_id` was not seen during training | `UNKNOWN_USER` | +| 422 | Request body failed schema validation | `VALIDATION_ERROR` | +| 503 | Recipe is not loaded | `RECIPE_UNAVAILABLE` | + +::: tip UNKNOWN_USER is not a server error +A 404 for an unknown user is expected for new users not seen during training. Handle it in your application layer — for example, fall back to popularity-based recommendations. +::: **curl example:** ```bash -curl -s -X POST http://localhost:8080/predict/news_articles \ +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:recommend \ -H "X-API-Key: " \ -H "Content-Type: application/json" \ - -d '{"user_id": "u1", "cutoff": 10}' | jq . + -d '{"user_id": "u1", "limit": 10}' | jq . ``` -::: tip 404 user_not_found -A 404 response for an unknown user is expected for new users not seen during training. Handle this in your application layer — for example, fall back to popularity-based recommendations. The 404 is not an error condition on the server side. -::: - --- -### GET /health +#### POST /v1/recipes/{name}:recommend-related -Overall health status. Safe for Kubernetes readiness and liveness probes. +Get items related to one or more seed items. -**Authentication:** None (unauthenticated). +**Authentication:** Required (`X-API-Key`). + +**Request body:** -**Response body (200 OK or 503 Service Unavailable):** +| Field | Type | Constraints | Default | Description | +|---|---|---|---|---| +| `seed_items` | string[] | required, 1–100 items | — | Item IDs used as seeds. | +| `limit` | integer | 1–1000 | `10` | Maximum number of items to return. | +| `exclude_items` | string[] \| null | optional | null | Item IDs to exclude from the result. | ```json { - "status": "ok", - "total": 3, - "loaded": 3 + "seed_items": ["item-42", "item-17"], + "limit": 10 } ``` -| Field | Type | Description | -|-------|------|-------------| -| `status` | `"ok"` \| `"degraded"` | `"ok"` when every registered recipe is loaded and error-free. `"degraded"` when any recipe is unloaded or carries a load error. | -| `total` | integer | Total number of recipe entries known to the registry. | -| `loaded` | integer | Number of recipes successfully loaded and ready to serve predictions. | +**Response body (200 OK):** Same shape as `:recommend`. **Status codes:** -| Code | Condition | -|------|-----------| -| 200 | All registered recipes are loaded and error-free. | -| 503 | One or more recipes are unloaded or carry a load error. | - -::: tip -Use HTTP status code only for probe logic. A `status: degraded` response returns 503, which causes Kubernetes readiness probes to remove the pod from the Service endpoints. This is intentional — a pod where every predict call returns 503 should not receive traffic. -::: +| Code | Condition | Error code | +|---|---|---| +| 200 | Success | — | +| 401 | Authentication failure | `MISSING_API_KEY` / `INVALID_API_KEY` | +| 404 | All seed items are unknown to the model | `UNKNOWN_SEED_ITEMS` | +| 404 | Seeds are known but no candidates survive ranking | `NO_CANDIDATES` | +| 422 | Schema validation failure | `VALIDATION_ERROR` | +| 503 | Recipe is not loaded | `RECIPE_UNAVAILABLE` | **curl example:** ```bash -curl -s http://localhost:8080/health | jq . +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:recommend-related \ + -H "X-API-Key: <plaintext>" \ + -H "Content-Type: application/json" \ + -d '{"seed_items": ["item-42"], "limit": 5}' | jq . ``` --- -### GET /health/details +#### POST /v1/recipes/{name}:batch-recommend -Per-recipe health detail including `kid`, `trained_at`, `best_class`, and load errors. +Get recommendations for multiple users in a single request. Uses an Algolia-style batch envelope. **Authentication:** Required (`X-API-Key`). -Per-recipe detail is behind authentication because it includes artifact key identifiers (`kid`) which should not be publicly discoverable. Use `GET /health` for unauthenticated probe-safe status. +**Request body:** -**Response body (200 OK or 503):** +| Field | Type | Constraints | Default | Description | +|---|---|---|---|---| +| `requests` | RecommendRequest[] | 1–256 items | — | Per-user recommendation requests. Each element has the same shape as the `:recommend` body. | +| `include_metadata` | boolean | — | `false` | When `false`, metadata-joined fields are omitted from `items` for bulk-performance reasons. Set to `true` to get the same item shape as the single-user endpoint. | ```json { - "status": "ok", - "recipes": { - "news_articles": { - "loaded": true, - "trained_at": "2026-05-07T01:23:45Z", - "best_class": "IALSRecommender", - "kid": "prod-2026-q2" + "requests": [ + {"user_id": "u1", "limit": 5}, + {"user_id": "u2", "limit": 5, "exclude_items": ["item-99"]} + ], + "include_metadata": false +} +``` + +**Response body (200 OK):** + +```json +{ + "request_id": "a1b2c3d4e5f6", + "recipe": "purchase_log", + "model_version": "sha256:a3f2...e91d", + "results": [ + { + "index": 0, + "status": "ok", + "items": [{"item_id": "item-42", "score": 0.91}] }, - "product_recs": { - "loaded": false, - "error": "signature mismatch" + { + "index": 1, + "status": "error", + "error": {"code": "UNKNOWN_USER", "message": "user not seen during training"} } - } + ] } ``` -Every recipe found in the recipes directory appears here, regardless of whether its artifact loaded — startup-failed recipes appear as stubs with `loaded: false` and an `error` field. Optional fields (`trained_at`, `best_class`, `kid`, `error`) are present only when their underlying value is set; absent fields imply the corresponding value is unset. +`results` preserves the original order of `requests` via the `index` field. A failed element carries `status: "error"` and an `error` object; other elements in the same batch are still processed. + +**Batch-specific rules:** -**Status codes:** Same as `GET /health` — 503 when any recipe is unloaded or carries a load error. +- The `requests` array must contain 1–256 elements. Arrays outside this range return a `422` for the entire request. +- The sum of all `requests[].limit` values must not exceed **5000**. Elements that push the sum over the limit receive a per-element `VALIDATION_ERROR` result; later elements continue to be processed. +- An individual element with a schema error does not fail the whole batch. The element receives a per-element `VALIDATION_ERROR` result and the overall HTTP response remains `200`. +- `X-Recotem-Items-Degraded` is not sent on batch responses. +- `503` is returned only when the recipe itself is unavailable (not loaded). Per-element errors such as `UNKNOWN_USER` do not affect the HTTP status code. **curl example:** ```bash -curl -s http://localhost:8080/health/details \ - -H "X-API-Key: <plaintext>" | jq . +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:batch-recommend \ + -H "X-API-Key: <plaintext>" \ + -H "Content-Type: application/json" \ + -d '{ + "requests": [ + {"user_id": "u1", "limit": 5}, + {"user_id": "u2", "limit": 5} + ], + "include_metadata": false + }' | jq . ``` --- -### GET /models +#### POST /v1/recipes/{name}:batch-recommend-related -List metadata for all currently loaded models. +Get related-item recommendations for multiple seeds in a single request. **Authentication:** Required (`X-API-Key`). -Stub entries for recipes whose artifact failed to load at startup are excluded — they appear in `/health/details` instead. +**Request body:** Same envelope as `:batch-recommend`, with each element following the `:recommend-related` body shape. + +```json +{ + "requests": [ + {"seed_items": ["item-42"], "limit": 5}, + {"seed_items": ["item-17", "item-8"], "limit": 10} + ], + "include_metadata": false +} +``` + +**Response body (200 OK):** Same envelope as `:batch-recommend`. + +**Batch rules:** Identical to `:batch-recommend` above. + +**curl example:** + +```bash +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:batch-recommend-related \ + -H "X-API-Key: <plaintext>" \ + -H "Content-Type: application/json" \ + -d '{ + "requests": [ + {"seed_items": ["item-42"], "limit": 5} + ] + }' | jq . +``` + +--- + +### Recipe Discovery + +#### GET /v1/recipes + +List all currently loaded recipes. + +**Authentication:** Required (`X-API-Key`). + +Stub entries for recipes whose artifact or YAML failed to load at startup are excluded — they appear in `GET /v1/health/details` instead. **Response body (200 OK):** ```json -[ - { - "name": "news_articles", - "recipe_name": "news_articles", - "recipe_hash": "ab12cd34...", - "trained_at": "2026-05-07T01:23:45Z", - "best_class": "IALSRecommender", - "best_params": { "alpha": 1.0 }, - "best_score": 0.1234, - "metric": "ndcg", - "cutoff": 20, - "tuning": { "tried_algorithms": ["IALS", "TopPop"], "n_trials": 40, "n_completed": 40 }, - "data_stats": { "n_rows": 12345, "n_users": 678, "n_items": 90 }, - "kid": "prod-2026-q2", - "recotem_version": "2.0.0", - "irspack_version": "0.3.14" +{ + "recipes": [ + { + "name": "purchase_log", + "model_version": "sha256:a3f2...e91d", + "loaded_at": "2026-05-21T00:00:00Z", + "supported_verbs": [ + "recommend", + "recommend-related", + "batch-recommend", + "batch-recommend-related" + ], + "kind": "user-item" + } + ] +} +``` + +| Field | Type | Description | +|---|---|---| +| `name` | string | Recipe name (stem of the recipe YAML file). | +| `model_version` | string | `sha256:<64-hex>` digest of the artifact. | +| `loaded_at` | string (ISO 8601) | Timestamp when the artifact was loaded into memory. | +| `supported_verbs` | string[] | Colon-verbs this recipe supports. Depends on the recipe `kind`. | +| `kind` | `"user-item"` \| `"item-item"` | Whether the model produces user-to-item or item-to-item recommendations. `"item-item"` recipes do not support `recommend` or `batch-recommend`. | + +**curl example:** + +```bash +curl -s http://localhost:8080/v1/recipes \ + -H "X-API-Key: <plaintext>" | jq . +``` + +--- + +#### GET /v1/recipes/{name} + +Detailed metadata for a single loaded recipe. + +**Authentication:** Required (`X-API-Key`). + +**Response body (200 OK):** + +All fields from `GET /v1/recipes` plus: + +| Field | Type | Description | +|---|---|---| +| `config_digest` | string \| null | `sha256:<hex>` of the recipe YAML, or null if unavailable. | +| `algorithms` | string[] | All algorithm classes evaluated during tuning. | +| `best_algorithm` | string | Algorithm class selected as best. | +| `best_class` | string \| null | Fully qualified class name of the best algorithm. | +| `best_params` | object \| null | Hyperparameters of the best algorithm. | +| `best_score` | number \| null | Validation score of the best model. NaN and Inf are normalized to null. | +| `metric` | `"ndcg"` \| `"map"` \| `"recall"` \| `"hit"` \| null | Evaluation metric used during tuning. | +| `cutoff` | integer \| null | Cutoff K used when computing the offline evaluation metric during tuning. This is unrelated to the per-request `limit` — it only describes how the recipe was scored at training time. | +| `tuning` | object \| null | Tuning metadata (`tried_algorithms`, `n_trials`, `n_completed`). | +| `data_stats` | object \| null | Training data statistics (`n_rows`, `n_users`, `n_items`). | +| `recotem_version` | string \| null | Version of recotem that trained this artifact. | +| `irspack_version` | string \| null | Version of irspack used during training. | +| `recipe_hash` | string \| null | 64-character lowercase hex digest of the recipe configuration at training time (no `sha256:` prefix — distinct from `config_digest`). | +| `trained_at` | string (ISO 8601) \| null | Timestamp when training completed. | + +Optional fields above are `null` for older artifacts that did not record them. + +**Status codes:** + +| Code | Condition | Error code | +|---|---|---| +| 200 | Recipe is loaded | — | +| 404 | Recipe name does not exist in the registry | `RECIPE_NOT_FOUND` | +| 503 | Recipe exists but is not loaded | `RECIPE_UNAVAILABLE` | + +**curl example:** + +```bash +curl -s http://localhost:8080/v1/recipes/purchase_log \ + -H "X-API-Key: <plaintext>" | jq . +``` + +--- + +### Health and Metrics + +#### GET /v1/health + +Overall liveness and readiness status. Suitable for Kubernetes liveness and readiness probes. + +**Authentication:** None (unauthenticated). + +**Response body:** + +```json +{"status": "ok", "total": 3, "loaded": 3} +``` + +| Field | Type | Description | +|---|---|---| +| `status` | `"ok"` \| `"degraded"` | `"ok"` when every configured recipe is loaded. `"degraded"` when any recipe is unloaded. When `total == 0`, the status is always `"ok"`. | +| `total` | integer | Total number of recipe entries in the registry. | +| `loaded` | integer | Number of recipes successfully loaded and ready to serve. | + +**Status codes:** + +| Code | Condition | +|---|---| +| 200 | All recipes are loaded. | +| 503 | One or more recipes are not loaded. | + +::: tip Kubernetes readiness probes +A `503` response removes the pod from the Service endpoints. This is intentional — a pod where every recommendation request would return `503` should not receive traffic. Use `GET /v1/health` for both readiness and liveness probes. +::: + +**curl example:** + +```bash +curl -s http://localhost:8080/v1/health | jq . +``` + +--- + +#### GET /v1/health/details + +Per-recipe health detail including load errors and artifact identifiers. + +**Authentication:** Required (`X-API-Key`). + +Per-recipe detail is behind authentication because it includes artifact key identifiers (`kid`) that should not be publicly discoverable. Use `GET /v1/health` for unauthenticated probe-safe status. + +**Response body:** + +```json +{ + "status": "ok", + "recipes": { + "purchase_log": { + "loaded": true, + "trained_at": "2026-05-21T00:00:00Z", + "best_class": "IALSRecommender", + "kid": "prod-2026-q2" + }, + "product_recs": { + "loaded": false, + "error": "signature mismatch" + } } -] +} ``` -Each entry is the artifact header JSON plus the registered recipe `name` and the active `kid`. No key material is included. The header schema is documented in [Architecture — Artifact format](./#artifact-format). +Every recipe in the registry appears here, including stubs for recipes that failed to load at startup. Optional fields (`trained_at`, `best_class`, `kid`, `error`) are present only when their underlying value is set. + +**Status codes:** Same as `GET /v1/health` — `503` when any recipe carries `loaded: false` or an `error` field. **curl example:** ```bash -curl -s http://localhost:8080/models \ +curl -s http://localhost:8080/v1/health/details \ -H "X-API-Key: <plaintext>" | jq . ``` --- -### GET /metrics +#### GET /v1/metrics Prometheus metrics exposition (opt-in). -**Authentication:** None (unauthenticated). +**Authentication:** Required (`X-API-Key`). + +**Availability:** This route is registered only when both conditions are met: -**Availability:** Only registered when both conditions are met: 1. `RECOTEM_METRICS_ENABLED` is set to a truthy value (`1`, `true`, `yes`, `on`). 2. The `recotem[metrics]` extra is installed (`pip install "recotem[metrics]"`). -This endpoint is excluded from the OpenAPI schema (`include_in_schema=False`). +This endpoint is excluded from the OpenAPI schema. + +::: warning Prometheus scraper configuration +Unlike most Prometheus targets, `/v1/metrics` requires `X-API-Key`. Configure your scraper to send the header: -::: warning Network exposure -`/metrics` and `/health` are unauthenticated by design — the same posture Prometheus and Kubernetes liveness/readiness probes expect. These endpoints surface recipe names, kid IDs, load-error strings, model-load timestamps, and predict-latency histograms. Restrict them with your cluster's NetworkPolicy rather than relying on the API-key middleware. +```yaml +# prometheus.yml scrape config (Prometheus 2.45+) +scrape_configs: + - job_name: recotem + metrics_path: /v1/metrics + static_configs: + - targets: ["localhost:8080"] + http_headers: + X-API-Key: + values: ["<plaintext>"] +``` ::: **Available metrics:** | Metric | Type | Labels | -|--------|------|--------| -| `recotem_predict_total` | Counter | `recipe`, `status` | -| `recotem_predict_latency_seconds` | Histogram | `recipe` | +|---|---|---| +| `recotem_v1_requests_total` | Counter | `recipe`, `verb`, `status` | +| `recotem_v1_request_latency_seconds` | Histogram | `recipe`, `verb` | +| `recotem_v1_batch_size` | Histogram | `recipe`, `verb` | +| `recotem_v1_batch_element_errors_total` | Counter | `recipe`, `verb`, `code` | +| `recotem_v1_metadata_degraded_items_total` | Counter | `recipe`, `verb`, `kind` | +| `recotem_v1_validation_errors_outside_verb_total` | Counter | — | | `recotem_model_loaded` | Gauge | `recipe` | -| `recotem_artifact_load_failures_total` | Counter | `recipe` | +| `recotem_artifact_load_failures_total` | Counter | `recipe`, `reason` | | `recotem_active_recipes` | Gauge | — | | `recotem_swap_total` | Counter | `recipe`, `result` | | `recotem_artifact_stat_failures_total` | Counter | `recipe` | | `recotem_watcher_unhandled_errors_total` | Counter | — | -| `recotem_metadata_lookup_errors_total` | Counter | `recipe` | +| `recotem_metadata_index_build_errors_total` | Counter | `recipe` | +| `recotem_metadata_serialization_errors_total` | Counter | `recipe`, `verb` | | `recotem_recipe_rescan_errors_total` | Counter | `recipe` | +| `recotem_recommender_layout_unexpected_total` | Counter | `recipe` | +| `recotem_watcher_state_divergence_total` | Counter | — | | `recotem_bigquery_storage_fallback_total` | Counter | `reason` | | `recotem_recipes_dir_scan_failures_total` | Counter | `error_class` | -The `status` label on `recotem_predict_total` takes values `ok`, `user_not_found`, `unavailable`, and `error`. +The `verb` label takes values `recommend`, `recommend-related`, `batch-recommend`, `batch-recommend-related`. The `status` label on `recotem_v1_requests_total` takes values `ok`, `unknown_user`, `unknown_seed_items`, `no_candidates`, `unavailable`, `recipe_not_found`, `validation_error`, and `error`. The `reason` label on `recotem_artifact_load_failures_total` takes values `read`, `parse`, `hmac`, `header_json`, `deserialize`, `metadata`, `yaml`, `unexpected`, `dir_scan`, and `timeout`. + +**curl example:** + +```bash +curl -s http://localhost:8080/v1/metrics \ + -H "X-API-Key: <plaintext>" +``` --- -## OpenAPI documentation endpoints +## Error Format -Interactive documentation at `/docs` (Swagger UI), `/redoc`, and the raw schema at `/openapi.json` are available by default. +All error responses use a flat JSON body with at minimum `detail` (human-readable) and `code` (machine-readable UPPER_SNAKE_CASE). -::: warning -When `RECOTEM_ENV` is set to `production`, `prod`, or `staging`, these three endpoints are **disabled**. Do not rely on them in production deployments. -::: +**Standard error body:** + +```json +{"detail": "recipe purchase_log is not loaded", "code": "RECIPE_UNAVAILABLE"} +``` + +**Validation error body (422 only):** Includes a `request_id` and a structured `errors` array. + +```json +{ + "request_id": "a1b2c3d4e5f6", + "detail": "Request validation failed", + "code": "VALIDATION_ERROR", + "errors": [ + {"loc": ["body", "limit"], "msg": "ensure this value is less than or equal to 1000", "type": "value_error.number.not_le"} + ] +} +``` + +**Internal error body (500 only):** Includes a `request_id` for correlation with server logs. + +```json +{"detail": "internal error", "code": "INTERNAL_ERROR", "request_id": "a1b2c3d4e5f6"} +``` + +### Error Codes + +| Code | HTTP | When | +|---|---|---| +| `RECIPE_UNAVAILABLE` | 503 | Recipe exists in the registry but its artifact is not loaded. | +| `RECIPE_NOT_FOUND` | 404 | Recipe name does not exist in the registry at all. | +| `UNKNOWN_USER` | 404 | `user_id` was not present in the training idmap. | +| `UNKNOWN_SEED_ITEMS` | 404 | All items in `seed_items` are unknown to the model. | +| `NO_CANDIDATES` | 404 | Seed items are known but no candidates survive the ranking stage. | +| `VALIDATION_ERROR` | 422 (HTTP) / per-element (batch) | Request or element body failed schema validation. | +| `MISSING_API_KEY` | 401 | `X-API-Key` header is absent. | +| `INVALID_API_KEY` | 401 | `X-API-Key` does not match any configured key. | +| `INTERNAL_ERROR` | 500 (HTTP) / per-element (batch) | Unhandled exception during request processing. | --- @@ -284,9 +554,9 @@ When `RECOTEM_ENV` is set to `production`, `prod`, or `staging`, these three end ### TrustedHostMiddleware -`RECOTEM_ALLOWED_HOSTS` (default: `127.0.0.1,localhost`) controls the `Host` header allow-list. Requests with a `Host` header not in this list receive `400 Bad Request`. This applies to every endpoint including `/health`. +`RECOTEM_ALLOWED_HOSTS` (default: `127.0.0.1,localhost`) controls the `Host` header allow-list. Requests with a `Host` header not in this list receive `400 Bad Request`. This applies to every endpoint including `GET /v1/health`. -In Kubernetes, kubelet probes send `Host: localhost` by default — this is why `localhost` is always in the default allow-list. When exposing via Ingress, add the Ingress hostname explicitly (or use the Helm chart which derives it automatically from `ingress.hosts`). +In Kubernetes, kubelet probes send `Host: localhost` by default — this is why `localhost` is always in the default allow-list. When exposing via Ingress, add the Ingress hostname to `RECOTEM_ALLOWED_HOSTS` explicitly. ### CORS @@ -295,3 +565,13 @@ In Kubernetes, kubelet probes send `Host: localhost` by default — this is why ```yaml RECOTEM_ALLOWED_ORIGINS: "https://app.example.com,https://admin.example.com" ``` + +--- + +## OpenAPI Documentation + +Interactive documentation is available at `/docs` (Swagger UI) and `/redoc`. The raw schema is at `/openapi.json`. + +::: warning Development environments only +These three endpoints are available only when `RECOTEM_ENV` is set to `development`, `dev`, or `test`. They are disabled in all other environments. Do not rely on them in production deployments. +::: diff --git a/guide/batch.md b/guide/batch.md index 6010e6a..655c262 100644 --- a/guide/batch.md +++ b/guide/batch.md @@ -168,7 +168,7 @@ Set `concurrencyPolicy: Forbid` so overlapping runs are skipped rather than runn | 2 | Recipe error (bad YAML, schema violation) | Do not retry — fix the ConfigMap | | 3 | Data source error | Usually do not retry (persistent issue) | | 4 | Training error | Retry up to `backoffLimit` | -| 5 | Artifact error (signing key issue) | Do not retry — fix the Secret | +| 5 | Artifact error (corrupt file, HMAC verification failed) | Do not retry — investigate the artifact file or the signing key used to produce it | | 6 | Lock contested (`--fail-on-busy` set) | Retry or route elsewhere | | 7 | HTTP fetch error (transient) | Retry | | 8 | Configuration error (missing env vars) | Do not retry — fix the Secret | @@ -189,14 +189,14 @@ Once `recotem serve` is running, it polls for new artifacts every `RECOTEM_WATCH 2. Reads the full artifact bytes. 3. Verifies the HMAC signature against the configured signing keys. 4. If verification passes, atomically swaps the in-memory model. -5. If verification fails (wrong key, corrupt file), keeps the previous good model and logs the error to `/health`. +5. If verification fails (wrong key, corrupt file), keeps the previous good model and records the error in the structured log and on `/v1/health/details` (per-recipe `last_load_error`). No requests are dropped during the swap. The previous model continues to serve until the new one is ready. You can check current model state at any time: ```bash -curl http://localhost:8080/health +curl http://localhost:8080/v1/health # {"status":"ok","total":1,"loaded":1} ``` diff --git a/guide/cli.md b/guide/cli.md index efc48fd..b5da397 100644 --- a/guide/cli.md +++ b/guide/cli.md @@ -102,7 +102,11 @@ What it does: 1. Parses the YAML and checks all fields against the recipe schema. 2. Instantiates the data source plugin (catches missing extras like `recotem[bigquery]`). -3. Runs the source's optional `probe()` method — for HTTP/HTTPS sources this is an HTTP HEAD request; for BigQuery this verifies credentials. +3. Runs the source's optional `probe()` method: + - **Local / object-storage paths** (`file`, `s3://`, `gs://`, `az://`) — confirms the file exists. + - **HTTP / HTTPS paths** — runs the SSRF host-publicity check (the full byte cap, redirect-scheme policy, and `sha256` verification only fire at fetch time). + - **BigQuery** — issues a free dry-run query that validates ADC, project access, and SQL/parameter syntax. + - **SQL** — opens a connection and runs a trivial liveness query. **Example:** @@ -141,7 +145,7 @@ recotem keygen --type api --kid <name> | Flag | Default | Description | |---|---|---| | `--type` | `signing` | `signing` for an HMAC artifact key; `api` for a client authentication key. | -| `--kid <name>` | auto-generated (UUID prefix) | A short identifier for the key. Used in logs, the authenticated `/health/details` and `/models` endpoints, and rotation procedures. | +| `--kid <name>` | auto-generated (UUID prefix) | A short identifier for the key. Used in structured logs (server access logs include the matching `kid` for every authenticated request), in the per-recipe `kid` field of `/v1/health/details`, and during key-rotation procedures. | The plaintext is shown only once. Store it in a secrets manager immediately — there is no way to recover it later. If lost, generate a new key. diff --git a/guide/index.md b/guide/index.md index 12d7730..b0ace2d 100644 --- a/guide/index.md +++ b/guide/index.md @@ -9,7 +9,7 @@ Recotem trains and serves a recommender model from a single small YAML configura You describe your data source, a few training preferences, and where to save the result. Recotem handles the rest: fetching the data, finding the best algorithm, training the model, and answering recommendation requests over HTTP. -We call that configuration file a **recipe** — it describes what data to use, how to train, and where to serve. One recipe produces one model and one API endpoint. +We call that configuration file a **recipe** — it describes what data to use, how to train, and where to serve. One recipe produces one model exposed through a small set of HTTP endpoints (single and batch, item-to-user and item-to-item). No database, no message broker, no administration interface. A recipe file, two commands, and an HTTP endpoint. @@ -19,7 +19,7 @@ Recotem is built around two processes that communicate only through a signed bin 1. **`recotem train`** reads your recipe, fetches the interaction data (purchases, clicks, reads), searches for the best algorithm and settings, trains the final model, and writes a signed artifact to disk or cloud storage. -2. **`recotem serve`** watches the artifact directory and loads each model as a REST endpoint at `/predict/{name}`. When `recotem train` produces a new artifact, the server picks it up automatically — no restart needed. +2. **`recotem serve`** watches the artifact directory and loads each model as REST endpoints at `/v1/recipes/{name}:recommend` (and the related verb endpoints). When `recotem train` produces a new artifact, the server picks it up automatically — no restart needed. Because the two processes share nothing except the artifact file, they can run on different machines. A nightly batch job can write to an S3 bucket while a long-running server reads from the same bucket, hot-swapping the model as soon as a fresh one appears. @@ -27,7 +27,10 @@ Because the two processes share nothing except the artifact file, they can run o recipe.yaml → recotem train → artifact.recotem → recotem serve (batch job) (HMAC-signed) (FastAPI, hot-swap) -any scheduler local FS / S3 / GCS POST /predict/{name} +any scheduler local FS / S3 / GCS POST /v1/recipes/{name}:recommend + POST /v1/recipes/{name}:recommend-related + POST /v1/recipes/{name}:batch-recommend + POST /v1/recipes/{name}:batch-recommend-related ``` The artifact is protected by an HMAC signature (a tamper-detection code). The serving process verifies the signature before loading any model, so a corrupt or altered file is rejected rather than used. @@ -49,7 +52,7 @@ It is a good fit when: 2. **Run `recotem train`.** One command fetches data, searches for the best algorithm and settings, trains the final model, and writes a signed artifact. -3. **Run `recotem serve`.** One command starts an HTTP server that loads the artifact and answers `POST /predict/{name}` requests. Retrain and the server updates itself. +3. **Run `recotem serve`.** One command starts an HTTP server that loads the artifact and answers `POST /v1/recipes/{name}:recommend` (and the related verb endpoints). Retrain and the server updates itself. ## Next steps diff --git a/guide/installation.md b/guide/installation.md index e977d5e..27d916b 100644 --- a/guide/installation.md +++ b/guide/installation.md @@ -31,7 +31,6 @@ The core package ships with CSV and Parquet data sources. Install extras for add | PostgreSQL data source | `pip install "recotem[postgres]"` | Read interaction data from PostgreSQL via psycopg | | MySQL / MariaDB data source | `pip install "recotem[mysql]"` | Read interaction data from MySQL or MariaDB via PyMySQL | | SQLite data source | `pip install "recotem[sqlite]"` | Read interaction data from SQLite (uses stdlib `sqlite3`) | -| Google Analytics 4 data source | `pip install "recotem[ga4]"` | Read interaction events from GA4 via the Data API | | Amazon S3 | `pip install "recotem[s3]"` | Read/write artifacts and data from S3 | | Google Cloud Storage | `pip install "recotem[gcs]"` | Read/write artifacts and data from GCS | | Azure Blob Storage | `pip install "recotem[azure]"` | Read/write artifacts and data from Azure | diff --git a/guide/recipe-basics.md b/guide/recipe-basics.md index 65d50d9..3a79b45 100644 --- a/guide/recipe-basics.md +++ b/guide/recipe-basics.md @@ -5,7 +5,7 @@ description: Walk through every section of a Recotem recipe file, with annotated # Recipe Basics -A recipe is the single configuration file for one recommender. It tells Recotem where the data is, which columns are user IDs and item IDs, what training options to use, and where to save the trained model. One recipe produces one model and one `/predict/{name}` HTTP endpoint. +A recipe is the single configuration file for one recommender. It tells Recotem where the data is, which columns are user IDs and item IDs, what training options to use, and where to save the trained model. One recipe produces one model and a set of `/v1/recipes/{name}:<verb>` HTTP endpoints. You write a recipe once and then run `recotem train` as often as you like — on a schedule, after a data refresh, or whenever you want to try different settings. Every field has a sensible default; you only need to fill in what is specific to your data. @@ -27,7 +27,7 @@ output: ... # required: where to write the trained model file ## `name` — your endpoint name -The `name` value becomes the URL path: `name: purchase_log` → `/predict/purchase_log`. +The `name` value is used in the endpoint path: `name: purchase_log` → `/v1/recipes/purchase_log:recommend` (and the other verb endpoints: `:recommend-related`, `:batch-recommend`, `:batch-recommend-related`). ```yaml name: purchase_log @@ -115,7 +115,7 @@ If the data falls below any `min_*` threshold, training exits with a clear error ## `item_metadata` — item details in predictions -If you want `/predict` responses to include item details (titles, categories, image URLs), point this section to a metadata file. Only the columns listed in `fields` are joined and returned. +If you want recommendation responses to include item details (titles, categories, image URLs), point this section to a metadata file. Only the columns listed in `fields` are joined and returned. ```yaml item_metadata: @@ -125,7 +125,7 @@ item_metadata: on_field_missing: error # fail at model load if a listed field is missing ``` -This section is optional. Without it, `/predict` returns only `item_id` and `score`. +This section is optional. Without it, recommendation responses return only `item_id` and `score`. --- @@ -184,7 +184,7 @@ The path can be local or a cloud storage URI (`s3://`, `gs://`, `az://`). With ` ## Before you run -Run `recotem validate` on any recipe before committing to a full training run. It checks the schema and probes the data source (for example, an HTTP HEAD request for a URL-based CSV) without downloading the full file: +Run `recotem validate` on any recipe before committing to a full training run. It checks the schema and probes the data source (for HTTP/HTTPS paths the probe runs the SSRF host-publicity check; for BigQuery it runs a free dry-run query; for local or object-storage paths it confirms the file exists) without downloading the full dataset: ```bash recotem validate my_recipe.yaml diff --git a/guide/tutorial/index.md b/guide/tutorial/index.md index 1c99a08..e1406db 100644 --- a/guide/tutorial/index.md +++ b/guide/tutorial/index.md @@ -5,7 +5,7 @@ description: Train a recommender from a real purchase log dataset and serve pred # Tutorial -This tutorial walks you through a complete Recotem run: fetch data, train a model, serve it, and call `/predict`. The dataset is a small public purchase log CSV (the same file used by Recotem's own integration tests) and training takes about a minute on a laptop. +This tutorial walks you through a complete Recotem run: fetch data, train a model, serve it, and call the recommendation endpoint. The dataset is a small public purchase log CSV (the same file used by Recotem's own integration tests) and training takes about a minute on a laptop. **Prerequisites:** either Docker with the Compose plugin, or Python 3.12+ with Recotem installed. About 50 MB of disk and network access to `raw.githubusercontent.com`. @@ -118,7 +118,7 @@ docker compose up -d serve Check that the server started and loaded the model: ```bash -curl http://localhost:8080/health +curl http://localhost:8080/v1/health ``` Expected response: @@ -130,29 +130,28 @@ Expected response: ### Step 4 — Predict ```bash -curl -sX POST http://localhost:8080/predict/purchase_log \ +curl -sX POST http://localhost:8080/v1/recipes/purchase_log:recommend \ -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ -H "Content-Type: application/json" \ - -d '{"user_id": "1", "cutoff": 5}' | python3 -m json.tool + -d '{"user_id": "1", "limit": 5}' | python3 -m json.tool ``` -Expected response shape (exact scores vary by training run): +Expected response shape (exact scores and digest vary by training run): ```json { + "request_id": "...", + "recipe": "purchase_log", + "model_version": "sha256:7f9c2ba4e88f827d616045507605853ed73b8093a07ef41c995c66e94c4eaa1d", "items": [ {"item_id": "42", "score": 0.91}, {"item_id": "17", "score": 0.87} - ], - "model": { - "recipe": "purchase_log", - "best_class": "IALSRecommender", - "kid": "dev" - }, - "request_id": "..." + ] } ``` +`model_version` is `sha256:` followed by the 64-character hex SHA-256 of the loaded artifact — the same digest is also returned in the `X-Recotem-Model-Version` response header so clients can record exactly which model version produced each prediction. + ### Step 5 — Tear down ```bash @@ -191,7 +190,7 @@ export RECOTEM_API_PLAINTEXT="<plaintext-from-api>" recotem validate examples/tutorial-purchase-log/recipe.yaml ``` -This parses the recipe and runs a quick connectivity check (an HTTP HEAD request to the CSV URL) without downloading the full file. Useful for catching configuration problems before committing to a full training run. +This parses the recipe and runs the data source's `probe()` method without downloading the full file. For HTTP/HTTPS sources, the probe runs the SSRF host-publicity check; the full byte cap, redirect-scheme policy, and `sha256` verification still fire at fetch time. Useful for catching configuration problems before committing to a full training run. ### Step 4 — Train @@ -213,10 +212,10 @@ recotem serve --recipes examples/tutorial-purchase-log/ In a separate terminal: ```bash -curl -sX POST http://127.0.0.1:8080/predict/purchase_log \ +curl -sX POST http://127.0.0.1:8080/v1/recipes/purchase_log:recommend \ -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ -H "Content-Type: application/json" \ - -d '{"user_id": "1", "cutoff": 5}' | python3 -m json.tool + -d '{"user_id": "1", "limit": 5}' | python3 -m json.tool ``` --- @@ -224,8 +223,8 @@ curl -sX POST http://127.0.0.1:8080/predict/purchase_log \ ## What just happened - `recotem train` parsed the recipe, fetched the CSV over HTTPS, verified the sha256, ran an Optuna hyperparameter search across IALS and TopPop, and wrote a binary artifact signed with your signing key. -- `recotem serve` watched the artifact directory, found the new file, HMAC-verified it against the same signing key, and registered the `/predict/purchase_log` endpoint. -- The `/predict` request was authenticated by the API key allow-list and scored using the trained model. +- `recotem serve` watched the artifact directory, found the new file, HMAC-verified it against the same signing key, and registered the `/v1/recipes/purchase_log:recommend` (and related verb) endpoints. +- The request was authenticated by the API key allow-list and scored using the trained model. --- @@ -237,8 +236,8 @@ curl -sX POST http://127.0.0.1:8080/predict/purchase_log \ | `DataSourceError: sha256 mismatch` | The upstream file changed | Re-compute with `curl -sL <url> \| shasum -a 256` and update the recipe | | `DataSourceError: HTTP 404 fetching ...` | The URL changed | Verify the URL in a browser; check the `v1.0.0` tag is still present | | `ArtifactError: RECOTEM_SIGNING_KEYS not set` | Step 1 (key generation) was not exported | Re-run the export and try again | -| `401 Unauthorized` on `/predict` | Wrong API key value | Use the `plaintext` line from `keygen --type api`, not the `hash` line | -| `503 recipe_unavailable` immediately after training | The watcher has not polled yet | Wait up to `RECOTEM_WATCH_INTERVAL` seconds (default 5 s; the tutorial compose sets 10 s). Check `/health`. | +| `401 Unauthorized` on `/v1/recipes/...` | Wrong API key value | Use the `plaintext` line from `keygen --type api`, not the `hash` line | +| `503 RECIPE_UNAVAILABLE` immediately after training | The watcher has not polled yet | Wait up to `RECOTEM_WATCH_INTERVAL` seconds (default 5 s; the tutorial compose sets 10 s). Check `/v1/health`. | | Path B: artifact written to the wrong directory | The recipe's `output.path` is relative to the working directory | Run `recotem train` from the repository root, or change `output.path` to an absolute path | | `recotem: command not found` after pip install | The venv is not activated | Activate the venv, or run `python -m recotem ...` | diff --git a/ja/docs/data-sources/ga4.md b/ja/docs/data-sources/ga4.md deleted file mode 100644 index 9ae3eae..0000000 --- a/ja/docs/data-sources/ga4.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: GA4 ソース ---- - -# GA4 ソース - -`ga4` ソースは [Google Analytics 4 Data API](https://developers.google.com/analytics/devguides/reporting/data/v1) から直接 Recotem の学習を実行できるようにします。BigQuery Export を経由しません。BQ Export を有効にしていないプロパティ向けです。 - -動作する出発点としては recotem リポジトリの `examples/ga4-data-api/` を参照してください。 - -BQ Export が**有効**なプロパティでは通常 [BigQuery ソース](./bigquery) のほうが適しています — BigQuery はよりスケールし、イベントペイロード全体を扱えます。 - -## インストール - -```bash -pip install "recotem[ga4]" -``` - -このエクストラなしで `recotem train` を実行すると、以下のメッセージで終了します: - -``` -DataSourceError: google-analytics-data is required for GA4Source. Install with: pip install 'recotem[ga4]' -``` - -## 認証 - -Application Default Credentials (ADC) のみ。レシピに認証情報を埋め込みません。以下のいずれかを設定してください: - -```bash -# ローカル開発 -gcloud auth application-default login - -# GKE — Pod のサービスアカウントを Google サービスアカウントにバインドする -# Workload Identity。環境変数の設定は不要。 - -# Cloud Run / Cloud Functions -# デプロイ時に --service-account=<sa>@<project>.iam.gserviceaccount.com - -# サービスアカウントキーファイル (本番環境では非推奨) -export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json -``` - -サービスアカウントには GA4 プロパティに対する `roles/analytics.viewer` が必要です。 - -## レシピ設定 - -```yaml -source: - type: ga4 - property_id: "123456789" # 数値のプロパティ ID。G-XXXX 形式の測定 ID ではない - user_dimension: userPseudoId # userPseudoId または userId - item_dimension: itemId # itemId | itemName | itemCategory - time_dimension: date # date | dateHour | dateHourMinute - event_names: [purchase, view_item, add_to_cart] - # lookback_days か (start_date + end_date) の正確に一方のみを指定: - lookback_days: 90 - # start_date: "2026-01-01" - # end_date: "2026-05-01" - max_rows: 1_000_000 # 必須 - weight_column: event_count - api_timeout_seconds: 60 -``` - -| フィールド | 必須 | デフォルト | 備考 | -|------------|------|-----------|------| -| `property_id` | yes | — | 数値のみ (`^\d+$`)。`G-XXXX` 形式の測定 ID では**ありません**。 | -| `user_dimension` | yes | — | `userId` を使うにはプロパティで User-ID 機能の設定が必要。`userPseudoId` はクッキーベースのデフォルト。 | -| `item_dimension` | no | `itemId` | GA4 のアイテムスコープのディメンション。 | -| `time_dimension` | no | `date` | 時刻バケットの粒度。`date` / `dateHour` / `dateHourMinute`。 | -| `event_names` | yes | — | 1〜50 個のイベント名。各値は `^[A-Za-z_][A-Za-z0-9_]{0,39}$` に一致。 | -| `lookback_days` | XOR | — | 1〜3650 日。プロパティのタイムゾーンにおける前日 (昨日) で終わるローリングウィンドウ。 | -| `start_date` / `end_date` | XOR | — | ISO 形式の日付。いずれかを設定する場合は両方必須。`start_date <= end_date`。 | -| `max_rows` | yes | — | 返却される行数のハードキャップ。有効範囲 `[1, 50_000_000]`。範囲外は `ValidationError`。 | -| `weight_column` | no | `event_count` | `eventCount` メトリックの出力 DataFrame カラム名。`schema.weight_column` と一致する必要があります。`user_dimension`、`item_dimension`、`time_dimension`、または `eventName` という値と衝突する値は拒否されます — 衝突するとディメンションが暗黙的に上書きされてしまうためです。 | -| `api_timeout_seconds` | no | `60` | 有効範囲 `[5, 600]`。範囲外は `ValidationError`。 | - -::: warning 日付範囲の指定方式は正確に 1 つ -`lookback_days` を設定する**か**、`start_date` と `end_date` の両方を設定してください。両方の方式を設定したり、どちらも設定しなかった場合はレシピロード時に `ValidationError` が発生します。`lookback_days` はプロパティのタイムゾーンにおける直前の完了日 (つまり昨日、今日ではない) で終わるローリングウィンドウを生成します。 -::: - -## 行が DataFrame に到達する流れ - -GA4 リクエストは 4 つのディメンションと 1 つのメトリックを要求します: - -``` -dimensions = [<user_dimension>, <item_dimension>, <time_dimension>, eventName] -metric = eventCount -``` - -レスポンスはページング (ページサイズ 100,000) されます。各行はレシピのスキーマのカラム名で DataFrame の 1 行になります。内部の `eventName` カラムは `fetch()` がリターンする前に削除されるため、同じ `(user, item, time)` に対する複数のイベント種別は複数の行として現れます。 - -::: warning GA4 では `cleansing.dedup: none` を使用してください -GA4 ソースは `(user, item, time, eventName)` ごとに 1 行を返します。`keep_first` / `keep_last` は他のイベント種別の重みを破棄してしまいます。irspack は内部で重複する `(user, item)` の重みを集約するため、別々の行のままにしておくのが正しい挙動です。 -::: - -## クォータ、ページング、リトライ - -- ページサイズ 100,000 (Data API のハード最大値)。 -- フェッチャは `row_count` が枯渇するか、`max_rows` に到達するか、`RECOTEM_GA4_MAX_PAGES` (デフォルト 500) に達するまでループします。 -- `RESOURCE_EXHAUSTED` / `UNAVAILABLE` の gRPC コードは `google.api_core.retry.Retry` 経由でリトライされます (初期 1 秒、最大 30 秒までの指数バックオフ、総バジェット = 3 × `api_timeout_seconds`)。 -- `PERMISSION_DENIED` → 即座に `DataSourceError` を発生し、必要なロール (`roles/analytics.viewer`) とプロパティ ID をメッセージに含めます。 -- その他の `GoogleAPICallError` サブクラス (例: `NOT_FOUND`、`INVALID_ARGUMENT`) → 即座に `DataSourceError` を発生し、API エラーのクラス名とメッセージを保持します。 -- 1 回の取得あたりの実時間バジェット `10 × api_timeout_seconds` がページング処理全体を制限します。デッドラインは `run_report` 呼び出しの**前**と**後**の両方でチェックされるため、リトライバジェットを消費した不運なページが追加のリトライサイクル 1 つ分超過することはありません。 - -### バジェットの相互作用 - -ページごとの `Retry(timeout=3 × api_timeout_seconds)` バジェットは、最悪ケースでは発生まで 3× の待機時間をすべて消費する可能性があります。試行ごとの `timeout=api_timeout_seconds` と組み合わせると、最悪ケースで 1 ページが約 `3 × api_timeout_seconds` を消費する可能性があります。外側の 10× 実時間バジェットは意図的にサーキットブレーカーであり、緩い上限ではありません: 持続的な `RESOURCE_EXHAUSTED` の背圧下では、無制限に実行がドリフトすることを許さず、おおよそ 3 ページ消費した時点で中断します。ワークロードがより多くのリトライページを正当に必要とする場合はクエリを絞るか、`api_timeout_seconds` を上げてください (実時間バジェットも線形に増加します)。 - -## 環境変数 - -| 変数 | デフォルト | 備考 | -|------|-----------|------| -| `GOOGLE_APPLICATION_CREDENTIALS` | (未設定) | ADC キーファイルのパス。空 = デフォルトチェーン (`gcloud` ユーザー認証情報 → メタデータサーバー) を使用。 | -| `RECOTEM_GA4_MAX_PAGES` | `500` | ページングループのハード上限。クランプ範囲 `[1, 10_000]`。 | -| `RECOTEM_METRICS_ENABLED` | (未設定) | 真の値で `recotem_ga4_pages_fetched_total`、`recotem_ga4_rows_fetched_total`、`recotem_ga4_quota_remaining` の Prometheus メトリクスを公開 (`recotem[metrics]` が必要)。 | - -## トラブルシューティング - -| エラー | 想定される原因 | 対処 | -|--------|--------------|------| -| `google-analytics-data is required for GA4Source. Install with: pip install 'recotem[ga4]'` | エクストラ未インストール。 | `pip install "recotem[ga4]"` | -| `GA4 access denied for property ...` | サービスアカウントにロールが付与されていない。 | GA4 プロパティに `roles/analytics.viewer` を付与してください。 | -| `set exactly one of lookback_days OR (start_date + end_date)` | 両方または両方が未設定。 | どちらか一方を選択してください。 | -| `GA4 result exceeds max_rows=...` | 結果が本当に巨大。 | `event_names` を絞るかウィンドウを短くしてください。 | -| `GA4 fetch reached max_pages=<n> without seeing a short page; increase RECOTEM_GA4_MAX_PAGES or tighten the query` | プロパティがデフォルト上限に対して大きすぎる。 | クォータを確認の上で `RECOTEM_GA4_MAX_PAGES` を引き上げてください。 | - -## 備考 - -- `recotem validate recipes/my_recipe.yaml` は学習開始前に ADC チェーンとレシピスキーマを検証します。実際の Data API リクエストは発行**しません** — プロパティのクォータは保持されます。 -- 整数でない `eventCount` 値は生の `ValueError` (終了コード 1) ではなく `DataSourceError` (終了コード 3) として表面化します。 -- `GOOGLE_*` および `GCP_*` 環境変数はレシピの `${...}` 展開からブラックリストされています。クラウド認証情報はレシピファイルではなく ADC から提供する必要があります。 diff --git a/ja/docs/deployment/docker.md b/ja/docs/deployment/docker.md index b7cb23a..295a43a 100644 --- a/ja/docs/deployment/docker.md +++ b/ja/docs/deployment/docker.md @@ -69,7 +69,7 @@ services: healthcheck: test: - "CMD-SHELL" - - "python -c \"import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5).status == 200 else 1)\"" + - "python -c \"import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/v1/health', timeout=5).status == 200 else 1)\"" interval: 30s timeout: 10s retries: 3 @@ -122,7 +122,7 @@ mkdir -p ./artifacts && chown 1000:1000 ./artifacts ### イメージレベルの HEALTHCHECK -Dockerfile は独自の `HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3` を宣言しており、`urllib.request.urlopen(f'http://127.0.0.1:{RECOTEM_PORT}/health', timeout=3)` でパブリックな `/health` エンドポイントをプローブします (これにより上書きされた `RECOTEM_PORT` も反映されます)。ワンショットの `train` コンテナでは、プロセスがすでに終了した後にこれが実行されますが、誤った失敗は発生しません。アノテーション付き例の Compose レベルのヘルスチェックも `/health` を対象とし、`serve` サービスのイメージデフォルトを上書きします — オーケストレーターは `/health` からの HTTP 200 レスポンスに依存してください。 +Dockerfile は独自の `HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3` を宣言しており、`urllib.request.urlopen(f'http://127.0.0.1:{RECOTEM_PORT}/health', timeout=3)` をプローブします (これにより上書きされた `RECOTEM_PORT` も反映されます)。注意: イメージデフォルトのプローブは `/health` (`/v1` プレフィックスなし) を対象としています。v1 ルーターは `/v1` にマウントされているため、パブリックなヘルスエンドポイントは `/v1/health` です。アノテーション付き例の Compose レベルのヘルスチェックは `serve` サービスのイメージデフォルトを上書きして `/v1/health` を対象とします — オーケストレーターは `/v1/health` からの HTTP 200 レスポンスに依存してください。ワンショットの `train` コンテナでは、プロセスがすでに終了した後にイメージのヘルスチェックが実行されますが、誤った失敗は発生しません。 ### リバースプロキシバインディング @@ -177,18 +177,18 @@ docker run --rm \ | `RECOTEM_ENV` | いいえ | `""` | `development`、`dev`、または `test` に設定した場合のみ `--insecure-no-auth` が許可される。`--dev-allow-unsigned` は `development` に設定した場合のみ許可される。 | | `RECOTEM_ARTIFACT_ROOT` | いいえ | `""` | 設定した場合、ローカルの `output.path` はこのディレクトリ配下に解決されなければならない (シンボリックリンク回避ガード) | | `RECOTEM_LOCK_DIR` | いいえ | `""` | レシピごとの学習ロックファイルのディレクトリを上書き。`output.path` がリモート URI の場合に必要。未設定の場合はシステムの一時ディレクトリ配下にフォールバック。 | -| `RECOTEM_METADATA_FIELD_DENY` | いいえ | `""` | メタデータ結合後に `/predict` レスポンスから除外するカンマ区切りの列名 | -| `RECOTEM_METRICS_ENABLED` | いいえ | `""` | `1`/`true`/`yes`/`on` に設定すると Prometheus `/metrics` エンドポイントを有効化。`recotem[metrics]` エクストラが必要。 | +| `RECOTEM_METADATA_FIELD_DENY` | いいえ | `""` | メタデータ結合後に `/v1/recipes/{name}:recommend` および `:recommend-related` レスポンスから除外するカンマ区切りの列名 | +| `RECOTEM_METRICS_ENABLED` | いいえ | `""` | `1`/`true`/`yes`/`on` に設定すると Prometheus `/v1/metrics` エンドポイントを有効化。`recotem[metrics]` エクストラが必要。 | | `RECOTEM_STARTUP_PARALLELISM` | いいえ | `""` (自動) | 起動時にアーティファクトを並列ロードするスレッド数。デフォルトは `min(len(recipes), 8)`。1〜32 にクランプ。デバッグ時は `1` に設定して逐次ロードを強制。 | *`auto` は TTY の場合は `console`、それ以外は `json` に切り替わります。 ## ヘルスチェック -`/health` エンドポイントは認証不要でコンテナプローブに安全です。 +`/v1/health` エンドポイントは認証不要でコンテナプローブに安全です。 ```bash -curl http://localhost:8080/health +curl http://localhost:8080/v1/health ``` ```json @@ -201,4 +201,4 @@ curl http://localhost:8080/health いずれかのレシピのロードに失敗した場合、`status` は `degraded` (HTTP 503) になります。このエンドポイントを対象に Kubernetes の readiness probe または Docker の HEALTHCHECK を設定してください。完全なレスポンス仕様については [Serving API](../serving-api) を参照してください。 -`kid`、`trained_at`、`best_class` などレシピごとの詳細については、認証が必要な `/health/details` エンドポイントを使用してください。 +`kid`、`trained_at`、`best_class` などレシピごとの詳細については、認証が必要な `/v1/health/details` エンドポイントを使用してください。 diff --git a/ja/docs/environment-variables.md b/ja/docs/environment-variables.md index 7e16247..f3951a0 100644 --- a/ja/docs/environment-variables.md +++ b/ja/docs/environment-variables.md @@ -72,9 +72,9 @@ title: 環境変数 | 変数 | デフォルト | スコープ | クランプ | 説明 | |---|---|---|---|---| -| `RECOTEM_ENV` | (空) | serve | — | デプロイメント環境タグ。`--insecure-no-auth` は `development`、`dev`、または `test` に設定した場合のみ許可される。`--dev-allow-unsigned` は `development` に設定した場合のみ許可される。`production`、`prod`、または `staging` に設定すると `/docs`、`/redoc`、`/openapi.json` エンドポイントが無効化される (リクエストは 404 を返す)。 | +| `RECOTEM_ENV` | (空) | serve | — | デプロイメント環境タグ。`--insecure-no-auth` は `development`、`dev`、または `test` に設定した場合のみ許可される。`--dev-allow-unsigned` は `development` に設定した場合のみ許可される。`/docs`、`/redoc`、`/openapi.json` エンドポイントはフェールセキュアで、この変数が `development`、`dev`、または `test` のときのみ有効になる。それ以外の値 (未設定、`production`、`prod`、`staging`、またはカスタムタグ) の場合、これらのパスは 404 を返す。 | | `RECOTEM_DRAIN_SECONDS` | `30` | serve | [1, 300] | SIGTERM グレースフルドレインウィンドウ (秒)。進行中のリクエストはこのウィンドウが完了するまで待機でき、その後 uvicorn は残りの接続を閉じる。Kubernetes では `terminationGracePeriodSeconds` を少なくとも `RECOTEM_DRAIN_SECONDS + 5` に設定すること。 | -| `RECOTEM_LOG_FORMAT` | `auto` | both | — | ログ出力フォーマット。`auto` は stdout が TTY でない場合は JSON、それ以外はコンソール形式を使用する。`json` は構造化 JSON を強制する。`console` は人間が読める出力を強制する。 | +| `RECOTEM_LOG_FORMAT` | `auto` | both | — | ログ出力フォーマット。`auto` は stderr が TTY でない場合は JSON、それ以外はコンソール形式を使用する。`json` は構造化 JSON を強制する。`console` は人間が読める出力を強制する。 | ## 運用 @@ -84,19 +84,18 @@ title: 環境変数 |---|---|---|---|---| | `RECOTEM_ARTIFACT_ROOT` | (空) | train | — | 設定した場合、レシピのローカル `output.path` の値はこのディレクトリ配下に存在しなければならない。シンボリックリンクエスケープは拒否される。ホスト上で train プロセスがアーティファクトを書き込める場所を制限するために使用する。 | | `RECOTEM_LOCK_DIR` | (空) | train | — | レシピごとの学習ロックファイルのディレクトリを上書きする。ローカルの `output.path` 値は常に `<output_path>.lock` でロックされる。リモートの `output.path` 値 (`s3://`、`gs://` など) はホストローカルのロックファイルを必要とする。`RECOTEM_LOCK_DIR` が未設定の場合は `<tempdir>/recotem-locks/` にフォールバックする。注意: `flock` はホストローカル — ホスト間のシングルライター保証にはスケジューラーレベルのミューテックスを使用すること (Kubernetes の `concurrencyPolicy: Forbid` など)。 | -| `RECOTEM_METADATA_FIELD_DENY` | (空) | serve | — | アイテムメタデータ結合後に `/predict` レスポンスから除外する列名のカンマ区切りリスト。マッチングは大文字小文字を区別しない — メタデータの `"Internal_ID"` は拒否リストに `"internal_id"` があればストリップされる。PII 列を API レスポンスから除外するために使用する。 | -| `RECOTEM_METRICS_ENABLED` | (未設定) | serve | — | 真値: `1`、`true`、`yes`、`on`。Prometheus `/metrics` エンドポイントを有効化する。`recotem[metrics]` エクストラが必要 (`pip install "recotem[metrics]"`)。エンドポイントはオプトインでデフォルトでは無効。 | +| `RECOTEM_METADATA_FIELD_DENY` | (空) | serve | — | アイテムメタデータインデックスのロード時に除外する列名のカンマ区切りリスト。除外された列はすべての推薦エンドポイント (`:recommend`、`:recommend-related`、および `include_metadata=true` の `:batch-recommend*`) のレスポンスに含まれない。マッチングは大文字小文字を区別しない — メタデータの `"Internal_ID"` は拒否リストに `"internal_id"` があればストリップされる。PII 列を API レスポンスから除外するために使用する。 | +| `RECOTEM_METRICS_ENABLED` | (未設定) | serve | — | 真値: `1`、`true`、`yes`、`on`。Prometheus `/v1/metrics` エンドポイントを有効化する。`recotem[metrics]` エクストラが必要 (`pip install "recotem[metrics]"`)。エンドポイントはオプトインでデフォルトでは無効。 | ## データソース -これらの変数は特定のデータソースの動作を調整します。`recotem train` のみが、対応するソースが使用されたときのみ読み取ります。詳細は [データソース](./data-sources/) リファレンスを参照してください。 +これらの変数は特定のデータソースの動作を調整します。`recotem train` のみが、対応するソースが使用されたときのみ読み取ります。詳細は [データソース](./recipe-reference#source) リファレンスを参照してください。 | 変数 | デフォルト | スコープ | クランプ | 説明 | |---|---|---|---|---| | `RECOTEM_BQ_REQUIRE_STORAGE_API` | (未設定) | train | — | 真値: `1`、`true`、`yes`、`on`。設定すると、BigQuery Storage Read API が失敗した場合 (例: `bigquery.readSessions.create` IAM 権限の欠落) に BigQuery ソースがより遅い REST API にサイレントフォールバックするのではなく、`DataSourceError` (終了コード 3) を発生させる。スループット低下を受け入れるのではなく IAM のギャップを表面化させるために使用する。 | | `RECOTEM_MAX_SQL_ROWS` | `50_000_000` | train | [1_000, 500_000_000] | SQL データソースが返す行数のハードキャップ。上限を超えると `DataSourceError` (終了コード 3) を発生させる。**行数**をキャップするのであって、DataFrame の常駐メモリではない — [SQL ソース — メモリバウンドの注意点](./data-sources/sql#memory-bound-caveat) を参照。 | | `RECOTEM_SQL_ALLOW_PRIVATE` | (未設定) | train | — | 真値: `1`、`true`、`yes`、`on`。SQL ソースがプライベート / ループバックの DSN ホストを受け入れるオプトイン (デフォルトは SSRF 対策のため拒否)。あらゆるドライバルーティング形式 (netloc、`?host=`、`?hostaddr=`、`?service=`、`?unix_socket=`、絶対パスホスト、ホスト情報のないネットワーク DSN) をカバー — このフラグなしでは全てデフォルトで拒否される。各プローブ / フェッチ前の DNS リバインディング再チェックも無効化される — オプトインはホストをエンドツーエンドで信頼することを意味する。 | -| `RECOTEM_GA4_MAX_PAGES` | `500` | train | [1, 10_000] | GA4 Data API ページネーションループのハード上限。デフォルト上限ではプロパティが大きすぎる場合に到達する。クォータを確認してから引き上げること。 | ## レシピ展開 diff --git a/ja/docs/index.md b/ja/docs/index.md index 19a83b9..ee7ea2f 100644 --- a/ja/docs/index.md +++ b/ja/docs/index.md @@ -4,7 +4,7 @@ title: アーキテクチャ # アーキテクチャ -Recotem はレシピ駆動の推薦システムです。単一の YAML ファイル (_レシピ_) がデータソース、学習設定、アーティファクトの出力先を定義します。1 つのレシピが 1 つの学習済みモデルと 1 つの `/predict/{name}` HTTP エンドポイントを生成します。 +Recotem はレシピ駆動の推薦システムです。単一の YAML ファイル (_レシピ_) がデータソース、学習設定、アーティファクトの出力先を定義します。1 つのレシピが 1 つの学習済みモデルと `/v1/recipes/{name}:<verb>` HTTP エンドポイント群を生成します。 ## システム概要 @@ -26,10 +26,10 @@ Recotem はレシピ駆動の推薦システムです。単一の YAML ファイ │ │ │ │ ├── HMAC verify │ │ ├── deserialize payload │ - │ └── FastAPI /predict/{name} │ - │ │ │ - │ ▼ │ - │ API client request │ + │ └── FastAPI /v1/recipes/{name}:recommend │ + │ │ │ + │ ▼ │ + │ API client request │ └─────────────────────────────────────┘ ``` @@ -40,11 +40,14 @@ Recotem はレシピ駆動の推薦システムです。単一の YAML ファイ レシピはモデルの唯一の情報源です。 ``` -1 recipe YAML → 1 trained artifact → 1 /predict/{name} endpoint +1 recipe YAML → 1 trained artifact → /v1/recipes/{name}:recommend + /v1/recipes/{name}:recommend-related + /v1/recipes/{name}:batch-recommend + /v1/recipes/{name}:batch-recommend-related ``` レシピが記述する内容: -- **データの取得先** (`source` ブロック — CSV、Parquet、BigQuery、SQL、GA4、またはプラグイン) +- **データの取得先** (`source` ブロック — CSV、Parquet、BigQuery、SQL、またはプラグイン) - **カラムのマッピング** (`schema` ブロック — ユーザー ID、アイテム ID、任意のタイムスタンプ) - **データ品質ゲート** (`cleansing` ブロック — null 除去、重複除去、最低閾値) - **学習内容** (`training` ブロック — アルゴリズム、Optuna バジェット、分割方式) @@ -75,8 +78,8 @@ magic | version | reserved | kid | hmac | header_json | payload |----------|----------|------------| | オペレーター | レシピ YAML、署名鍵、環境変数、`RECOTEM_SIGNING_KEYS` | 完全に信頼 | | 学習ホスト | ソースデータの読み取り、署名済みアーティファクトの書き出し | 信頼 (オペレーター管理) | -| 配信ホスト | アーティファクトディレクトリの読み取り、`/predict` の配信 | 信頼 (オペレーター管理) | -| API クライアント | API キーを使って `/predict` リクエストを送信 | 信頼しないユーザー入力 | +| 配信ホスト | アーティファクトディレクトリの読み取り、`/v1/recipes/{name}:<verb>` の配信 | 信頼 (オペレーター管理) | +| API クライアント | API キーを使って `/v1/recipes/{name}:<verb>` リクエストを送信 | 信頼しないユーザー入力 | | アーティファクトファイル | 変更不可の署名済みバイナリ。改ざんがあれば HMAC が失敗 | HMAC で認証済み | レシピは動的な値のために環境変数を参照できます (`${RECOTEM_RECIPE_*}` 展開)。展開メカニズムはそのプレフィックスに限定されており、SQL インジェクションを防ぐために `source.query` や `source.query_parameters` の内部では決して適用されません。 @@ -90,7 +93,7 @@ magic | version | reserved | kid | hmac | header_json | payload 3. インメモリのモデル参照をアトミックに置き換えます。 4. 旧モデルは破棄され、以降のすべてのリクエストは新しいモデルを使用します。 -ホットスワップは**レシピスコープ**です。アーティファクト `A` を更新しても、レシピ `B` の処理中モデルには影響しません。配信プロセスは再起動しません。新しいアーティファクトの HMAC 検証またはデシリアライズが失敗した場合、旧モデルが引き続き配信され、障害は `/health` および `recotem_artifact_load_failures_total` Prometheus メトリクス (メトリクスが有効な場合) に記録されます。 +ホットスワップは**レシピスコープ**です。アーティファクト `A` を更新しても、レシピ `B` の処理中モデルには影響しません。配信プロセスは再起動しません。新しいアーティファクトの HMAC 検証またはデシリアライズが失敗した場合、旧モデルが引き続き配信され、障害は `/v1/health` および `recotem_artifact_load_failures_total` Prometheus メトリクス (メトリクスが有効な場合) に記録されます。 ウォッチャーのポーリング間隔は `RECOTEM_WATCH_INTERVAL` で設定します (デフォルト 5 秒、1〜30 秒にクランプ)。 @@ -123,7 +126,7 @@ artifacts/news_articles.<sha8>.recotem | コマンド | 用途 | |----------|------| | `recotem train <recipe.yaml>` | データ取得、Optuna 探索、最良モデルの学習、アーティファクト署名 | -| `recotem serve --recipes <dir>` | ホットスワップ付き FastAPI `/predict` サーバーの起動 | +| `recotem serve --recipes <dir>` | ホットスワップ付き FastAPI `/v1/recipes` サーバーの起動 | | `recotem inspect <artifact>` | アーティファクトヘッダーの読み取りと検証 (ペイロードのデシリアライズなし) | | `recotem validate <recipe.yaml>` | レシピスキーマの検証とデータソース接続確認 | | `recotem schema` | レシピモデルの JSON Schema を出力 (IDE 連携) | @@ -149,7 +152,6 @@ artifacts/news_articles.<sha8>.recotem - [CSV / Parquet ソース](./data-sources/csv) — ローカル、オブジェクトストレージ、HTTP ソースのオプション - [BigQuery ソース](./data-sources/bigquery) — 認証、パラメータバインド、GA4 パターン - [SQL ソース](./data-sources/sql) — SQLAlchemy 2 経由の PostgreSQL / MySQL / MariaDB / SQLite -- [GA4 ソース](./data-sources/ga4) — BigQuery Export を経由しない Google Analytics 4 Data API - [プラグインデータソース](./data-sources/plugins) — カスタムプラグインによる `source.type` の拡張 - デプロイガイド — Docker、Kubernetes、cron スケジューリング - 運用ガイド — 鍵ローテーション、リカバリ、サイジング、トラブルシューティング diff --git a/ja/docs/operations.md b/ja/docs/operations.md index 37de338..425bbc0 100644 --- a/ja/docs/operations.md +++ b/ja/docs/operations.md @@ -54,16 +54,16 @@ RECOTEM_SIGNING_KEYS="prod-2026-q3:ddeeff...,prod-2026-q2:aabbcc..." RECOTEM_SIGNING_KEYS="prod-2026-q3:ddeeff..." ``` -`recotem serve` を再起動します。古い kid で署名されたアーティファクトはロードに失敗し、`/health/details` で `loaded: false` と表示されます。それらのレシピを再学習してください。 +`recotem serve` を再起動します。古い kid で署名されたアーティファクトはロードに失敗し、`/v1/health/details` で `loaded: false` と表示されます。それらのレシピを再学習してください。 -すべてのレシピが正常にロードされたことを確認してください。レシピごとの状態は認証が必要な `/health/details` エンドポイントにあります — パブリックな `/health` は `{status, total, loaded}` の集計値のみを返します。 +すべてのレシピが正常にロードされたことを確認してください。レシピごとの状態は認証が必要な `/v1/health/details` エンドポイントにあります — パブリックな `/v1/health` は `{status, total, loaded}` の集計値のみを返します。 ```bash # -f / --fail は 4xx/5xx で終了コード 22 を返し、503 を隠す場合がある。 # 代わりに -w でステータスコードを取得する。 HTTP_STATUS=$(curl -s -o /tmp/health.json -w "%{http_code}" \ -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ - http://localhost:8080/health/details) + http://localhost:8080/v1/health/details) echo "HTTP $HTTP_STATUS" jq '.recipes | to_entries[] | select(.value.loaded == false)' /tmp/health.json ``` @@ -132,7 +132,7 @@ RECOTEM_API_KEYS="client-a-v2:sha256:newhhh..." `kid` フィールドが `"<unknown>"` になるのは、アーティファクトが完全な kid を保持するには短すぎる場合 (不完全な書き込み、ゼロバイトファイル) のみです。期待される長さの改ざんまたは誤ったマジックファイルの場合、解析された kid 文字列がそのまま表示されます。 -サーバーは継続して動作し、そのレシピの `/predict/{name}` エンドポイントに対して 503 を返します。 +サーバーは継続して動作し、そのレシピの推薦エンドポイントに対して 503 を返します。 **リカバリ手順:** @@ -158,7 +158,7 @@ recotem train ./recipes/my_recipe.yaml ```bash curl -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ - http://localhost:8080/health/details | jq '.recipes.my_recipe' + http://localhost:8080/v1/health/details | jq '.recipes.my_recipe' # {"loaded": true, "best_class": "IALSRecommender", ...} ``` @@ -229,8 +229,10 @@ recotem inspect https://host/artifacts/my.recotem # HTTPS URI | `train_done` | 終了 | `name`, `run_id`, `exit_code`, `artifact`, `best_class`, `best_score`, `trials`, `n_orphaned`, `trained_at`, `kid`, `recipe_hash`, `n_rows`, `n_users`, `n_items` | | `train_error` | 失敗 | `error`, `code` (非ドメイン例外は `internal_error`)、`recipe`, `run_id`, `exit_code`, `trained_at`; `code=min_data_violation` の場合はさらに `n_rows`, `n_users`, `n_items`, `min_rows`, `min_users`, `min_items` | | `recipe_lock_contended_skipping` | 開始 | `recipe`, `run_id` (デフォルト `--fail-on-busy=False` は終了コード 0) | -| `csv_source_redirect`, `csv_source_size_exceeded` | データソース | `path`, `status`, `cap` | -| `metadata_source_redirect`, `metadata_source_size_exceeded` | データソース | `path`, `status`, `cap` | +| `csv_source_redirect` | データソース | `from_`, `to`, `status` | +| `csv_source_size_exceeded` | データソース | `path`, `bytes_read`, `cap` | +| `metadata_source_redirect` | データソース | `from_`, `to`, `status` | +| `metadata_source_size_exceeded` | データソース | `path`, `bytes_read`, `cap` | `csv_source_redirect` / `csv_source_size_exceeded` にアラートを設定するオペレーターは、`metadata_source_redirect` / `metadata_source_size_exceeded` にも同等のアラートを追加してください。どちらのイベントファミリーも、HTTP/HTTPS フェッチがリダイレクト上限またはバイト上限に達したときに発生します。 @@ -315,8 +317,8 @@ Recotem は内部的に SLO を強制しません。本番環境の推奨ベー | メトリクス | ターゲット | |--------|--------| -| `/predict/{name}` p99 レイテンシ | < 50 ms (純粋なレコメンダー、メタデータ結合なし) | -| `/health` p99 レイテンシ | < 5 ms | +| 推薦エンドポイント p99 レイテンシ | < 50 ms (純粋なレコメンダー、メタデータ結合なし) | +| `/v1/health` p99 レイテンシ | < 5 ms | | 可用性 (レシピごと) | `recotem_model_loaded{recipe}` Prometheus ゲージで測定 | | アーティファクトホットスワップ時間 | ≤ `RECOTEM_WATCH_INTERVAL` + モデルロード時間 | | 学習から提供までのラグ | 学習をスケジュール; serve は ≤ `RECOTEM_WATCH_INTERVAL` 秒で検知 | @@ -327,7 +329,7 @@ Prometheus メトリクスを有効化: pip install "recotem[metrics]" ``` -`RECOTEM_METRICS_ENABLED=1` を設定して `/metrics` エンドポイントを有効化してください。 +`RECOTEM_METRICS_ENABLED=1` を設定して `/v1/metrics` エンドポイントを有効化してください。 --- @@ -339,11 +341,11 @@ pip install "recotem[metrics]" - `recotem serve` のシャットダウン (SIGTERM) 時に、`ArtifactWatcher.stop()` は `executor.shutdown(wait=False, cancel_futures=True)` を呼び出し、キューに入っているが未開始のフューチャーが即座に破棄されます。 - 変更はアーティファクトポインターの mtime/size (ローカル FS) または ETag/VersionId (オブジェクトストア) から検知されます。マーカーが変化すると、ウォッチャーは完全なバイトを一度読み取り、sha256 を計算し、**sha256 も変化した場合のみリロードします** — 同じ内容のファイルに置き換えると mtime は変化しますが不要なスワップはトリガーされません。 - レシピディレクトリは各ティックで再スキャンされます: 新しい `*.yaml` ファイルは `recipe_discovered` と即時の強制ロードをトリガーし、削除されたファイルは `recipe_removed` をトリガーしてエントリがレジストリから削除されます。 -- リロード中に何らかの失敗 (`artifact_load_failed`、`artifact_load_unexpected_error`) が発生した場合、既存のエントリは引き続きサービスされ、`last_load_error` フィールドが設定されるため `/health` は陳腐化を示しつつ `/predict` は前の正常なモデルを返し続けます。 +- リロード中に何らかの失敗 (`artifact_load_failed`、`artifact_load_unexpected_error`) が発生した場合、既存のエントリは引き続きサービスされ、`last_load_error` フィールドが設定されるため `/v1/health` は陳腐化を示しつつ推薦エンドポイントは前の正常なモデルを返し続けます。 ### 初期ロードの失敗 -起動時にアーティファクトのロードが失敗した場合、レシピはスタブとして登録されます (`loaded=false`、`error=<理由>`)。サーバーは起動し、`/health` は `degraded` を報告し、`/predict/{name}` は 503 を返します。部分的な障害はプロセスを再起動せずに再学習によって回復できます。 +起動時にアーティファクトのロードが失敗した場合、レシピはスタブとして登録されます (`loaded=false`、`error=<理由>`)。サーバーは起動し、`/v1/health` は `degraded` を報告し、レシピの推薦エンドポイントは 503 を返します。部分的な障害はプロセスを再起動せずに再学習によって回復できます。 起動専用のイベントバリアント: @@ -380,8 +382,8 @@ pip install "recotem[metrics]" | 再起動からのアーティファクトロード失敗 | `recotem_artifact_load_failures_total{recipe=...}` の増加 | warn | | アーティファクト stat 失敗 (ウォッチャーポーリング) | `recotem_artifact_stat_failures_total{recipe=...}` の増加 | warn | | ウォッチャーの未処理エラー | `recotem_watcher_unhandled_errors_total` の増加 | warn | -| predict エラー率 | `rate(recotem_predict_total{status="error"}[5m]) / rate(recotem_predict_total[5m])` | 1% で warn、10% で page | -| predict レイテンシ | `histogram_quantile(0.99, recotem_predict_latency_seconds_bucket)` | レシピごとの SLO | +| predict エラー率 | `rate(recotem_v1_requests_total{status="error"}[5m]) / rate(recotem_v1_requests_total[5m])` | 1% で warn、10% で page | +| predict レイテンシ | `histogram_quantile(0.99, recotem_v1_request_latency_seconds_bucket)` | レシピごとの SLO | | アクティブレシピ | 前回のスクレイプから `recotem_active_recipes` が 0 より減少 | warn | | BigQuery Storage API フォールバック | `rate(recotem_bigquery_storage_fallback_total{reason="api_error"}[5m]) > 0` | warn | | レシピディレクトリスキャン失敗 | `rate(recotem_recipes_dir_scan_failures_total[5m]) > 0` | warn | @@ -408,7 +410,7 @@ serve フリートのゼロダウンタイムアップグレードには、新 ```bash curl -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ - http://localhost:8080/health/details | jq '.recipes' + http://localhost:8080/v1/health/details | jq '.recipes' ``` ```json @@ -462,16 +464,16 @@ gcloud projects add-iam-policy-binding <PROJECT_ID> \ - 分割によって空のテストセットが生成された (ユーザー数またはインタラクション数が少なすぎる)。`split.scheme: random` を試すか `split.heldout_ratio` を下げてください。 - クレンジング後のデータのアイテム数がカットオフに対して少なすぎる。`training.cutoff` を下げてください。 -### /predict で 401 +### 推薦エンドポイントで 401 - `X-API-Key` ヘッダーの先頭または末尾の空白は鍵の一部として扱われ、一致しません。クライアント側でトリムしてください。 - `RECOTEM_API_KEYS` のハッシュが、送信しているプレーンテキストに対して `recotem keygen --type api` で生成されたことを確認してください。ワイヤープレフィックスは `sha256:` ですが、ダイジェストは scrypt です — 単純な `sha256(plaintext)` では一致しません。 -### /predict/{name} で 503 +### /v1/recipes/{name}:recommend で 503 (および関連動詞) -レシピが不健全です (`loaded: false`)。エラーは `/health/details` を確認してください。通常は署名の不一致または破損したアーティファクトです。 +レシピが不健全です (`loaded: false`)。エラーは `/v1/health/details` を確認してください。通常は署名の不一致または破損したアーティファクトです。 -### /predict/{name} で 404 +### /v1/recipes/{name}:recommend で 404 UNKNOWN_USER リクエストの `user_id` が学習データに存在しませんでした。これは新規ユーザーの場合に期待される動作です。アプリケーションレイヤーで処理してください (例: 人気度ベースのレコメンデーションにフォールバックする)。 diff --git a/ja/docs/recipe-reference.md b/ja/docs/recipe-reference.md index f93e766..3438188 100644 --- a/ja/docs/recipe-reference.md +++ b/ja/docs/recipe-reference.md @@ -4,17 +4,17 @@ title: レシピリファレンス # レシピリファレンス -レシピは、取得するデータ、学習方法、アーティファクトの書き出し先を定義する YAML ファイルです。1 つのレシピが 1 つのモデルと 1 つの `/predict/{name}` エンドポイントを生成します。 +レシピは、取得するデータ、学習方法、アーティファクトの書き出し先を定義する YAML ファイルです。1 つのレシピが 1 つのモデルと `/v1/recipes/{name}:recommend` (および関連) エンドポイント群を生成します。 ## トップレベルフィールド | フィールド | 型 | 必須 | 説明 | |------------|-----|------|------| -| `name` | string | yes | エンドポイント名。パターン: `^[A-Za-z0-9_-]{1,64}$`。`/predict/{name}` になります。 | -| `source` | object | yes | データソース設定。`type` フィールドが識別子 (`csv`、`parquet`、`bigquery`、`sql`、`ga4`、またはプラグイン)。バリデーションは 2 段階: まずレシピの残りの部分がパースされ、次にソースの dict がプラグインの `Config` クラスに振り分けられます。そのため `source.*` のエラーは他のフィールドのエラーの*後*に表示されます。不明な `source.type` は登録済みの全型名を列挙した `DataSourceError` を発生させます。 | +| `name` | string | yes | エンドポイント名。パターン: `^[A-Za-z0-9_-]{1,64}$`。`/v1/recipes/{name}:recommend` などのエンドポイントパスで使用されます。 | +| `source` | object | yes | データソース設定。`type` フィールドが識別子 (`csv`、`parquet`、`bigquery`、`sql`、またはプラグイン)。バリデーションは 2 段階: まずレシピの残りの部分がパースされ、次にソースの dict がプラグインの `Config` クラスに振り分けられます。そのため `source.*` のエラーは他のフィールドのエラーの*後*に表示されます。不明な `source.type` は登録済みの全型名を列挙した `DataSourceError` を発生させます。 | | `schema` | object | yes | カラムマッピング。 | | `cleansing` | object | no | データ品質ゲート。 | -| `item_metadata` | object | no | predict レスポンスに結合するメタデータ。 | +| `item_metadata` | object | no | 推薦レスポンスに結合するメタデータ。 | | `training` | object | yes | アルゴリズムとチューニングの設定。 | | `output` | object | yes | アーティファクトのパスとバージョニング。 | @@ -100,37 +100,6 @@ source: エクストラを 1 つインストール: `pip install "recotem[postgres]"`、`recotem[mysql]`、または `recotem[sqlite]`。詳細リファレンス: [SQL ソース](./data-sources/sql)。 -### `source.type: ga4` - -```yaml -source: - type: ga4 - property_id: "123456789" - user_dimension: userPseudoId - item_dimension: itemId - time_dimension: date - event_names: [purchase, view_item, add_to_cart] - lookback_days: 90 # start_date + end_date とは排他 - max_rows: 1_000_000 - weight_column: event_count - api_timeout_seconds: 60 -``` - -| フィールド | 型 | デフォルト | 備考 | -|------------|-----|-----------|------| -| `property_id` | string | required | 数値のみ (`^\d+$`)。`G-XXXX` 形式の測定 ID ではありません。 | -| `user_dimension` | string | required | `userId` または `userPseudoId`。 | -| `item_dimension` | string | `itemId` | GA4 のアイテムスコープのディメンション。 | -| `time_dimension` | string | `date` | `date` / `dateHour` / `dateHourMinute`。 | -| `event_names` | list[string] | required | 1〜50 個のイベント名。各値は `^[A-Za-z_][A-Za-z0-9_]{0,39}$` に一致。 | -| `lookback_days` | int | XOR | 1〜3650 日。前日で終わるローリングウィンドウ。 | -| `start_date` / `end_date` | string (ISO) | XOR | いずれかを設定する場合は両方必須。 | -| `max_rows` | int | required | 有効範囲 `[1, 50_000_000]`。 | -| `weight_column` | string | `event_count` | ディメンションキーや `eventName` という値と衝突してはいけません。 | -| `api_timeout_seconds` | int | `60` | 有効範囲 `[5, 600]`。 | - -エクストラのインストール: `pip install "recotem[ga4]"`。詳細リファレンス: [GA4 ソース](./data-sources/ga4)。 - --- ## `schema` @@ -198,12 +167,12 @@ item_metadata: |------------|-----|-----------|------| | `type` | string | required | `csv` または `parquet`。 | | `path` | string | required | [パスルール](#パスルール) を参照してください。 | -| `fields` | list[string] | required | 空不可。列挙されたフィールドのみ predict レスポンスで返されます。 | -| `on_field_missing` | string | `error` | `fields` に指定したエントリがファイルに存在しない場合の動作。`error` はモデルのロードを失敗させます (起動時はレシピが `loaded=false` と `last_load_error` 付きで登録され、ホットスワップ時は旧モデルが引き続き配信され、障害は `/health` および `recotem_artifact_load_failures_total` メトリクスで公開されます)。`null` はカラムを `null` で埋めます。 | +| `fields` | list[string] | required | 空不可。列挙されたフィールドのみ推薦レスポンスで返されます。 | +| `on_field_missing` | string | `error` | `fields` に指定したエントリがファイルに存在しない場合の動作。`error` はモデルのロードを失敗させます (起動時はレシピが `loaded=false` と `last_load_error` 付きで登録され、ホットスワップ時は旧モデルが引き続き配信され、障害は `/v1/health` および `recotem_artifact_load_failures_total` メトリクスで公開されます)。`null` はカラムを `null` で埋めます。 | | `sha256` | string | 任意 (`path` が `http://` または `https://` の場合は必須) | 64 文字の小文字 hex。取得したバイト列に対して検証され、不一致は `DataSourceError` を発生させます。 | | `item_id_column` | string | `"item_id"` | メタデータファイルでアイテム識別子を保持するカラム名。メタデータファイルが異なるカラム名 (例: `product_id`) を使用している場合に上書きします。空でない、空白でない文字列である必要があります。 | -サーバーサイドのフィールド抑制は `RECOTEM_METADATA_FIELD_DENY` (カンマ区切りのカラム名) でも可能で、結合後のカラム除去として適用されます。 +サーバーサイドのフィールド抑制は `RECOTEM_METADATA_FIELD_DENY` (カンマ区切りのカラム名) でも可能です。指定されたカラムはメタデータインデックスのロード時に除外されるため、どの推薦エンドポイントのレスポンスにも含まれません。 --- diff --git a/ja/docs/security.md b/ja/docs/security.md index dbf08fb..7d9c802 100644 --- a/ja/docs/security.md +++ b/ja/docs/security.md @@ -16,8 +16,16 @@ title: セキュリティ │ recotem serve │ │ binds to RECOTEM_HOST:RECOTEM_PORT │ API clients │ │ - (authenticated) ─────►│ POST /predict/{name} X-API-Key header │ - │ GET /health │ + (authenticated) ─────►│ POST /v1/recipes/{name}:recommend │ + │ POST /v1/recipes/{name}:recommend-related │ + │ POST /v1/recipes/{name}:batch-recommend │ + │ POST /v1/recipes/{name}:batch-recommend-related │ + │ GET /v1/recipes │ + │ GET /v1/recipes/{name} │ + │ GET /v1/health/details │ + │ GET /v1/metrics (opt-in; auth required) │ + │ GET /v1/health (no auth required) │ + │ X-API-Key header (all other endpoints) │ └──────────────┬────────────────────────────┘ │ reads (signed) ┌──────────────▼────────────────────────────┐ @@ -48,7 +56,7 @@ title: セキュリティ | アーティファクトの Stat-then-read TOCTOU | 読み取り一回プロトコル: バイトを一度メモリに読み込み、sha256 を計算し、同じバッファから HMAC を検証 | | ログへの鍵情報流出 | structlog リダクションプロセッサーがチェーンの先頭で実行される; ユニットテストがすべてのログレベルで鍵情報がないことを確認 | | API キーのブルートフォース / タイミング攻撃 | `hmac.compare_digest` 定数時間比較; プレーンテキストやハッシュをログに記録しない | -| レシピの環境変数展開を通じた認証情報注入 | `RECOTEM_SIGNING_KEYS`、`RECOTEM_API_KEYS`、`*_SECRET*`、`*_PASSWORD*`、`*_TOKEN*`、`*_KEY*`、`AWS_*`、`GOOGLE_*`、`GCP_*` は `${...}` 展開のブラックリストに登録済み | +| レシピの環境変数展開を通じた認証情報注入 | `RECOTEM_SIGNING_KEYS`、`RECOTEM_API_KEYS`、`*_SECRET*`、`*_PASSWORD*`、`*_TOKEN*`、`*_KEY*` およびクラウドプレフィックス (`AWS_*`、`GCP_*`、`GOOGLE_*`、`AZURE_*`、`ALIYUN_*`、`ALICLOUD_*`、`OCI_*`、`IBM_*`、`DO_*`、`HCLOUD_*`、`DIGITALOCEAN_*`) は `${...}` 展開のブラックリストに登録済み | | レシピを通じた SQL インジェクション | 環境変数展開は `source.query` 内では実行されない; 動的な値は BigQuery の `@param` プレースホルダーを使用すること | | レシピを通じたパストラバーサル | `name` は読み込み時およびすべてのファイルシステム使用前に `^[A-Za-z0-9_-]{1,64}$` で検証される; `RECOTEM_ARTIFACT_ROOT` によるアーティファクトルート制限 | | ネットワークフェッチデータの改ざんまたはローテーション | スキームが `http://` または `https://` の場合、`source.path` / `item_metadata.path` に sha256 整合性ピンが**必須**; 不一致はバイトがパーサーに到達する前に `DataSourceError` (終了コード 3) を発生させる | @@ -235,7 +243,7 @@ S3 の場合: | ルール | パターン (大文字小文字を区別しない) | |------|----------------------------| | 完全一致 | `RECOTEM_SIGNING_KEYS`, `RECOTEM_API_KEYS` | -| プレフィックス一致 | `AWS_*`, `GCP_*`, `GOOGLE_*`, `AZURE_*` | +| プレフィックス一致 | `AWS_*`, `GCP_*`, `GOOGLE_*`, `AZURE_*`, `ALIYUN_*`, `ALICLOUD_*`, `OCI_*`, `IBM_*`, `DO_*`, `HCLOUD_*`, `DIGITALOCEAN_*` | | 部分文字列一致 | `*SECRET*`, `*PASSWORD*`, `*PASSWD*`, `*TOKEN*`, `*KEY*`, `*AUTH*`, `*BEARER*`, `*CRED*`, `*PRIVATE*` | `*KEY*` 部分文字列は意図的に広く設定されています。大文字化した名前に部分文字列 `KEY` を含む任意の `RECOTEM_RECIPE_*` 変数は拒否されます — これには `RECOTEM_RECIPE_PARTITION_KEY`、`RECOTEM_RECIPE_APIKEY`、`RECOTEM_RECIPE_KEYBOARD` が含まれます。`KEY` を含まない名前を使用してください (例: `RECOTEM_RECIPE_PARTITION_COLUMN`)。ブラックリストに登録された参照は `RecipeError` (終了コード 2) を発生させます。 @@ -253,7 +261,7 @@ S3 の場合: **秘密にしなければならないもの:** - `RECOTEM_SIGNING_KEYS` — アーティファクトの署名と検証のための HMAC 鍵。 -- `RECOTEM_API_KEYS` — API キープレーンテキストの scrypt ダイジェストを含む (`hashlib.scrypt` でソルト `b"recotem.api-key.v1"`、n=2、r=8、p=1、dklen=32)。ダイジェストの露出はオフラインの pre-image 攻撃を可能にします。シークレットとして扱ってください。 +- `RECOTEM_API_KEYS` — API キープレーンテキストの scrypt ダイジェストを含む (`hashlib.scrypt` でソルト `b"recotem.api-key.v1"`、n=2、r=8、p=1、dklen=32 — `recotem.serving.auth._hash_api_key` を参照)。ワイヤープレフィックス `sha256:` はダイジェストファミリーラベルであり、アルゴリズム名ではありません。ダイジェストの露出はオフラインの pre-image 攻撃を可能にします。シークレットとして扱ってください。 - API キープレーンテキスト — `recotem keygen` 時に一度だけ表示されます。パスワードマネージャーまたはシークレットマネージャーに保管してください。 **保管の推奨事項:** @@ -270,9 +278,9 @@ S3 の場合: ## API キーの最小長 -Recotem は `X-API-Key` ヘッダー値に 32 文字の最小長を強制します。32 文字未満のプレーンテキストキーは、ダイジスト比較が試みられる前に 401 (`invalid_api_key`) で拒否されます。 +Recotem は `X-API-Key` ヘッダー値に 32 文字の最小長を強制します。32 文字未満のプレーンテキストキーは、ダイジェスト比較が試みられる前に 401 (`INVALID_API_KEY`) で拒否されます。エラーメッセージは最小文字数を呼び出し側に開示しません。 -推奨されるワークフローは `recotem keygen --type api` です。これは 43 文字の base64url プレーンテキスト (`os.urandom` の 32 生バイト) を生成します。 +推奨されるワークフローは `recotem keygen --type api` です。これは 43 文字の base64url プレーンテキスト (`os.urandom` の 32 生バイト) を生成します。オペレーターが選択したパスフレーズやパスワードは最低 32 文字必要です。それより短い値は起動時に設定エラーとはならず、実行時に認証が暗黙のうちに失敗します。 ## `recotem keygen` 出力フォーマット @@ -288,6 +296,7 @@ env_entry=RECOTEM_SIGNING_KEYS=prod-2026-q3:<64 hex chars> ``` - `env_entry=` の値を `RECOTEM_SIGNING_KEYS` にコピーしてください。 +- `fingerprint=` の値は `sha256(key_bytes)[:8]` です。起動時に出力される `security.posture` ログ行の `fingerprint` フィールドと一致します。正しい鍵がロードされていることを確認するために使用できます — 鍵の内容を露出しません。 - `fingerprint=` 行は情報提供のみです。`RECOTEM_SIGNING_KEYS` や任意の設定値に使用**してはいけません**。 **API キー** (`--type api`): @@ -376,7 +385,7 @@ azure_* | `--dev-allow-unsigned` | `RECOTEM_ENV=development` かつ `--i-understand-this-loads-arbitrary-code` | HMAC 検証をスキップする; 管理されたテスト環境以外では絶対に使用しないこと | ::: warning 注意 — 本番環境の OpenAPI スキーマ -`RECOTEM_ENV` が `production`、`prod`、または `staging` に設定されている場合、`/docs`、`/redoc`、`/openapi.json` エンドポイントはアプリ構築時に無効化されます。これらのパスへのリクエストは 404 を返します。 +`/docs`、`/redoc`、`/openapi.json` エンドポイントはフェールセキュアです: `RECOTEM_ENV` が `development`、`dev`、または `test` のときのみ有効です。それ以外の値 (未設定、`production`、`prod`、`staging`、またはカスタムタグを含む) ではアプリ構築時に無効化され、これらのパスへのリクエストは 404 を返します。 ::: 両フラグとも、要件に一致しない環境では起動時に明示的なエラーメッセージとともに拒否されます。 @@ -387,26 +396,28 @@ azure_* | イベント | レベル | トリガー | ステータス | |-------|-------|---------|--------| -| `auth_missing_header` | WARN | `X-API-Key` ヘッダーのないリクエスト (`RECOTEM_API_KEYS` が非空) | 401、コード `missing_api_key` | -| `auth_invalid_key` | WARN | ヘッダーが存在するが kid ハッシュが一致しない | 401、コード `invalid_api_key` | +| `auth_missing_header` | WARN | `X-API-Key` ヘッダーのないリクエスト (`RECOTEM_API_KEYS` が非空) | 401、コード `MISSING_API_KEY` | +| `auth_invalid_key` | WARN | ヘッダーが存在するが kid ハッシュが一致しない | 401、コード `INVALID_API_KEY` | | `auth_anonymous_bypass` | DEBUG | `RECOTEM_API_KEYS` が空 (no-auth モード) のときのすべてのリクエスト | — | | `auth_anonymous_bypass_first_seen` | INFO | no-auth モードでの特定の `client_host` からの最初のリクエスト | — | ## predict レスポンスの情報漏洩 -`POST /predict/{name}` は以下を返します: +`POST /v1/recipes/{name}:recommend` (および関連動詞エンドポイント) は以下を返します: -- 503 (`recipe_unavailable`) — レシピスタブまたは陳腐化したエントリ。 -- 404 (`user_not_found`) — `user_id` が学習データにいなかった。ユーザーの存在がアプリケーションで機密な場合、リバースプロキシで 404 レスポンスをマスクしてください。 -- 200 — レコメンデーション、オプションでアイテムメタデータと結合。`RECOTEM_METADATA_FIELD_DENY` で PII 列を除外できます。 +- 503 (`RECIPE_UNAVAILABLE`) — レシピスタブまたは陳腐化したエントリ。集計ステータスは `/v1/health` で認証なしに確認できます。 +- 404 (`UNKNOWN_USER`) — `user_id` が学習データにいなかった。このレスポンスは「既知ユーザーだが推薦なし」と「不明なユーザー」を区別します。ユーザーの存在がアプリケーションで機密な場合、リバースプロキシで 404 レスポンスをマスクし、汎用の空推薦ボディを返してください。 +- 200 — レコメンデーション、オプションでアイテムメタデータと結合。`RECOTEM_METADATA_FIELD_DENY` で PII 列を除外できます (大文字小文字を区別しない列名)。 -`cutoff` はリクエストスキーマによって `[1, 1000]` に制限されます。 +`limit` はリクエストスキーマによって `[1, 1000]` に制限されます。サイズ超過のリクエストはレコメンダーに到達する前に FastAPI から 422 (`VALIDATION_ERROR`) を受け取ります。 ## レート制限と DoS -Recotem 自体はリクエストレート制限を実装していません。オペレーターは**必ず** `recotem serve` の前段にリバースプロキシを配置し、`/predict/*` にクォータを適用してください。本番環境ではこれは任意ではありません。 +Recotem 自体はリクエストレート制限を実装していません。オペレーターは**必ず** `recotem serve` の前段にリバースプロキシを配置し、`/v1/recipes/` にクォータを適用してください。本番環境ではこれは任意ではありません。 -すべての認証試行は保存されている API キーごとに scrypt 鍵導出チェックを実行します。未認証の攻撃者は CPU バインドの scrypt 処理をトリガーできます。Recotem は独自のレートリミッターを実装しません。それはプロキシの責任です。 +**scrypt 増幅の理由 — プロキシ層が責任を持つ理由。** すべての認証試行は保存されている API キーごとに scrypt 鍵導出チェック (`hashlib.scrypt`、n=2、r=8、p=1、dklen=32) を実行します。未認証の攻撃者はネットワーク層からリクエストを送信するだけで CPU バインドの scrypt 処理をトリガーできます。Recotem は独自のレートリミッターを実装しません。それはプロキシの責任です。 + +推薦エンドポイント (`/v1/recipes/`) もレコメンダー推論において CPU バインドです。レコメンダーの推論スループットを超えた持続的なリクエストレートは uvicorn のキューに積み重なり、リクエストレイテンシが上昇します。プロキシで測定し上限を設けてください。 **推奨される nginx 設定:** @@ -417,7 +428,7 @@ limit_req_zone $binary_remote_addr zone=recotem_predict:10m rate=20r/s; server { # ... TLS とアップストリームの設定 ... - location /predict/ { + location /v1/recipes/ { limit_req zone=recotem_predict burst=40 nodelay; limit_req_status 429; proxy_pass http://recotem_backend; @@ -425,6 +436,8 @@ server { } ``` +API キーごとのレート制限には、`$http_x_api_key` 変数をキーとするか、ヘッダー値ごとにクォータを適用できる WAF (AWS WAF、GCP Cloud Armor、Cloudflare) を使用してください。 + ## 署名鍵のエントロピーと保管 - **生成**: `recotem keygen --type signing` は `os.urandom(32)` から鍵を導出します (256 ビットの OS エントロピー)。`KeyRing` は hex デコード後に正確に 32 バイトを強制します。 diff --git a/ja/docs/serving-api.md b/ja/docs/serving-api.md index 986d1fb..34e7d9e 100644 --- a/ja/docs/serving-api.md +++ b/ja/docs/serving-api.md @@ -1,282 +1,552 @@ --- -title: サービング API +title: "Serving API" +description: "recotem サービング API の完全リファレンス — 全エンドポイント、認証、リクエスト / レスポンスの形状、エラーコード、ミドルウェア。" --- -# サービング API +# Serving API -`recotem serve` は FastAPI アプリケーションを HTTP 上で公開します。全エンドポイントのリクエスト / レスポンスの形状、認証要件、エラーコードをここに記載します。 +`recotem serve` は FastAPI アプリケーションを HTTP 上で公開します。全エンドポイントは `/v1` 名前空間に属します。カスタム動詞は [AIP-136](https://google.aip.dev/136) の colon-verb 規約に従います — 例: `/v1/recipes/{name}:recommend`。 ## 認証 -API キー認証は `X-API-Key` リクエストヘッダーを使用します。キーは `RECOTEM_API_KEYS` にカンマ区切りの `<kid>:sha256:<hex64>` エントリのリストとして設定します。サーバーは送信されたプレーンテキストを保存された scrypt ハッシュと照合します。 +`GET /v1/health` を除く全エンドポイントは、プレーンテキストの API キーを持つ `X-API-Key` リクエストヘッダを必要とします。 -`RECOTEM_API_KEYS` が空の場合: -- サーバーは `RECOTEM_HOST` に関わらず `127.0.0.1` をバインドホストに強制します。 -- `127.0.0.1` からの全リクエストはキーなしで受け付けられます。 -- ローカル開発で認証を明示的に無効化するには `RECOTEM_ENV` を `development`、`dev`、または `test` に設定した上で `--insecure-no-auth` を使用してください。 +キーは `RECOTEM_API_KEYS` にカンマ区切りの `<kid>:sha256:<hex64>` エントリのリストとして設定します。サーバーは送信されたプレーンテキストを、エントリに格納された scrypt 派生ハッシュと照合します (scrypt パラメータ: N=2, r=8, p=1, salt=`recotem.api-key.v1`)。キーの長さは 32〜256 文字でなければなりません。 + +有効な API キーを生成するには以下のコマンドを使用します: + +```bash +recotem keygen --type api +``` + +このコマンドは 43 文字の base64url 文字列を生成します。これがプレーンテキストのキーとして使用できます。対応する `sha256:<hex64>` ダイジェストも出力されるため、`RECOTEM_API_KEYS` に設定してください。 + +`RECOTEM_API_KEYS` が空で、かつ `--insecure-no-auth` が指定されていない場合: + +- `RECOTEM_HOST` の設定に関わらず、サーバーはバインドホストを `127.0.0.1` に強制します。 +- 全リクエストはキーなしで受け付けられます (クライアントはログ上 `kid=anonymous` としてタグ付けされます)。 ::: warning -`X-API-Key` ヘッダーの前後の空白はキーの一部として扱われ、一致しません。送信前にクライアント側でトリムしてください。 +`X-API-Key` ヘッダの前後の空白はキーの一部として扱われるため、一致しません。送信前にクライアント側でトリムしてください。 ::: -## 共通ヘッダー +## 共通ヘッダ -| ヘッダー | 方向 | 説明 | -|----------|------|------| -| `X-API-Key` | リクエスト | 認証トークン (プレーンテキスト)。認証が必要な全エンドポイントで必須。 | -| `X-Request-ID` | リクエスト (任意) | クライアントが指定するリクエスト識別子。`[A-Za-z0-9_-]{1,64}` に一致する必要があります。一致しない値は新たに生成された UUID4 で置換されます。 | -| `X-Request-ID` | レスポンス | 内部で使用されたリクエスト ID のエコー — バリデーション済みのクライアント指定値または生成された UUID4。 | -| `X-Recotem-Metadata-Degraded` | レスポンス | レスポンス内の 1 件以上のアイテムでメタデータの参照失敗があった場合 (アイテムは学習データに存在するが、そのアイテムのメタデータ結合が失敗した場合) に `1` に設定されます。`items` リストには `item_id` と `score` のみを持つそれらのアイテムも含まれます。 | +| ヘッダ | 方向 | 説明 | +|---|---|---| +| `X-API-Key` | リクエスト | 認証トークン (プレーンテキスト)。`GET /v1/health` を除く全エンドポイントで必須。 | +| `X-Request-ID` | リクエスト / レスポンス | クライアントが指定するリクエスト識別子。`^[A-Za-z0-9_-]{1,128}$` に一致する必要があります。一致しない値または省略された値の場合、サーバーは新たに 12 桁の 16 進数識別子を生成します。実際に使用された値はレスポンスにエコーされます。 | +| `X-Recotem-Model-Version` | レスポンス | リクエストを処理したレシピのモデルバージョンハッシュ (`sha256:<64-hex>`)。全ての推薦レスポンスに付与されます。レスポンスボディの `model_version` フィールドと同じ値です。 | +| `X-Recotem-Items-Degraded` | レスポンス | 単一推薦エンドポイントのみ。メタデータの結合がフォールバックになった、またはドロップされたアイテムの総数が設定されます。レスポンスが完全にクリーンな場合は付与されません。バッチエンドポイントでは送信されません。 | + +## レシピ名の形式 + +パスパラメータとして使用するレシピ名は `^[A-Za-z0-9_-]{1,64}$` に一致する必要があります。一致しない名前のパスはルーターによって拒否されます — URL のパース方法によって、レスポンスは `404 Not Found` または `422 Unprocessable Entity` のどちらかになります。 ## エンドポイント -### POST /predict/{name} +### 推薦 + +#### POST /v1/recipes/{name}:recommend 単一ユーザーに対する上位 K 件の推薦を取得します。 **認証:** 必須 (`X-API-Key`)。 -**パスパラメータ:** +**パスパラメータ:** `name` — `^[A-Za-z0-9_-]{1,64}$` に一致するレシピ名。 -| パラメータ | 型 | 制約 | 説明 | -|------------|-----|------|------| -| `name` | string | `[A-Za-z0-9_-]{1,64}` | レシピ名 (レシピ YAML ファイルのステム)。 | +**リクエストボディ** (`extra` フィールドは禁止): -**リクエストボディ:** +| フィールド | 型 | 制約 | デフォルト | 説明 | +|---|---|---|---|---| +| `user_id` | string | 必須、1〜256 文字 | — | 学習データに存在するユーザー識別子。 | +| `limit` | integer | 1〜1000 | `10` | 返すアイテムの最大数。 | +| `exclude_items` | string[] \| null | 任意、最大 1000 件 | null | 結果から除外するアイテム ID。 | ```json { "user_id": "u1", - "cutoff": 10 + "limit": 10, + "exclude_items": ["item-99"] } ``` -| フィールド | 型 | 制約 | デフォルト | 説明 | -|------------|-----|------|-----------|------| -| `user_id` | string | required | — | 学習データに存在するユーザー識別子。 | -| `cutoff` | integer | 1〜1000 | `10` | 返すアイテム数。 | - **レスポンスボディ (200 OK):** ```json { + "request_id": "a1b2c3d4e5f6", + "recipe": "purchase_log", + "model_version": "sha256:a3f2...e91d", "items": [ - { - "item_id": "item-42", - "score": 0.9812, - "title": "Example Item", - "category": "news" - }, - { - "item_id": "item-17", - "score": 0.8754 - } - ], - "model": { - "recipe": "news_articles", - "trained_at": "2026-05-07T01:23:45Z", - "best_class": "IALSRecommender", - "kid": "prod-2026-q2" - }, - "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + {"item_id": "item-42", "score": 0.91, "title": "Example Item", "category": "books"}, + {"item_id": "item-17", "score": 0.84} + ] } ``` -`items` 配列は `score` の降順に並んでいます。各アイテムには常に `item_id` と `score` が含まれます。追加フィールドはレシピの `item_metadata` ブロックで設定されたアイテムメタデータから結合されます。`RECOTEM_METADATA_FIELD_DENY` に列挙されたフィールドはレスポンスの送信前に除去されます。`item_id` または `score` という名前のメタデータカラムは、信頼されたレコメンダーの値を上書きできません。 +アイテムは `score` の降順に並んでいます。`score` フィールドは常に有限数です (NaN および Inf は内部で拒否されます)。各アイテムには常に `item_id` と `score` が含まれます。追加フィールドはレシピの `item_metadata` ブロックで設定されたアイテムメタデータから結合されます。`RecommendItem` はフィールドの追加を許容するため、メタデータ由来のフィールドが `item_id` と `score` とともに表示されます。 **ステータスコード:** -| コード | 条件 | レスポンスボディの `code` フィールド | -|--------|------|-------------------------------------| +| コード | 条件 | エラーコード | +|---|---|---| | 200 | 成功 | — | -| 401 | `X-API-Key` が欠落または不正 | `missing_api_key` または `invalid_api_key` | -| 404 | `user_id` が学習データに存在しない | `user_not_found` | -| 422 | リクエストボディのスキーマバリデーション失敗 (`user_id` の欠落、`cutoff` が範囲外) | — (FastAPI デフォルトのバリデーション形式) | -| 503 | レシピがロードされていないか異常 | `recipe_unavailable` | +| 401 | `X-API-Key` が欠落 | `MISSING_API_KEY` | +| 401 | キーがどのエントリとも一致しない | `INVALID_API_KEY` | +| 404 | `user_id` が学習時に存在しなかった | `UNKNOWN_USER` | +| 422 | リクエストボディのスキーマバリデーション失敗 | `VALIDATION_ERROR` | +| 503 | レシピがロードされていない | `RECIPE_UNAVAILABLE` | + +::: tip UNKNOWN_USER はサーバーエラーではありません +未知のユーザーに対する 404 は、学習時に存在しなかった新規ユーザーでは想定通りの動作です。アプリケーション層でこれを処理してください — 例えば人気ベースの推薦にフォールバックするなど。 +::: **curl の例:** ```bash -curl -s -X POST http://localhost:8080/predict/news_articles \ +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:recommend \ -H "X-API-Key: <plaintext>" \ -H "Content-Type: application/json" \ - -d '{"user_id": "u1", "cutoff": 10}' | jq . + -d '{"user_id": "u1", "limit": 10}' | jq . ``` -::: tip 404 user_not_found -未知のユーザーに対する 404 レスポンスは、学習時に存在しなかった新規ユーザーでは想定通りの動作です。アプリケーション層でこれを処理してください — 例えば人気ベースの推薦にフォールバックするなど。この 404 はサーバー側のエラー状態ではありません。 -::: - --- -### GET /health +#### POST /v1/recipes/{name}:recommend-related -全体のヘルス状態。Kubernetes の readiness プローブと liveness プローブに対応しています。 +1 件以上のシードアイテムに関連するアイテムを取得します。 -**認証:** なし (認証不要)。 +**認証:** 必須 (`X-API-Key`)。 + +**リクエストボディ:** -**レスポンスボディ (200 OK または 503 Service Unavailable):** +| フィールド | 型 | 制約 | デフォルト | 説明 | +|---|---|---|---|---| +| `seed_items` | string[] | 必須、1〜100 件 | — | シードとして使用するアイテム ID。 | +| `limit` | integer | 1〜1000 | `10` | 返すアイテムの最大数。 | +| `exclude_items` | string[] \| null | 任意 | null | 結果から除外するアイテム ID。 | ```json { - "status": "ok", - "total": 3, - "loaded": 3 + "seed_items": ["item-42", "item-17"], + "limit": 10 } ``` -| フィールド | 型 | 説明 | -|------------|-----|------| -| `status` | `"ok"` \| `"degraded"` | 全登録レシピがロード済みかつエラーなしの場合 `"ok"`。いずれかのレシピが未ロードまたはロードエラーを持つ場合 `"degraded"`。 | -| `total` | integer | レジストリが認識しているレシピエントリの総数。 | -| `loaded` | integer | 正常にロードされ、推薦の配信準備ができているレシピ数。 | +**レスポンスボディ (200 OK):** `:recommend` と同じ形状。 **ステータスコード:** -| コード | 条件 | -|--------|------| -| 200 | 全登録レシピがロード済みかつエラーなし。 | -| 503 | 1 件以上のレシピが未ロードまたはロードエラーを持つ。 | - -::: tip -プローブのロジックには HTTP ステータスコードのみを使用してください。`status: degraded` のレスポンスは 503 を返し、Kubernetes の readiness プローブがその Pod を Service エンドポイントから除外します。これは意図的な動作です — 全ての predict 呼び出しが 503 を返す Pod にはトラフィックを送るべきではありません。 -::: +| コード | 条件 | エラーコード | +|---|---|---| +| 200 | 成功 | — | +| 401 | 認証失敗 | `MISSING_API_KEY` / `INVALID_API_KEY` | +| 404 | シードアイテムが全てモデルに未知 | `UNKNOWN_SEED_ITEMS` | +| 404 | シードは既知だがランキング後に候補が残らない | `NO_CANDIDATES` | +| 422 | スキーマバリデーション失敗 | `VALIDATION_ERROR` | +| 503 | レシピがロードされていない | `RECIPE_UNAVAILABLE` | **curl の例:** ```bash -curl -s http://localhost:8080/health | jq . +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:recommend-related \ + -H "X-API-Key: <plaintext>" \ + -H "Content-Type: application/json" \ + -d '{"seed_items": ["item-42"], "limit": 5}' | jq . ``` --- -### GET /health/details +#### POST /v1/recipes/{name}:batch-recommend -`kid`、`trained_at`、`best_class`、ロードエラーを含むレシピごとのヘルス詳細。 +単一リクエストで複数ユーザーの推薦を取得します。Algolia スタイルのバッチエンベロープを使用します。 **認証:** 必須 (`X-API-Key`)。 -アーティファクトのキー識別子 (`kid`) が含まれるため、レシピごとの詳細は認証が必要です。これは公開されるべきではありません。認証不要のプローブ用ステータスには `GET /health` を使用してください。 +**リクエストボディ:** -**レスポンスボディ (200 OK または 503):** +| フィールド | 型 | 制約 | デフォルト | 説明 | +|---|---|---|---|---| +| `requests` | RecommendRequest[] | 1〜256 件 | — | ユーザーごとの推薦リクエスト。各要素は `:recommend` ボディと同じ形状。 | +| `include_metadata` | boolean | — | `false` | `false` の場合、バルクパフォーマンスのためメタデータ結合フィールドが `items` から省略されます。単一ユーザーエンドポイントと同じアイテム形状を得るには `true` に設定してください。 | ```json { - "status": "ok", - "recipes": { - "news_articles": { - "loaded": true, - "trained_at": "2026-05-07T01:23:45Z", - "best_class": "IALSRecommender", - "kid": "prod-2026-q2" + "requests": [ + {"user_id": "u1", "limit": 5}, + {"user_id": "u2", "limit": 5, "exclude_items": ["item-99"]} + ], + "include_metadata": false +} +``` + +**レスポンスボディ (200 OK):** + +```json +{ + "request_id": "a1b2c3d4e5f6", + "recipe": "purchase_log", + "model_version": "sha256:a3f2...e91d", + "results": [ + { + "index": 0, + "status": "ok", + "items": [{"item_id": "item-42", "score": 0.91}] }, - "product_recs": { - "loaded": false, - "error": "signature mismatch" + { + "index": 1, + "status": "error", + "error": {"code": "UNKNOWN_USER", "message": "user not seen during training"} } - } + ] } ``` -レシピディレクトリで見つかった全レシピは、アーティファクトがロードされたかどうかに関わらずここに表示されます。起動時に失敗したレシピは `loaded: false` と `error` フィールドを持つスタブとして表示されます。オプションフィールド (`trained_at`、`best_class`、`kid`、`error`) は対応する値が設定されている場合のみ存在します。フィールドが存在しない場合、対応する値は未設定であることを意味します。 +`results` は `index` フィールドによって `requests` の元の順序を保持します。失敗した要素は `status: "error"` と `error` オブジェクトを持ちます。同じバッチ内の他の要素は引き続き処理されます。 + +**バッチ固有のルール:** -**ステータスコード:** `GET /health` と同じ — いずれかのレシピが未ロードまたはロードエラーを持つ場合は 503。 +- `requests` 配列は 1〜256 件でなければなりません。この範囲外の配列はリクエスト全体に対して `422` を返します。 +- 全 `requests[].limit` の合計は **5000** を超えてはなりません。合計がこの上限を超える要素は要素単位の `VALIDATION_ERROR` 結果を受け取ります。以降の要素は引き続き処理されます。 +- スキーマエラーを持つ個別の要素はバッチ全体を失敗させません。その要素は要素単位の `VALIDATION_ERROR` 結果を受け取り、HTTP レスポンス全体は `200` のままです。 +- `X-Recotem-Items-Degraded` はバッチレスポンスでは送信されません。 +- `503` が返されるのはレシピ自体が利用不可 (未ロード) の場合のみです。`UNKNOWN_USER` などの要素単位のエラーは HTTP ステータスコードに影響しません。 **curl の例:** ```bash -curl -s http://localhost:8080/health/details \ - -H "X-API-Key: <plaintext>" | jq . +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:batch-recommend \ + -H "X-API-Key: <plaintext>" \ + -H "Content-Type: application/json" \ + -d '{ + "requests": [ + {"user_id": "u1", "limit": 5}, + {"user_id": "u2", "limit": 5} + ], + "include_metadata": false + }' | jq . ``` --- -### GET /models +#### POST /v1/recipes/{name}:batch-recommend-related -現在ロードされている全モデルのメタデータを一覧表示します。 +単一リクエストで複数シードのアイテム関連推薦を取得します。 **認証:** 必須 (`X-API-Key`)。 -起動時にアーティファクトのロードに失敗したレシピのスタブエントリは除外されます — それらは `/health/details` に表示されます。 +**リクエストボディ:** `:batch-recommend` と同じエンベロープで、各要素は `:recommend-related` ボディの形状に従います。 + +```json +{ + "requests": [ + {"seed_items": ["item-42"], "limit": 5}, + {"seed_items": ["item-17", "item-8"], "limit": 10} + ], + "include_metadata": false +} +``` + +**レスポンスボディ (200 OK):** `:batch-recommend` と同じエンベロープ。 + +**バッチルール:** 上記の `:batch-recommend` と同一。 + +**curl の例:** + +```bash +curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:batch-recommend-related \ + -H "X-API-Key: <plaintext>" \ + -H "Content-Type: application/json" \ + -d '{ + "requests": [ + {"seed_items": ["item-42"], "limit": 5} + ] + }' | jq . +``` + +--- + +### レシピディスカバリ + +#### GET /v1/recipes + +現在ロードされている全レシピを一覧表示します。 + +**認証:** 必須 (`X-API-Key`)。 + +起動時にアーティファクトまたは YAML のロードに失敗したレシピのスタブエントリは除外されます — それらは `GET /v1/health/details` に表示されます。 **レスポンスボディ (200 OK):** ```json -[ - { - "name": "news_articles", - "recipe_name": "news_articles", - "recipe_hash": "ab12cd34...", - "trained_at": "2026-05-07T01:23:45Z", - "best_class": "IALSRecommender", - "best_params": { "alpha": 1.0 }, - "best_score": 0.1234, - "metric": "ndcg", - "cutoff": 20, - "tuning": { "tried_algorithms": ["IALS", "TopPop"], "n_trials": 40, "n_completed": 40 }, - "data_stats": { "n_rows": 12345, "n_users": 678, "n_items": 90 }, - "kid": "prod-2026-q2", - "recotem_version": "2.0.0", - "irspack_version": "0.3.14" +{ + "recipes": [ + { + "name": "purchase_log", + "model_version": "sha256:a3f2...e91d", + "loaded_at": "2026-05-21T00:00:00Z", + "supported_verbs": [ + "recommend", + "recommend-related", + "batch-recommend", + "batch-recommend-related" + ], + "kind": "user-item" + } + ] +} +``` + +| フィールド | 型 | 説明 | +|---|---|---| +| `name` | string | レシピ名 (レシピ YAML ファイルのステム)。 | +| `model_version` | string | アーティファクトの `sha256:<64-hex>` ダイジェスト。 | +| `loaded_at` | string (ISO 8601) | アーティファクトがメモリにロードされたタイムスタンプ。 | +| `supported_verbs` | string[] | このレシピがサポートする colon-verb。レシピの `kind` に依存します。 | +| `kind` | `"user-item"` \| `"item-item"` | モデルがユーザー対アイテムまたはアイテム対アイテムの推薦を生成するかどうか。`"item-item"` レシピは `recommend` および `batch-recommend` をサポートしません。 | + +**curl の例:** + +```bash +curl -s http://localhost:8080/v1/recipes \ + -H "X-API-Key: <plaintext>" | jq . +``` + +--- + +#### GET /v1/recipes/{name} + +単一のロード済みレシピの詳細メタデータを取得します。 + +**認証:** 必須 (`X-API-Key`)。 + +**レスポンスボディ (200 OK):** + +`GET /v1/recipes` の全フィールドに加えて: + +| フィールド | 型 | 説明 | +|---|---|---| +| `config_digest` | string \| null | レシピ YAML の `sha256:<hex>`。利用不可の場合は null。 | +| `algorithms` | string[] | チューニング中に評価された全アルゴリズムクラス。 | +| `best_algorithm` | string | 最良として選択されたアルゴリズムクラス。 | +| `best_class` | string \| null | 最良アルゴリズムの完全修飾クラス名。 | +| `best_params` | object \| null | 最良アルゴリズムのハイパーパラメータ。 | +| `best_score` | number \| null | 最良モデルのバリデーションスコア。NaN および Inf は null に正規化されます。 | +| `metric` | `"ndcg"` \| `"map"` \| `"recall"` \| `"hit"` \| null | チューニング時に使用した評価指標。 | +| `cutoff` | integer \| null | チューニング時のオフライン評価指標の計算に使用したカットオフ K。これはリクエストごとの `limit` とは無関係であり、学習時にレシピがどのようにスコアリングされたかを表すのみです。 | +| `tuning` | object \| null | チューニングメタデータ (`tried_algorithms`、`n_trials`、`n_completed`)。 | +| `data_stats` | object \| null | 学習データの統計情報 (`n_rows`、`n_users`、`n_items`)。 | +| `recotem_version` | string \| null | このアーティファクトを学習した recotem のバージョン。 | +| `irspack_version` | string \| null | 学習時に使用した irspack のバージョン。 | +| `recipe_hash` | string \| null | 学習時のレシピ設定の 64 文字の小文字 16 進ダイジェスト (`sha256:` プレフィックスなし。`config_digest` とは異なる形式)。 | +| `trained_at` | string (ISO 8601) \| null | 学習が完了したタイムスタンプ。 | + +上記のオプションフィールドは、それらを記録していない旧アーティファクトでは `null` になります。 + +**ステータスコード:** + +| コード | 条件 | エラーコード | +|---|---|---| +| 200 | レシピがロード済み | — | +| 404 | レシピ名がレジストリに存在しない | `RECIPE_NOT_FOUND` | +| 503 | レシピは存在するがロードされていない | `RECIPE_UNAVAILABLE` | + +**curl の例:** + +```bash +curl -s http://localhost:8080/v1/recipes/purchase_log \ + -H "X-API-Key: <plaintext>" | jq . +``` + +--- + +### ヘルスとメトリクス + +#### GET /v1/health + +全体の liveness および readiness ステータス。Kubernetes の liveness プローブおよび readiness プローブに対応しています。 + +**認証:** なし (認証不要)。 + +**レスポンスボディ:** + +```json +{"status": "ok", "total": 3, "loaded": 3} +``` + +| フィールド | 型 | 説明 | +|---|---|---| +| `status` | `"ok"` \| `"degraded"` | 全ての設定済みレシピがロードされている場合 `"ok"`。いずれかのレシピが未ロードの場合 `"degraded"`。`total == 0` の場合、ステータスは常に `"ok"`。 | +| `total` | integer | レジストリ内のレシピエントリの総数。 | +| `loaded` | integer | 正常にロードされ、配信準備ができているレシピ数。 | + +**ステータスコード:** + +| コード | 条件 | +|---|---| +| 200 | 全レシピがロード済み。 | +| 503 | 1 件以上のレシピが未ロード。 | + +::: tip Kubernetes readiness プローブ +`503` レスポンスは Pod を Service エンドポイントから除外します。これは意図的な動作です — 全ての推薦リクエストが `503` を返す Pod にはトラフィックを送るべきではありません。readiness プローブと liveness プローブの両方に `GET /v1/health` を使用してください。 +::: + +**curl の例:** + +```bash +curl -s http://localhost:8080/v1/health | jq . +``` + +--- + +#### GET /v1/health/details + +ロードエラーとアーティファクト識別子を含むレシピごとのヘルス詳細。 + +**認証:** 必須 (`X-API-Key`)。 + +レシピごとの詳細は、公開されるべきでないアーティファクトのキー識別子 (`kid`) を含むため、認証が必要です。認証不要のプローブ用ステータスには `GET /v1/health` を使用してください。 + +**レスポンスボディ:** + +```json +{ + "status": "ok", + "recipes": { + "purchase_log": { + "loaded": true, + "trained_at": "2026-05-21T00:00:00Z", + "best_class": "IALSRecommender", + "kid": "prod-2026-q2" + }, + "product_recs": { + "loaded": false, + "error": "signature mismatch" + } } -] +} ``` -各エントリはアーティファクトのヘッダー JSON に、登録されたレシピの `name` とアクティブな `kid` を加えたものです。鍵の素材は含まれません。ヘッダースキーマは [アーキテクチャ — アーティファクト形式](./#アーティファクト形式) に記載されています。 +起動時にロードに失敗したレシピのスタブを含め、レジストリ内の全レシピがここに表示されます。オプションフィールド (`trained_at`、`best_class`、`kid`、`error`) は対応する値が設定されている場合のみ存在します。 + +**ステータスコード:** `GET /v1/health` と同じ — いずれかのレシピが `loaded: false` または `error` フィールドを持つ場合は `503`。 **curl の例:** ```bash -curl -s http://localhost:8080/models \ +curl -s http://localhost:8080/v1/health/details \ -H "X-API-Key: <plaintext>" | jq . ``` --- -### GET /metrics +#### GET /v1/metrics Prometheus メトリクスの公開 (オプトイン)。 -**認証:** なし (認証不要)。 +**認証:** 必須 (`X-API-Key`)。 + +**利用可能条件:** 以下の両方の条件が満たされた場合のみこのルートが登録されます: -**利用可能条件:** 以下の両方の条件が満たされた場合のみ登録されます: 1. `RECOTEM_METRICS_ENABLED` が真の値 (`1`、`true`、`yes`、`on`) に設定されている。 2. `recotem[metrics]` エクストラがインストールされている (`pip install "recotem[metrics]"`)。 -このエンドポイントは OpenAPI スキーマから除外されています (`include_in_schema=False`)。 +このエンドポイントは OpenAPI スキーマから除外されています。 + +::: warning Prometheus スクレイパーの設定 +多くの Prometheus ターゲットとは異なり、`/v1/metrics` は `X-API-Key` を必要とします。スクレイパーにヘッダを送信するよう設定してください: -::: warning ネットワークへの公開 -`/metrics` と `/health` は設計上認証不要です — Prometheus と Kubernetes liveness / readiness プローブが期待するスタンスと同じです。これらのエンドポイントはレシピ名、kid、ロードエラー文字列、モデルロードのタイムスタンプ、predict レイテンシのヒストグラムを公開します。API キーミドルウェアに頼るのではなく、クラスターの NetworkPolicy で制限してください。 +```yaml +# prometheus.yml スクレイプ設定 (Prometheus 2.45+) +scrape_configs: + - job_name: recotem + metrics_path: /v1/metrics + static_configs: + - targets: ["localhost:8080"] + http_headers: + X-API-Key: + values: ["<plaintext>"] +``` ::: **利用可能なメトリクス:** | メトリクス | 型 | ラベル | -|------------|-----|--------| -| `recotem_predict_total` | Counter | `recipe`, `status` | -| `recotem_predict_latency_seconds` | Histogram | `recipe` | +|---|---|---| +| `recotem_v1_requests_total` | Counter | `recipe`, `verb`, `status` | +| `recotem_v1_request_latency_seconds` | Histogram | `recipe`, `verb` | +| `recotem_v1_batch_size` | Histogram | `recipe`, `verb` | +| `recotem_v1_batch_element_errors_total` | Counter | `recipe`, `verb`, `code` | +| `recotem_v1_metadata_degraded_items_total` | Counter | `recipe`, `verb`, `kind` | +| `recotem_v1_validation_errors_outside_verb_total` | Counter | — | | `recotem_model_loaded` | Gauge | `recipe` | -| `recotem_artifact_load_failures_total` | Counter | `recipe` | +| `recotem_artifact_load_failures_total` | Counter | `recipe`, `reason` | | `recotem_active_recipes` | Gauge | — | | `recotem_swap_total` | Counter | `recipe`, `result` | | `recotem_artifact_stat_failures_total` | Counter | `recipe` | | `recotem_watcher_unhandled_errors_total` | Counter | — | -| `recotem_metadata_lookup_errors_total` | Counter | `recipe` | +| `recotem_metadata_index_build_errors_total` | Counter | `recipe` | +| `recotem_metadata_serialization_errors_total` | Counter | `recipe`, `verb` | | `recotem_recipe_rescan_errors_total` | Counter | `recipe` | +| `recotem_recommender_layout_unexpected_total` | Counter | `recipe` | +| `recotem_watcher_state_divergence_total` | Counter | — | | `recotem_bigquery_storage_fallback_total` | Counter | `reason` | | `recotem_recipes_dir_scan_failures_total` | Counter | `error_class` | -`recotem_predict_total` の `status` ラベルは `ok`、`user_not_found`、`unavailable`、`error` の値を取ります。 +`verb` ラベルは `recommend`、`recommend-related`、`batch-recommend`、`batch-recommend-related` の値を取ります。`recotem_v1_requests_total` の `status` ラベルは `ok`、`unknown_user`、`unknown_seed_items`、`no_candidates`、`unavailable`、`recipe_not_found`、`validation_error`、`error` の 8 値を取ります。`recotem_artifact_load_failures_total` の `reason` ラベルは `read`、`parse`、`hmac`、`header_json`、`deserialize`、`metadata`、`yaml`、`unexpected`、`dir_scan`、`timeout` の値を取ります。 + +**curl の例:** + +```bash +curl -s http://localhost:8080/v1/metrics \ + -H "X-API-Key: <plaintext>" +``` --- -## OpenAPI ドキュメントエンドポイント +## エラーフォーマット -インタラクティブドキュメントは `/docs` (Swagger UI)、`/redoc`、および生のスキーマは `/openapi.json` でデフォルトで利用できます。 +全てのエラーレスポンスは、最低限 `detail` (人間が読める形式) と `code` (機械が読める UPPER_SNAKE_CASE 形式) を持つフラットな JSON ボディを使用します。 -::: warning -`RECOTEM_ENV` が `production`、`prod`、または `staging` に設定されている場合、これら 3 つのエンドポイントは**無効化されます**。本番環境のデプロイメントではこれらに依存しないでください。 -::: +**標準エラーボディ:** + +```json +{"detail": "recipe purchase_log is not loaded", "code": "RECIPE_UNAVAILABLE"} +``` + +**バリデーションエラーボディ (422 のみ):** `request_id` と構造化された `errors` 配列を含みます。 + +```json +{ + "request_id": "a1b2c3d4e5f6", + "detail": "Request validation failed", + "code": "VALIDATION_ERROR", + "errors": [ + {"loc": ["body", "limit"], "msg": "ensure this value is less than or equal to 1000", "type": "value_error.number.not_le"} + ] +} +``` + +**内部エラーボディ (500 のみ):** サーバーログとの照合のために `request_id` を含みます。 + +```json +{"detail": "internal error", "code": "INTERNAL_ERROR", "request_id": "a1b2c3d4e5f6"} +``` + +### エラーコード + +| コード | HTTP | 発生条件 | +|---|---|---| +| `RECIPE_UNAVAILABLE` | 503 | レシピはレジストリに存在するが、そのアーティファクトがロードされていない。 | +| `RECIPE_NOT_FOUND` | 404 | レシピ名がレジストリに全く存在しない。 | +| `UNKNOWN_USER` | 404 | `user_id` が学習の idmap に存在しなかった。 | +| `UNKNOWN_SEED_ITEMS` | 404 | `seed_items` の全アイテムがモデルに未知。 | +| `NO_CANDIDATES` | 404 | シードアイテムは既知だが、ランキングステージを経て候補が残らなかった。 | +| `VALIDATION_ERROR` | 422 (HTTP) / 要素単位 (バッチ) | リクエストまたは要素ボディのスキーマバリデーション失敗。 | +| `MISSING_API_KEY` | 401 | `X-API-Key` ヘッダが存在しない。 | +| `INVALID_API_KEY` | 401 | `X-API-Key` が設定済みのどのキーとも一致しない。 | +| `INTERNAL_ERROR` | 500 (HTTP) / 要素単位 (バッチ) | リクエスト処理中に未処理の例外が発生した。 | --- @@ -284,9 +554,9 @@ Prometheus メトリクスの公開 (オプトイン)。 ### TrustedHostMiddleware -`RECOTEM_ALLOWED_HOSTS` (デフォルト: `127.0.0.1,localhost`) は `Host` ヘッダーの許可リストを制御します。このリストにない `Host` ヘッダーを持つリクエストは `400 Bad Request` を受け取ります。これは `/health` を含む全エンドポイントに適用されます。 +`RECOTEM_ALLOWED_HOSTS` (デフォルト: `127.0.0.1,localhost`) は `Host` ヘッダの許可リストを制御します。このリストにない `Host` ヘッダを持つリクエストは `400 Bad Request` を受け取ります。これは `GET /v1/health` を含む全エンドポイントに適用されます。 -Kubernetes では、kubelet プローブはデフォルトで `Host: localhost` を送信します — `localhost` が常にデフォルトの許可リストに含まれているのはそのためです。Ingress 経由で公開する場合は、Ingress のホスト名を明示的に追加してください (または Helm チャートを使用すると `ingress.hosts` から自動的に導出されます)。 +Kubernetes では、kubelet プローブはデフォルトで `Host: localhost` を送信します — `localhost` が常にデフォルトの許可リストに含まれているのはそのためです。Ingress 経由で公開する場合は、`RECOTEM_ALLOWED_HOSTS` に Ingress のホスト名を明示的に追加してください。 ### CORS @@ -295,3 +565,13 @@ Kubernetes では、kubelet プローブはデフォルトで `Host: localhost` ```yaml RECOTEM_ALLOWED_ORIGINS: "https://app.example.com,https://admin.example.com" ``` + +--- + +## OpenAPI ドキュメント + +インタラクティブドキュメントは `/docs` (Swagger UI) および `/redoc` で利用できます。生のスキーマは `/openapi.json` で参照できます。 + +::: warning 開発環境専用 +これら 3 つのエンドポイントは `RECOTEM_ENV` が `development`、`dev`、または `test` に設定されている場合のみ利用可能です。それ以外の全ての環境では無効化されます。本番環境のデプロイメントではこれらに依存しないでください。 +::: diff --git a/ja/guide/batch.md b/ja/guide/batch.md index 3119fda..9caea8a 100644 --- a/ja/guide/batch.md +++ b/ja/guide/batch.md @@ -168,7 +168,7 @@ spec: | 2 | レシピエラー (不正な YAML、スキーマ違反) | 再試行しない — ConfigMap を修正する | | 3 | データソースエラー | 通常は再試行しない (永続的な問題) | | 4 | 学習エラー | `backoffLimit` まで再試行 | -| 5 | アーティファクトエラー (署名鍵の問題) | 再試行しない — Secret を修正する | +| 5 | アーティファクトエラー (破損ファイル、HMAC 検証の失敗) | 再試行しない — アーティファクトファイルまたは署名に使った鍵を調査する | | 6 | ロック競合 (`--fail-on-busy` が設定されている) | 再試行するか別の場所にルーティング | | 7 | HTTP フェッチエラー (一時的) | 再試行 | | 8 | 設定エラー (環境変数の欠落) | 再試行しない — Secret を修正する | @@ -191,14 +191,14 @@ Recotem のファイルロックはホストローカルです。`s3://` や `gs 2. アーティファクトの全バイトを読み込みます。 3. 設定された署名鍵に対して HMAC 署名を検証します。 4. 検証が通れば、インメモリのモデルをアトミックに入れ替えます。 -5. 検証に失敗した場合 (鍵の不一致、ファイル破損)、以前の正常なモデルを維持してエラーを `/health` にログ記録します。 +5. 検証に失敗した場合 (鍵の不一致、ファイル破損)、以前の正常なモデルを維持し、エラーは構造化ログと `/v1/health/details` のレシピごとの `last_load_error` に記録されます。 入れ替え中にリクエストがドロップされることはありません。新しいモデルの準備が整うまで、以前のモデルが引き続き応答します。 現在のモデルの状態はいつでも確認できます。 ```bash -curl http://localhost:8080/health +curl http://localhost:8080/v1/health # {"status":"ok","total":1,"loaded":1} ``` diff --git a/ja/guide/cli.md b/ja/guide/cli.md index 9c1d558..69997ce 100644 --- a/ja/guide/cli.md +++ b/ja/guide/cli.md @@ -102,7 +102,11 @@ recotem validate <recipe.yaml> 1. YAML を解析してすべてのフィールドをレシピスキーマに対してチェックします。 2. データソースプラグインをインスタンス化します (`recotem[bigquery]` などのエクストラが欠落している場合に検出されます)。 -3. ソースのオプションの `probe()` メソッドを実行します。HTTP/HTTPS ソースの場合は HTTP HEAD リクエスト、BigQuery の場合は認証情報の確認です。 +3. ソースのオプションの `probe()` メソッドを実行します。 + - **ローカル / オブジェクトストレージパス** (`file`、`s3://`、`gs://`、`az://`) — ファイルの存在を確認します。 + - **HTTP / HTTPS パス** — SSRF ホスト公開チェックを実行します (バイト上限、リダイレクトスキームポリシー、`sha256` 検証はフェッチ時に発動します)。 + - **BigQuery** — ADC、プロジェクトアクセス、SQL/パラメータ構文を検証する無料のドライランクエリを実行します。 + - **SQL** — 接続を開いて軽微な疎通確認クエリを実行します。 **例:** @@ -141,7 +145,7 @@ recotem keygen --type api --kid <name> | フラグ | デフォルト | 説明 | |---|---|---| | `--type` | `signing` | HMAC アーティファクト鍵の場合は `signing`、クライアント認証鍵の場合は `api`。 | -| `--kid <name>` | 自動生成 (UUID プレフィックス) | 鍵の短い識別子。ログ、認証済みの `/health/details` と `/models` エンドポイント、ローテーション手順で使用されます。 | +| `--kid <name>` | 自動生成 (UUID プレフィックス) | 鍵の短い識別子。構造化ログ (サーバーアクセスログには認証済みリクエストごとに対応する `kid` が含まれます)、`/v1/health/details` のレシピごとの `kid` フィールド、および鍵ローテーション手順で使用されます。 | 平文は一度だけ表示されます。すぐにシークレットマネージャーに保存してください。後から復元する方法はありません。紛失した場合は新しい鍵を生成してください。 diff --git a/ja/guide/index.md b/ja/guide/index.md index 1d87e2d..485a42f 100644 --- a/ja/guide/index.md +++ b/ja/guide/index.md @@ -9,7 +9,7 @@ Recotem は、小さな YAML 設定ファイル 1 つから推薦モデルを学 データソース、いくつかの学習設定、保存先を記述するだけで、残りはすべて Recotem が行います。データの取得、最適なアルゴリズムの探索、モデルの学習、そして HTTP 経由での推薦リクエストへの応答まで対応します。 -この設定ファイルを **レシピ** と呼びます。レシピには「どのデータを使うか」「どのように学習するか」「どこで配信するか」が記述されています。1 つのレシピが 1 つのモデルと 1 つの API エンドポイントを生み出します。 +この設定ファイルを **レシピ** と呼びます。レシピには「どのデータを使うか」「どのように学習するか」「どこで配信するか」が記述されています。1 つのレシピが 1 つのモデルを生み出し、それを HTTP エンドポイント群 (単発/バッチ、user-to-item / item-to-item) として公開します。 データベースも、メッセージブローカーも、管理画面も不要です。レシピファイルと 2 つのコマンド、そして HTTP エンドポイントがあれば十分です。 @@ -19,7 +19,7 @@ Recotem は、**アーティファクト** (学習済みモデル) と呼ばれ 1. **`recotem train`** はレシピを読み込み、インタラクションデータ (購買履歴、クリック、閲覧など) を取得し、最適なアルゴリズムと設定を探索して最終モデルを学習し、署名付きアーティファクトをディスクまたはクラウドストレージに書き出します。 -2. **`recotem serve`** はアーティファクトディレクトリを監視し、各モデルを `/predict/{name}` という REST エンドポイントとして読み込みます。`recotem train` が新しいアーティファクトを生成すると、サーバーが自動的に検出して読み込みます。再起動は不要です。 +2. **`recotem serve`** はアーティファクトディレクトリを監視し、各モデルを `/v1/recipes/{name}:recommend` (および関連 verb エンドポイント) という REST エンドポイントとして読み込みます。`recotem train` が新しいアーティファクトを生成すると、サーバーが自動的に検出して読み込みます。再起動は不要です。 2 つのプロセスはアーティファクトファイルしか共有しないため、別々のマシンで実行できます。夜間のバッチジョブが S3 バケットに書き込み、常時稼働のサーバーが同じバケットから読み込んで、新しいモデルが登場した瞬間にホットスワップする構成が可能です。 @@ -27,7 +27,10 @@ Recotem は、**アーティファクト** (学習済みモデル) と呼ばれ recipe.yaml → recotem train → artifact.recotem → recotem serve (バッチジョブ) (HMAC 署名済み) (FastAPI、ホットスワップ) -任意のスケジューラ ローカル FS / S3 / GCS POST /predict/{name} +任意のスケジューラ ローカル FS / S3 / GCS POST /v1/recipes/{name}:recommend + POST /v1/recipes/{name}:recommend-related + POST /v1/recipes/{name}:batch-recommend + POST /v1/recipes/{name}:batch-recommend-related ``` アーティファクトは HMAC 署名 (改ざん検知コード) によって保護されています。配信プロセスはモデルを読み込む前に署名を検証するため、破損または改ざんされたファイルは使用されずに拒否されます。 @@ -49,7 +52,7 @@ Recotem は、専任の ML エンジニアや複雑なデータインフラが 2. **`recotem train` を実行する。** 1 つのコマンドがデータを取得し、最適なアルゴリズムと設定を探索し、最終モデルを学習し、署名付きアーティファクトを書き出します。 -3. **`recotem serve` を実行する。** 1 つのコマンドが HTTP サーバーを起動し、アーティファクトを読み込んで `POST /predict/{name}` リクエストに応答します。再学習すればサーバーが自動更新されます。 +3. **`recotem serve` を実行する。** 1 つのコマンドが HTTP サーバーを起動し、アーティファクトを読み込んで `POST /v1/recipes/{name}:recommend` (および関連 verb エンドポイント) リクエストに応答します。再学習すればサーバーが自動更新されます。 ## 次のステップ diff --git a/ja/guide/installation.md b/ja/guide/installation.md index 24d88c7..db32baa 100644 --- a/ja/guide/installation.md +++ b/ja/guide/installation.md @@ -31,7 +31,6 @@ recotem --help | PostgreSQL データソース | `pip install "recotem[postgres]"` | psycopg 経由で PostgreSQL からインタラクションデータを読み込む | | MySQL / MariaDB データソース | `pip install "recotem[mysql]"` | PyMySQL 経由で MySQL または MariaDB からインタラクションデータを読み込む | | SQLite データソース | `pip install "recotem[sqlite]"` | SQLite からインタラクションデータを読み込む (標準ライブラリの `sqlite3` を使用) | -| Google Analytics 4 データソース | `pip install "recotem[ga4]"` | Data API 経由で GA4 からインタラクションイベントを読み込む | | Amazon S3 | `pip install "recotem[s3]"` | S3 からアーティファクトとデータを読み書きする | | Google Cloud Storage | `pip install "recotem[gcs]"` | GCS からアーティファクトとデータを読み書きする | | Azure Blob Storage | `pip install "recotem[azure]"` | Azure からアーティファクトとデータを読み書きする | diff --git a/ja/guide/recipe-basics.md b/ja/guide/recipe-basics.md index d182ef1..7d4598f 100644 --- a/ja/guide/recipe-basics.md +++ b/ja/guide/recipe-basics.md @@ -5,7 +5,7 @@ description: Recotem のレシピファイルの各セクションを、注釈 # レシピの基本 -レシピは 1 つの推薦システムに対応する設定ファイルです。データがどこにあるか、ユーザー ID とアイテム ID はどの列か、どの学習設定を使うか、学習済みモデルをどこに保存するかを Recotem に伝えます。1 つのレシピが 1 つのモデルと 1 つの `/predict/{name}` HTTP エンドポイントを生み出します。 +レシピは 1 つの推薦システムに対応する設定ファイルです。データがどこにあるか、ユーザー ID とアイテム ID はどの列か、どの学習設定を使うか、学習済みモデルをどこに保存するかを Recotem に伝えます。1 つのレシピが 1 つのモデルと `/v1/recipes/{name}:<verb>` HTTP エンドポイント群を生み出します。 レシピは一度書いたら、スケジュールに従って、データが更新されるたびに、または別の設定を試したいときにいつでも `recotem train` を実行できます。すべてのフィールドには適切なデフォルト値があり、自分のデータに固有の設定だけを記述すれば十分です。 @@ -27,7 +27,7 @@ output: ... # 必須: 学習済みモデルファイルの書き出 ## `name` — エンドポイント名 -`name` の値が URL パスになります: `name: purchase_log` → `/predict/purchase_log` +`name` の値がエンドポイントパスに使われます: `name: purchase_log` → `/v1/recipes/purchase_log:recommend` (および他の verb エンドポイント: `:recommend-related`、`:batch-recommend`、`:batch-recommend-related`) ```yaml name: purchase_log @@ -115,7 +115,7 @@ cleansing: ## `item_metadata` — 予測に含めるアイテムの詳細 -`/predict` のレスポンスにアイテムの詳細情報 (タイトル、カテゴリ、画像 URL など) を含めたい場合は、このセクションでメタデータファイルを指定します。`fields` に列挙した列のみが結合されてレスポンスに含まれます。 +推薦レスポンスにアイテムの詳細情報 (タイトル、カテゴリ、画像 URL など) を含めたい場合は、このセクションでメタデータファイルを指定します。`fields` に列挙した列のみが結合されてレスポンスに含まれます。 ```yaml item_metadata: @@ -125,7 +125,7 @@ item_metadata: on_field_missing: error # 列挙したフィールドが欠損していればモデル読み込み時にエラー ``` -このセクションは任意です。指定しない場合、`/predict` は `item_id` と `score` のみを返します。 +このセクションは任意です。指定しない場合、推薦レスポンスは `item_id` と `score` のみを返します。 --- @@ -184,7 +184,7 @@ output: ## 実行前のチェック -フル学習を始める前に任意のレシピに対して `recotem validate` を実行してください。ファイル全体をダウンロードせずにスキーマを検証し、データソースの疎通確認 (URL ベースの CSV の場合は HTTP HEAD リクエストなど) を行います。 +フル学習を始める前に任意のレシピに対して `recotem validate` を実行してください。ファイル全体をダウンロードせずにスキーマを検証し、データソースをプローブします (HTTP/HTTPS パスの場合は SSRF ホスト公開チェックを実行し、BigQuery の場合は無料のドライランクエリを実行し、ローカルまたはオブジェクトストレージパスの場合はファイルの存在を確認します)。 ```bash recotem validate my_recipe.yaml diff --git a/ja/guide/tutorial/index.md b/ja/guide/tutorial/index.md index 00ddcd0..e3b1554 100644 --- a/ja/guide/tutorial/index.md +++ b/ja/guide/tutorial/index.md @@ -5,7 +5,7 @@ description: 実際の購買ログデータセットから推薦システムを # チュートリアル -このチュートリアルでは、Recotem の一連の操作を体験します。データの取得、モデルの学習、配信、そして `/predict` の呼び出しです。使用するデータセットは小さな公開購買ログ CSV (Recotem の統合テストでも使用しているファイル) で、ラップトップで約 1 分で学習が完了します。 +このチュートリアルでは、Recotem の一連の操作を体験します。データの取得、モデルの学習、配信、そして推薦エンドポイントの呼び出しです。使用するデータセットは小さな公開購買ログ CSV (Recotem の統合テストでも使用しているファイル) で、ラップトップで約 1 分で学習が完了します。 **前提条件:** Docker と Compose プラグイン、または Python 3.12 以上と Recotem がインストールされた環境が必要です。ディスクとネットワークアクセスは約 50 MB 程度 (`raw.githubusercontent.com` へのアクセスが必要です)。 @@ -118,7 +118,7 @@ docker compose up -d serve サーバーが起動してモデルを読み込んだか確認します。 ```bash -curl http://localhost:8080/health +curl http://localhost:8080/v1/health ``` 期待されるレスポンス: @@ -130,29 +130,28 @@ curl http://localhost:8080/health ### ステップ 4 — 予測 ```bash -curl -sX POST http://localhost:8080/predict/purchase_log \ +curl -sX POST http://localhost:8080/v1/recipes/purchase_log:recommend \ -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ -H "Content-Type: application/json" \ - -d '{"user_id": "1", "cutoff": 5}' | python3 -m json.tool + -d '{"user_id": "1", "limit": 5}' | python3 -m json.tool ``` -期待されるレスポンスの形式 (スコアの値は学習の実行ごとに異なります): +期待されるレスポンスの形式 (スコアの値とダイジェストは学習の実行ごとに異なります): ```json { + "request_id": "...", + "recipe": "purchase_log", + "model_version": "sha256:7f9c2ba4e88f827d616045507605853ed73b8093a07ef41c995c66e94c4eaa1d", "items": [ {"item_id": "42", "score": 0.91}, {"item_id": "17", "score": 0.87} - ], - "model": { - "recipe": "purchase_log", - "best_class": "IALSRecommender", - "kid": "dev" - }, - "request_id": "..." + ] } ``` +`model_version` は `sha256:` に続いて読み込まれたアーティファクトの 64 文字の hex SHA-256 ダイジェストが付きます。同じダイジェストは `X-Recotem-Model-Version` レスポンスヘッダーにも返されるため、クライアントはどのモデルバージョンが各予測を生成したかを記録できます。 + ### ステップ 5 — 後片付け ```bash @@ -191,7 +190,7 @@ export RECOTEM_API_PLAINTEXT="<API の plaintext>" recotem validate examples/tutorial-purchase-log/recipe.yaml ``` -これにより、ファイル全体をダウンロードせずにレシピを解析し、簡単な接続確認 (CSV URL への HTTP HEAD リクエスト) を実行します。フル学習を始める前に設定上の問題を早期に発見するのに役立ちます。 +これにより、レシピを解析してデータソースの `probe()` メソッドをファイル全体のダウンロードなしに実行します。HTTP/HTTPS ソースの場合、プローブは SSRF ホスト公開チェックを実行します。バイト上限、リダイレクトスキームポリシー、`sha256` 検証はフェッチ時に発動します。フル学習を始める前に設定上の問題を早期に発見するのに役立ちます。 ### ステップ 4 — 学習 @@ -213,10 +212,10 @@ recotem serve --recipes examples/tutorial-purchase-log/ 別のターミナルで実行します。 ```bash -curl -sX POST http://127.0.0.1:8080/predict/purchase_log \ +curl -sX POST http://127.0.0.1:8080/v1/recipes/purchase_log:recommend \ -H "X-API-Key: $RECOTEM_API_PLAINTEXT" \ -H "Content-Type: application/json" \ - -d '{"user_id": "1", "cutoff": 5}' | python3 -m json.tool + -d '{"user_id": "1", "limit": 5}' | python3 -m json.tool ``` --- @@ -224,8 +223,8 @@ curl -sX POST http://127.0.0.1:8080/predict/purchase_log \ ## 何が起きたのか - `recotem train` はレシピを解析し、HTTPS 経由で CSV を取得して sha256 を検証し、IALS と TopPop に対して Optuna によるハイパーパラメータ探索を実行し、署名鍵で署名したバイナリアーティファクトを書き出しました。 -- `recotem serve` はアーティファクトディレクトリを監視し、新しいファイルを検出して同じ署名鍵で HMAC 検証を行い、`/predict/purchase_log` エンドポイントとして登録しました。 -- `/predict` リクエストは API キーの許可リストで認証され、学習済みモデルでスコアリングされました。 +- `recotem serve` はアーティファクトディレクトリを監視し、新しいファイルを検出して同じ署名鍵で HMAC 検証を行い、`/v1/recipes/purchase_log:recommend` (および関連 verb) エンドポイントとして登録しました。 +- リクエストは API キーの許可リストで認証され、学習済みモデルでスコアリングされました。 --- @@ -237,8 +236,8 @@ curl -sX POST http://127.0.0.1:8080/predict/purchase_log \ | `DataSourceError: sha256 mismatch` | 上流のファイルが変更された | `curl -sL <url> \| shasum -a 256` で再計算してレシピを更新する | | `DataSourceError: HTTP 404 fetching ...` | URL が変更された | ブラウザで URL を確認し、`v1.0.0` タグがまだ存在するか確認する | | `ArtifactError: RECOTEM_SIGNING_KEYS not set` | ステップ 1 (鍵の生成) がエクスポートされていない | エクスポートを再実行して再試行する | -| `/predict` で `401 Unauthorized` | API キーの値が間違っている | `keygen --type api` の `hash` ではなく `plaintext` の値を使用する | -| 学習直後に `503 recipe_unavailable` | ウォッチャーがまだポーリングしていない | `RECOTEM_WATCH_INTERVAL` 秒 (デフォルト 5 秒、チュートリアルの compose では 10 秒) 待つ。`/health` を確認する | +| `/v1/recipes/...` で `401 Unauthorized` | API キーの値が間違っている | `keygen --type api` の `hash` ではなく `plaintext` の値を使用する | +| 学習直後に `503 RECIPE_UNAVAILABLE` | ウォッチャーがまだポーリングしていない | `RECOTEM_WATCH_INTERVAL` 秒 (デフォルト 5 秒、チュートリアルの compose では 10 秒) 待つ。`/v1/health` を確認する | | パス B: アーティファクトが予期しないディレクトリに書き出される | レシピの `output.path` が作業ディレクトリからの相対パス | リポジトリのルートから `recotem train` を実行するか、`output.path` を絶対パスに変更する | | pip インストール後に `recotem: command not found` | 仮想環境がアクティベートされていない | 仮想環境をアクティベートするか、`python -m recotem ...` で実行する |