podojo-cli 1.5.2__tar.gz → 1.6.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.
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/CHANGELOG.md +5 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/PKG-INFO +7 -1
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/README.md +6 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/pyproject.toml +1 -1
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/aiinterviews.py +65 -1
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_aiinterviews.py +83 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/uv.lock +1 -1
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/.gitignore +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/CLAUDE.md +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/LICENSE +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/client.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/usertests.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/config.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/main.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/conftest.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_showreel.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_transcripts.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.6.0}/tests/test_usertests.py +0 -0
|
@@ -5,6 +5,11 @@ 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.6.0] - 2026-07-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
8
13
|
## [1.5.2] - 2026-07-02
|
|
9
14
|
|
|
10
15
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: podojo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -143,6 +143,12 @@ podojo aiinterviews delete checkout-experience-v1
|
|
|
143
143
|
base URL can be overridden via `ai_interviews_url` in `~/.podojo.toml` or
|
|
144
144
|
`PODOJO_AI_INTERVIEWS_URL`.
|
|
145
145
|
|
|
146
|
+
A study can open with an optional participant screener: on-screen single-select
|
|
147
|
+
`screening_questions` whose options carry `qualifies: true` flags. Participants
|
|
148
|
+
must pick a qualifying option on every question; everyone else sees the study's
|
|
149
|
+
`rejection_message` and never reaches the voice interview. See
|
|
150
|
+
`podojo aiinterviews example` for the exact shape.
|
|
151
|
+
|
|
146
152
|
### Synthetic participants
|
|
147
153
|
|
|
148
154
|
The `synth` group drives a Playwright browser through a user test preview so an
|
|
@@ -112,6 +112,12 @@ podojo aiinterviews delete checkout-experience-v1
|
|
|
112
112
|
base URL can be overridden via `ai_interviews_url` in `~/.podojo.toml` or
|
|
113
113
|
`PODOJO_AI_INTERVIEWS_URL`.
|
|
114
114
|
|
|
115
|
+
A study can open with an optional participant screener: on-screen single-select
|
|
116
|
+
`screening_questions` whose options carry `qualifies: true` flags. Participants
|
|
117
|
+
must pick a qualifying option on every question; everyone else sees the study's
|
|
118
|
+
`rejection_message` and never reaches the voice interview. See
|
|
119
|
+
`podojo aiinterviews example` for the exact shape.
|
|
120
|
+
|
|
115
121
|
### Synthetic participants
|
|
116
122
|
|
|
117
123
|
The `synth` group drives a Playwright browser through a user test preview so an
|
|
@@ -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
|
|
|
@@ -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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|