hyperstudy 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/.github/workflows/publish.yml +2 -1
  2. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/.github/workflows/test.yml +2 -1
  3. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/CHANGELOG.md +27 -0
  4. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/PKG-INFO +53 -3
  5. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/README.md +51 -1
  6. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/pyproject.toml +1 -1
  7. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/__init__.py +15 -1
  8. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/_http.py +17 -0
  9. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/_types.py +1 -0
  10. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/client.py +134 -14
  11. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/experiments.py +13 -17
  12. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/models.py +176 -23
  13. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/personas.py +29 -8
  14. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/conftest.py +14 -0
  15. hyperstudy-0.4.2/tests/fixtures/agent_decision_detail_response.json +44 -0
  16. hyperstudy-0.4.2/tests/fixtures/deployment_single_response.json +22 -0
  17. hyperstudy-0.4.2/tests/test_agent_authoring.py +190 -0
  18. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_client.py +2 -2
  19. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_deployments_write.py +6 -10
  20. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_http.py +20 -0
  21. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_personas.py +12 -16
  22. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_recordings_download.py +4 -2
  23. hyperstudy-0.4.2/tests/test_voice_and_filters.py +139 -0
  24. hyperstudy-0.4.2/tests/test_wire_keys.py +195 -0
  25. hyperstudy-0.4.1/tests/fixtures/agent_decision_detail_response.json +0 -30
  26. hyperstudy-0.4.1/tests/fixtures/deployment_single_response.json +0 -20
  27. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/.github/workflows/sync-release-notes.yml +0 -0
  28. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/.gitignore +0 -0
  29. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/LICENSE +0 -0
  30. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  31. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/_dataframe.py +0 -0
  32. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/_display.py +0 -0
  33. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/_downloads.py +0 -0
  34. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/_pagination.py +0 -0
  35. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/src/hyperstudy/exceptions.py +0 -0
  36. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/__init__.py +0 -0
  37. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/agent_decisions_response.json +0 -0
  38. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/agent_decisions_truncated_response.json +0 -0
  39. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/agent_runs_response.json +0 -0
  40. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/deployment_sessions_response.json +0 -0
  41. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/deployments_list_response.json +0 -0
  42. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/error_401.json +0 -0
  43. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/error_403.json +0 -0
  44. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/events_response.json +0 -0
  45. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/experiment.schema.json +0 -0
  46. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/experiment_single_response.json +0 -0
  47. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/experiments_list_response.json +0 -0
  48. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/paginated_page1.json +0 -0
  49. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/paginated_page2.json +0 -0
  50. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/pre_experiment_response.json +0 -0
  51. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/recordings_response.json +0 -0
  52. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/sparse_ratings_response.json +0 -0
  53. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/fixtures/warnings_response.json +0 -0
  54. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_agent_data.py +0 -0
  55. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_dataframe.py +0 -0
  56. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_downloads.py +0 -0
  57. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_experiments.py +0 -0
  58. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_models.py +0 -0
  59. {hyperstudy-0.4.1 → hyperstudy-0.4.2}/tests/test_pagination.py +0 -0
@@ -6,7 +6,8 @@ on:
6
6
 
7
7
  jobs:
8
8
  test:
9
- runs-on: ubuntu-latest
9
+ # Python 3.9 has no build for ubuntu-latest (26.04); pinned until 3.9 support is dropped.
10
+ runs-on: ubuntu-24.04
10
11
  strategy:
11
12
  matrix:
12
13
  python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
@@ -7,7 +7,8 @@ on:
7
7
 
8
8
  jobs:
9
9
  test:
10
- runs-on: ubuntu-latest
10
+ # Python 3.9 has no build for ubuntu-latest (26.04); pinned until 3.9 support is dropped.
11
+ runs-on: ubuntu-24.04
11
12
  strategy:
12
13
  matrix:
13
14
  python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2
4
+
5
+ ### Breaking Changes
6
+
7
+ - **Removed `Persona.language`.** The server discarded it and nothing read it. The SDK strips it from persona payloads, so personas rebuilt from older stored documents still save, and warns when it drops a value someone set. To set the language participants speak, use the experiment's `agent_config.voice_transcription.language`.
8
+
9
+ ### Fixes
10
+
11
+ - **Single-object responses are now returned as objects.** The v3 API always sends `data` as an array, so `get_persona`, `create_persona`, `update_persona`, `duplicate_persona`, `create_deployment`, `get_agent_spend`, `run_more`, `retry_room` and `get_agent_decision` returned a one-element list instead of a dict, and `download_recording` / `download_recordings` raised `AttributeError` for recordings with a `downloadPath`. A single-object response with no record now raises `HyperStudyError` instead of returning `{}`. Test mocks now build the envelope exactly as the server does.
12
+ - **Keys that are not the SDK's own are sent as written** on the kwargs and raw-dict paths. Previously `extra_params={"top_k": 20}` reached a custom provider as `topK`, a cognition context named `post_trial` was stored as `postTrial`, voice turn-detection settings (`silence_duration_ms`, ...) were camelCased and rejected by the server, `prolific_enabled` / `prolific_config` were renamed and dropped, and stimulus mapping names were renamed. Schema fields such as `waiting_room_config` are still converted.
13
+ - **Typed models can be passed as keyword arguments**: `update_experiment(agent_config=AgentConfig(...))`, `update_experiment(waiting_room_config=WaitingRoomConfig(...))` and `update_persona(voice=PersonaVoice(...))` raised `TypeError: Object of type ... is not JSON serializable`.
14
+ - `Persona(display_name=...)` and other undeclared persona fields were sent in snake_case and silently ignored by the server; `display_name` is now a declared field.
15
+
16
+ ### Features
17
+
18
+ - **Voice agent conversations**: `get_voice(scope_id, scope=, deployment_id=, room_id=, include_context=, order=)` returns one row per voice event: every speaker's utterances with transcripts, discarded and silent agent replies, what the voice engine was told, and engine connection drops. `DataType.VOICE`; `get_all_data` includes `voice`. Requires the `read:events` scope.
19
+ - **Voice personas**: `Persona.voice` (`PersonaVoice`, `RealtimeVoice`: engine, preset, speed, style, realtime provider and model), `Persona.display_name`, and `Persona.chat` (`PersonaChat`: `initiate`, `initiate_delay_ms`).
20
+ - **Voice settings per experiment**: `AgentConfig.voice_turn_taking` (`VoiceTurnTaking` with `EngineTurnDetection`, sent in the engine's own snake_case) and `AgentConfig.voice_transcription` (`VoiceTranscription`). Ranges mirror the server's validators, so mistakes fail when the model is built.
21
+ - **Experiment fields**: `visibility`, `tags`, `enable_participant_messaging`, `experimenter_message_style` (`ExperimenterMessageStyle`), `stimulus_mappings`, `device_integration`, `prolific_enabled`, `prolific_config`.
22
+ - **Data filters**: `get_components(component_type=, state_id=, completed=, start_time=, end_time=, sort=, order=)`, `get_participants(completion_code=, sort=, order=)`, `get_rooms(sort=, order=)`.
23
+ - **`check_data_access(experiment_id)`**: `{canView, canExport}` without fetching data.
24
+
25
+ ### Notes
26
+
27
+ - `update_persona` and `update_experiment` replace a nested block (`voice`, `chat`, `prompt`, `guardrails`, `agent_config`, ...) whole; pass the complete block to change one setting in it. Only an experiment's or persona's owner, or an organization admin, may change its `visibility`.
28
+ - New server-side columns arrive in the existing DataFrames with no SDK change: event clock fields (`clientTime`, `timestampSource`, `clockOffsetMs`, `rawOnset`, `clockOffsetApplied`), `decisionError` on agent decisions, and `completionReason` / `agentStartFailures` on rooms.
29
+
3
30
  ## v0.4.1
4
31
 
5
32
  ### Breaking Changes
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hyperstudy
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for the HyperStudy experiment platform API
5
5
  Project-URL: Homepage, https://hyperstudy.io
6
6
  Project-URL: Documentation, https://docs.hyperstudy.io/developers/python-sdk
@@ -75,6 +75,7 @@ All data retrieval methods follow the same pattern:
75
75
  events = hs.get_events("exp_id")
76
76
  recordings = hs.get_recordings("exp_id")
77
77
  chat = hs.get_chat("exp_id")
78
+ voice = hs.get_voice("exp_id") # voice agent conversations
78
79
  videochat = hs.get_videochat("exp_id")
79
80
  sync = hs.get_sync("exp_id")
80
81
  ratings = hs.get_ratings("exp_id", kind="continuous")
@@ -94,6 +95,9 @@ variables = hs.get_variables("room_id")
94
95
 
95
96
  # Per-data-type document counts for one participant in a room
96
97
  counts = hs.get_counts("participant_id", room_id="room_id")
98
+
99
+ # Whether you may view / export an experiment's data (no data fetched)
100
+ access = hs.check_data_access("exp_id") # {"canView": True, "canExport": False}
97
101
  ```
98
102
 
99
103
  ### Output Formats
@@ -115,6 +119,9 @@ events = hs.get_events(
115
119
  sort="onset",
116
120
  limit=100,
117
121
  )
122
+
123
+ ratings = hs.get_components("exp_id", component_type="vasrating", completed=True)
124
+ abandoned = hs.get_participants("exp_id", completion_code="ABANDONED")
118
125
  ```
119
126
 
120
127
  ### Auto-Pagination
@@ -231,6 +238,49 @@ detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
231
238
  catalog = hs.get_cognition_catalog() # {"abilities": ..., "recipes": ..., "offlineRecipes": ...}
232
239
  ```
233
240
 
241
+ ### Voice agents
242
+
243
+ A persona with a `voice` speaks in the experiment's audio. The experiment sets how its voice agents take turns and whether participants' speech is transcribed.
244
+
245
+ ```python
246
+ from hyperstudy import (
247
+ PersonaVoice, RealtimeVoice, VoiceTurnTaking, EngineTurnDetection, VoiceTranscription,
248
+ )
249
+
250
+ voice_persona = hs.create_persona(persona=Persona(
251
+ name="Interviewer",
252
+ display_name="Sam", # what other participants see
253
+ provider="openai",
254
+ voice=PersonaVoice(
255
+ engine="realtime",
256
+ preset="marin", # the provider's voice
257
+ speed=1.0,
258
+ style="warm, a little hesitant",
259
+ realtime=RealtimeVoice(provider="openai", model="gpt-realtime-2.1"),
260
+ ),
261
+ ))
262
+
263
+ exp = hs.create_experiment(experiment=Experiment(
264
+ name="Voice interview",
265
+ runtime="v2",
266
+ roles={"interviewer": Role(mode="agent", persona_id=voice_persona["id"])},
267
+ agent_config=AgentConfig(
268
+ voice_turn_taking=VoiceTurnTaking(
269
+ engine_turn_detection=EngineTurnDetection(type="server_vad", silence_duration_ms=800),
270
+ min_response_delay_ms=250,
271
+ ),
272
+ voice_transcription=VoiceTranscription(enabled=True, language="en"),
273
+ ),
274
+ states=[...],
275
+ ))
276
+
277
+ # One room's conversation: every speaker's turns, plus what the engine was told
278
+ conversation = hs.get_voice("room_id", scope="room")
279
+ turns = conversation[conversation["kind"] == "utterance"][["onset", "speaker", "transcript"]]
280
+ ```
281
+
282
+ `update_persona` and `update_experiment` replace a nested block whole, so pass the complete `voice` or `agent_config` when changing one setting in it.
283
+
234
284
  Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
235
285
 
236
286
  ## All Data for a Participant
@@ -238,7 +288,7 @@ Persona methods need the `read:personas` / `write:personas` API-key scopes; depl
238
288
  ```python
239
289
  data = hs.get_all_data("participant_id", room_id="room_id")
240
290
  # Returns dict with keys: events, recordings, chat, videochat, sync,
241
- # ratings_continuous, ratings_sparse, components, eyetracking,
291
+ # ratings_continuous, ratings_sparse, components, eyetracking, voice,
242
292
  # questionnaire, instructions, consent, agent_decisions
243
293
  ```
244
294
 
@@ -40,6 +40,7 @@ All data retrieval methods follow the same pattern:
40
40
  events = hs.get_events("exp_id")
41
41
  recordings = hs.get_recordings("exp_id")
42
42
  chat = hs.get_chat("exp_id")
43
+ voice = hs.get_voice("exp_id") # voice agent conversations
43
44
  videochat = hs.get_videochat("exp_id")
44
45
  sync = hs.get_sync("exp_id")
45
46
  ratings = hs.get_ratings("exp_id", kind="continuous")
@@ -59,6 +60,9 @@ variables = hs.get_variables("room_id")
59
60
 
60
61
  # Per-data-type document counts for one participant in a room
61
62
  counts = hs.get_counts("participant_id", room_id="room_id")
63
+
64
+ # Whether you may view / export an experiment's data (no data fetched)
65
+ access = hs.check_data_access("exp_id") # {"canView": True, "canExport": False}
62
66
  ```
63
67
 
64
68
  ### Output Formats
@@ -80,6 +84,9 @@ events = hs.get_events(
80
84
  sort="onset",
81
85
  limit=100,
82
86
  )
87
+
88
+ ratings = hs.get_components("exp_id", component_type="vasrating", completed=True)
89
+ abandoned = hs.get_participants("exp_id", completion_code="ABANDONED")
83
90
  ```
84
91
 
85
92
  ### Auto-Pagination
@@ -196,6 +203,49 @@ detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
196
203
  catalog = hs.get_cognition_catalog() # {"abilities": ..., "recipes": ..., "offlineRecipes": ...}
197
204
  ```
198
205
 
206
+ ### Voice agents
207
+
208
+ A persona with a `voice` speaks in the experiment's audio. The experiment sets how its voice agents take turns and whether participants' speech is transcribed.
209
+
210
+ ```python
211
+ from hyperstudy import (
212
+ PersonaVoice, RealtimeVoice, VoiceTurnTaking, EngineTurnDetection, VoiceTranscription,
213
+ )
214
+
215
+ voice_persona = hs.create_persona(persona=Persona(
216
+ name="Interviewer",
217
+ display_name="Sam", # what other participants see
218
+ provider="openai",
219
+ voice=PersonaVoice(
220
+ engine="realtime",
221
+ preset="marin", # the provider's voice
222
+ speed=1.0,
223
+ style="warm, a little hesitant",
224
+ realtime=RealtimeVoice(provider="openai", model="gpt-realtime-2.1"),
225
+ ),
226
+ ))
227
+
228
+ exp = hs.create_experiment(experiment=Experiment(
229
+ name="Voice interview",
230
+ runtime="v2",
231
+ roles={"interviewer": Role(mode="agent", persona_id=voice_persona["id"])},
232
+ agent_config=AgentConfig(
233
+ voice_turn_taking=VoiceTurnTaking(
234
+ engine_turn_detection=EngineTurnDetection(type="server_vad", silence_duration_ms=800),
235
+ min_response_delay_ms=250,
236
+ ),
237
+ voice_transcription=VoiceTranscription(enabled=True, language="en"),
238
+ ),
239
+ states=[...],
240
+ ))
241
+
242
+ # One room's conversation: every speaker's turns, plus what the engine was told
243
+ conversation = hs.get_voice("room_id", scope="room")
244
+ turns = conversation[conversation["kind"] == "utterance"][["onset", "speaker", "transcript"]]
245
+ ```
246
+
247
+ `update_persona` and `update_experiment` replace a nested block whole, so pass the complete `voice` or `agent_config` when changing one setting in it.
248
+
199
249
  Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
200
250
 
201
251
  ## All Data for a Participant
@@ -203,7 +253,7 @@ Persona methods need the `read:personas` / `write:personas` API-key scopes; depl
203
253
  ```python
204
254
  data = hs.get_all_data("participant_id", room_id="room_id")
205
255
  # Returns dict with keys: events, recordings, chat, videochat, sync,
206
- # ratings_continuous, ratings_sparse, components, eyetracking,
256
+ # ratings_continuous, ratings_sparse, components, eyetracking, voice,
207
257
  # questionnaire, instructions, consent, agent_decisions
208
258
  ```
209
259
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperstudy"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Python SDK for the HyperStudy experiment platform API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -33,17 +33,24 @@ from .models import (
33
33
  AgentConfig,
34
34
  ComponentType,
35
35
  DisconnectTimeout,
36
+ EngineTurnDetection,
36
37
  Experiment,
38
+ ExperimenterMessageStyle,
37
39
  FocusComponent,
38
40
  GlobalComponentType,
39
41
  Guardrails,
40
42
  InstructionsPage,
41
43
  Persona,
44
+ PersonaChat,
45
+ PersonaVoice,
42
46
  PostExperimentQuestionnaire,
43
47
  PromptLayer,
48
+ RealtimeVoice,
44
49
  Role,
45
50
  State,
46
51
  TransitionRules,
52
+ VoiceTranscription,
53
+ VoiceTurnTaking,
47
54
  WaitingRoomConfig,
48
55
  likert_scale,
49
56
  multiple_choice,
@@ -56,7 +63,7 @@ from .models import (
56
63
  waiting,
57
64
  )
58
65
 
59
- __version__ = "0.4.1"
66
+ __version__ = "0.4.2"
60
67
 
61
68
  __all__ = [
62
69
  "HyperStudy",
@@ -82,7 +89,14 @@ __all__ = [
82
89
  "AgentConfig",
83
90
  "PromptLayer",
84
91
  "Persona",
92
+ "PersonaVoice",
93
+ "RealtimeVoice",
94
+ "PersonaChat",
85
95
  "Guardrails",
96
+ "VoiceTurnTaking",
97
+ "EngineTurnDetection",
98
+ "VoiceTranscription",
99
+ "ExperimenterMessageStyle",
86
100
  "TransitionRules",
87
101
  "WaitingRoomConfig",
88
102
  "DisconnectTimeout",
@@ -25,6 +25,23 @@ _STATUS_MAP = {
25
25
  }
26
26
 
27
27
 
28
+ def single_record(body: dict) -> dict:
29
+ """Return the one record of a single-object response.
30
+
31
+ The v3 envelope always carries ``data`` as an array: a route that responds
32
+ with one object (a persona, a created deployment, a signed download URL)
33
+ arrives as ``[obj]``.
34
+ """
35
+ data = body.get("data")
36
+ if not isinstance(data, list):
37
+ raise HyperStudyError(
38
+ f"Unexpected response: expected 'data' to be an array, got {type(data).__name__}"
39
+ )
40
+ if not data or data[0] is None:
41
+ raise HyperStudyError("Unexpected response: the server returned no record")
42
+ return data[0]
43
+
44
+
28
45
  class HttpTransport:
29
46
  """Thin wrapper around ``requests.Session`` for the HyperStudy API.
30
47
 
@@ -17,6 +17,7 @@ class DataType(str, Enum):
17
17
  EVENTS = "events"
18
18
  RECORDINGS = "recordings"
19
19
  CHAT = "chat"
20
+ VOICE = "voice"
20
21
  VIDEOCHAT = "videochat"
21
22
  SYNC = "sync"
22
23
  RATINGS = "ratings"
@@ -10,7 +10,7 @@ from tqdm.auto import tqdm
10
10
 
11
11
  from ._dataframe import to_pandas, to_polars
12
12
  from ._downloads import build_filename, download_file, get_download_url
13
- from ._http import HttpTransport
13
+ from ._http import HttpTransport, single_record
14
14
  from ._pagination import fetch_all_pages
15
15
  from ._types import Scope
16
16
  from .exceptions import HyperStudyError
@@ -146,6 +146,69 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
146
146
  output=output, progress=progress,
147
147
  )
148
148
 
149
+ def get_voice(
150
+ self,
151
+ scope_id: str,
152
+ *,
153
+ scope: str = "experiment",
154
+ deployment_id: str | None = None,
155
+ room_id: str | None = None,
156
+ include_context: bool = True,
157
+ order: str | None = None,
158
+ limit: int | None = None,
159
+ offset: int = 0,
160
+ output: str = "pandas",
161
+ progress: bool = True,
162
+ ):
163
+ """Fetch voice agent conversations, one flat row per voice event.
164
+
165
+ ``kind`` says what a row is:
166
+
167
+ - ``utterance``: a speech turn that reached the room. ``speaker`` is
168
+ ``participant`` or ``agent``; ``transcript`` is what was said (for an
169
+ agent, the generated text).
170
+ - ``discarded``: a false start, or an agent reply dropped before any
171
+ of it was heard.
172
+ - ``response``: an agent reply that acted without speaking, such as
173
+ answering an on-screen question (see ``toolCalls``).
174
+ - ``context``: what the voice engine was told (``contextKind``,
175
+ ``contextText``).
176
+ - ``connection``: the engine connection was lost, restored or failed
177
+ (``connectionEvent``); the study was paused meanwhile.
178
+
179
+ Agent rows also report how much of the generated speech the room
180
+ heard (``heardMs``, ``generatedMs``, ``truncated``) and
181
+ ``responseLatencyMs``, from the participant's end of speech to the
182
+ agent's first audio.
183
+
184
+ Participant scope returns only that participant's own events. For a
185
+ whole conversation, every speaker plus what the engine was told, use
186
+ ``scope="room"``. Requires the ``read:events`` scope.
187
+
188
+ Args:
189
+ scope_id: ID of the experiment, room, or participant.
190
+ scope: ``"experiment"``, ``"room"``, or ``"participant"``.
191
+ deployment_id: Filter by deployment (experiment scope only).
192
+ room_id: Narrow a participant's events to one room.
193
+ include_context: Include ``context`` rows (default True).
194
+ order: ``"asc"`` (default) or ``"desc"`` by time.
195
+ limit: Max records. ``None`` fetches all pages.
196
+ offset: Starting offset.
197
+ output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
198
+ progress: Show progress bar when paginating.
199
+ """
200
+ extra: dict[str, Any] = {}
201
+ if not include_context:
202
+ extra["includeContext"] = "false"
203
+ return self._fetch_data(
204
+ "voice", scope_id,
205
+ scope=scope, deployment_id=deployment_id, room_id=room_id,
206
+ order=order,
207
+ limit=limit, offset=offset,
208
+ output=output, progress=progress,
209
+ **extra,
210
+ )
211
+
149
212
  def get_videochat(
150
213
  self,
151
214
  scope_id: str,
@@ -271,17 +334,41 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
271
334
  scope: str = "experiment",
272
335
  deployment_id: str | None = None,
273
336
  room_id: str | None = None,
337
+ component_type: str | None = None,
338
+ state_id: str | None = None,
339
+ completed: bool | None = None,
340
+ start_time: str | None = None,
341
+ end_time: str | None = None,
342
+ sort: str | None = None,
343
+ order: str | None = None,
274
344
  limit: int | None = None,
275
345
  offset: int = 0,
276
346
  output: str = "pandas",
277
347
  progress: bool = True,
278
348
  ):
279
- """Fetch component response data."""
349
+ """Fetch component response data.
350
+
351
+ Args:
352
+ component_type: Only this component type (e.g. ``"vasrating"``).
353
+ state_id: Only components shown in this state.
354
+ completed: Only completed (True) or uncompleted (False) components.
355
+ *: All other args are the same as :meth:`get_events`.
356
+ """
357
+ extra: dict[str, Any] = {}
358
+ if component_type:
359
+ extra["componentType"] = component_type
360
+ if state_id:
361
+ extra["stateId"] = state_id
362
+ if completed is not None:
363
+ extra["completed"] = "true" if completed else "false"
280
364
  return self._fetch_data(
281
365
  "components", scope_id,
282
366
  scope=scope, deployment_id=deployment_id, room_id=room_id,
367
+ start_time=start_time, end_time=end_time,
368
+ sort=sort, order=order,
283
369
  limit=limit, offset=offset,
284
370
  output=output, progress=progress,
371
+ **extra,
285
372
  )
286
373
 
287
374
  def get_participants(
@@ -291,17 +378,32 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
291
378
  scope: str = "experiment",
292
379
  deployment_id: str | None = None,
293
380
  room_id: str | None = None,
381
+ completion_code: str | None = None,
382
+ sort: str | None = None,
383
+ order: str | None = None,
294
384
  limit: int | None = None,
295
385
  offset: int = 0,
296
386
  output: str = "pandas",
297
387
  progress: bool = True,
298
388
  ):
299
- """Fetch participant data."""
389
+ """Fetch participant data.
390
+
391
+ Args:
392
+ completion_code: Only participants with this outcome: ``SUCCESS``,
393
+ ``TIMEOUT``, ``TECHNICAL``, ``NOCONSENT``, ``ABANDONED``, or the
394
+ derived ``IN_PROGRESS`` / ``COMPLETED_NO_CODE``.
395
+ *: All other args are the same as :meth:`get_events`.
396
+ """
397
+ extra: dict[str, Any] = {}
398
+ if completion_code:
399
+ extra["completionCode"] = completion_code
300
400
  return self._fetch_data(
301
401
  "participants", scope_id,
302
402
  scope=scope, deployment_id=deployment_id, room_id=room_id,
403
+ sort=sort, order=order,
303
404
  limit=limit, offset=offset,
304
405
  output=output, progress=progress,
406
+ **extra,
305
407
  )
306
408
 
307
409
  def get_rooms(
@@ -310,15 +412,23 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
310
412
  *,
311
413
  scope: str = "experiment",
312
414
  deployment_id: str | None = None,
415
+ sort: str | None = None,
416
+ order: str | None = None,
313
417
  limit: int | None = None,
314
418
  offset: int = 0,
315
419
  output: str = "pandas",
316
420
  progress: bool = True,
317
421
  ):
318
- """Fetch room/session data."""
422
+ """Fetch room/session data.
423
+
424
+ Each room reports ``completionReason`` (how it ended: ``completed``,
425
+ or an end reason such as ``ABANDONED`` or ``disconnect_timeout``) and
426
+ ``agentStartFailures`` (why its agents could not start, by role).
427
+ """
319
428
  return self._fetch_data(
320
429
  "rooms", scope_id,
321
430
  scope=scope, deployment_id=deployment_id,
431
+ sort=sort, order=order,
322
432
  limit=limit, offset=offset,
323
433
  output=output, progress=progress,
324
434
  )
@@ -381,8 +491,18 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
381
491
  body = self._transport.get(
382
492
  f"data/counts/participant/{participant_id}", params={"roomId": room_id}
383
493
  )
384
- data = body.get("data", [])
385
- return data[0] if isinstance(data, list) and data else data
494
+ return single_record(body)
495
+
496
+ def check_data_access(self, experiment_id: str) -> dict[str, bool]:
497
+ """Check whether you may view and export an experiment's data.
498
+
499
+ Returns no data, so it is a cheap check before a large fetch.
500
+
501
+ Returns:
502
+ ``{"canView": bool, "canExport": bool}``
503
+ """
504
+ body = self._transport.get(f"data/check-access/experiment/{experiment_id}")
505
+ return {"canView": body["canView"], "canExport": body["canExport"]}
386
506
 
387
507
  # ------------------------------------------------------------------
388
508
  # Convenience: category-filtered events
@@ -513,8 +633,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
513
633
  Deployment dict.
514
634
  """
515
635
  body = self._transport.get(f"deployments/{deployment_id}")
516
- data = body.get("data", [])
517
- return data[0] if isinstance(data, list) and data else data
636
+ return single_record(body)
518
637
 
519
638
  def get_deployment_sessions(
520
639
  self,
@@ -573,12 +692,12 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
573
692
  body = self._transport.post(
574
693
  "deployments", json={"experimentId": experiment_id, "config": merged}
575
694
  )
576
- return body.get("data", {})
695
+ return single_record(body)
577
696
 
578
697
  def get_agent_spend(self, deployment_id: str) -> dict[str, Any]:
579
698
  """Total + per-room agent LLM spend for an agent-only deployment."""
580
699
  body = self._transport.get(f"deployments/{deployment_id}/agent-spend")
581
- return body.get("data", {})
700
+ return single_record(body)
582
701
 
583
702
  def run_more(
584
703
  self, deployment_id: str, *, rooms: int, budget_usd: float
@@ -598,7 +717,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
598
717
  f"deployments/{deployment_id}/run-more",
599
718
  json={"rooms": rooms, "budgetUsd": budget_usd},
600
719
  )
601
- return body.get("data", {})
720
+ return single_record(body)
602
721
 
603
722
  def stop_room(self, deployment_id: str, room_id: str) -> None:
604
723
  """Force-end a running agent room."""
@@ -609,7 +728,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
609
728
  body = self._transport.post(
610
729
  f"deployments/{deployment_id}/rooms/{room_id}/retry"
611
730
  )
612
- return body.get("data", {})
731
+ return single_record(body)
613
732
 
614
733
  # ------------------------------------------------------------------
615
734
  # Agent data — decisions and run manifests
@@ -681,7 +800,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
681
800
  body = self._transport.get(
682
801
  f"data/agent-decisions/room/{room_id}/decision/{decision_id}"
683
802
  )
684
- return body.get("data", {})
803
+ return single_record(body)
685
804
 
686
805
  def get_agent_runs(
687
806
  self,
@@ -730,6 +849,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
730
849
  "ratings_sparse": self.get_ratings(participant_id, kind="sparse", **common),
731
850
  "components": self.get_components(participant_id, **common),
732
851
  "eyetracking": self.get_eyetracking(participant_id, **common),
852
+ "voice": self.get_voice(participant_id, **common),
733
853
  "questionnaire": self.get_questionnaire(participant_id, **common),
734
854
  "instructions": self.get_instructions(participant_id, **common),
735
855
  "consent": self.get_consent(participant_id, **common),
@@ -752,7 +872,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
752
872
  download_path = recording.get("downloadPath")
753
873
  if download_path:
754
874
  body = self._transport.get(download_path)
755
- return (body.get("data") or {}).get("url")
875
+ return single_record(body).get("url")
756
876
  return get_download_url(recording)
757
877
 
758
878
  def download_recording(