hyperstudy 0.4.0__py3-none-any.whl → 0.4.2__py3-none-any.whl
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.
- hyperstudy/__init__.py +15 -1
- hyperstudy/_http.py +17 -0
- hyperstudy/_types.py +2 -0
- hyperstudy/client.py +223 -12
- hyperstudy/experiments.py +22 -15
- hyperstudy/models.py +176 -23
- hyperstudy/personas.py +45 -6
- {hyperstudy-0.4.0.dist-info → hyperstudy-0.4.2.dist-info}/METADATA +68 -4
- hyperstudy-0.4.2.dist-info/RECORD +16 -0
- {hyperstudy-0.4.0.dist-info → hyperstudy-0.4.2.dist-info}/WHEEL +1 -1
- hyperstudy-0.4.0.dist-info/RECORD +0 -16
- {hyperstudy-0.4.0.dist-info → hyperstudy-0.4.2.dist-info}/licenses/LICENSE +0 -0
hyperstudy/__init__.py
CHANGED
|
@@ -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.
|
|
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",
|
hyperstudy/_http.py
CHANGED
|
@@ -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
|
|
hyperstudy/_types.py
CHANGED
|
@@ -17,9 +17,11 @@ 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"
|
|
24
|
+
EYETRACKING = "eyetracking"
|
|
23
25
|
COMPONENTS = "components"
|
|
24
26
|
PARTICIPANTS = "participants"
|
|
25
27
|
ROOMS = "rooms"
|
hyperstudy/client.py
CHANGED
|
@@ -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,
|
|
@@ -235,6 +298,35 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
235
298
|
output=output, progress=progress,
|
|
236
299
|
)
|
|
237
300
|
|
|
301
|
+
def get_eyetracking(
|
|
302
|
+
self,
|
|
303
|
+
scope_id: str,
|
|
304
|
+
*,
|
|
305
|
+
scope: str = "experiment",
|
|
306
|
+
room_id: str | None = None,
|
|
307
|
+
limit: int | None = None,
|
|
308
|
+
offset: int = 0,
|
|
309
|
+
output: str = "pandas",
|
|
310
|
+
progress: bool = True,
|
|
311
|
+
):
|
|
312
|
+
"""Fetch eye-tracking gaze data.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
scope_id: ID of the experiment, room, or participant.
|
|
316
|
+
scope: ``"experiment"``, ``"room"``, or ``"participant"``.
|
|
317
|
+
room_id: Required when ``scope="participant"``.
|
|
318
|
+
limit: Max records. ``None`` fetches all pages.
|
|
319
|
+
offset: Starting offset.
|
|
320
|
+
output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
|
|
321
|
+
progress: Show progress bar when paginating.
|
|
322
|
+
"""
|
|
323
|
+
return self._fetch_data(
|
|
324
|
+
"eyetracking", scope_id,
|
|
325
|
+
scope=scope, room_id=room_id,
|
|
326
|
+
limit=limit, offset=offset,
|
|
327
|
+
output=output, progress=progress,
|
|
328
|
+
)
|
|
329
|
+
|
|
238
330
|
def get_components(
|
|
239
331
|
self,
|
|
240
332
|
scope_id: str,
|
|
@@ -242,17 +334,41 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
242
334
|
scope: str = "experiment",
|
|
243
335
|
deployment_id: str | None = None,
|
|
244
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,
|
|
245
344
|
limit: int | None = None,
|
|
246
345
|
offset: int = 0,
|
|
247
346
|
output: str = "pandas",
|
|
248
347
|
progress: bool = True,
|
|
249
348
|
):
|
|
250
|
-
"""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"
|
|
251
364
|
return self._fetch_data(
|
|
252
365
|
"components", scope_id,
|
|
253
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,
|
|
254
369
|
limit=limit, offset=offset,
|
|
255
370
|
output=output, progress=progress,
|
|
371
|
+
**extra,
|
|
256
372
|
)
|
|
257
373
|
|
|
258
374
|
def get_participants(
|
|
@@ -262,17 +378,32 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
262
378
|
scope: str = "experiment",
|
|
263
379
|
deployment_id: str | None = None,
|
|
264
380
|
room_id: str | None = None,
|
|
381
|
+
completion_code: str | None = None,
|
|
382
|
+
sort: str | None = None,
|
|
383
|
+
order: str | None = None,
|
|
265
384
|
limit: int | None = None,
|
|
266
385
|
offset: int = 0,
|
|
267
386
|
output: str = "pandas",
|
|
268
387
|
progress: bool = True,
|
|
269
388
|
):
|
|
270
|
-
"""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
|
|
271
400
|
return self._fetch_data(
|
|
272
401
|
"participants", scope_id,
|
|
273
402
|
scope=scope, deployment_id=deployment_id, room_id=room_id,
|
|
403
|
+
sort=sort, order=order,
|
|
274
404
|
limit=limit, offset=offset,
|
|
275
405
|
output=output, progress=progress,
|
|
406
|
+
**extra,
|
|
276
407
|
)
|
|
277
408
|
|
|
278
409
|
def get_rooms(
|
|
@@ -281,19 +412,98 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
281
412
|
*,
|
|
282
413
|
scope: str = "experiment",
|
|
283
414
|
deployment_id: str | None = None,
|
|
415
|
+
sort: str | None = None,
|
|
416
|
+
order: str | None = None,
|
|
284
417
|
limit: int | None = None,
|
|
285
418
|
offset: int = 0,
|
|
286
419
|
output: str = "pandas",
|
|
287
420
|
progress: bool = True,
|
|
288
421
|
):
|
|
289
|
-
"""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
|
+
"""
|
|
290
428
|
return self._fetch_data(
|
|
291
429
|
"rooms", scope_id,
|
|
292
430
|
scope=scope, deployment_id=deployment_id,
|
|
431
|
+
sort=sort, order=order,
|
|
293
432
|
limit=limit, offset=offset,
|
|
294
433
|
output=output, progress=progress,
|
|
295
434
|
)
|
|
296
435
|
|
|
436
|
+
def get_variables(
|
|
437
|
+
self,
|
|
438
|
+
room_id: str,
|
|
439
|
+
*,
|
|
440
|
+
output: str = "pandas",
|
|
441
|
+
):
|
|
442
|
+
"""Fetch the reconstructed shared-variable timeline for a room.
|
|
443
|
+
|
|
444
|
+
Returns a dict with four keys:
|
|
445
|
+
|
|
446
|
+
- ``writes`` — the time-ordered write log (one row per variable
|
|
447
|
+
write: seeded constant, human component response, or agent
|
|
448
|
+
submit-response, tagged with source and persisted flag), converted
|
|
449
|
+
per ``output``
|
|
450
|
+
- ``timeline`` — the derived per-state forward-filled variable
|
|
451
|
+
snapshot matrix, converted per ``output``
|
|
452
|
+
- ``variable_names`` — ordered list of every variable seen
|
|
453
|
+
- ``matrix_columns`` — ordered column set of the timeline matrix
|
|
454
|
+
- ``dropped_writes`` — writes that failed the server's ground-truth
|
|
455
|
+
cross-checks (a bug signal; empty is the healthy state)
|
|
456
|
+
- ``mode`` — the reconstruction mode the server processor ran in
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
room_id: Room ID.
|
|
460
|
+
output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``
|
|
461
|
+
for the tabular values.
|
|
462
|
+
"""
|
|
463
|
+
body = self._transport.get(f"data/variables/room/{room_id}")
|
|
464
|
+
metadata = body.get("metadata") or {}
|
|
465
|
+
return {
|
|
466
|
+
"writes": self._convert_output(body.get("data", []), output),
|
|
467
|
+
"timeline": self._convert_output(metadata.get("timeline") or [], output),
|
|
468
|
+
"variable_names": metadata.get("variableNames") or [],
|
|
469
|
+
"matrix_columns": metadata.get("matrixColumns") or [],
|
|
470
|
+
"dropped_writes": metadata.get("droppedWrites") or [],
|
|
471
|
+
"mode": metadata.get("mode"),
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
def get_counts(
|
|
475
|
+
self,
|
|
476
|
+
participant_id: str,
|
|
477
|
+
room_id: str,
|
|
478
|
+
) -> dict[str, Any]:
|
|
479
|
+
"""Fetch per-data-type document counts for a participant in a room.
|
|
480
|
+
|
|
481
|
+
Cheap count queries grouped by data type — useful for checking
|
|
482
|
+
which data types exist before fetching them.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
participant_id: Participant ID.
|
|
486
|
+
room_id: Room ID.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Dict with ``counts`` and ``hasData`` keyed by data type.
|
|
490
|
+
"""
|
|
491
|
+
body = self._transport.get(
|
|
492
|
+
f"data/counts/participant/{participant_id}", params={"roomId": room_id}
|
|
493
|
+
)
|
|
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"]}
|
|
506
|
+
|
|
297
507
|
# ------------------------------------------------------------------
|
|
298
508
|
# Convenience: category-filtered events
|
|
299
509
|
# ------------------------------------------------------------------
|
|
@@ -423,8 +633,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
423
633
|
Deployment dict.
|
|
424
634
|
"""
|
|
425
635
|
body = self._transport.get(f"deployments/{deployment_id}")
|
|
426
|
-
|
|
427
|
-
return data[0] if isinstance(data, list) and data else data
|
|
636
|
+
return single_record(body)
|
|
428
637
|
|
|
429
638
|
def get_deployment_sessions(
|
|
430
639
|
self,
|
|
@@ -483,12 +692,12 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
483
692
|
body = self._transport.post(
|
|
484
693
|
"deployments", json={"experimentId": experiment_id, "config": merged}
|
|
485
694
|
)
|
|
486
|
-
return body
|
|
695
|
+
return single_record(body)
|
|
487
696
|
|
|
488
697
|
def get_agent_spend(self, deployment_id: str) -> dict[str, Any]:
|
|
489
698
|
"""Total + per-room agent LLM spend for an agent-only deployment."""
|
|
490
699
|
body = self._transport.get(f"deployments/{deployment_id}/agent-spend")
|
|
491
|
-
return body
|
|
700
|
+
return single_record(body)
|
|
492
701
|
|
|
493
702
|
def run_more(
|
|
494
703
|
self, deployment_id: str, *, rooms: int, budget_usd: float
|
|
@@ -508,7 +717,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
508
717
|
f"deployments/{deployment_id}/run-more",
|
|
509
718
|
json={"rooms": rooms, "budgetUsd": budget_usd},
|
|
510
719
|
)
|
|
511
|
-
return body
|
|
720
|
+
return single_record(body)
|
|
512
721
|
|
|
513
722
|
def stop_room(self, deployment_id: str, room_id: str) -> None:
|
|
514
723
|
"""Force-end a running agent room."""
|
|
@@ -519,7 +728,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
519
728
|
body = self._transport.post(
|
|
520
729
|
f"deployments/{deployment_id}/rooms/{room_id}/retry"
|
|
521
730
|
)
|
|
522
|
-
return body
|
|
731
|
+
return single_record(body)
|
|
523
732
|
|
|
524
733
|
# ------------------------------------------------------------------
|
|
525
734
|
# Agent data — decisions and run manifests
|
|
@@ -591,7 +800,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
591
800
|
body = self._transport.get(
|
|
592
801
|
f"data/agent-decisions/room/{room_id}/decision/{decision_id}"
|
|
593
802
|
)
|
|
594
|
-
return body
|
|
803
|
+
return single_record(body)
|
|
595
804
|
|
|
596
805
|
def get_agent_runs(
|
|
597
806
|
self,
|
|
@@ -639,6 +848,8 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
639
848
|
"ratings_continuous": self.get_ratings(participant_id, kind="continuous", **common),
|
|
640
849
|
"ratings_sparse": self.get_ratings(participant_id, kind="sparse", **common),
|
|
641
850
|
"components": self.get_components(participant_id, **common),
|
|
851
|
+
"eyetracking": self.get_eyetracking(participant_id, **common),
|
|
852
|
+
"voice": self.get_voice(participant_id, **common),
|
|
642
853
|
"questionnaire": self.get_questionnaire(participant_id, **common),
|
|
643
854
|
"instructions": self.get_instructions(participant_id, **common),
|
|
644
855
|
"consent": self.get_consent(participant_id, **common),
|
|
@@ -661,7 +872,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
|
661
872
|
download_path = recording.get("downloadPath")
|
|
662
873
|
if download_path:
|
|
663
874
|
body = self._transport.get(download_path)
|
|
664
|
-
return (body
|
|
875
|
+
return single_record(body).get("url")
|
|
665
876
|
return get_download_url(recording)
|
|
666
877
|
|
|
667
878
|
def download_recording(
|
hyperstudy/experiments.py
CHANGED
|
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel
|
|
8
|
-
from pydantic.alias_generators import to_camel
|
|
9
8
|
|
|
10
9
|
from ._display import ExperimentInfo
|
|
11
|
-
from ._http import HttpTransport
|
|
10
|
+
from ._http import HttpTransport, single_record
|
|
12
11
|
from ._pagination import fetch_all_pages
|
|
13
12
|
from .models import camelize_wire
|
|
14
13
|
|
|
@@ -43,9 +42,7 @@ def _build_experiment_payload(
|
|
|
43
42
|
raise TypeError(
|
|
44
43
|
f"experiment must be an Experiment or dict, got {type(experiment).__name__}"
|
|
45
44
|
)
|
|
46
|
-
|
|
47
|
-
ck = to_camel(key)
|
|
48
|
-
payload[ck] = camelize_wire(value, _parent_key=ck)
|
|
45
|
+
payload.update(camelize_wire(overrides))
|
|
49
46
|
return payload
|
|
50
47
|
|
|
51
48
|
|
|
@@ -105,15 +102,23 @@ class ExperimentMixin:
|
|
|
105
102
|
An :class:`ExperimentInfo` object (dict-like, with ``_repr_html_``).
|
|
106
103
|
"""
|
|
107
104
|
body = self._transport.get(f"experiments/{experiment_id}")
|
|
108
|
-
|
|
109
|
-
record = data[0] if isinstance(data, list) and data else data
|
|
105
|
+
record = single_record(body)
|
|
110
106
|
return ExperimentInfo(record)
|
|
111
107
|
|
|
112
108
|
def get_experiment_config(self, experiment_id: str) -> dict[str, Any]:
|
|
113
109
|
"""Get raw experiment config (lightweight, no enrichment)."""
|
|
114
110
|
body = self._transport.get(f"experiments/{experiment_id}/config")
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
return single_record(body)
|
|
112
|
+
|
|
113
|
+
def export_experiment(self, experiment_id: str) -> dict[str, Any]:
|
|
114
|
+
"""Export an experiment as portable JSON.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The export payload (experiment definition plus media-asset
|
|
118
|
+
manifest), suitable for re-import.
|
|
119
|
+
"""
|
|
120
|
+
body = self._transport.get(f"experiments/{experiment_id}/export")
|
|
121
|
+
return single_record(body)
|
|
117
122
|
|
|
118
123
|
# ------------------------------------------------------------------
|
|
119
124
|
# Write
|
|
@@ -160,8 +165,7 @@ class ExperimentMixin:
|
|
|
160
165
|
"(pass name=... or experiment=Experiment(name=...))"
|
|
161
166
|
)
|
|
162
167
|
body = self._transport.post("experiments", json=payload)
|
|
163
|
-
|
|
164
|
-
record = data[0] if isinstance(data, list) and data else data
|
|
168
|
+
record = single_record(body)
|
|
165
169
|
return ExperimentInfo(record)
|
|
166
170
|
|
|
167
171
|
def update_experiment(
|
|
@@ -173,6 +177,11 @@ class ExperimentMixin:
|
|
|
173
177
|
) -> dict[str, Any]:
|
|
174
178
|
"""Update an experiment.
|
|
175
179
|
|
|
180
|
+
Each top-level field you pass replaces the stored one whole: passing
|
|
181
|
+
``agent_config=AgentConfig(voice_turn_taking=...)`` also clears any
|
|
182
|
+
stored ``role_overrides``, so send the complete ``agent_config``.
|
|
183
|
+
Only the owner or an organization admin may change ``visibility``.
|
|
184
|
+
|
|
176
185
|
Args:
|
|
177
186
|
experiment_id: ID of the experiment to update.
|
|
178
187
|
experiment: Optional typed :class:`Experiment` carrying the new
|
|
@@ -190,8 +199,7 @@ class ExperimentMixin:
|
|
|
190
199
|
"(pass experiment=... or keyword fields)."
|
|
191
200
|
)
|
|
192
201
|
body = self._transport.put(f"experiments/{experiment_id}", json=payload)
|
|
193
|
-
|
|
194
|
-
return data[0] if isinstance(data, list) and data else data
|
|
202
|
+
return single_record(body)
|
|
195
203
|
|
|
196
204
|
def validate_experiment(
|
|
197
205
|
self,
|
|
@@ -210,8 +218,7 @@ class ExperimentMixin:
|
|
|
210
218
|
raise TypeError("validate_experiment requires an Experiment or dict")
|
|
211
219
|
payload = _build_experiment_payload(experiment)
|
|
212
220
|
body = self._transport.post("experiments/validate", json=payload)
|
|
213
|
-
|
|
214
|
-
return data[0] if isinstance(data, list) and data else data
|
|
221
|
+
return single_record(body)
|
|
215
222
|
|
|
216
223
|
def delete_experiment(
|
|
217
224
|
self, experiment_id: str, *, skip_data_check: bool = False
|
hyperstudy/models.py
CHANGED
|
@@ -29,20 +29,56 @@ _FREEFORM_MAP_FIELDS = frozenset({
|
|
|
29
29
|
"roleOverrides",
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
+
# Fields whose whole value goes on the wire exactly as written, as
|
|
33
|
+
# (parent field, field) pairs; a parent of None is the top level of an
|
|
34
|
+
# experiment or persona payload. Their keys belong to someone else: a
|
|
35
|
+
# provider's raw request params (extraParams), the voice engine's
|
|
36
|
+
# turn-detection settings, which use OpenAI's snake_case names
|
|
37
|
+
# (engineTurnDetection), and names the researcher chose (cognition contexts,
|
|
38
|
+
# stimulus mappings, free-form metadata). Matching on position, not the bare
|
|
39
|
+
# key, keeps a variable or role that happens to be named "metadata" a variable.
|
|
40
|
+
_VERBATIM_FIELDS = frozenset({
|
|
41
|
+
(None, "extraParams"), (None, "cognition"), (None, "stimulusMappings"),
|
|
42
|
+
(None, "metadata"), (None, "prolific_config"),
|
|
43
|
+
("voiceTurnTaking", "engineTurnDetection"),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
# Experiment fields the backend stores under snake_case names.
|
|
47
|
+
_SNAKE_CASE_FIELDS = frozenset({"prolific_enabled", "prolific_config"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _wire_key(key: str) -> str:
|
|
51
|
+
return key if key in _SNAKE_CASE_FIELDS else to_camel(key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _dump(model: BaseModel) -> dict[str, Any]:
|
|
55
|
+
return model.model_dump(by_alias=True, exclude_none=True)
|
|
56
|
+
|
|
32
57
|
|
|
33
58
|
def camelize_wire(value: Any, _parent_key: "str | None" = None) -> Any:
|
|
34
59
|
"""Recursively convert snake_case dict keys to camelCase for the wire format.
|
|
35
60
|
|
|
36
61
|
Skips converting the immediate keys of free-form map fields (variables, roles,
|
|
37
62
|
globalComponents, globalComponentsVisibility) — those are user-defined names —
|
|
38
|
-
but still recurses into their values.
|
|
63
|
+
but still recurses into their values. Values of ``_VERBATIM_FIELDS`` are
|
|
64
|
+
passed through untouched; their positions are relative to the payload root,
|
|
65
|
+
so call this on a whole experiment or persona payload, not a sub-dict.
|
|
66
|
+
|
|
67
|
+
A typed model is dumped in its own wire format, the same one the typed
|
|
68
|
+
builders send: its declared fields are aliased and its dict-valued fields
|
|
69
|
+
are sent as written.
|
|
39
70
|
"""
|
|
71
|
+
if isinstance(value, BaseModel):
|
|
72
|
+
return _dump(value)
|
|
40
73
|
if isinstance(value, dict):
|
|
41
74
|
skip_keys = _parent_key in _FREEFORM_MAP_FIELDS
|
|
42
75
|
out: dict[str, Any] = {}
|
|
43
76
|
for k, v in value.items():
|
|
44
|
-
new_key = k if skip_keys else (
|
|
45
|
-
|
|
77
|
+
new_key = k if skip_keys else (_wire_key(k) if isinstance(k, str) else k)
|
|
78
|
+
if (_parent_key, new_key) in _VERBATIM_FIELDS:
|
|
79
|
+
out[new_key] = _dump(v) if isinstance(v, BaseModel) else v
|
|
80
|
+
else:
|
|
81
|
+
out[new_key] = camelize_wire(v, _parent_key=new_key)
|
|
46
82
|
return out
|
|
47
83
|
if isinstance(value, list):
|
|
48
84
|
return [camelize_wire(v, _parent_key=_parent_key) for v in value]
|
|
@@ -159,17 +195,62 @@ class PromptLayer(_Model):
|
|
|
159
195
|
additional_instructions: Optional[str] = None
|
|
160
196
|
|
|
161
197
|
|
|
198
|
+
class EngineTurnDetection(BaseModel):
|
|
199
|
+
"""Turn detection handed to the voice engine.
|
|
200
|
+
|
|
201
|
+
The keys are the engine's own (OpenAI realtime) names, so unlike every
|
|
202
|
+
other model here this one is sent in snake_case, exactly as written.
|
|
203
|
+
``server_vad`` takes ``threshold``, ``prefix_padding_ms`` and
|
|
204
|
+
``silence_duration_ms``; ``semantic_vad`` takes ``eagerness``.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
model_config = ConfigDict(extra="allow")
|
|
208
|
+
|
|
209
|
+
type: str # "server_vad" | "semantic_vad"
|
|
210
|
+
threshold: Optional[float] = Field(default=None, ge=0, le=1)
|
|
211
|
+
prefix_padding_ms: Optional[int] = Field(default=None, ge=0, le=2000)
|
|
212
|
+
silence_duration_ms: Optional[int] = Field(default=None, ge=100, le=5000)
|
|
213
|
+
eagerness: Optional[str] = None # semantic_vad: low | medium | high | auto
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class VoiceTurnTaking(_Model):
|
|
217
|
+
"""How an experiment's voice agents take turns.
|
|
218
|
+
|
|
219
|
+
Unset, the engine detects turns with ``server_vad`` and 500 ms of
|
|
220
|
+
silence, and a spoken reply is cut off after 60 s.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
mode: Optional[str] = None # "engine" (the only mode so far)
|
|
224
|
+
engine_turn_detection: Optional[EngineTurnDetection] = None
|
|
225
|
+
min_response_delay_ms: Optional[int] = Field(default=None, ge=0, le=10_000)
|
|
226
|
+
max_response_ms: Optional[int] = Field(default=None, ge=1000, le=300_000)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class VoiceTranscription(_Model):
|
|
230
|
+
"""Transcription of participants' speech by the voice engine (off unless enabled).
|
|
231
|
+
|
|
232
|
+
``language`` is a two-letter ISO-639-1 hint for a study run in one
|
|
233
|
+
language; unset, the model detects it.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
enabled: Optional[bool] = None
|
|
237
|
+
model: Optional[str] = None # gpt-4o-transcribe (default) | gpt-4o-mini-transcribe | whisper-1
|
|
238
|
+
language: Optional[str] = Field(default=None, pattern=r"^[a-z]{2}$")
|
|
239
|
+
|
|
240
|
+
|
|
162
241
|
class AgentConfig(_Model):
|
|
163
242
|
"""Experiment-level agent inputs.
|
|
164
243
|
|
|
165
|
-
The bound persona is the source of truth for provider/model/prompt
|
|
166
|
-
the experiment contributes
|
|
167
|
-
and
|
|
244
|
+
The bound persona is the source of truth for provider/model/prompt and
|
|
245
|
+
voice; the experiment contributes per-role prompt overrides, pacing, the
|
|
246
|
+
random seed, and how its voice agents take turns and transcribe speech.
|
|
168
247
|
"""
|
|
169
248
|
|
|
170
249
|
role_overrides: Optional[dict[str, PromptLayer]] = None
|
|
171
|
-
pacing: Optional[dict[str, Any]] = None
|
|
250
|
+
pacing: Optional[dict[str, Any]] = None # sent as written: {"readingOffsetMs": 300, ...}
|
|
172
251
|
seed: Optional[int] = None
|
|
252
|
+
voice_turn_taking: Optional[VoiceTurnTaking] = None
|
|
253
|
+
voice_transcription: Optional[VoiceTranscription] = None
|
|
173
254
|
|
|
174
255
|
|
|
175
256
|
class Guardrails(_Model):
|
|
@@ -182,15 +263,50 @@ class Guardrails(_Model):
|
|
|
182
263
|
max_consecutive_decision_errors: Optional[int] = Field(default=None, ge=1)
|
|
183
264
|
|
|
184
265
|
|
|
266
|
+
class RealtimeVoice(_Model):
|
|
267
|
+
"""The realtime speech engine behind a voice persona."""
|
|
268
|
+
|
|
269
|
+
provider: str # "openai" (the only provider so far)
|
|
270
|
+
model: Optional[str] = None # e.g. "gpt-realtime-2.1"; the provider's default when unset
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class PersonaVoice(_Model):
|
|
274
|
+
"""Gives a persona a voice in the experiment's audio.
|
|
275
|
+
|
|
276
|
+
Only ``engine="realtime"`` with ``realtime=RealtimeVoice(provider="openai")``
|
|
277
|
+
is supported so far. ``preset`` is the provider's voice (OpenAI: alloy,
|
|
278
|
+
ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar); ``style``
|
|
279
|
+
is natural-language delivery direction, e.g. ``"warm, a little hesitant"``.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
engine: str # "realtime"
|
|
283
|
+
preset: Optional[str] = None
|
|
284
|
+
speed: Optional[float] = Field(default=None, ge=0.25, le=1.5)
|
|
285
|
+
style: Optional[str] = Field(default=None, max_length=500)
|
|
286
|
+
realtime: Optional[RealtimeVoice] = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class PersonaChat(_Model):
|
|
290
|
+
"""Whether the agent opens a text chat, and how long it waits first."""
|
|
291
|
+
|
|
292
|
+
initiate: Optional[bool] = None
|
|
293
|
+
initiate_delay_ms: Optional[int] = Field(default=None, gt=0)
|
|
294
|
+
|
|
295
|
+
|
|
185
296
|
class Persona(_Model):
|
|
186
297
|
"""A reusable AI-agent definition (agent library entry).
|
|
187
298
|
|
|
188
|
-
The persona owns the agent's identity, model settings, guardrails
|
|
189
|
-
(optionally) cognition. ``cognition``
|
|
190
|
-
|
|
299
|
+
The persona owns the agent's identity, model settings, guardrails,
|
|
300
|
+
voice and (optionally) cognition. ``cognition`` is accepted as a plain
|
|
301
|
+
dict — its schema is experimental and still evolving. Offline loops live
|
|
302
|
+
inside ``cognition["config"]["contexts"][<name>]["offline"]``.
|
|
303
|
+
|
|
304
|
+
``display_name`` is the name other participants see in chat and video;
|
|
305
|
+
empty, they see ``name``.
|
|
191
306
|
"""
|
|
192
307
|
|
|
193
308
|
name: str = Field(min_length=1)
|
|
309
|
+
display_name: Optional[str] = None
|
|
194
310
|
description: Optional[str] = None
|
|
195
311
|
prompt: Optional[PromptLayer] = None
|
|
196
312
|
provider: Optional[str] = None # anthropic (default) | openai | gemini | custom
|
|
@@ -204,13 +320,13 @@ class Persona(_Model):
|
|
|
204
320
|
disable_thinking: Optional[bool] = None
|
|
205
321
|
seed: Optional[int] = None
|
|
206
322
|
cognition: Optional[dict[str, Any]] = None
|
|
207
|
-
offline_cognition: Optional[dict[str, Any]] = None
|
|
208
323
|
memory_persistence: Optional[str] = None # "none" (default) | "cross-experiment"
|
|
209
|
-
language: Optional[str] = None
|
|
210
324
|
seed_memories: Optional[list[dict[str, Any]]] = None
|
|
211
325
|
guardrails: Optional[Guardrails] = None
|
|
212
|
-
pacing: Optional[dict[str, Any]] = None
|
|
213
|
-
|
|
326
|
+
pacing: Optional[dict[str, Any]] = None # sent as written: {"readingOffsetMs": 300, ...}
|
|
327
|
+
chat: Optional[PersonaChat] = None
|
|
328
|
+
voice: Optional[PersonaVoice] = None
|
|
329
|
+
visibility: Optional[str] = None # private (default) | organization | public
|
|
214
330
|
|
|
215
331
|
|
|
216
332
|
class WaitingRoomConfig(_Model):
|
|
@@ -224,6 +340,16 @@ class DisconnectTimeout(_Model):
|
|
|
224
340
|
auto_reconnect_delay: Optional[int] = Field(default=None, ge=0)
|
|
225
341
|
|
|
226
342
|
|
|
343
|
+
class ExperimenterMessageStyle(_Model):
|
|
344
|
+
"""Text style of messages the experimenter sends from deployment monitoring.
|
|
345
|
+
|
|
346
|
+
Unset, messages are 20px white.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
font_size: Optional[float] = Field(default=None, ge=10, le=72)
|
|
350
|
+
color: Optional[str] = Field(default=None, pattern=r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
351
|
+
|
|
352
|
+
|
|
227
353
|
class InstructionsPage(_Model):
|
|
228
354
|
title: Optional[str] = None
|
|
229
355
|
content: Optional[str] = None
|
|
@@ -263,6 +389,16 @@ class Experiment(_Model):
|
|
|
263
389
|
instructions_content: Optional[str] = None
|
|
264
390
|
instructions_pages: Optional[list[InstructionsPage]] = None
|
|
265
391
|
post_experiment_questionnaire: Optional[PostExperimentQuestionnaire] = None
|
|
392
|
+
# Only the owner or an organization admin may change visibility.
|
|
393
|
+
visibility: Optional[str] = None # private (default) | organization | public
|
|
394
|
+
tags: Optional[list[str]] = None
|
|
395
|
+
enable_participant_messaging: Optional[bool] = None
|
|
396
|
+
experimenter_message_style: Optional[ExperimenterMessageStyle] = None
|
|
397
|
+
stimulus_mappings: Optional[dict[str, Any]] = None
|
|
398
|
+
device_integration: Optional[dict[str, Any]] = None
|
|
399
|
+
# Stored under snake_case names by the backend.
|
|
400
|
+
prolific_enabled: Optional[bool] = Field(default=None, alias="prolific_enabled")
|
|
401
|
+
prolific_config: Optional[dict[str, Any]] = Field(default=None, alias="prolific_config")
|
|
266
402
|
|
|
267
403
|
|
|
268
404
|
# ---------------------------------------------------------------------------
|
|
@@ -280,11 +416,16 @@ class Experiment(_Model):
|
|
|
280
416
|
# of ``output_variable=``) gets the factory's value, not their typo.
|
|
281
417
|
|
|
282
418
|
|
|
419
|
+
def _config_extra(extra: dict[str, Any]) -> dict[str, Any]:
|
|
420
|
+
"""A factory's extra kwargs, converted as fields of a component ``config``."""
|
|
421
|
+
return camelize_wire(extra, _parent_key="config")
|
|
422
|
+
|
|
423
|
+
|
|
283
424
|
def show_text(text: str, *, id: Optional[str] = None, **extra: Any) -> FocusComponent:
|
|
284
425
|
"""Build a ``showtext`` focus component."""
|
|
285
426
|
return FocusComponent(
|
|
286
427
|
type=ComponentType.SHOW_TEXT,
|
|
287
|
-
config={**
|
|
428
|
+
config={**_config_extra(extra), "text": text},
|
|
288
429
|
id=id,
|
|
289
430
|
)
|
|
290
431
|
|
|
@@ -293,7 +434,7 @@ def show_image(url: str, *, id: Optional[str] = None, **extra: Any) -> FocusComp
|
|
|
293
434
|
"""Build a ``showimage`` focus component."""
|
|
294
435
|
return FocusComponent(
|
|
295
436
|
type=ComponentType.SHOW_IMAGE,
|
|
296
|
-
config={**
|
|
437
|
+
config={**_config_extra(extra), "url": url},
|
|
297
438
|
id=id,
|
|
298
439
|
)
|
|
299
440
|
|
|
@@ -302,7 +443,7 @@ def show_video(url: str, *, id: Optional[str] = None, **extra: Any) -> FocusComp
|
|
|
302
443
|
"""Build a ``showvideo`` focus component."""
|
|
303
444
|
return FocusComponent(
|
|
304
445
|
type=ComponentType.SHOW_VIDEO,
|
|
305
|
-
config={**
|
|
446
|
+
config={**_config_extra(extra), "url": url},
|
|
306
447
|
id=id,
|
|
307
448
|
)
|
|
308
449
|
|
|
@@ -317,7 +458,7 @@ def vas_rating(
|
|
|
317
458
|
"""Build a ``vasrating`` (visual analog scale) focus component."""
|
|
318
459
|
return FocusComponent(
|
|
319
460
|
type=ComponentType.VAS_RATING,
|
|
320
|
-
config={**
|
|
461
|
+
config={**_config_extra(extra), "prompt": prompt, "outputVariable": output_variable},
|
|
321
462
|
id=id,
|
|
322
463
|
)
|
|
323
464
|
|
|
@@ -332,7 +473,7 @@ def text_input(
|
|
|
332
473
|
"""Build a ``textinput`` focus component."""
|
|
333
474
|
return FocusComponent(
|
|
334
475
|
type=ComponentType.TEXT_INPUT,
|
|
335
|
-
config={**
|
|
476
|
+
config={**_config_extra(extra), "prompt": prompt, "outputVariable": output_variable},
|
|
336
477
|
id=id,
|
|
337
478
|
)
|
|
338
479
|
|
|
@@ -349,7 +490,7 @@ def multiple_choice(
|
|
|
349
490
|
return FocusComponent(
|
|
350
491
|
type=ComponentType.MULTIPLE_CHOICE,
|
|
351
492
|
config={
|
|
352
|
-
**
|
|
493
|
+
**_config_extra(extra),
|
|
353
494
|
"prompt": prompt,
|
|
354
495
|
"options": list(options),
|
|
355
496
|
"outputVariable": output_variable,
|
|
@@ -362,7 +503,7 @@ def waiting(duration_ms: int, *, id: Optional[str] = None, **extra: Any) -> Focu
|
|
|
362
503
|
"""Build a ``waiting`` focus component."""
|
|
363
504
|
return FocusComponent(
|
|
364
505
|
type=ComponentType.WAITING,
|
|
365
|
-
config={**
|
|
506
|
+
config={**_config_extra(extra), "durationMs": int(duration_ms)},
|
|
366
507
|
id=id,
|
|
367
508
|
)
|
|
368
509
|
|
|
@@ -379,7 +520,7 @@ def likert_scale(
|
|
|
379
520
|
return FocusComponent(
|
|
380
521
|
type=ComponentType.LIKERT_SCALE,
|
|
381
522
|
config={
|
|
382
|
-
**
|
|
523
|
+
**_config_extra(extra),
|
|
383
524
|
"prompt": prompt,
|
|
384
525
|
"outputVariable": output_variable,
|
|
385
526
|
"scalePoints": int(scale_points),
|
|
@@ -400,7 +541,7 @@ def ranking(
|
|
|
400
541
|
return FocusComponent(
|
|
401
542
|
type=ComponentType.RANKING,
|
|
402
543
|
config={
|
|
403
|
-
**
|
|
544
|
+
**_config_extra(extra),
|
|
404
545
|
"prompt": prompt,
|
|
405
546
|
"options": list(options),
|
|
406
547
|
"outputVariable": output_variable,
|
|
@@ -425,6 +566,18 @@ __all__ = [
|
|
|
425
566
|
"DisconnectTimeout",
|
|
426
567
|
"InstructionsPage",
|
|
427
568
|
"PostExperimentQuestionnaire",
|
|
569
|
+
"ExperimenterMessageStyle",
|
|
570
|
+
# Agent models
|
|
571
|
+
"AgentConfig",
|
|
572
|
+
"PromptLayer",
|
|
573
|
+
"VoiceTurnTaking",
|
|
574
|
+
"EngineTurnDetection",
|
|
575
|
+
"VoiceTranscription",
|
|
576
|
+
"Persona",
|
|
577
|
+
"PersonaVoice",
|
|
578
|
+
"RealtimeVoice",
|
|
579
|
+
"PersonaChat",
|
|
580
|
+
"Guardrails",
|
|
428
581
|
# Factories
|
|
429
582
|
"show_text",
|
|
430
583
|
"show_image",
|
hyperstudy/personas.py
CHANGED
|
@@ -7,8 +7,11 @@ Personas are reusable AI-agent definitions. These methods target the
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import warnings
|
|
10
11
|
from typing import TYPE_CHECKING, Any
|
|
11
12
|
|
|
13
|
+
from ._http import single_record
|
|
14
|
+
|
|
12
15
|
if TYPE_CHECKING:
|
|
13
16
|
from .models import Persona
|
|
14
17
|
|
|
@@ -25,6 +28,22 @@ def _persona_payload(persona: "Persona | None", kwargs: dict[str, Any]) -> dict[
|
|
|
25
28
|
payload.update(persona.model_dump(by_alias=True, exclude_none=True))
|
|
26
29
|
if kwargs:
|
|
27
30
|
payload.update(camelize_wire(kwargs))
|
|
31
|
+
# The server hard-discards offlineCognition (offline loops now live inside
|
|
32
|
+
# cognition.config.contexts.<name>.offline), so never send it — including
|
|
33
|
+
# when a Persona was rebuilt from an older API response via extra fields.
|
|
34
|
+
payload.pop("offlineCognition", None)
|
|
35
|
+
payload.pop("offline_cognition", None)
|
|
36
|
+
# Personas no longer have a language: nothing read it, and the server
|
|
37
|
+
# discards it. Older stored personas still carry the key, so strip it. It
|
|
38
|
+
# was stored as null unless someone set it, so a value means someone
|
|
39
|
+
# believed it did something: tell them, wherever it came from.
|
|
40
|
+
if payload.pop("language", None):
|
|
41
|
+
warnings.warn(
|
|
42
|
+
"Persona 'language' was removed and is ignored by the server. To set "
|
|
43
|
+
"the language participants speak, use the experiment's "
|
|
44
|
+
"agent_config.voice_transcription.language.",
|
|
45
|
+
stacklevel=3,
|
|
46
|
+
)
|
|
28
47
|
return payload
|
|
29
48
|
|
|
30
49
|
|
|
@@ -43,7 +62,7 @@ class PersonaMixin:
|
|
|
43
62
|
def get_persona(self, persona_id: str) -> dict[str, Any]:
|
|
44
63
|
"""Get one persona by ID."""
|
|
45
64
|
body = self._transport.get(f"personas/{persona_id}")
|
|
46
|
-
return body
|
|
65
|
+
return single_record(body)
|
|
47
66
|
|
|
48
67
|
def create_persona(
|
|
49
68
|
self,
|
|
@@ -69,7 +88,7 @@ class PersonaMixin:
|
|
|
69
88
|
if not payload.get("name"):
|
|
70
89
|
raise ValueError("Persona name is required")
|
|
71
90
|
body = self._transport.post("personas", json=payload)
|
|
72
|
-
return body
|
|
91
|
+
return single_record(body)
|
|
73
92
|
|
|
74
93
|
def update_persona(
|
|
75
94
|
self,
|
|
@@ -80,14 +99,22 @@ class PersonaMixin:
|
|
|
80
99
|
) -> dict[str, Any]:
|
|
81
100
|
"""Update a persona (merge-patch).
|
|
82
101
|
|
|
83
|
-
Only the fields you pass are changed; everything else on
|
|
84
|
-
persona is preserved.
|
|
102
|
+
Only the top-level fields you pass are changed; everything else on
|
|
103
|
+
the stored persona is preserved. A nested block you pass (``voice``,
|
|
104
|
+
``chat``, ``prompt``, ``guardrails``, ``cognition``) replaces the
|
|
105
|
+
stored block whole, so send all of it. For a persona that has a
|
|
106
|
+
voice (``voice`` is ``None`` on a text-only persona)::
|
|
107
|
+
|
|
108
|
+
voice = hs.get_persona(pid)["voice"]
|
|
109
|
+
hs.update_persona(pid, voice={**voice, "speed": 1.1})
|
|
110
|
+
|
|
111
|
+
Only the owner or an organization admin may change ``visibility``.
|
|
85
112
|
"""
|
|
86
113
|
payload = _persona_payload(persona, kwargs)
|
|
87
114
|
if not payload:
|
|
88
115
|
raise ValueError("No fields to update")
|
|
89
116
|
body = self._transport.put(f"personas/{persona_id}", json=payload)
|
|
90
|
-
return body
|
|
117
|
+
return single_record(body)
|
|
91
118
|
|
|
92
119
|
def delete_persona(self, persona_id: str) -> None:
|
|
93
120
|
"""Delete a persona."""
|
|
@@ -100,4 +127,16 @@ class PersonaMixin:
|
|
|
100
127
|
The new persona (named ``"<original name> (copy)"``).
|
|
101
128
|
"""
|
|
102
129
|
body = self._transport.post(f"personas/{persona_id}/duplicate")
|
|
103
|
-
return body
|
|
130
|
+
return single_record(body)
|
|
131
|
+
|
|
132
|
+
def get_cognition_catalog(self) -> dict[str, Any]:
|
|
133
|
+
"""Fetch the cognition authoring catalog.
|
|
134
|
+
|
|
135
|
+
Lists the valid building blocks for persona ``cognition`` configs.
|
|
136
|
+
Requires the ``read:personas`` scope.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Dict with ``abilities``, ``recipes``, and ``offlineRecipes``.
|
|
140
|
+
"""
|
|
141
|
+
body = self._transport.get("agent-cognition/catalog")
|
|
142
|
+
return single_record(body)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hyperstudy
|
|
3
|
-
Version: 0.4.
|
|
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,9 +75,11 @@ 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")
|
|
82
|
+
eyetracking = hs.get_eyetracking("exp_id")
|
|
81
83
|
components = hs.get_components("exp_id")
|
|
82
84
|
participants = hs.get_participants("exp_id")
|
|
83
85
|
rooms = hs.get_rooms("exp_id")
|
|
@@ -86,6 +88,16 @@ rooms = hs.get_rooms("exp_id")
|
|
|
86
88
|
questionnaire = hs.get_questionnaire("exp_id")
|
|
87
89
|
instructions = hs.get_instructions("exp_id")
|
|
88
90
|
consent = hs.get_consent("exp_id")
|
|
91
|
+
|
|
92
|
+
# Room-scoped: reconstructed shared-variable timeline
|
|
93
|
+
# -> {"writes", "timeline", "variable_names", "dropped_writes"}
|
|
94
|
+
variables = hs.get_variables("room_id")
|
|
95
|
+
|
|
96
|
+
# Per-data-type document counts for one participant in a room
|
|
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}
|
|
89
101
|
```
|
|
90
102
|
|
|
91
103
|
### Output Formats
|
|
@@ -107,6 +119,9 @@ events = hs.get_events(
|
|
|
107
119
|
sort="onset",
|
|
108
120
|
limit=100,
|
|
109
121
|
)
|
|
122
|
+
|
|
123
|
+
ratings = hs.get_components("exp_id", component_type="vasrating", completed=True)
|
|
124
|
+
abandoned = hs.get_participants("exp_id", completion_code="ABANDONED")
|
|
110
125
|
```
|
|
111
126
|
|
|
112
127
|
### Auto-Pagination
|
|
@@ -130,6 +145,9 @@ exp = hs.get_experiment("exp_id")
|
|
|
130
145
|
new_exp = hs.create_experiment(name="My Study", description="...")
|
|
131
146
|
hs.update_experiment("exp_id", name="Updated Name")
|
|
132
147
|
hs.delete_experiment("exp_id")
|
|
148
|
+
|
|
149
|
+
# Portable experiment JSON (definition + media-asset manifest)
|
|
150
|
+
export = hs.export_experiment("exp_id")
|
|
133
151
|
```
|
|
134
152
|
|
|
135
153
|
### Building experiments programmatically (0.3.0+)
|
|
@@ -215,8 +233,54 @@ hs.run_more(dep["id"], rooms=5, budget_usd=2.5) # additive batch
|
|
|
215
233
|
decisions = hs.get_agent_decisions(exp["id"]) # per-turn logs
|
|
216
234
|
runs = hs.get_agent_runs(exp["id"]) # run manifests
|
|
217
235
|
detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
|
|
236
|
+
|
|
237
|
+
# --- Cognition authoring catalog ---
|
|
238
|
+
catalog = hs.get_cognition_catalog() # {"abilities": ..., "recipes": ..., "offlineRecipes": ...}
|
|
218
239
|
```
|
|
219
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
|
+
|
|
220
284
|
Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
|
|
221
285
|
|
|
222
286
|
## All Data for a Participant
|
|
@@ -224,8 +288,8 @@ Persona methods need the `read:personas` / `write:personas` API-key scopes; depl
|
|
|
224
288
|
```python
|
|
225
289
|
data = hs.get_all_data("participant_id", room_id="room_id")
|
|
226
290
|
# Returns dict with keys: events, recordings, chat, videochat, sync,
|
|
227
|
-
# ratings_continuous, ratings_sparse, components,
|
|
228
|
-
# instructions, consent, agent_decisions
|
|
291
|
+
# ratings_continuous, ratings_sparse, components, eyetracking, voice,
|
|
292
|
+
# questionnaire, instructions, consent, agent_decisions
|
|
229
293
|
```
|
|
230
294
|
|
|
231
295
|
## API Key
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
hyperstudy/__init__.py,sha256=8yoNbRsLUNtzRWR98EHvrb78hcr4YeN7DoTy7rnnsA0,2369
|
|
2
|
+
hyperstudy/_dataframe.py,sha256=8pjuMG8phMv8vwdY7sgZ-aF_6_o0UMgP2XXNp5muZ0s,3620
|
|
3
|
+
hyperstudy/_display.py,sha256=5svr4NFOUJkVif57-xJzrXNOSdA5O9tKesu8UhE_jYo,1896
|
|
4
|
+
hyperstudy/_downloads.py,sha256=8s-Ml0oK_3f5CNn9hSp0z5d1oyDBR7ldPYVPbDo0zss,2616
|
|
5
|
+
hyperstudy/_http.py,sha256=QG9JA_2Pug620PKlQRqo_PZ9ycMWjG4FD_EOGclLUJ8,5196
|
|
6
|
+
hyperstudy/_pagination.py,sha256=2NbvNGW_jFQkYquSTi3SNkAmGgsbKop7J9LkGaZWwWA,1812
|
|
7
|
+
hyperstudy/_types.py,sha256=nREuLxEe4ylnyxRPyR1kgBh0V4fDKf5OmfiLEmTkg-o,734
|
|
8
|
+
hyperstudy/client.py,sha256=DRKDmLND65bxXAIBO9EDK2IikX6RJ_6O0GgmyI6ISTI,40319
|
|
9
|
+
hyperstudy/exceptions.py,sha256=z_1Ngiq-RU-1yyDI_JqvQ5CvYm2jQ_lyJ9TUbqUKKv8,1529
|
|
10
|
+
hyperstudy/experiments.py,sha256=cQN0O6sPeSAJ9qqIGdIIR5Mvu1Mf7HJ9BKUc01_-cn0,8695
|
|
11
|
+
hyperstudy/models.py,sha256=Tdi5ygqDwkA-p9ibqdT-70zCaMi9BBIZSVPL4Y6e6Yg,20288
|
|
12
|
+
hyperstudy/personas.py,sha256=T4cbI0wWfokAthxKzgM73NA_we3mBtR-j_lcn3iYl4Q,5328
|
|
13
|
+
hyperstudy-0.4.2.dist-info/METADATA,sha256=aYnlSHF7l0VJ7ruRo-P0mafpdEHVtmyZOlXwJO_6dQc,10499
|
|
14
|
+
hyperstudy-0.4.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
+
hyperstudy-0.4.2.dist-info/licenses/LICENSE,sha256=WJdwRhBIOiiQ6lyK4l95u5IJYuuT-XxzwYRraS31NME,1067
|
|
16
|
+
hyperstudy-0.4.2.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
hyperstudy/__init__.py,sha256=ZbHxlP2hnepEMK29ebttkRY_uJC1YuMTPcbMt_ov7cs,2047
|
|
2
|
-
hyperstudy/_dataframe.py,sha256=8pjuMG8phMv8vwdY7sgZ-aF_6_o0UMgP2XXNp5muZ0s,3620
|
|
3
|
-
hyperstudy/_display.py,sha256=5svr4NFOUJkVif57-xJzrXNOSdA5O9tKesu8UhE_jYo,1896
|
|
4
|
-
hyperstudy/_downloads.py,sha256=8s-Ml0oK_3f5CNn9hSp0z5d1oyDBR7ldPYVPbDo0zss,2616
|
|
5
|
-
hyperstudy/_http.py,sha256=_RS0ZaRAcKvFQ2NF9II7PpcojE-lTYT6CslaJNlgfPQ,4569
|
|
6
|
-
hyperstudy/_pagination.py,sha256=2NbvNGW_jFQkYquSTi3SNkAmGgsbKop7J9LkGaZWwWA,1812
|
|
7
|
-
hyperstudy/_types.py,sha256=Mw5KJFMXgLi17U6F08UR8BRfhAYrsj95Y1byhHCJTDU,682
|
|
8
|
-
hyperstudy/client.py,sha256=5WJGP3ggWIHq-NII_ikKydpj2RVVf56KRc2MEklb3I4,31897
|
|
9
|
-
hyperstudy/exceptions.py,sha256=z_1Ngiq-RU-1yyDI_JqvQ5CvYm2jQ_lyJ9TUbqUKKv8,1529
|
|
10
|
-
hyperstudy/experiments.py,sha256=ZGCbOEwmzXq-HVJ-f7iPsinYZDxq1RXUZ7mBqo2T3hU,8472
|
|
11
|
-
hyperstudy/models.py,sha256=1wA_USRzqKSY5pEQkK89wj0dDhkMeBFanzyRsIL_pvU,13820
|
|
12
|
-
hyperstudy/personas.py,sha256=LukXLMiz7N3t06m1t7RZWQLrQRMMU1KsfGkwE7BhOBM,3485
|
|
13
|
-
hyperstudy-0.4.0.dist-info/METADATA,sha256=FOwcQWXh2iwAnvcIjohWyZ6EiitqqNucEvcS8O-nd40,7900
|
|
14
|
-
hyperstudy-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
-
hyperstudy-0.4.0.dist-info/licenses/LICENSE,sha256=WJdwRhBIOiiQ6lyK4l95u5IJYuuT-XxzwYRraS31NME,1067
|
|
16
|
-
hyperstudy-0.4.0.dist-info/RECORD,,
|
|
File without changes
|