open-notebook/api/client.py
MisonL 67dd85c928
Feat/localization tests docker (#371)
* feat(i18n): complete 100% internationalization and fix Next.js 15 compatibility

* feat(i18n): complete 100% internationalization coverage

* chore(test): finalize component tests and project cleanup

* test(logic): add unit tests for useModalManager hook

* fix(test): resolve timeout in AppSidebar tests by mocking TooltipProvider

* feat(i18n): comprehensive i18n audit, fixes for hardcoded strings, and complete zh-TW support

* fix(i18n): resolve TypeScript warnings and improve translation hook stability

- Remove unused useTranslation import from ConnectionGuard
- Add ref-based checking state to prevent dependency cycles
- Fix useTranslation hook to return empty string for undefined translations
- Add comment for backward compatibility on ExtractedReference interface
- Ensure .replace() string methods work safely with nested translation keys

* feat(i18n): complete internationalization implementation with Docker deployment

- Add LanguageLoadingOverlay component for smooth language transitions
- Update all translation files (en-US, zh-CN, zh-TW) with improved terminology
- Optimize Docker configuration for better performance
- Update version check and config handling for i18n support
- Fix route handling for language-specific content
- Add comprehensive task documentation

* fix(i18n): resolve localization errors, duplicates, and type issues

* chore(i18n): finalize 100% internationalization coverage

* chore(test): supplement i18n test cases and cleanup redundant files

* fix(test): resolve lint type errors and finalize delivery documents

* feat(i18n): finalize full internationalization and zh-TW localization

* fix(frontend): add missing devDependency and fix build tsconfig

* feat(ui): enhance sidebar hover effects with better visual feedback

* fix(frontend): resolve accessibility, i18n, and lint issues

- fix: add missing id, name, autocomplete attributes to dialog inputs
- fix: add aria labels and DialogDescription for accessibility
- fix: resolve uncontrolled component warning in SettingsForm
- fix: correct duplicate 'Traditional Chinese' label in zh-TW locale
- feat: add i18n support for podcast template names
- chore: fix lint errors in Dialogs

* fix: address all 21 PR feedback items from cubic-dev-ai bot

Configuration:
- Remove ignoreDuringBuilds flags from next.config.ts

Testing:
- Fix AppSidebar.test.tsx regex pattern and add missing assertion

Logic:
- Fix ConnectionGuard.tsx re-entry prevention logic

Internationalization (I18n) - Translations:
- Add missing keys: notebooks.archived, common.note/insight, accessibility keys
- Add specific keys: sources.allSourcesDescShort, transformations.selectModel
- Add singular/plural keys: podcasts.usedByCount_one/other, common.note/notes
- Add common.created/updated with {time} placeholder

Internationalization (I18n) - Usage:
- SourcesPage: use allSourcesDescShort instead of string splitting
- TransformationPlayground: use navigation.transformation and selectModel
- CommandPalette: use dedicated keys instead of string concatenation
- GeneratePodcastDialog: fix zh-TW date locale handling
- NotebookHeader: correctly interpolate {time} placeholder
- TransformationCard: use common.description instead of undefined key
- ChatPanel/SpeakerProfilesPanel: implement proper pluralization
- SystemInfo: correctly interpolate {version} placeholder
- LanguageLoadingOverlay: use t.common.loading instead of hardcoded string
- MessageActions: use specific error key cannotSaveNoteNoNotebook

Other:
- Fix SessionManager.tsx exhaustive-deps warning

* fix: remove duplicate locale keys and add missing zh-CN translations

- en-US: remove duplicate loading key (line 59) and addNew key (sources)
- zh-CN: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-CN: remove duplicate accessibility.searchNotebooks key
- zh-CN: remove duplicate sources.addNew key
- zh-CN: remove duplicate navigation.transformation key
- zh-CN: add missing usedByCount_one and usedByCount_other keys in podcasts
- zh-TW: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-TW: remove duplicate accessibility.searchNotebooks key
- zh-TW: remove duplicate sources.addNew key

* docs: remove info.md

* fix: remove duplicate notebook keys and unused ts-expect-error

- zh-CN: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- zh-TW: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- GeneratePodcastDialog: remove unused @ts-expect-error directive

* fix(a11y): fix unassociated labels in search page

- Replace <Label> with role='group' + aria-labelledby for search type section
- Replace <Label> with role='group' + aria-labelledby for search in section
- Follows WAI-ARIA best practices for labeling form field groups

* fix(a11y): fix unassociated labels across multiple components

- search/page.tsx: use role='group' + aria-labelledby for search type and search in sections
- RebuildEmbeddings.tsx: use role='group' + aria-labelledby for include checkboxes
- TransformationPlayground.tsx: replace Label with span for non-form output label

* chore: revert to npm stack and ensure i18n compatibility

* chore: polish zh-TW translations for better idiomatic usage

* fix: resolve linter errors (ruff import sort, mypy config duplicate)

* style: apply ruff formatting

* fix: finalize upstream compliance (Dockerfile.single, i18n hooks, docker-compose)

* style: polish strings, fix timeout cleanup, and improve test mocks

* fix: use relative imports in test setup to resolve IDE path errors

* perf(docker): optimize build speed by removing apt-get upgrade and build tools

- Remove apt-get upgrade from both builder and runtime stages (saves 10-15 min each)
- Remove gcc/g++/make/git from builder (uv downloads pre-built wheels)
- Add --no-install-recommends to minimize package footprint
- Keep npm mirror (npmmirror.com) for faster frontend deps
- Add npm registry config for reliable China network access

Also includes:
- fix(a11y): add missing labels and aria attributes to form fields
- fix(i18n): add 2s safety timeout to LanguageLoadingOverlay
- fix(i18n): add robustness checks to use-translation proxy

Build time reduced from 2+ hours to ~34 minutes (~70% improvement)

* fix(a11y): resolve 16 form field accessibility warnings in notebook and podcast pages

* fix(a11y): resolve 4 button and 1 select field accessibility warnings in models page

* fix(a11y): resolve redundant attributes and residual warnings in transformations and podcast forms

* fix(i18n): deep fix for language switch hang using proxy protection and safer access

* fix(a11y): add name attributes to ModelSelector, TransformationPlayground, and SourceDetailContent

* fix: add missing Label import to SourceDetailContent

* fix(i18n): use native react-i18next in LanguageLoadingOverlay to prevent hang during language switch

* fix(i18n): rewrite use-translation Proxy with strict depth limit and expanded blocked props to prevent language switch hang

* fix: add type assertion to fix TypeScript comparison error

* fix(i18n): disable useSuspense to prevent thread hang during language resource loading

* fix(i18n): add infinite loop detection circuit breaker to useTranslation hook

* fix(i18n): update traditional chinese label to native script in en-US

* feat: add new localization strings for notebook and note management.

* fix: resolve config priority, docker build deps, and ui glitches

* refactor: improve ui details and test coverage based on feedback

* refactor: improve ui details (version check/lang toggle) and test coverage

* fix: polish language matching and test cleanup

* fix(test): update mocks to resolve timeouts and proxy errors

* fix(frontend): restore tsconfig.json structure and enable IDE support for tests

* fix: address PR review findings and resolve CI OIDC failure

* fix: merge exception headers in custom handler

* fix: comprehensive PR review remediations and async performance fixes

* refactor: address all PR #371 review feedback

- Docker: consolidate SURREAL_URL to docker.env, add single-container override
- Security: restore apt-get upgrade in Dockerfile and Dockerfile.single
- Create centralized getDateLocale helper (lib/utils/date-locale.ts)
- Refactor 7 files to use getDateLocale helper
- Revert config/route.ts to origin/main version
- Move test files to co-located pattern (3 files)
- Remove local useTranslation mock from ConfirmDialog.test.tsx
- Simplify use-version-check to single useEffect pattern
- Fix test import paths after moving to co-located pattern

* fix: add jest-dom types for test files

* fix: address remaining review issues

- Add apt-get upgrade -y to Dockerfile.single backend-builder stage
- Refactor ChatColumn.test.tsx: use 'as unknown as ReturnType<typeof hook>' instead of 'as any'
- Use toBeInTheDocument() assertions instead of toBeDefined()
2026-01-15 13:51:05 -03:00

529 lines
20 KiB
Python

"""
API client for Open Notebook API.
This module provides a client interface to interact with the Open Notebook API.
"""
import os
from typing import Any, Dict, List, Optional, Union
import httpx
from loguru import logger
class APIClient:
"""Client for Open Notebook API."""
def __init__(self, base_url: Optional[str] = None):
self.base_url = base_url or os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
# Timeout increased to 5 minutes (300s) to accommodate slow LLM operations
# (transformations, insights) on slower hardware (Ollama, LM Studio, remote APIs)
# Configurable via API_CLIENT_TIMEOUT environment variable (in seconds)
timeout_str = os.getenv("API_CLIENT_TIMEOUT", "300.0")
try:
timeout_value = float(timeout_str)
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
if timeout_value < 30:
logger.warning(
f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s"
)
timeout_value = 30.0
elif timeout_value > 3600:
logger.warning(
f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s"
)
timeout_value = 3600.0
self.timeout = timeout_value
except ValueError:
logger.error(
f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s"
)
self.timeout = 300.0
# Add authentication header if password is set
self.headers = {}
password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
if password:
self.headers["Authorization"] = f"Bearer {password}"
def _make_request(
self, method: str, endpoint: str, timeout: Optional[float] = None, **kwargs
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Make HTTP request to the API."""
url = f"{self.base_url}{endpoint}"
request_timeout = timeout if timeout is not None else self.timeout
# Merge headers
headers = kwargs.get("headers", {})
headers.update(self.headers)
kwargs["headers"] = headers
try:
with httpx.Client(timeout=request_timeout) as client:
response = client.request(method, url, **kwargs)
response.raise_for_status()
return response.json()
except httpx.RequestError as e:
logger.error(f"Request error for {method} {url}: {str(e)}")
raise ConnectionError(f"Failed to connect to API: {str(e)}")
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error {e.response.status_code} for {method} {url}: {e.response.text}"
)
raise RuntimeError(
f"API request failed: {e.response.status_code} - {e.response.text}"
)
except Exception as e:
logger.error(f"Unexpected error for {method} {url}: {str(e)}")
raise
# Notebooks API methods
def get_notebooks(
self, archived: Optional[bool] = None, order_by: str = "updated desc"
) -> List[Dict[Any, Any]]:
"""Get all notebooks."""
params: Dict[str, Any] = {"order_by": order_by}
if archived is not None:
params["archived"] = str(archived).lower()
result = self._make_request("GET", "/api/notebooks", params=params)
return result if isinstance(result, list) else [result]
def create_notebook(
self, name: str, description: str = ""
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new notebook."""
data = {"name": name, "description": description}
return self._make_request("POST", "/api/notebooks", json=data)
def get_notebook(
self, notebook_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific notebook."""
return self._make_request("GET", f"/api/notebooks/{notebook_id}")
def update_notebook(
self, notebook_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a notebook."""
return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
def delete_notebook(
self, notebook_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a notebook."""
return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
# Search API methods
def search(
self,
query: str,
search_type: str = "text",
limit: int = 100,
search_sources: bool = True,
search_notes: bool = True,
minimum_score: float = 0.2,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Search the knowledge base."""
data = {
"query": query,
"type": search_type,
"limit": limit,
"search_sources": search_sources,
"search_notes": search_notes,
"minimum_score": minimum_score,
}
return self._make_request("POST", "/api/search", json=data)
def ask_simple(
self,
question: str,
strategy_model: str,
answer_model: str,
final_answer_model: str,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Ask the knowledge base a question (simple, non-streaming)."""
data = {
"question": question,
"strategy_model": strategy_model,
"answer_model": answer_model,
"final_answer_model": final_answer_model,
}
# Use configured timeout for long-running ask operations
return self._make_request(
"POST", "/api/search/ask/simple", json=data, timeout=self.timeout
)
# Models API methods
def get_models(self, model_type: Optional[str] = None) -> List[Dict[Any, Any]]:
"""Get all models with optional type filtering."""
params = {}
if model_type:
params["type"] = model_type
result = self._make_request("GET", "/api/models", params=params)
return result if isinstance(result, list) else [result]
def create_model(
self, name: str, provider: str, model_type: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new model."""
data = {
"name": name,
"provider": provider,
"type": model_type,
}
return self._make_request("POST", "/api/models", json=data)
def delete_model(
self, model_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a model."""
return self._make_request("DELETE", f"/api/models/{model_id}")
def get_default_models(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get default model assignments."""
return self._make_request("GET", "/api/models/defaults")
def update_default_models(
self, **defaults
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update default model assignments."""
return self._make_request("PUT", "/api/models/defaults", json=defaults)
# Transformations API methods
def get_transformations(self) -> List[Dict[Any, Any]]:
"""Get all transformations."""
result = self._make_request("GET", "/api/transformations")
return result if isinstance(result, list) else [result]
def create_transformation(
self,
name: str,
title: str,
description: str,
prompt: str,
apply_default: bool = False,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new transformation."""
data = {
"name": name,
"title": title,
"description": description,
"prompt": prompt,
"apply_default": apply_default,
}
return self._make_request("POST", "/api/transformations", json=data)
def get_transformation(
self, transformation_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific transformation."""
return self._make_request("GET", f"/api/transformations/{transformation_id}")
def update_transformation(
self, transformation_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a transformation."""
return self._make_request(
"PUT", f"/api/transformations/{transformation_id}", json=updates
)
def delete_transformation(
self, transformation_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a transformation."""
return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
def execute_transformation(
self, transformation_id: str, input_text: str, model_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Execute a transformation on input text."""
data = {
"transformation_id": transformation_id,
"input_text": input_text,
"model_id": model_id,
}
# Use configured timeout for transformation operations
return self._make_request(
"POST", "/api/transformations/execute", json=data, timeout=self.timeout
)
# Notes API methods
def get_notes(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
"""Get all notes with optional notebook filtering."""
params = {}
if notebook_id:
params["notebook_id"] = notebook_id
result = self._make_request("GET", "/api/notes", params=params)
return result if isinstance(result, list) else [result]
def create_note(
self,
content: str,
title: Optional[str] = None,
note_type: str = "human",
notebook_id: Optional[str] = None,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new note."""
data = {
"content": content,
"note_type": note_type,
}
if title:
data["title"] = title
if notebook_id:
data["notebook_id"] = notebook_id
return self._make_request("POST", "/api/notes", json=data)
def get_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific note."""
return self._make_request("GET", f"/api/notes/{note_id}")
def update_note(
self, note_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a note."""
return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
def delete_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a note."""
return self._make_request("DELETE", f"/api/notes/{note_id}")
# Embedding API methods
def embed_content(
self, item_id: str, item_type: str, async_processing: bool = False
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Embed content for vector search."""
data = {
"item_id": item_id,
"item_type": item_type,
"async_processing": async_processing,
}
# Use configured timeout for embedding operations
return self._make_request("POST", "/api/embed", json=data, timeout=self.timeout)
def rebuild_embeddings(
self,
mode: str = "existing",
include_sources: bool = True,
include_notes: bool = True,
include_insights: bool = True,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Rebuild embeddings in bulk.
Note: This operation can take a long time for large databases.
Consider increasing API_CLIENT_TIMEOUT to 600-900s for bulk rebuilds.
"""
data = {
"mode": mode,
"include_sources": include_sources,
"include_notes": include_notes,
"include_insights": include_insights,
}
# Use double the configured timeout for bulk rebuild operations (or configured value if already high)
rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
return self._make_request(
"POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout
)
def get_rebuild_status(
self, command_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get status of a rebuild operation."""
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
# Settings API methods
def get_settings(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get all application settings."""
return self._make_request("GET", "/api/settings")
def update_settings(
self, **settings
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update application settings."""
return self._make_request("PUT", "/api/settings", json=settings)
# Context API methods
def get_notebook_context(
self, notebook_id: str, context_config: Optional[Dict] = None
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get context for a notebook."""
data: Dict[str, Any] = {"notebook_id": notebook_id}
if context_config:
data["context_config"] = context_config
result = self._make_request(
"POST", f"/api/notebooks/{notebook_id}/context", json=data
)
return result if isinstance(result, dict) else {}
# Sources API methods
def get_sources(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
"""Get all sources with optional notebook filtering."""
params = {}
if notebook_id:
params["notebook_id"] = notebook_id
result = self._make_request("GET", "/api/sources", params=params)
return result if isinstance(result, list) else [result]
def create_source(
self,
notebook_id: Optional[str] = None,
notebooks: Optional[List[str]] = None,
source_type: str = "text",
url: Optional[str] = None,
file_path: Optional[str] = None,
content: Optional[str] = None,
title: Optional[str] = None,
transformations: Optional[List[str]] = None,
embed: bool = False,
delete_source: bool = False,
async_processing: bool = False,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new source."""
data = {
"type": source_type,
"embed": embed,
"delete_source": delete_source,
"async_processing": async_processing,
}
# Handle backward compatibility for notebook_id vs notebooks
if notebooks:
data["notebooks"] = notebooks
elif notebook_id:
data["notebook_id"] = notebook_id
else:
raise ValueError("Either notebook_id or notebooks must be provided")
if url:
data["url"] = url
if file_path:
data["file_path"] = file_path
if content:
data["content"] = content
if title:
data["title"] = title
if transformations:
data["transformations"] = transformations
# Use configured timeout for source creation (especially PDF processing with OCR)
return self._make_request(
"POST", "/api/sources/json", json=data, timeout=self.timeout
)
def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific source."""
return self._make_request("GET", f"/api/sources/{source_id}")
def get_source_status(
self, source_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get processing status for a source."""
return self._make_request("GET", f"/api/sources/{source_id}/status")
def update_source(
self, source_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a source."""
return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
def delete_source(
self, source_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a source."""
return self._make_request("DELETE", f"/api/sources/{source_id}")
# Insights API methods
def get_source_insights(self, source_id: str) -> List[Dict[Any, Any]]:
"""Get all insights for a specific source."""
result = self._make_request("GET", f"/api/sources/{source_id}/insights")
return result if isinstance(result, list) else [result]
def get_insight(
self, insight_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific insight."""
return self._make_request("GET", f"/api/insights/{insight_id}")
def delete_insight(
self, insight_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a specific insight."""
return self._make_request("DELETE", f"/api/insights/{insight_id}")
def save_insight_as_note(
self, insight_id: str, notebook_id: Optional[str] = None
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Convert an insight to a note."""
data = {}
if notebook_id:
data["notebook_id"] = notebook_id
return self._make_request(
"POST", f"/api/insights/{insight_id}/save-as-note", json=data
)
def create_source_insight(
self, source_id: str, transformation_id: str, model_id: Optional[str] = None
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new insight for a source by running a transformation."""
data = {"transformation_id": transformation_id}
if model_id:
data["model_id"] = model_id
return self._make_request(
"POST", f"/api/sources/{source_id}/insights", json=data
)
# Episode Profiles API methods
def get_episode_profiles(self) -> List[Dict[Any, Any]]:
"""Get all episode profiles."""
result = self._make_request("GET", "/api/episode-profiles")
return result if isinstance(result, list) else [result]
def get_episode_profile(
self, profile_name: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific episode profile by name."""
return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
def create_episode_profile(
self,
name: str,
description: str = "",
speaker_config: str = "",
outline_provider: str = "",
outline_model: str = "",
transcript_provider: str = "",
transcript_model: str = "",
default_briefing: str = "",
num_segments: int = 5,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new episode profile."""
data = {
"name": name,
"description": description,
"speaker_config": speaker_config,
"outline_provider": outline_provider,
"outline_model": outline_model,
"transcript_provider": transcript_provider,
"transcript_model": transcript_model,
"default_briefing": default_briefing,
"num_segments": num_segments,
}
return self._make_request("POST", "/api/episode-profiles", json=data)
def update_episode_profile(
self, profile_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update an episode profile."""
return self._make_request(
"PUT", f"/api/episode-profiles/{profile_id}", json=updates
)
def delete_episode_profile(
self, profile_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete an episode profile."""
return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")
# Global client instance
api_client = APIClient()