Metadata-Version: 2.5
Name: tvpulse
Version: 1.0.0
Summary: Official Python SDK for the TVPulse customer API (voice, text, combined search).
Project-URL: Homepage, https://gateway.tvpulse.io/apidocs
Project-URL: Documentation, https://gateway.tvpulse.io/apidocs
Project-URL: Repository, https://gitlab.imind.dev/tvpulse/tvpulse
Author-email: InfiniMind <support@infinimind.io>
License: Proprietary
Keywords: asr,media,ocr,search,tv,tvpulse
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: httpx<1.0,>=0.24
Requires-Dist: pydantic<3.0,>=2.0
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-bdd==7.3.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.20; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# TVPulse Python SDK

日本のテレビ放送に関する検索・集計APIを、型付きのPythonモデルで利用するためのSDKです。カスタマーAPIの説明、入力検証、エラー情報、有限リトライをSDKの公開契約として扱います。

## インストール

```bash
pip install tvpulse==1.0.0
```

対応するPythonは3.10、3.11、3.12です。APIキーはコードに直接書かず、`TVPULSE_API_KEY`で渡してください。

```bash
export TVPULSE_API_KEY='組織のAPIキー'
```

カスタマーAPIの公式ドキュメントは、[APIドキュメント](https://gateway.tvpulse.io/apidocs)をご覧ください。

## 0.xからの移行 (Migrating from 0.x)

PyPIの`tvpulse` 0.0.0-0.2.0は、本SDKとは別の、アーカイブ済みリポジトリ
(`infinimind-inc/tvpulse_python_sdk`)由来の旧SDKです。検索(searches)・エクスポート(exports)
APIを含む現行のカスタマーAPIには対応していません。1.0.0はその後継ではなく、
現行のTVPulseカスタマーAPI向けに書き直した別物のSDKです。

- 旧SDKを使い続ける場合は `pip install "tvpulse<1"` でピン留めしてください。
- 旧SDKのモジュール構成は本リポジトリの外にあり、対応関係を機械的に検証できないため
  網羅的な対応表は提供しません。確認できている範囲では、旧SDKのクライアントに相当するのが
  本SDKの`TVPulseClient`/`AsyncTVPulseClient`（下記「同期クライアント」参照）です。
- 移行時の疑問は support@infinimind.io までご連絡ください。

## 同期クライアント

```python
from tvpulse import SearchTrendRequest, TVPulseClient

request = SearchTrendRequest(
    keyword="チーズケーキ",
    start_date="2026-05-01 00:00:00",
    end_date="2026-05-01 23:59:59",
    streams=["cx", "ntv"],
    frequency="hour",
)

with TVPulseClient() as client:
    response = client.combined.search(request)
    print(response.data)
```

## 原文検索のキーワードなしモード

`SearchRawRequest`の`keyword`は省略できます。キーワードなしの場合は、`streams`と`start_date`/`end_date`の半開区間（最大24時間）か、正の`airing_id`のどちらか一つを指定します。キーワードを指定する場合は、`streams`と開始・終了日時も必要です。`mode`はキーワード検索だけで使えます。

`cm_filter`は`include`（既定）、`exclude`、`only`から選べます。`limit`は1〜100件で、応答の`next_page_token`を次のリクエストに渡すと続きのページを取得できます。検索コンテキストは約2分で失効するため、`cursor_expired`の場合は最初のページから再実行してください。

キーワードなし検索の局コードは1〜15件の重複しないサポート対象局です。15局の入力は受け付けますが、標準料金外のためGatewayで`custom_pricing_required`（403）になります。14局以下は既存の標準料金で計算されます。

```python
from tvpulse import SearchRawRequest, TVPulseClient

request = SearchRawRequest(
    start_date="2026-08-01 00:00:00",
    end_date="2026-08-01 01:00:00",
    streams=["ntv", "tbs"],
    cm_filter="only",
    limit=25,
)

with TVPulseClient() as client:
    page = client.text.search_raw(request)
    if page.next_page_token:
        next_page = client.text.search_raw(request.model_copy(update={"next_page_token": page.next_page_token}))
```

## 検索定義と有界投影

新しい`/v1/api/searches`は、検索条件を軽量なオーナースコープの定義として保存し、必要な投影だけを個別に取得します。作成時は、同じオーナー内で再送を識別する不透明な`idempotency_key`を必ず指定してください。

```python
from datetime import datetime, timezone

from tvpulse import SearchDefinitionCreateRequest, TVPulseClient

request = SearchDefinitionCreateRequest(
    keyword="コーヒー",
    visual_query="コーヒーのパッケージ",
    sources=["ocr", "asr", "visual"],
    streams=["cx", "ntv"],
    start_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
    end_at=datetime(2026, 5, 2, tzinfo=timezone.utc),
)

with TVPulseClient() as client:
    search = client.searches.create(request, idempotency_key="customer-search-20260501-1")
    trend = search.trend(resolution="hour")
    breakdown = search.breakdown()
    evidence_page = search.evidence(limit=50)
    context = search.context(max_chars=5_000)
    programs = search.programs()
```

`projection_hints.evidence_limit`は定義作成時に宣言する任意の深度で、既定値は50、範囲は1〜100です。定義作成の料金はこの宣言を使います。`search.evidence(limit=...)`のGETはクレジットを消費せず、要求値が宣言値を超える場合はHTTP 200のまま宣言値までのページを返します。ページの継続では最初の要求と同じ`limit`を使ってください。これは、定義作成後にGETの`limit`だけを増やして追加の深度を取得できない、意図的な契約変更です。

`search.evidence()`は1ページだけを返します。`evidence_page.next_cursor`がある場合は、その値を次の呼び出しの`cursor`に渡し、処理済みページを保持しないでください。サーバーの要求上限は1ページ100件、カーソル256文字です。投影が個別に非同期として受理された場合はHTTP 202と`ProjectionStatusResponse`が返ります。定義が期限切れの場合は安全な`TVPulseSearchExpiredError`（HTTP 410）が返ります。

Visual ANNはランキングされた`visual_evidence`であり、露出母数、シェア、露出秒数ではありません。検証済みの時間区間をcoalesceしてunionする将来のexposure productだけが、露出秒数を提供できます。

## レコード・集計・エクスポート（アルファ版）

`/v1alpha1/`のレコード関連APIは**アルファ版**です。契約はリリース間で変わる可能性があります。キーワードなしで、OCR観測レコード（`client.text`）とASRセグメントレコード（`client.voice`）をカーソルページングで列挙できます。

- 応答のタイムスタンプはすべてRFC3339の`+09:00`（JST）付きです。入力は任意のオフセット付きRFC3339を受け付けます。期間は半開区間`[start_at, end_at)`です。
- 1ページが課金単位です。`iter_records()`は`next_cursor`を自動で追跡し、`max_records`で上限を指定できます。カーソルが進まなくなった場合（ライブ端）も安全に停止します。
- すべての応答に`coverage`（ストリームごとの`high_water_mark`と`archive_floor`、番組フィルタ時は解決済み区間）が含まれます。
- `commercial_status`は`commercial | program | unknown`の3値です。CM区間メタデータから判定され、レコード側のフラグは使いません。

```python
from tvpulse import TVPulseClient

with TVPulseClient() as client:
    for record in client.text.iter_records(
        streams=["ntv"],
        start_at="2026-08-13T00:00:00+09:00",
        end_at="2026-08-14T00:00:00+09:00",
        page_size=1000,
        max_records=5000,
    ):
        print(record.observed_at, record.commercial_status, record.text)

    aggregates = client.text.aggregates(
        streams=["ntv"],
        start_at="2026-08-13T00:00:00+09:00",
        end_at="2026-08-14T00:00:00+09:00",
        granularity="hour",
        group_by="stream",
    )
```

集計（`aggregates()`）は`granularity`（`minute | hour | day | week`）と`group_by`（`stream` | `total`）を受け付けます。単位はOCR観測数またはASRセグメント数で、モダリティをまたぐ合算はありません。アルファ版では`commercial`は`include`のみサーバーが受理します。

大きな期間は`client.exports`で非同期エクスポートします。`dry_run=True`の見積りが提出時の価格になり、課金は提出時に発生します。状態ポーリングも通常リクエストとして課金されるため、`wait()`の既定`poll_interval`は5秒です。

```python
from tvpulse import ExportCreateRequest, TVPulseClient

request = ExportCreateRequest(
    modality="ocr",
    streams=["ntv"],
    start_at="2026-08-13T00:00:00+09:00",
    end_at="2026-08-14T00:00:00+09:00",
    idempotency_key="export-20260813-ntv-1",
)

with TVPulseClient() as client:
    estimate = client.exports.create(request.model_copy(update={"dry_run": True}))
    accepted = client.exports.create(request)
    final = client.exports.wait(accepted.export_id, poll_interval=5, timeout=1800)
    paths = client.exports.download(accepted.export_id, "./exports", verify_checksums=True)
```

`download()`はマニフェストの各パーツ（presigned URL、15分で失効、ポーリングごとに再発行）をディスクへストリーミングし、sha256を検証してからファイルパスの一覧を返します。チェックサム不一致は`TVPulseExportIntegrityError`になります。エクスポートの成果物は7日で失効します。

## トレンドカードとウォッチ（アルファ版）

`client.trend_cards`は、Xまたはニュースをソースにした自由なトピックの分析カードを生成します。`create()`は、生成を受理した`TrendCardAcceptedResponse`または、十分に新しいカードを再利用した`TrendCardJobResponse`を返します。`create_and_wait()`は受理されたカードだけをポーリングし、キャッシュ済みのHTTP 200は追加の状態確認なしで返します。

`client.watches`は、保存したカード条件を読み取り時だけ更新するlazy watchを管理します。`latest()`の200応答は、新鮮なカード、`refreshing=True`の前回カード、または`card=None`と`refresh_deferred_concurrency_limit`警告を持つ遅延応答です。初回生成は`WatchLatestAcceptedResponse`の202です。200応答の`warnings`は常に確認してください。

カードの状態確認は通常のリクエストとして課金されます。`create_and_wait()`の`poll_interval`には余裕を持たせてください。404は`TVPulseNotFoundError`、409は`TVPulseConflictError`に変換されます。

```python
from tvpulse import TrendCardCreateRequest, TVPulseClient, WatchCreateRequest

with TVPulseClient() as client:
    card = client.trend_cards.create_and_wait(
        TrendCardCreateRequest(topic="コーヒー", source_type="x", window="weekly"),
        poll_interval=5,
    )
    watch = client.watches.create(
        WatchCreateRequest(topic="コーヒー", source_type="x", window="weekly"),
    )
    latest = client.watches.latest(watch.watch_id)
    print(card.status, latest)
```

## 非同期クライアント

```python
import asyncio

from tvpulse import AsyncTVPulseClient, SearchRawRequest

async def main():
    request = SearchRawRequest(
        keyword="ニュース",
        start_date="2026-05-01 00:00:00",
        end_date="2026-05-01 00:30:00",
        streams=["ntv"],
    )

    async with AsyncTVPulseClient() as client:
        response = await client.voice.search_raw(request)
        for hit in response.data:
            print(hit.text)


asyncio.run(main())
```

## 公開操作の対応表

| Gateway操作 | SDKの呼び出し | 応答モデル |
| --- | --- | --- |
| `POST /v1/api/combined/search` | `client.combined.search()` | `CombinedSearchResponse` |
| `POST /v1/api/combined/search/stream` | `client.combined.search()`が条件に応じて自動選択 | `CombinedSearchResponse` |
| `POST /v1/api/combined/summary` | `client.combined.summary()` | `SummaryResponse` |
| `POST /v1/api/combined/nlp` | `client.combined.nlp()` | `CombinedNLPResponse` |
| `POST /v1/api/combined/related-keywords` | `client.combined.related_keywords()` | `RelatedKeywordsResponse` |
| `POST /v1/api/text/search/raw` | `client.text.search_raw()` | `TextSearchResponse` |
| `POST /v1/api/voice/search/raw` | `client.voice.search_raw()` | `VoiceSearchResponse` |
| `POST /v1/api/searches` | `client.searches.create()` | product-shaped search handle |
| `GET /v1/api/searches/{search_id}` | `client.searches.get()` | product-shaped search handle |
| `GET /v1/api/searches/{search_id}/trend` | `search.trend()` | `TrendProjectionResponse`または`ProjectionStatusResponse` |
| `GET /v1/api/searches/{search_id}/breakdown` | `search.breakdown()` | `BreakdownProjectionResponse`または`ProjectionStatusResponse` |
| `GET /v1/api/searches/{search_id}/evidence` | `search.evidence()` | `EvidenceProjectionResponse`または`ProjectionStatusResponse` |
| `GET /v1/api/searches/{search_id}/context` | `search.context()` | `ContextProjectionResponse`または`ProjectionStatusResponse` |
| `GET /v1/api/searches/{search_id}/programs` | `search.programs()` | `ProgramsProjectionResponse`または`ProjectionStatusResponse` |
| `GET /v1alpha1/trend-watch/catalog/reports` | `client.trend_watch.list_reports()` | `TrendWatchReportListResponse` |
| `GET /v1alpha1/trend-watch/catalog/reports/{report_id}` | `client.trend_watch.get_report()` | `TrendWatchReportDetail` |
| `POST /v1alpha1/trend-watch/catalog/reports/exports` | `client.trend_watch.export_reports()` | `TrendWatchExport` |
| `POST /v1alpha1/visual-search/searches` | `client.visual.search()` | `VisualSearchResponse`または`VisualSearchAcceptedResponse` |
| `POST /v1/api/credits/estimate` | `client.credits.estimate()` | `CreditEstimate` |
| `POST /v1/api/credits/estimate/rows` | `client.credits.estimate_rows()` | `CreditEstimateRows` |
| `GET /v1/api/credits` | `client.credits.balance()` | `CreditBalance` |
| `GET /v1alpha1/text/records`（アルファ版） | `client.text.records()` / `client.text.iter_records()` | `TextRecordsPage` / `TextRecord`のイテレータ |
| `GET /v1alpha1/voice/records`（アルファ版） | `client.voice.records()` / `client.voice.iter_records()` | `VoiceRecordsPage` / `VoiceRecord`のイテレータ |
| `GET /v1alpha1/text/aggregates`（アルファ版） | `client.text.aggregates()` | `RecordsAggregatesResponse` |
| `GET /v1alpha1/voice/aggregates`（アルファ版） | `client.voice.aggregates()` | `RecordsAggregatesResponse` |
| `POST /v1alpha1/exports`（アルファ版） | `client.exports.create()` | `ExportAcceptedResponse`または`ExportEstimate` |
| `GET /v1alpha1/exports`（アルファ版） | `client.exports.list()` | `ExportListResponse` |
| `GET /v1alpha1/exports/{export_id}`（アルファ版） | `client.exports.get()` / `client.exports.wait()` / `client.exports.download()` | `ExportStatusResponse` / ダウンロード済みファイルパス |
| `POST /v1alpha1/trend-cards`（アルファ版） | `client.trend_cards.create()` / `client.trend_cards.create_and_wait()` | `TrendCardAcceptedResponse`または`TrendCardJobResponse` |
| `GET /v1alpha1/trend-cards/{card_id}`（アルファ版） | `client.trend_cards.get()` | `TrendCardJobResponse` |
| `POST /v1alpha1/watches`（アルファ版） | `client.watches.create()` | `WatchResponse` |
| `GET /v1alpha1/watches`（アルファ版） | `client.watches.list()` | `WatchListResponse` |
| `GET /v1alpha1/watches/{watch_id}/latest`（アルファ版） | `client.watches.latest()` | `WatchLatestResponse`または`WatchLatestAcceptedResponse` |
| `DELETE /v1alpha1/watches/{watch_id}`（アルファ版） | `client.watches.delete()` | `None` |

Published search projections are synchronous. `search.projection_status()` remains compatibility-only until a real producer exists; no supported production flow creates a projection ID, and the status route is not published in customer OpenAPI.

ビジュアル検索が受付応答になった場合は、公開操作数に含まれない状態確認用の`client.visual.get_search()`を使えます。受付から完了までをまとめる場合は`client.visual.search_and_wait()`を使ってください。状態確認のパスは現在のゲートウェイ実装にありますが、公開OpenAPIでは非表示です。

## クレジットの見積もりと残高

実行前に消費クレジットを確認し、残高と突き合わせられます。どちらの呼び出しも**0クレジット**です（レート制限は消費します）。残高が0のキーやAPIサブスクリプションを持たないキーでも呼び出せます。

```python
from tvpulse import SearchTrendRequest, TVPulseClient

request = SearchTrendRequest(
    keyword="コーヒー",
    start_date="2026-05-01 00:00:00",
    end_date="2026-05-02 00:00:00",
    streams=["ntv"],
)

with TVPulseClient() as client:
    est = client.credits.estimate("/v1/api/combined/search", request)
    if est.balance.sufficient:
        response = client.combined.search(request)
    bal = client.credits.balance()
    print(est.credits, est.outcome, est.factor_revision, bal.total_credits)
```

見積もりは実行時とまったく同じ計算式で計算されるため、同一のリクエストボディ・同一の料金係数リビジョンなら実際の請求と一致します。応答の`factor_revision`が、どの料金表で計算されたかの証明です。`outcome`が`"enterprise_review"`の場合、そのワークロードはEnterprise/個別見積もりの対象です。実行時には403（`custom_pricing_required`）で拒否されクレジットは消費されないため、`credits`は0で、`flags`に`custom_pricing_required:*`（対象の条件）が入ります。

### estimate_rows: 検索結果行数の見積もり

`client.credits.estimate_rows()`は`POST /v1/api/credits/estimate/rows`を呼び出し、検索を実行せずに完全一致する行数と取得クレジットを返します。こちらも**0クレジット**のプリフライトです。エンドポイントは`/v1/api/searches`のみを受け付けます。

```python
from datetime import datetime, timezone

from tvpulse import SearchDefinitionCreateRequest, TVPulseClient

request = SearchDefinitionCreateRequest(
    keyword="コーヒー",
    sources=["ocr", "asr", "visual"],
    streams=["cx", "ntv"],
    start_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
    end_at=datetime(2026, 5, 2, tzinfo=timezone.utc),
)

with TVPulseClient() as client:
    rows = client.credits.estimate_rows("/v1/api/searches", request)
    print(rows.total_rows, rows.retrieval, rows.outcome)
```

`total_rows`はOCR/ASRのテキストソースに一致する行数の合計です。ビジュアル行は行数に含まれず、`visual_excluded`が`true`で`flags`に`visual_rows_not_counted`が入ります。`retrieval`は完全な結果セットを取得するためのクレジットで、フラットな1クレジットの継続ページを含みます。`outcome`が`"enterprise_review"`の場合、宣言されたリクエストまたは取得のページング上限を超えるワークロードで、Enterprise/個別見積もりの対象です。`declared_request_credits`は宣言されたリクエスト自体のクレジットです。

残高応答の`buckets`は種別ごとの内訳です。プラン付帯の月次クレジット（`allowance`）は毎月リセットされ繰り越されません。購入済みクレジットパック（`pack`）は失効しないため`expires_at`は`null`です。

## 入力と応答

日時は操作ごとの契約に合わせて検証します。通常の検索は日本時間のタイムゾーンなし`YYYY-MM-DD HH:mm:ss`、ビジュアル検索はオフセット付きRFC3339です。大文字の検索モード`AND`と`OR`は互換入力として受け付けますが、送信値は`and`と`or`に正規化します。

辞書を渡す場合も、型付きリクエストモデルを通した場合と同じ検証を行います。応答は型付きモデルで返り、未知の応答フィールドも保持します。辞書が必要な連携では、各応答の`to_raw()`を使ってください。

## エラーとリトライ

次の例外を公開しています。

- `TVPulseValidationError`: 400または422
- `TVPulseAuthError`: 401
- `TVPulseAuthorizationError`: 権限不足の403
- `TVPulseNotFoundError`: 404（例: `export_not_found`。互換性のため`TVPulseUnknownServerError`のサブクラス）
- `TVPulseConflictError`: 409（例: エクスポートの同時実行上限。互換性のため`TVPulseUnknownServerError`のサブクラス）
- `TVPulseSearchExpiredError`: 期限切れの検索定義（410、`error_code=search_expired`）
- `TVPulseInsufficientCreditError`: `error_code=insufficient_credit`の403
- `TVPulseRateLimitError`: 429
- `TVPulseBackendError`: 5xx
- `TVPulseUnknownServerError`: 契約外のステータスまたは解釈できない応答
- `TVPulseTimeoutError`: タイムアウト
- `TVPulseNetworkError`: 接続などのネットワークエラー
- `TVPulseExportIntegrityError`: エクスポートパーツのsha256チェックサム不一致

APIエラーには`status_code`、`error_code`、`correlation_id`、`retry_after`、`retryable`、`body`が含まれます。旧SDKとの互換性のため`code`と`request_id`も利用できます。

既定では最大2回の追加試行を行います。ネットワークエラー、429、5xxだけを対象にし、429と5xxでは`Retry-After`を優先します。それ以外の4xxは再試行しません。必要に応じて`max_retries=0`、`retry_backoff=秒数`で調整できます。

```python
from tvpulse import TVPulseClient

with TVPulseClient(max_retries=2, retry_backoff=0.5) as client:
    response = client.text.search_raw(request)
```

## サンプル

- [`examples/voice_search.py`](examples/voice_search.py): 音声検索
- [`examples/combined_search.py`](examples/combined_search.py): 集計検索と型付き応答
- [`examples/searches.py`](examples/searches.py): 検索定義と有界投影
- [`examples/text_records.py`](examples/text_records.py): レコード列挙とエクスポート（アルファ版）

サンプルの日付は契約形式を示す固定例です。利用時は、利用可能な放送期間に置き換えてください。
