podojo-cli 1.3.0__tar.gz → 1.5.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.3.0 → podojo_cli-1.5.0}/CHANGELOG.md +10 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/CLAUDE.md +0 -2
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/PKG-INFO +1 -1
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/pyproject.toml +1 -1
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/client.py +67 -0
- podojo_cli-1.5.0/src/podojo_cli/commands/aiinterviews.py +323 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/usertests.py +36 -2
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/config.py +4 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/main.py +2 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/conftest.py +1 -0
- podojo_cli-1.5.0/tests/test_aiinterviews.py +328 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/uv.lock +1 -1
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/.gitignore +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/LICENSE +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/README.md +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/test_showreel.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/test_transcripts.py +0 -0
- {podojo_cli-1.3.0 → podojo_cli-1.5.0}/tests/test_usertests.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.5.0] - 2026-07-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- New `aiinterviews` command group to manage AI interview (voice-interviewer) study configs: `example`, `validate`, `create`, `get`, `update`, `delete`, and `list`. Participant Preview/Live URLs are built from the new `ai_interviews_url` config value (`~/.podojo.toml` key `ai_interviews_url`, env `PODOJO_AI_INTERVIEWS_URL`, or the default frontend URL).
|
|
12
|
+
|
|
13
|
+
## [1.4.0] - 2026-05-31
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Support `image_file:` in usertest step config — a local image path that is uploaded to Podojo-hosted storage on `create`/`update` and replaced with the hosted URL. Relative paths resolve against the YAML file's location. Externally-hosted `image:` URLs continue to work.
|
|
17
|
+
|
|
8
18
|
## [1.3.0] - 2026-05-25
|
|
9
19
|
|
|
10
20
|
### Removed
|
|
@@ -132,6 +132,30 @@ class PodojoClient:
|
|
|
132
132
|
r.raise_for_status()
|
|
133
133
|
return r.json()
|
|
134
134
|
|
|
135
|
+
IMAGE_CONTENT_TYPES = {
|
|
136
|
+
".png": "image/png",
|
|
137
|
+
".jpg": "image/jpeg",
|
|
138
|
+
".jpeg": "image/jpeg",
|
|
139
|
+
".webp": "image/webp",
|
|
140
|
+
".gif": "image/gif",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def upload_usertest_image(self, file_path: Path) -> dict:
|
|
144
|
+
content_type = self.IMAGE_CONTENT_TYPES.get(file_path.suffix.lower())
|
|
145
|
+
if content_type is None:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Unsupported image type '{file_path.suffix}'. Allowed: PNG, JPEG, WebP, GIF."
|
|
148
|
+
)
|
|
149
|
+
with file_path.open("rb") as f:
|
|
150
|
+
r = httpx.post(
|
|
151
|
+
f"{self.base_url}/usertests/images",
|
|
152
|
+
files={"file": (file_path.name, f, content_type)},
|
|
153
|
+
headers=self._headers(),
|
|
154
|
+
timeout=httpx.Timeout(None),
|
|
155
|
+
)
|
|
156
|
+
r.raise_for_status()
|
|
157
|
+
return r.json()
|
|
158
|
+
|
|
135
159
|
def create_usertest(self, data: dict) -> dict:
|
|
136
160
|
r = httpx.post(
|
|
137
161
|
f"{self.base_url}/usertests",
|
|
@@ -157,3 +181,46 @@ class PodojoClient:
|
|
|
157
181
|
)
|
|
158
182
|
r.raise_for_status()
|
|
159
183
|
return r.json()
|
|
184
|
+
|
|
185
|
+
def list_ai_interviews(self, skip: int = 0, limit: int = 50) -> dict:
|
|
186
|
+
r = httpx.get(
|
|
187
|
+
f"{self.base_url}/ai-interviews",
|
|
188
|
+
params={"skip": skip, "limit": limit},
|
|
189
|
+
headers=self._headers(),
|
|
190
|
+
)
|
|
191
|
+
r.raise_for_status()
|
|
192
|
+
return r.json()
|
|
193
|
+
|
|
194
|
+
def get_ai_interview(self, interview_id: str) -> dict:
|
|
195
|
+
r = httpx.get(
|
|
196
|
+
f"{self.base_url}/ai-interviews/{interview_id}",
|
|
197
|
+
headers=self._headers(),
|
|
198
|
+
)
|
|
199
|
+
r.raise_for_status()
|
|
200
|
+
return r.json()
|
|
201
|
+
|
|
202
|
+
def create_ai_interview(self, data: dict) -> dict:
|
|
203
|
+
r = httpx.post(
|
|
204
|
+
f"{self.base_url}/ai-interviews",
|
|
205
|
+
json=data,
|
|
206
|
+
headers=self._headers(),
|
|
207
|
+
)
|
|
208
|
+
r.raise_for_status()
|
|
209
|
+
return r.json()
|
|
210
|
+
|
|
211
|
+
def update_ai_interview(self, interview_id: str, data: dict) -> dict:
|
|
212
|
+
r = httpx.put(
|
|
213
|
+
f"{self.base_url}/ai-interviews/{interview_id}",
|
|
214
|
+
json=data,
|
|
215
|
+
headers=self._headers(),
|
|
216
|
+
)
|
|
217
|
+
r.raise_for_status()
|
|
218
|
+
return r.json()
|
|
219
|
+
|
|
220
|
+
def delete_ai_interview(self, interview_id: str) -> dict:
|
|
221
|
+
r = httpx.delete(
|
|
222
|
+
f"{self.base_url}/ai-interviews/{interview_id}",
|
|
223
|
+
headers=self._headers(),
|
|
224
|
+
)
|
|
225
|
+
r.raise_for_status()
|
|
226
|
+
return r.json()
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
import yaml
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from ..client import PodojoClient
|
|
10
|
+
from ..config import load_config
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage AI voice interviews")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
REQUIRED_FIELDS = ["interview_id", "title", "questions", "closing_message"]
|
|
16
|
+
REQUIRED_QUESTION_FIELDS = ["text"]
|
|
17
|
+
|
|
18
|
+
EXAMPLE_YAML = """\
|
|
19
|
+
# Podojo AI Interview Configuration
|
|
20
|
+
#
|
|
21
|
+
# One file = one study. This YAML is the source of truth: editing it edits the
|
|
22
|
+
# interview. The AI interviewer conducts a voice conversation built from the
|
|
23
|
+
# questions below.
|
|
24
|
+
#
|
|
25
|
+
# Required fields: interview_id, title, questions, closing_message
|
|
26
|
+
# Optional fields: language (default en-US), project_name, overview,
|
|
27
|
+
# decision, live
|
|
28
|
+
#
|
|
29
|
+
# Each question:
|
|
30
|
+
# text (required) the main question, asked verbatim-ish in order
|
|
31
|
+
# section (optional) researcher-facing grouping label
|
|
32
|
+
# max_follow_ups (optional, default 2) adaptive follow-up budget
|
|
33
|
+
# probe_for (optional) what a concrete answer must cover — drives follow-ups
|
|
34
|
+
|
|
35
|
+
interview_id: checkout-experience-v1
|
|
36
|
+
title: Checkout Experience Research
|
|
37
|
+
language: en-US
|
|
38
|
+
|
|
39
|
+
# Optional: link interview to a project
|
|
40
|
+
project_name: checkout-redesign-q1
|
|
41
|
+
|
|
42
|
+
# Optional: research context that grounds the interviewer's follow-ups
|
|
43
|
+
overview: |
|
|
44
|
+
We're redesigning the checkout flow of our online store. This study targets
|
|
45
|
+
recent customers to understand how they decide what to buy, what slows them
|
|
46
|
+
down during checkout, and what would make them complete a purchase.
|
|
47
|
+
|
|
48
|
+
Key questions:
|
|
49
|
+
- How do customers move from browsing to buying?
|
|
50
|
+
- What friction shows up during checkout (payment, shipping, account creation)?
|
|
51
|
+
- What would make customers abandon a purchase at the last step?
|
|
52
|
+
|
|
53
|
+
# Optional: the decision this research informs
|
|
54
|
+
decision: >
|
|
55
|
+
Redesign the checkout flow to reduce cart abandonment.
|
|
56
|
+
|
|
57
|
+
# Optional: set interview live (default: false)
|
|
58
|
+
# live: true
|
|
59
|
+
|
|
60
|
+
questions:
|
|
61
|
+
- section: Shopping Habits
|
|
62
|
+
text: >
|
|
63
|
+
Think about the last time you bought something online. Walk me through
|
|
64
|
+
how you went from finding the product to completing the purchase.
|
|
65
|
+
max_follow_ups: 3
|
|
66
|
+
probe_for: >
|
|
67
|
+
Specific steps taken (search, comparison, reviews), devices used, and
|
|
68
|
+
anything that slowed them down or almost made them give up.
|
|
69
|
+
|
|
70
|
+
- section: Checkout Friction
|
|
71
|
+
text: >
|
|
72
|
+
Think about a time you abandoned an online purchase at checkout.
|
|
73
|
+
What made you stop?
|
|
74
|
+
max_follow_ups: 3
|
|
75
|
+
probe_for: >
|
|
76
|
+
Unexpected costs, forced account creation, missing payment options,
|
|
77
|
+
delivery times. Ask what would have changed their mind.
|
|
78
|
+
|
|
79
|
+
- section: Checkout Friction
|
|
80
|
+
text: >
|
|
81
|
+
When you reach a checkout page, what's the first thing you look at
|
|
82
|
+
before entering your details?
|
|
83
|
+
max_follow_ups: 1
|
|
84
|
+
|
|
85
|
+
closing_message: >
|
|
86
|
+
Thank you for sharing your experience! Your feedback is incredibly valuable
|
|
87
|
+
and will help us improve the shopping experience.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_ai_interview_data(data: dict) -> list[str]:
|
|
92
|
+
"""Validate AI interview YAML data, return list of error strings."""
|
|
93
|
+
errors = []
|
|
94
|
+
for field in REQUIRED_FIELDS:
|
|
95
|
+
if field not in data or data[field] is None:
|
|
96
|
+
errors.append(f"Missing required field: '{field}'")
|
|
97
|
+
|
|
98
|
+
questions = data.get("questions")
|
|
99
|
+
if questions is not None:
|
|
100
|
+
if not isinstance(questions, list):
|
|
101
|
+
errors.append("'questions' must be a list")
|
|
102
|
+
elif len(questions) == 0:
|
|
103
|
+
errors.append("'questions' must contain at least one question")
|
|
104
|
+
else:
|
|
105
|
+
for i, question in enumerate(questions, 1):
|
|
106
|
+
if not isinstance(question, dict):
|
|
107
|
+
errors.append(f"Question {i}: must be a mapping with 'text'")
|
|
108
|
+
continue
|
|
109
|
+
for field in REQUIRED_QUESTION_FIELDS:
|
|
110
|
+
if field not in question:
|
|
111
|
+
errors.append(f"Question {i}: missing required field '{field}'")
|
|
112
|
+
max_follow_ups = question.get("max_follow_ups")
|
|
113
|
+
if max_follow_ups is not None and (
|
|
114
|
+
isinstance(max_follow_ups, bool)
|
|
115
|
+
or not isinstance(max_follow_ups, int)
|
|
116
|
+
or max_follow_ups < 0
|
|
117
|
+
):
|
|
118
|
+
errors.append(
|
|
119
|
+
f"Question {i}: 'max_follow_ups' must be an integer >= 0, got '{max_follow_ups}'"
|
|
120
|
+
)
|
|
121
|
+
return errors
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_yaml(path: Path) -> dict:
|
|
125
|
+
"""Load and parse YAML file."""
|
|
126
|
+
if not path.exists():
|
|
127
|
+
console.print(f"[red]Error:[/red] File not found: {path}")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
try:
|
|
130
|
+
with open(path) as f:
|
|
131
|
+
data = yaml.safe_load(f)
|
|
132
|
+
except yaml.YAMLError as e:
|
|
133
|
+
console.print(f"[red]Error:[/red] Invalid YAML syntax:\n{e}")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
console.print("[red]Error:[/red] YAML file must contain a mapping (key-value pairs)")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _format_api_error(e: httpx.HTTPStatusError) -> str:
|
|
142
|
+
"""Format API error into actionable message."""
|
|
143
|
+
try:
|
|
144
|
+
detail = e.response.json().get("detail", "")
|
|
145
|
+
if isinstance(detail, list):
|
|
146
|
+
messages = []
|
|
147
|
+
for err in detail:
|
|
148
|
+
loc = " -> ".join(str(x) for x in err.get("loc", []))
|
|
149
|
+
messages.append(f" {loc}: {err.get('msg', '')}")
|
|
150
|
+
return "Validation errors:\n" + "\n".join(messages)
|
|
151
|
+
return str(detail)
|
|
152
|
+
except Exception:
|
|
153
|
+
return e.response.text
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ai_interviews_url() -> str:
|
|
157
|
+
return load_config()["ai_interviews_url"].rstrip("/")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("list")
|
|
161
|
+
def list_ai_interviews(
|
|
162
|
+
skip: int = typer.Option(0, help="Number of AI interviews to skip"),
|
|
163
|
+
limit: int = typer.Option(50, help="Max AI interviews to return"),
|
|
164
|
+
):
|
|
165
|
+
"""List all AI interviews."""
|
|
166
|
+
client = PodojoClient()
|
|
167
|
+
try:
|
|
168
|
+
result = client.list_ai_interviews(skip=skip, limit=limit)
|
|
169
|
+
except httpx.HTTPStatusError as e:
|
|
170
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
171
|
+
raise typer.Exit(1)
|
|
172
|
+
|
|
173
|
+
ai_interviews = result.get("ai_interviews", [])
|
|
174
|
+
if not ai_interviews:
|
|
175
|
+
console.print("No AI interviews found.")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
table = Table(title="AI Interviews")
|
|
179
|
+
table.add_column("Interview ID")
|
|
180
|
+
table.add_column("Title")
|
|
181
|
+
table.add_column("Language")
|
|
182
|
+
table.add_column("Questions", justify="right")
|
|
183
|
+
table.add_column("Live")
|
|
184
|
+
table.add_column("Last Updated")
|
|
185
|
+
|
|
186
|
+
for s in ai_interviews:
|
|
187
|
+
live = "[green]Yes[/green]" if s.get("live") else "[dim]No[/dim]"
|
|
188
|
+
table.add_row(
|
|
189
|
+
s.get("interview_id", ""),
|
|
190
|
+
s.get("title", ""),
|
|
191
|
+
s.get("language", ""),
|
|
192
|
+
str(s.get("question_count", "")),
|
|
193
|
+
live,
|
|
194
|
+
s.get("last_updated", ""),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
console.print(table)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("get")
|
|
201
|
+
def get_ai_interview(
|
|
202
|
+
interview_id: str = typer.Argument(help="AI interview ID to retrieve"),
|
|
203
|
+
):
|
|
204
|
+
"""Get an AI interview and output as YAML."""
|
|
205
|
+
client = PodojoClient()
|
|
206
|
+
try:
|
|
207
|
+
interview = client.get_ai_interview(interview_id)
|
|
208
|
+
except httpx.HTTPStatusError as e:
|
|
209
|
+
if e.response.status_code == 404:
|
|
210
|
+
console.print(f"[red]Error:[/red] AI interview '{interview_id}' not found")
|
|
211
|
+
else:
|
|
212
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
# Remove server-managed fields for a clean editable output
|
|
216
|
+
group = interview.pop("group", "")
|
|
217
|
+
for key in ("id", "created_at", "created_by", "last_updated"):
|
|
218
|
+
interview.pop(key, None)
|
|
219
|
+
|
|
220
|
+
console.print(yaml.dump(interview, default_flow_style=False, sort_keys=False, allow_unicode=True))
|
|
221
|
+
if group:
|
|
222
|
+
base = _ai_interviews_url()
|
|
223
|
+
console.print(f"Preview: {base}/preview/{group}/{interview_id}")
|
|
224
|
+
console.print(f"Live: {base}/{group}/{interview_id}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command("create")
|
|
228
|
+
def create_ai_interview(
|
|
229
|
+
from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with AI interview config"),
|
|
230
|
+
):
|
|
231
|
+
"""Create a new AI interview from a YAML file."""
|
|
232
|
+
data = _load_yaml(from_file)
|
|
233
|
+
|
|
234
|
+
errors = validate_ai_interview_data(data)
|
|
235
|
+
if errors:
|
|
236
|
+
console.print("[red]Validation errors:[/red]")
|
|
237
|
+
for err in errors:
|
|
238
|
+
console.print(f" {err}")
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
|
|
241
|
+
client = PodojoClient()
|
|
242
|
+
try:
|
|
243
|
+
result = client.create_ai_interview(data)
|
|
244
|
+
except httpx.HTTPStatusError as e:
|
|
245
|
+
if e.response.status_code == 409:
|
|
246
|
+
console.print(f"[red]Error:[/red] AI interview '{data.get('interview_id')}' already exists")
|
|
247
|
+
else:
|
|
248
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
249
|
+
raise typer.Exit(1)
|
|
250
|
+
|
|
251
|
+
interview_id = result.get("interview_id", "")
|
|
252
|
+
group = result.get("group", "")
|
|
253
|
+
console.print(f"[green]Created AI interview:[/green] {interview_id}")
|
|
254
|
+
if group:
|
|
255
|
+
base = _ai_interviews_url()
|
|
256
|
+
console.print(f" Preview: {base}/preview/{group}/{interview_id}")
|
|
257
|
+
console.print(f" Live: {base}/{group}/{interview_id}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@app.command("update")
|
|
261
|
+
def update_ai_interview(
|
|
262
|
+
interview_id: str = typer.Argument(help="AI interview ID to update"),
|
|
263
|
+
from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with fields to update"),
|
|
264
|
+
):
|
|
265
|
+
"""Update an AI interview from a YAML file (partial updates OK)."""
|
|
266
|
+
data = _load_yaml(from_file)
|
|
267
|
+
|
|
268
|
+
client = PodojoClient()
|
|
269
|
+
try:
|
|
270
|
+
result = client.update_ai_interview(interview_id, data)
|
|
271
|
+
except httpx.HTTPStatusError as e:
|
|
272
|
+
if e.response.status_code == 404:
|
|
273
|
+
console.print(f"[red]Error:[/red] AI interview '{interview_id}' not found")
|
|
274
|
+
else:
|
|
275
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
276
|
+
raise typer.Exit(1)
|
|
277
|
+
|
|
278
|
+
console.print(f"[green]Updated AI interview:[/green] {result.get('interview_id', interview_id)}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command("delete")
|
|
282
|
+
def delete_ai_interview(
|
|
283
|
+
interview_id: str = typer.Argument(help="AI interview ID to delete"),
|
|
284
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
285
|
+
):
|
|
286
|
+
"""Delete an AI interview."""
|
|
287
|
+
if not yes:
|
|
288
|
+
typer.confirm(f"Delete AI interview '{interview_id}'?", abort=True)
|
|
289
|
+
|
|
290
|
+
client = PodojoClient()
|
|
291
|
+
try:
|
|
292
|
+
client.delete_ai_interview(interview_id)
|
|
293
|
+
except httpx.HTTPStatusError as e:
|
|
294
|
+
if e.response.status_code == 404:
|
|
295
|
+
console.print(f"[red]Error:[/red] AI interview '{interview_id}' not found")
|
|
296
|
+
else:
|
|
297
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
console.print(f"[green]Deleted AI interview:[/green] {interview_id}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.command("validate")
|
|
304
|
+
def validate(
|
|
305
|
+
file: Path = typer.Argument(help="YAML file to validate"),
|
|
306
|
+
):
|
|
307
|
+
"""Validate an AI interview YAML file without creating it."""
|
|
308
|
+
data = _load_yaml(file)
|
|
309
|
+
|
|
310
|
+
errors = validate_ai_interview_data(data)
|
|
311
|
+
if errors:
|
|
312
|
+
console.print("[red]Validation errors:[/red]")
|
|
313
|
+
for err in errors:
|
|
314
|
+
console.print(f" {err}")
|
|
315
|
+
raise typer.Exit(1)
|
|
316
|
+
|
|
317
|
+
console.print("[green]Valid AI interview config.[/green]")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@app.command("example")
|
|
321
|
+
def example():
|
|
322
|
+
"""Print an example AI interview YAML template."""
|
|
323
|
+
print(EXAMPLE_YAML)
|
|
@@ -70,7 +70,10 @@ project_name: checkout-redesign-q1
|
|
|
70
70
|
# Each step requires: type ("screen" or "prototype") and title
|
|
71
71
|
# Screen steps should have a variant: "question" (open-ended), "task" (action-based),
|
|
72
72
|
# or "instruction" (briefing screen shown before a prototype step — uses a "Continue" button)
|
|
73
|
-
# Optional per step: text (markdown),
|
|
73
|
+
# Optional per step: text (markdown), and either:
|
|
74
|
+
# image: a URL to an externally-hosted image, or
|
|
75
|
+
# image_file: a path to a local image (uploaded to Podojo storage on create/update;
|
|
76
|
+
# relative paths resolve against this YAML file's location)
|
|
74
77
|
steps:
|
|
75
78
|
- type: screen
|
|
76
79
|
variant: question
|
|
@@ -78,7 +81,7 @@ steps:
|
|
|
78
81
|
text: |
|
|
79
82
|
Look at this homepage screenshot.
|
|
80
83
|
What stands out to you first?
|
|
81
|
-
|
|
84
|
+
image_file: ./screenshots/homepage.png
|
|
82
85
|
|
|
83
86
|
- type: screen
|
|
84
87
|
variant: instruction
|
|
@@ -160,6 +163,35 @@ def _load_yaml(path: Path) -> dict:
|
|
|
160
163
|
return data
|
|
161
164
|
|
|
162
165
|
|
|
166
|
+
def _resolve_image_files(data: dict, base_dir: Path, client: PodojoClient) -> None:
|
|
167
|
+
"""Upload any step `image_file` (local path) and replace it with the hosted URL."""
|
|
168
|
+
steps = data.get("steps")
|
|
169
|
+
if not isinstance(steps, list):
|
|
170
|
+
return
|
|
171
|
+
for i, step in enumerate(steps, 1):
|
|
172
|
+
if not isinstance(step, dict) or "image_file" not in step:
|
|
173
|
+
continue
|
|
174
|
+
raw_path = step.pop("image_file")
|
|
175
|
+
if not raw_path:
|
|
176
|
+
continue
|
|
177
|
+
image_path = Path(raw_path)
|
|
178
|
+
if not image_path.is_absolute():
|
|
179
|
+
image_path = (base_dir / image_path).resolve()
|
|
180
|
+
if not image_path.exists():
|
|
181
|
+
console.print(f"[red]Error:[/red] Step {i}: image file not found: {image_path}")
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
try:
|
|
184
|
+
result = client.upload_usertest_image(image_path)
|
|
185
|
+
except ValueError as ve:
|
|
186
|
+
console.print(f"[red]Error:[/red] Step {i}: {ve}")
|
|
187
|
+
raise typer.Exit(1)
|
|
188
|
+
except httpx.HTTPStatusError as he:
|
|
189
|
+
console.print(f"[red]Error:[/red] Step {i}: image upload failed: {_format_api_error(he)}")
|
|
190
|
+
raise typer.Exit(1)
|
|
191
|
+
step["image"] = result["url"]
|
|
192
|
+
console.print(f"[dim]Uploaded image for step {i}: {result['url']}[/dim]")
|
|
193
|
+
|
|
194
|
+
|
|
163
195
|
def _format_api_error(e: httpx.HTTPStatusError) -> str:
|
|
164
196
|
"""Format API error into actionable message."""
|
|
165
197
|
try:
|
|
@@ -257,6 +289,7 @@ def create_usertest(
|
|
|
257
289
|
data["project_name"] = data["usertest_id"]
|
|
258
290
|
|
|
259
291
|
client = PodojoClient()
|
|
292
|
+
_resolve_image_files(data, from_file.parent, client)
|
|
260
293
|
try:
|
|
261
294
|
result = client.create_usertest(data)
|
|
262
295
|
except httpx.HTTPStatusError as e:
|
|
@@ -288,6 +321,7 @@ def update_usertest(
|
|
|
288
321
|
data = _load_yaml(from_file)
|
|
289
322
|
|
|
290
323
|
client = PodojoClient()
|
|
324
|
+
_resolve_image_files(data, from_file.parent, client)
|
|
291
325
|
try:
|
|
292
326
|
result = client.update_usertest(usertest_id, data)
|
|
293
327
|
except httpx.HTTPStatusError as e:
|
|
@@ -8,6 +8,7 @@ KEYRING_SERVICE = "podojo-cli"
|
|
|
8
8
|
KEYRING_USERNAME = "api_key"
|
|
9
9
|
CONFIG_PATH = Path.home() / ".podojo.toml"
|
|
10
10
|
DEFAULT_BASE_URL = "https://podojo-fastapi-mcp.onrender.com"
|
|
11
|
+
DEFAULT_AI_INTERVIEWS_URL = "https://podojo-unmod-discover-frontend.onrender.com"
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def load_config() -> dict:
|
|
@@ -15,6 +16,9 @@ def load_config() -> dict:
|
|
|
15
16
|
if CONFIG_PATH.exists():
|
|
16
17
|
config = tomllib.loads(CONFIG_PATH.read_text())
|
|
17
18
|
config.setdefault("base_url", os.getenv("PODOJO_BASE_URL", DEFAULT_BASE_URL))
|
|
19
|
+
config.setdefault(
|
|
20
|
+
"ai_interviews_url", os.getenv("PODOJO_AI_INTERVIEWS_URL", DEFAULT_AI_INTERVIEWS_URL)
|
|
21
|
+
)
|
|
18
22
|
api_key = (
|
|
19
23
|
os.getenv("PODOJO_API_KEY")
|
|
20
24
|
or keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
|
|
3
3
|
from .commands import (
|
|
4
|
+
aiinterviews,
|
|
4
5
|
auth,
|
|
5
6
|
interviews,
|
|
6
7
|
projects,
|
|
@@ -28,6 +29,7 @@ app.add_typer(auth.app, name="auth")
|
|
|
28
29
|
app.add_typer(projects.app, name="projects")
|
|
29
30
|
app.add_typer(interviews.app, name="interviews")
|
|
30
31
|
app.add_typer(usertests.app, name="usertests")
|
|
32
|
+
app.add_typer(aiinterviews.app, name="aiinterviews")
|
|
31
33
|
app.add_typer(synth.app, name="synth")
|
|
32
34
|
app.add_typer(transcripts.app, name="transcripts")
|
|
33
35
|
app.add_typer(videos.app, name="videos")
|
|
@@ -41,6 +41,7 @@ def mock_keyring():
|
|
|
41
41
|
@pytest.fixture(autouse=True)
|
|
42
42
|
def mock_config(monkeypatch):
|
|
43
43
|
monkeypatch.setenv("PODOJO_BASE_URL", "http://test.local")
|
|
44
|
+
monkeypatch.setenv("PODOJO_AI_INTERVIEWS_URL", "http://interviews.test.local")
|
|
44
45
|
monkeypatch.setenv("PODOJO_API_KEY", "test-key")
|
|
45
46
|
monkeypatch.setattr("podojo_cli.config.CONFIG_PATH", Path("/nonexistent/.podojo.toml"))
|
|
46
47
|
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
|
|
3
|
+
from podojo_cli.commands.aiinterviews import EXAMPLE_YAML
|
|
4
|
+
from podojo_cli.main import app
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
VALID_AI_INTERVIEW_YAML = """\
|
|
8
|
+
interview_id: test-interview-1
|
|
9
|
+
title: Test AI Interview
|
|
10
|
+
questions:
|
|
11
|
+
- section: Intro
|
|
12
|
+
text: Walk me through your last purchase.
|
|
13
|
+
max_follow_ups: 3
|
|
14
|
+
probe_for: Specific steps and pain points.
|
|
15
|
+
- text: What would you improve?
|
|
16
|
+
closing_message: Thank you for your time!
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
LIST_RESPONSE = {
|
|
20
|
+
"ai_interviews": [
|
|
21
|
+
{
|
|
22
|
+
"id": "abc1",
|
|
23
|
+
"interview_id": "ai-1",
|
|
24
|
+
"title": "Interview One",
|
|
25
|
+
"language": "en-US",
|
|
26
|
+
"question_count": 3,
|
|
27
|
+
"project_name": "proj-1",
|
|
28
|
+
"live": True,
|
|
29
|
+
"created_at": "2026-06-01T12:00:00",
|
|
30
|
+
"created_by": "user@example.com",
|
|
31
|
+
"last_updated": "2026-06-01T12:00:00",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "abc2",
|
|
35
|
+
"interview_id": "ai-2",
|
|
36
|
+
"title": "Interview Two",
|
|
37
|
+
"language": "de-DE",
|
|
38
|
+
"question_count": 5,
|
|
39
|
+
"project_name": "proj-2",
|
|
40
|
+
"live": False,
|
|
41
|
+
"created_at": "2026-06-02T12:00:00",
|
|
42
|
+
"created_by": "user@example.com",
|
|
43
|
+
"last_updated": "2026-06-02T12:00:00",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
"total": 2,
|
|
47
|
+
"skip": 0,
|
|
48
|
+
"limit": 50,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
GET_RESPONSE = {
|
|
52
|
+
"id": "abc123",
|
|
53
|
+
"interview_id": "ai-1",
|
|
54
|
+
"title": "Interview One",
|
|
55
|
+
"language": "en-US",
|
|
56
|
+
"project_name": "proj-1",
|
|
57
|
+
"overview": "Study overview",
|
|
58
|
+
"decision": "Improve the thing",
|
|
59
|
+
"questions": [{"text": "Walk me through your last purchase.", "max_follow_ups": 2}],
|
|
60
|
+
"closing_message": "Thanks!",
|
|
61
|
+
"live": False,
|
|
62
|
+
"group": "test-group",
|
|
63
|
+
"created_at": "2026-06-01T12:00:00",
|
|
64
|
+
"created_by": "user@example.com",
|
|
65
|
+
"last_updated": "2026-06-01T12:00:00",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_list_ai_interviews(runner, httpx_mock):
|
|
70
|
+
httpx_mock.add_response(
|
|
71
|
+
url="http://test.local/api/v1/ai-interviews?skip=0&limit=50",
|
|
72
|
+
json=LIST_RESPONSE,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
result = runner.invoke(app, ["aiinterviews", "list"])
|
|
76
|
+
|
|
77
|
+
assert result.exit_code == 0
|
|
78
|
+
assert "Interview One" in result.output
|
|
79
|
+
assert "Interview Two" in result.output
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_list_ai_interviews_empty(runner, httpx_mock):
|
|
83
|
+
httpx_mock.add_response(
|
|
84
|
+
url="http://test.local/api/v1/ai-interviews?skip=0&limit=50",
|
|
85
|
+
json={"ai_interviews": [], "total": 0, "skip": 0, "limit": 50},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
result = runner.invoke(app, ["aiinterviews", "list"])
|
|
89
|
+
|
|
90
|
+
assert result.exit_code == 0
|
|
91
|
+
assert "No AI interviews found" in result.output
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_get_ai_interview(runner, httpx_mock):
|
|
95
|
+
httpx_mock.add_response(
|
|
96
|
+
url="http://test.local/api/v1/ai-interviews/ai-1",
|
|
97
|
+
json=GET_RESPONSE,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
result = runner.invoke(app, ["aiinterviews", "get", "ai-1"])
|
|
101
|
+
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
assert "interview_id: ai-1" in result.output
|
|
104
|
+
assert "title: Interview One" in result.output
|
|
105
|
+
# Server-managed fields should be stripped
|
|
106
|
+
assert "created_at" not in result.output
|
|
107
|
+
assert "created_by" not in result.output
|
|
108
|
+
assert "group: test-group" not in result.output
|
|
109
|
+
# URLs should be displayed
|
|
110
|
+
assert "http://interviews.test.local/preview/test-group/ai-1" in result.output
|
|
111
|
+
assert "http://interviews.test.local/test-group/ai-1" in result.output
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_get_ai_interview_not_found(runner, httpx_mock):
|
|
115
|
+
httpx_mock.add_response(
|
|
116
|
+
url="http://test.local/api/v1/ai-interviews/nonexistent",
|
|
117
|
+
status_code=404,
|
|
118
|
+
json={"detail": "AI interview not found"},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
result = runner.invoke(app, ["aiinterviews", "get", "nonexistent"])
|
|
122
|
+
|
|
123
|
+
assert result.exit_code == 1
|
|
124
|
+
assert "not found" in result.output
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_create_ai_interview(runner, httpx_mock, tmp_path):
|
|
128
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
129
|
+
yaml_file.write_text(VALID_AI_INTERVIEW_YAML)
|
|
130
|
+
|
|
131
|
+
httpx_mock.add_response(
|
|
132
|
+
url="http://test.local/api/v1/ai-interviews",
|
|
133
|
+
method="POST",
|
|
134
|
+
json={
|
|
135
|
+
"id": "abc123",
|
|
136
|
+
"interview_id": "test-interview-1",
|
|
137
|
+
"title": "Test AI Interview",
|
|
138
|
+
"group": "test-group",
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
|
|
143
|
+
|
|
144
|
+
assert result.exit_code == 0
|
|
145
|
+
assert "test-interview-1" in result.output
|
|
146
|
+
assert "http://interviews.test.local/preview/test-group/test-interview-1" in result.output
|
|
147
|
+
assert "http://interviews.test.local/test-group/test-interview-1" in result.output
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_create_ai_interview_file_not_found(runner):
|
|
151
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", "/nonexistent/interview.yaml"])
|
|
152
|
+
|
|
153
|
+
assert result.exit_code == 1
|
|
154
|
+
assert "File not found" in result.output
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_create_ai_interview_invalid_yaml(runner, tmp_path):
|
|
158
|
+
yaml_file = tmp_path / "bad.yaml"
|
|
159
|
+
yaml_file.write_text(":\n invalid: [yaml\n broken")
|
|
160
|
+
|
|
161
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
|
|
162
|
+
|
|
163
|
+
assert result.exit_code == 1
|
|
164
|
+
assert "Invalid YAML" in result.output
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_create_ai_interview_missing_fields(runner, tmp_path):
|
|
168
|
+
yaml_file = tmp_path / "incomplete.yaml"
|
|
169
|
+
yaml_file.write_text("title: Just a title\n")
|
|
170
|
+
|
|
171
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
|
|
172
|
+
|
|
173
|
+
assert result.exit_code == 1
|
|
174
|
+
assert "Missing required field" in result.output
|
|
175
|
+
assert "'interview_id'" in result.output
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_create_ai_interview_question_missing_text(runner, tmp_path):
|
|
179
|
+
yaml_file = tmp_path / "badquestion.yaml"
|
|
180
|
+
yaml_file.write_text(
|
|
181
|
+
"interview_id: test\n"
|
|
182
|
+
"title: Test\n"
|
|
183
|
+
"closing_message: Thanks!\n"
|
|
184
|
+
"questions:\n"
|
|
185
|
+
" - section: Intro\n"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
|
|
189
|
+
|
|
190
|
+
assert result.exit_code == 1
|
|
191
|
+
assert "Question 1: missing required field 'text'" in result.output
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_create_ai_interview_invalid_max_follow_ups(runner, tmp_path):
|
|
195
|
+
yaml_file = tmp_path / "badfollowups.yaml"
|
|
196
|
+
yaml_file.write_text(
|
|
197
|
+
"interview_id: test\n"
|
|
198
|
+
"title: Test\n"
|
|
199
|
+
"closing_message: Thanks!\n"
|
|
200
|
+
"questions:\n"
|
|
201
|
+
" - text: A question\n"
|
|
202
|
+
" max_follow_ups: -1\n"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
|
|
206
|
+
|
|
207
|
+
assert result.exit_code == 1
|
|
208
|
+
assert "'max_follow_ups' must be an integer >= 0" in result.output
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_create_ai_interview_duplicate(runner, httpx_mock, tmp_path):
|
|
212
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
213
|
+
yaml_file.write_text(VALID_AI_INTERVIEW_YAML)
|
|
214
|
+
|
|
215
|
+
httpx_mock.add_response(
|
|
216
|
+
url="http://test.local/api/v1/ai-interviews",
|
|
217
|
+
method="POST",
|
|
218
|
+
status_code=409,
|
|
219
|
+
json={"detail": "AI interview with this ID already exists"},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
|
|
223
|
+
|
|
224
|
+
assert result.exit_code == 1
|
|
225
|
+
assert "already exists" in result.output
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_update_ai_interview(runner, httpx_mock, tmp_path):
|
|
229
|
+
yaml_file = tmp_path / "update.yaml"
|
|
230
|
+
yaml_file.write_text("title: Updated Title\n")
|
|
231
|
+
|
|
232
|
+
httpx_mock.add_response(
|
|
233
|
+
url="http://test.local/api/v1/ai-interviews/ai-1",
|
|
234
|
+
method="PUT",
|
|
235
|
+
json={"interview_id": "ai-1", "title": "Updated Title"},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
result = runner.invoke(app, ["aiinterviews", "update", "ai-1", "-f", str(yaml_file)])
|
|
239
|
+
|
|
240
|
+
assert result.exit_code == 0
|
|
241
|
+
assert "Updated AI interview" in result.output
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_update_ai_interview_not_found(runner, httpx_mock, tmp_path):
|
|
245
|
+
yaml_file = tmp_path / "update.yaml"
|
|
246
|
+
yaml_file.write_text("title: Updated Title\n")
|
|
247
|
+
|
|
248
|
+
httpx_mock.add_response(
|
|
249
|
+
url="http://test.local/api/v1/ai-interviews/nonexistent",
|
|
250
|
+
method="PUT",
|
|
251
|
+
status_code=404,
|
|
252
|
+
json={"detail": "AI interview not found"},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
result = runner.invoke(app, ["aiinterviews", "update", "nonexistent", "-f", str(yaml_file)])
|
|
256
|
+
|
|
257
|
+
assert result.exit_code == 1
|
|
258
|
+
assert "not found" in result.output
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_delete_ai_interview(runner, httpx_mock):
|
|
262
|
+
httpx_mock.add_response(
|
|
263
|
+
url="http://test.local/api/v1/ai-interviews/ai-1",
|
|
264
|
+
method="DELETE",
|
|
265
|
+
json={"status": "deleted", "interview_id": "ai-1"},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
result = runner.invoke(app, ["aiinterviews", "delete", "ai-1", "--yes"])
|
|
269
|
+
|
|
270
|
+
assert result.exit_code == 0
|
|
271
|
+
assert "Deleted AI interview" in result.output
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_delete_ai_interview_not_found(runner, httpx_mock):
|
|
275
|
+
httpx_mock.add_response(
|
|
276
|
+
url="http://test.local/api/v1/ai-interviews/nonexistent",
|
|
277
|
+
method="DELETE",
|
|
278
|
+
status_code=404,
|
|
279
|
+
json={"detail": "AI interview not found"},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
result = runner.invoke(app, ["aiinterviews", "delete", "nonexistent", "--yes"])
|
|
283
|
+
|
|
284
|
+
assert result.exit_code == 1
|
|
285
|
+
assert "not found" in result.output
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_validate_valid(runner, tmp_path):
|
|
289
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
290
|
+
yaml_file.write_text(VALID_AI_INTERVIEW_YAML)
|
|
291
|
+
|
|
292
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
293
|
+
|
|
294
|
+
assert result.exit_code == 0
|
|
295
|
+
assert "Valid AI interview config" in result.output
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_validate_invalid(runner, tmp_path):
|
|
299
|
+
yaml_file = tmp_path / "bad.yaml"
|
|
300
|
+
yaml_file.write_text("title: Just a title\n")
|
|
301
|
+
|
|
302
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
303
|
+
|
|
304
|
+
assert result.exit_code == 1
|
|
305
|
+
assert "Missing required field" in result.output
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_example(runner):
|
|
309
|
+
result = runner.invoke(app, ["aiinterviews", "example"])
|
|
310
|
+
|
|
311
|
+
assert result.exit_code == 0
|
|
312
|
+
assert "interview_id:" in result.output
|
|
313
|
+
assert "questions:" in result.output
|
|
314
|
+
assert "closing_message:" in result.output
|
|
315
|
+
assert "max_follow_ups:" in result.output
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_example_validates(runner, tmp_path):
|
|
319
|
+
data = yaml.safe_load(EXAMPLE_YAML)
|
|
320
|
+
assert isinstance(data, dict)
|
|
321
|
+
|
|
322
|
+
yaml_file = tmp_path / "example.yaml"
|
|
323
|
+
yaml_file.write_text(EXAMPLE_YAML)
|
|
324
|
+
|
|
325
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
326
|
+
|
|
327
|
+
assert result.exit_code == 0
|
|
328
|
+
assert "Valid AI interview config" in result.output
|
|
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
|