podojo-cli 1.5.2__tar.gz → 1.7.0__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 (37) hide show
  1. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/CHANGELOG.md +10 -0
  2. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/PKG-INFO +13 -1
  3. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/README.md +12 -0
  4. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/pyproject.toml +1 -1
  5. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/aiinterviews.py +65 -1
  6. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/usertests.py +66 -0
  7. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_aiinterviews.py +83 -0
  8. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_usertests.py +95 -0
  9. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/uv.lock +1 -1
  10. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/.github/workflows/publish.yml +0 -0
  11. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/.gitignore +0 -0
  12. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/CLAUDE.md +0 -0
  13. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/LICENSE +0 -0
  14. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/__init__.py +0 -0
  15. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/client.py +0 -0
  16. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/__init__.py +0 -0
  17. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/auth.py +0 -0
  18. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/interviews.py +0 -0
  19. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/projects.py +0 -0
  20. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/showreel.py +0 -0
  21. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/synth.py +0 -0
  22. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/transcripts.py +0 -0
  23. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/videos.py +0 -0
  24. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/config.py +0 -0
  25. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/main.py +0 -0
  26. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/synth/__init__.py +0 -0
  27. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/synth/driver.py +0 -0
  28. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/synth/session.py +0 -0
  29. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/version_check.py +0 -0
  30. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/video/__init__.py +0 -0
  31. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/video/showreel.py +0 -0
  32. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/conftest.py +0 -0
  33. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_auth.py +0 -0
  34. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_interviews.py +0 -0
  35. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_projects.py +0 -0
  36. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_showreel.py +0 -0
  37. {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_transcripts.py +0 -0
@@ -5,6 +5,16 @@ All notable changes to the Podojo CLI will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
+ ## [1.7.0] - 2026-07-04
9
+
10
+ ### Added
11
+ - User tests support an optional participant screener: `screening_questions` (single-select, options flagged `qualifies: true`) and `rejection_message`, mirroring AI interviews. `validate`/`create` check the screener shape, and `example` shows the new fields.
12
+
13
+ ## [1.6.0] - 2026-07-03
14
+
15
+ ### Added
16
+ - AI interviews support an optional participant screener: `screening_questions` (single-select, options flagged `qualifies: true`) and `rejection_message`. `validate`/`create` check the screener shape, and `example` shows the new fields.
17
+
8
18
  ## [1.5.2] - 2026-07-02
9
19
 
10
20
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: podojo-cli
3
- Version: 1.5.2
3
+ Version: 1.7.0
4
4
  Summary: CLI for the Podojo user research platform
5
5
  Project-URL: Homepage, https://github.com/podojo/cli-podojo
6
6
  Project-URL: Source, https://github.com/podojo/cli-podojo
@@ -123,6 +123,12 @@ podojo usertests delete checkout-usability-v1
123
123
  podojo usertests snippet # recorder script for self-hosted prototypes
124
124
  ```
125
125
 
126
+ A user test can open with an optional participant screener: on-screen
127
+ single-select `screening_questions` whose options carry `qualifies: true`
128
+ flags. Participants must pick a qualifying option on every question; everyone
129
+ else sees the test's `rejection_message` and never reaches the recorded test.
130
+ See `podojo usertests example` for the exact shape.
131
+
126
132
  ### AI interviews
127
133
 
128
134
  An AI interview is a self-serve voice conversation: an AI interviewer asks
@@ -143,6 +149,12 @@ podojo aiinterviews delete checkout-experience-v1
143
149
  base URL can be overridden via `ai_interviews_url` in `~/.podojo.toml` or
144
150
  `PODOJO_AI_INTERVIEWS_URL`.
145
151
 
152
+ A study can open with an optional participant screener: on-screen single-select
153
+ `screening_questions` whose options carry `qualifies: true` flags. Participants
154
+ must pick a qualifying option on every question; everyone else sees the study's
155
+ `rejection_message` and never reaches the voice interview. See
156
+ `podojo aiinterviews example` for the exact shape.
157
+
146
158
  ### Synthetic participants
147
159
 
148
160
  The `synth` group drives a Playwright browser through a user test preview so an
@@ -92,6 +92,12 @@ podojo usertests delete checkout-usability-v1
92
92
  podojo usertests snippet # recorder script for self-hosted prototypes
93
93
  ```
94
94
 
95
+ A user test can open with an optional participant screener: on-screen
96
+ single-select `screening_questions` whose options carry `qualifies: true`
97
+ flags. Participants must pick a qualifying option on every question; everyone
98
+ else sees the test's `rejection_message` and never reaches the recorded test.
99
+ See `podojo usertests example` for the exact shape.
100
+
95
101
  ### AI interviews
96
102
 
97
103
  An AI interview is a self-serve voice conversation: an AI interviewer asks
@@ -112,6 +118,12 @@ podojo aiinterviews delete checkout-experience-v1
112
118
  base URL can be overridden via `ai_interviews_url` in `~/.podojo.toml` or
113
119
  `PODOJO_AI_INTERVIEWS_URL`.
114
120
 
121
+ A study can open with an optional participant screener: on-screen single-select
122
+ `screening_questions` whose options carry `qualifies: true` flags. Participants
123
+ must pick a qualifying option on every question; everyone else sees the study's
124
+ `rejection_message` and never reaches the voice interview. See
125
+ `podojo aiinterviews example` for the exact shape.
126
+
115
127
  ### Synthetic participants
116
128
 
117
129
  The `synth` group drives a Playwright browser through a user test preview so an
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "podojo-cli"
3
- version = "1.5.2"
3
+ version = "1.7.0"
4
4
  description = "CLI for the Podojo user research platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,13 +24,21 @@ EXAMPLE_YAML = """\
24
24
  #
25
25
  # Required fields: interview_id, title, questions, closing_message
26
26
  # Optional fields: language (default en-US), project_name, overview,
27
- # decision, live
27
+ # decision, screening_questions, rejection_message, live
28
28
  #
29
29
  # Each question:
30
30
  # text (required) the main question, asked verbatim-ish in order
31
31
  # section (optional) researcher-facing grouping label
32
32
  # max_follow_ups (optional, default 2) adaptive follow-up budget
33
33
  # probe_for (optional) what a concrete answer must cover — drives follow-ups
34
+ #
35
+ # Each screening question (optional participant screener, answered on screen
36
+ # before the voice interview starts):
37
+ # text (required) single-select multiple-choice question
38
+ # options (required, at least 2) each with `text` and an optional
39
+ # `qualifies: true` — participants must pick a qualifying
40
+ # option on every question, otherwise they see the
41
+ # rejection_message and the interview never starts
34
42
 
35
43
  interview_id: checkout-experience-v1
36
44
  title: Checkout Experience Research
@@ -57,6 +65,30 @@ decision: >
57
65
  # Optional: set interview live (default: false)
58
66
  # live: true
59
67
 
68
+ # Optional: participant screener — shown on screen (no audio) before the
69
+ # conversation. Answers are captured alongside the session's recording.
70
+ screening_questions:
71
+ - text: How often do you shop online?
72
+ options:
73
+ - text: Rarely or never
74
+ - text: A few times a year
75
+ - text: At least once a month
76
+ qualifies: true
77
+ - text: Weekly or more
78
+ qualifies: true
79
+
80
+ - text: Have you abandoned an online purchase at checkout in the past 3 months?
81
+ options:
82
+ - text: "Yes"
83
+ qualifies: true
84
+ - text: "No"
85
+ - text: Not sure
86
+
87
+ # Optional: shown to participants whose screener answers don't qualify
88
+ rejection_message: >
89
+ Thank you for your time, you did not meet the research criteria for this
90
+ study!
91
+
60
92
  questions:
61
93
  - section: Shopping Habits
62
94
  text: >
@@ -118,6 +150,38 @@ def validate_ai_interview_data(data: dict) -> list[str]:
118
150
  errors.append(
119
151
  f"Question {i}: 'max_follow_ups' must be an integer >= 0, got '{max_follow_ups}'"
120
152
  )
153
+
154
+ screening_questions = data.get("screening_questions")
155
+ if screening_questions is not None:
156
+ if not isinstance(screening_questions, list):
157
+ errors.append("'screening_questions' must be a list")
158
+ else:
159
+ for i, question in enumerate(screening_questions, 1):
160
+ if not isinstance(question, dict) or "text" not in question:
161
+ errors.append(f"Screening question {i}: must be a mapping with 'text'")
162
+ continue
163
+ options = question.get("options")
164
+ if not isinstance(options, list) or len(options) < 2:
165
+ errors.append(f"Screening question {i}: 'options' must list at least 2 options")
166
+ continue
167
+ qualifying = 0
168
+ for j, option in enumerate(options, 1):
169
+ if not isinstance(option, dict) or "text" not in option:
170
+ errors.append(
171
+ f"Screening question {i}, option {j}: must be a mapping with 'text'"
172
+ )
173
+ continue
174
+ qualifies = option.get("qualifies", False)
175
+ if not isinstance(qualifies, bool):
176
+ errors.append(
177
+ f"Screening question {i}, option {j}: 'qualifies' must be true or false"
178
+ )
179
+ elif qualifies:
180
+ qualifying += 1
181
+ if qualifying == 0:
182
+ errors.append(
183
+ f"Screening question {i}: needs at least one option with 'qualifies: true'"
184
+ )
121
185
  return errors
122
186
 
123
187
 
@@ -37,7 +37,16 @@ EXAMPLE_YAML = """\
37
37
  #
38
38
  # Required fields: usertest_id, title, logo, prototype_url, steps
39
39
  # Optional fields: welcome_text, promo_code, promo_code_info,
40
+ # screening_questions, rejection_message,
40
41
  # project_name, live, collect_contact
42
+ #
43
+ # Each screening question (optional participant screener, answered on screen
44
+ # before consent — screening is never recorded):
45
+ # text (required) single-select multiple-choice question
46
+ # options (required, at least 2) each with `text` and an optional
47
+ # `qualifies: true` — participants must pick a qualifying
48
+ # option on every question, otherwise they see the
49
+ # rejection_message and the test never starts
41
50
 
42
51
  usertest_id: checkout-usability-v1
43
52
  title: Checkout Flow Usability Test
@@ -66,6 +75,31 @@ project_name: checkout-redesign-q1
66
75
  # Optional: collect participant name/email on the final screen (default: false)
67
76
  # collect_contact: true
68
77
 
78
+ # Optional: participant screener — shown on screen before consent and
79
+ # recording. Only qualified participants reach the test; screen-out answers
80
+ # are still captured for funnel metrics.
81
+ screening_questions:
82
+ - text: How often do you shop online?
83
+ options:
84
+ - text: Rarely or never
85
+ - text: A few times a year
86
+ - text: At least once a month
87
+ qualifies: true
88
+ - text: Weekly or more
89
+ qualifies: true
90
+
91
+ - text: Have you abandoned an online purchase at checkout in the past 3 months?
92
+ options:
93
+ - text: "Yes"
94
+ qualifies: true
95
+ - text: "No"
96
+ - text: Not sure
97
+
98
+ # Optional: shown to participants whose screener answers don't qualify
99
+ rejection_message: >
100
+ Thank you for your time, you did not meet the research criteria for this
101
+ study!
102
+
69
103
  # Steps define what participants see and do
70
104
  # Each step requires: type ("screen" or "prototype") and title
71
105
  # Screen steps should have a variant: "question" (open-ended), "task" (action-based),
@@ -143,6 +177,38 @@ def validate_usertest_data(data: dict) -> list[str]:
143
177
  errors.append(
144
178
  f"Step {i}: 'variant' must be one of {sorted(VALID_STEP_VARIANTS)}, got '{variant}'"
145
179
  )
180
+
181
+ screening_questions = data.get("screening_questions")
182
+ if screening_questions is not None:
183
+ if not isinstance(screening_questions, list):
184
+ errors.append("'screening_questions' must be a list")
185
+ else:
186
+ for i, question in enumerate(screening_questions, 1):
187
+ if not isinstance(question, dict) or "text" not in question:
188
+ errors.append(f"Screening question {i}: must be a mapping with 'text'")
189
+ continue
190
+ options = question.get("options")
191
+ if not isinstance(options, list) or len(options) < 2:
192
+ errors.append(f"Screening question {i}: 'options' must list at least 2 options")
193
+ continue
194
+ qualifying = 0
195
+ for j, option in enumerate(options, 1):
196
+ if not isinstance(option, dict) or "text" not in option:
197
+ errors.append(
198
+ f"Screening question {i}, option {j}: must be a mapping with 'text'"
199
+ )
200
+ continue
201
+ qualifies = option.get("qualifies", False)
202
+ if not isinstance(qualifies, bool):
203
+ errors.append(
204
+ f"Screening question {i}, option {j}: 'qualifies' must be true or false"
205
+ )
206
+ elif qualifies:
207
+ qualifying += 1
208
+ if qualifying == 0:
209
+ errors.append(
210
+ f"Screening question {i}: needs at least one option with 'qualifies: true'"
211
+ )
146
212
  return errors
147
213
 
148
214
 
@@ -305,6 +305,87 @@ def test_validate_invalid(runner, tmp_path):
305
305
  assert "Missing required field" in result.output
306
306
 
307
307
 
308
+ SCREENING_YAML = VALID_AI_INTERVIEW_YAML + """\
309
+ screening_questions:
310
+ - text: Do you currently drive for a ridehailing platform?
311
+ options:
312
+ - text: "Yes"
313
+ qualifies: true
314
+ - text: "No"
315
+ - text: How many rides did you complete last month?
316
+ options:
317
+ - text: 0 rides
318
+ - text: 1-10 rides
319
+ qualifies: true
320
+ - text: More than 10 rides
321
+ qualifies: true
322
+ rejection_message: You did not meet the research criteria for this study.
323
+ """
324
+
325
+
326
+ def test_validate_with_screening(runner, tmp_path):
327
+ yaml_file = tmp_path / "interview.yaml"
328
+ yaml_file.write_text(SCREENING_YAML)
329
+
330
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
331
+
332
+ assert result.exit_code == 0
333
+ assert "Valid AI interview config" in result.output
334
+
335
+
336
+ def test_validate_screening_question_needs_two_options(runner, tmp_path):
337
+ yaml_file = tmp_path / "interview.yaml"
338
+ yaml_file.write_text(
339
+ VALID_AI_INTERVIEW_YAML
340
+ + "screening_questions:\n"
341
+ + " - text: Only one way to answer?\n"
342
+ + " options:\n"
343
+ + " - text: \"Yes\"\n"
344
+ + " qualifies: true\n"
345
+ )
346
+
347
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
348
+
349
+ assert result.exit_code == 1
350
+ assert "'options' must list at least 2 options" in result.output
351
+
352
+
353
+ def test_validate_screening_question_needs_a_qualifying_option(runner, tmp_path):
354
+ yaml_file = tmp_path / "interview.yaml"
355
+ yaml_file.write_text(
356
+ VALID_AI_INTERVIEW_YAML
357
+ + "screening_questions:\n"
358
+ + " - text: Nobody can pass this one\n"
359
+ + " options:\n"
360
+ + " - text: A\n"
361
+ + " - text: B\n"
362
+ )
363
+
364
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
365
+
366
+ assert result.exit_code == 1
367
+ assert "needs at least one option with 'qualifies: true'" in result.output
368
+
369
+
370
+ def test_validate_screening_option_qualifies_must_be_bool(runner, tmp_path):
371
+ yaml_file = tmp_path / "interview.yaml"
372
+ yaml_file.write_text(
373
+ VALID_AI_INTERVIEW_YAML
374
+ + "screening_questions:\n"
375
+ + " - text: Q\n"
376
+ + " options:\n"
377
+ + " - text: A\n"
378
+ + " qualifies: yep\n"
379
+ + " - text: B\n"
380
+ + " qualifies: true\n"
381
+ )
382
+
383
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
384
+
385
+ assert result.exit_code == 1
386
+ assert "'qualifies' must be true or false" in result.output
387
+
388
+
308
389
  def test_example(runner):
309
390
  result = runner.invoke(app, ["aiinterviews", "example"])
310
391
 
@@ -313,6 +394,8 @@ def test_example(runner):
313
394
  assert "questions:" in result.output
314
395
  assert "closing_message:" in result.output
315
396
  assert "max_follow_ups:" in result.output
397
+ assert "screening_questions:" in result.output
398
+ assert "rejection_message:" in result.output
316
399
 
317
400
 
318
401
  def test_example_validates(runner, tmp_path):
@@ -292,6 +292,87 @@ def test_validate_invalid(runner, tmp_path):
292
292
  assert "Missing required field" in result.output
293
293
 
294
294
 
295
+ SCREENING_YAML = VALID_USERTEST_YAML + """\
296
+ screening_questions:
297
+ - text: Do you shop online?
298
+ options:
299
+ - text: "Yes"
300
+ qualifies: true
301
+ - text: "No"
302
+ - text: How often do you shop online?
303
+ options:
304
+ - text: Never
305
+ - text: Monthly
306
+ qualifies: true
307
+ - text: Weekly
308
+ qualifies: true
309
+ rejection_message: You did not meet the research criteria for this study.
310
+ """
311
+
312
+
313
+ def test_validate_with_screening(runner, tmp_path):
314
+ yaml_file = tmp_path / "usertest.yaml"
315
+ yaml_file.write_text(SCREENING_YAML)
316
+
317
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
318
+
319
+ assert result.exit_code == 0
320
+ assert "Valid user test config" in result.output
321
+
322
+
323
+ def test_validate_screening_question_needs_two_options(runner, tmp_path):
324
+ yaml_file = tmp_path / "usertest.yaml"
325
+ yaml_file.write_text(
326
+ VALID_USERTEST_YAML
327
+ + "screening_questions:\n"
328
+ + " - text: Only one way to answer?\n"
329
+ + " options:\n"
330
+ + " - text: \"Yes\"\n"
331
+ + " qualifies: true\n"
332
+ )
333
+
334
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
335
+
336
+ assert result.exit_code == 1
337
+ assert "'options' must list at least 2 options" in result.output
338
+
339
+
340
+ def test_validate_screening_question_needs_a_qualifying_option(runner, tmp_path):
341
+ yaml_file = tmp_path / "usertest.yaml"
342
+ yaml_file.write_text(
343
+ VALID_USERTEST_YAML
344
+ + "screening_questions:\n"
345
+ + " - text: Nobody can pass this one\n"
346
+ + " options:\n"
347
+ + " - text: A\n"
348
+ + " - text: B\n"
349
+ )
350
+
351
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
352
+
353
+ assert result.exit_code == 1
354
+ assert "needs at least one option with 'qualifies: true'" in result.output
355
+
356
+
357
+ def test_validate_screening_option_qualifies_must_be_bool(runner, tmp_path):
358
+ yaml_file = tmp_path / "usertest.yaml"
359
+ yaml_file.write_text(
360
+ VALID_USERTEST_YAML
361
+ + "screening_questions:\n"
362
+ + " - text: Q\n"
363
+ + " options:\n"
364
+ + " - text: A\n"
365
+ + " qualifies: yep\n"
366
+ + " - text: B\n"
367
+ + " qualifies: true\n"
368
+ )
369
+
370
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
371
+
372
+ assert result.exit_code == 1
373
+ assert "'qualifies' must be true or false" in result.output
374
+
375
+
295
376
  def test_example(runner):
296
377
  result = runner.invoke(app, ["usertests", "example"])
297
378
 
@@ -300,6 +381,20 @@ def test_example(runner):
300
381
  assert "steps:" in result.output
301
382
  assert "prototype" in result.output
302
383
  assert "screen" in result.output
384
+ assert "screening_questions:" in result.output
385
+ assert "rejection_message:" in result.output
386
+
387
+
388
+ def test_example_validates(runner, tmp_path):
389
+ from podojo_cli.commands.usertests import EXAMPLE_YAML
390
+
391
+ yaml_file = tmp_path / "example.yaml"
392
+ yaml_file.write_text(EXAMPLE_YAML)
393
+
394
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
395
+
396
+ assert result.exit_code == 0
397
+ assert "Valid user test config" in result.output
303
398
 
304
399
 
305
400
  def test_snippet(runner):
@@ -369,7 +369,7 @@ wheels = [
369
369
 
370
370
  [[package]]
371
371
  name = "podojo-cli"
372
- version = "1.5.1"
372
+ version = "1.7.0"
373
373
  source = { editable = "." }
374
374
  dependencies = [
375
375
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes