tavus-cli 0.1.0__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.
- tavus_cli-0.1.0.dist-info/METADATA +192 -0
- tavus_cli-0.1.0.dist-info/RECORD +32 -0
- tavus_cli-0.1.0.dist-info/WHEEL +4 -0
- tavus_cli-0.1.0.dist-info/entry_points.txt +3 -0
- tavus_mcp/__init__.py +4 -0
- tavus_mcp/cli/__init__.py +1 -0
- tavus_mcp/cli/main.py +1467 -0
- tavus_mcp/sdk/__init__.py +6 -0
- tavus_mcp/sdk/auth/__init__.py +1 -0
- tavus_mcp/sdk/auth/keyring_store.py +20 -0
- tavus_mcp/sdk/auth/oauth.py +131 -0
- tavus_mcp/sdk/auth/session.py +46 -0
- tavus_mcp/sdk/client/__init__.py +3 -0
- tavus_mcp/sdk/client/http.py +451 -0
- tavus_mcp/sdk/env.py +126 -0
- tavus_mcp/sdk/errors.py +46 -0
- tavus_mcp/sdk/patch.py +92 -0
- tavus_mcp/sdk/recipes/__init__.py +6 -0
- tavus_mcp/sdk/recipes/build_and_verify.py +618 -0
- tavus_mcp/sdk/recipes/options.py +67 -0
- tavus_mcp/sdk/recipes/quickstart.py +48 -0
- tavus_mcp/sdk/recipes/scaffold_embed.py +115 -0
- tavus_mcp/sdk/recipes/templates.py +58 -0
- tavus_mcp/sdk/recipes/tool_reuse.py +124 -0
- tavus_mcp/sdk/schemas/__init__.py +16 -0
- tavus_mcp/sdk/schemas/file_manifest.py +21 -0
- tavus_mcp/sdk/schemas/guardrail.py +84 -0
- tavus_mcp/sdk/schemas/objective.py +131 -0
- tavus_mcp/sdk/schemas/persona.py +174 -0
- tavus_mcp/sdk/schemas/pronunciation.py +73 -0
- tavus_mcp/sdk/schemas/tool.py +349 -0
- tavus_mcp/server.py +877 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""Persona build + verify loop.
|
|
2
|
+
|
|
3
|
+
Drives the conversational builder end-to-end:
|
|
4
|
+
``chat → creator answers → fan-out artifact updates → publish → select/attach
|
|
5
|
+
replica → CVI text-mode smoke test → LLM-judged verdict``. The builder
|
|
6
|
+
chat, artifact writers, replica selector, probe generator, and judge all
|
|
7
|
+
run on RQH, so end users don't need to bring their own LLM credentials.
|
|
8
|
+
|
|
9
|
+
The recipe is HTTP-only — it speaks to RQH ``/v2/builder/*``,
|
|
10
|
+
``/v2/persona-builder/*``, and the usual ``/v2/*`` resources, all authenticated
|
|
11
|
+
by the user's ``x-api-key`` like the rest of the SDK.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from tavus_mcp.sdk.client.http import TavusClient
|
|
20
|
+
|
|
21
|
+
BUILDER_SECTIONS = ("personality", "greeting", "objectives", "guardrails")
|
|
22
|
+
BUILDER_TARGETS = ("persona_name", *BUILDER_SECTIONS)
|
|
23
|
+
PERSONA_BUILDER_LLM_TIMEOUT_S = 120.0
|
|
24
|
+
CVI_SMOKE_TURN_TIMEOUT_S = 60.0
|
|
25
|
+
AnswerProvider = Callable[[dict[str, Any], int, list[dict[str, Any]]], str | None]
|
|
26
|
+
BuildProgress = Callable[[dict[str, Any], int, list[dict[str, Any]]], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def build_and_verify(
|
|
30
|
+
client: TavusClient,
|
|
31
|
+
*,
|
|
32
|
+
replica_id: str | None = None,
|
|
33
|
+
prompt: str | None = None,
|
|
34
|
+
goal: str | None = None,
|
|
35
|
+
name: str | None = None,
|
|
36
|
+
model: str = "claude-sonnet-4-6",
|
|
37
|
+
inner_model: str | None = None,
|
|
38
|
+
max_rounds: int = 4,
|
|
39
|
+
max_turns: int | None = None,
|
|
40
|
+
answers: Sequence[str] | None = None,
|
|
41
|
+
answer_provider: AnswerProvider | None = None,
|
|
42
|
+
on_builder_reply: BuildProgress | None = None,
|
|
43
|
+
probes: list[str] | None = None,
|
|
44
|
+
auto_refine: bool = False,
|
|
45
|
+
max_refine_rounds: int = 3,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Drive an end-to-end persona build + CVI text-mode smoke test.
|
|
48
|
+
|
|
49
|
+
All LLM work runs on RQH; no client-side LLM credentials are required.
|
|
50
|
+
Returns ``{builder_id, persona_id, persona_url, replica_id, system_prompt,
|
|
51
|
+
persona_spec, build_transcript, probes, smoke_transcript, verdict,
|
|
52
|
+
validated, refine_rounds_used}``.
|
|
53
|
+
"""
|
|
54
|
+
creator_prompt = _coerce_prompt(prompt, goal)
|
|
55
|
+
if max_turns is not None:
|
|
56
|
+
max_rounds = max_turns
|
|
57
|
+
max_rounds = max(1, max_rounds)
|
|
58
|
+
session_name = name or _derive_persona_name(creator_prompt)
|
|
59
|
+
|
|
60
|
+
if replica_id:
|
|
61
|
+
await _ensure_replica_exists(client, replica_id)
|
|
62
|
+
|
|
63
|
+
session = await client.builders.create({"name": session_name, "model": model})
|
|
64
|
+
builder_id = str(session["builder_id"])
|
|
65
|
+
persona_id = str(session["persona_id"])
|
|
66
|
+
|
|
67
|
+
build_transcript: list[dict[str, Any]] = []
|
|
68
|
+
await _run_build_loop(
|
|
69
|
+
client=client,
|
|
70
|
+
builder_id=builder_id,
|
|
71
|
+
prompt=creator_prompt,
|
|
72
|
+
inner_model=inner_model,
|
|
73
|
+
max_rounds=max_rounds,
|
|
74
|
+
answers=answers,
|
|
75
|
+
answer_provider=answer_provider,
|
|
76
|
+
on_builder_reply=on_builder_reply,
|
|
77
|
+
transcript=build_transcript,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
await client.builders.publish(builder_id)
|
|
81
|
+
replica_selection: dict[str, Any] | None = None
|
|
82
|
+
if replica_id:
|
|
83
|
+
await client.personas.patch(
|
|
84
|
+
persona_id,
|
|
85
|
+
[{"op": "replace", "path": "/default_replica_id", "value": replica_id}],
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
replica_selection = await _select_default_replica(client, persona_id, inner_model)
|
|
89
|
+
replica_id = str(replica_selection.get("replica_id") or "").strip()
|
|
90
|
+
|
|
91
|
+
spec = await _assemble_spec(client, persona_id)
|
|
92
|
+
chosen_probes = (
|
|
93
|
+
list(probes) if probes else await _generate_probes(client, spec, inner_model)
|
|
94
|
+
)
|
|
95
|
+
smoke_transcript = await _run_smoke_test(client, persona_id, chosen_probes)
|
|
96
|
+
verdict = await _judge_transcript(client, spec, smoke_transcript, inner_model)
|
|
97
|
+
|
|
98
|
+
refine_rounds_used = 0
|
|
99
|
+
while (
|
|
100
|
+
auto_refine
|
|
101
|
+
and verdict.get("overall") != "pass"
|
|
102
|
+
and refine_rounds_used < max_refine_rounds
|
|
103
|
+
):
|
|
104
|
+
gap_message = (
|
|
105
|
+
"The last CVI smoke test flagged these gaps. Tighten the persona to address "
|
|
106
|
+
f"them:\n{_summarize_gaps(verdict)}"
|
|
107
|
+
)
|
|
108
|
+
await _run_build_loop(
|
|
109
|
+
client=client,
|
|
110
|
+
builder_id=builder_id,
|
|
111
|
+
prompt=gap_message,
|
|
112
|
+
inner_model=inner_model,
|
|
113
|
+
max_rounds=max(3, max_rounds // 2),
|
|
114
|
+
answers=None,
|
|
115
|
+
answer_provider=None,
|
|
116
|
+
on_builder_reply=None,
|
|
117
|
+
transcript=build_transcript,
|
|
118
|
+
)
|
|
119
|
+
await client.builders.publish(builder_id)
|
|
120
|
+
await _prepend_validation_directive(client, persona_id, verdict)
|
|
121
|
+
spec = await _assemble_spec(client, persona_id)
|
|
122
|
+
smoke_transcript = await _run_smoke_test(client, persona_id, chosen_probes)
|
|
123
|
+
verdict = await _judge_transcript(client, spec, smoke_transcript, inner_model)
|
|
124
|
+
refine_rounds_used += 1
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"builder_id": builder_id,
|
|
128
|
+
"persona_id": persona_id,
|
|
129
|
+
"persona_name": spec.get("persona_name"),
|
|
130
|
+
"persona_url": _persona_url(client, persona_id),
|
|
131
|
+
"system_prompt": spec.get("system_prompt"),
|
|
132
|
+
"replica_id": replica_id or spec.get("default_replica_id"),
|
|
133
|
+
"replica_selection": replica_selection,
|
|
134
|
+
"persona_spec": spec,
|
|
135
|
+
"build_transcript": build_transcript,
|
|
136
|
+
"probes": chosen_probes,
|
|
137
|
+
"smoke_transcript": smoke_transcript,
|
|
138
|
+
"verdict": verdict,
|
|
139
|
+
"validated": verdict.get("overall") == "pass",
|
|
140
|
+
"refine_rounds_used": refine_rounds_used,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _coerce_prompt(prompt: str | None, goal: str | None) -> str:
|
|
145
|
+
value = prompt if prompt is not None else goal
|
|
146
|
+
if not value or not value.strip():
|
|
147
|
+
raise ValueError("prompt is required")
|
|
148
|
+
return value.strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _derive_persona_name(prompt: str) -> str:
|
|
152
|
+
"""Mirror creator-studio's seeded persona name for the initial row."""
|
|
153
|
+
import re
|
|
154
|
+
|
|
155
|
+
cleaned = re.sub(r"^\s*(an?|the)\s+", "", prompt, flags=re.IGNORECASE)
|
|
156
|
+
cleaned = re.sub(r"[.!?,;:].*$", "", cleaned).strip()
|
|
157
|
+
if not cleaned:
|
|
158
|
+
return "Untitled persona"
|
|
159
|
+
words = " ".join(cleaned.split()[:4])
|
|
160
|
+
return words[:1].upper() + words[1:]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _persona_url(client: TavusClient, persona_id: str) -> str:
|
|
164
|
+
base = str(client.config.dev_portal_url).rstrip("/")
|
|
165
|
+
return f"{base}/dev/personas/update?persona_id={persona_id}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _ensure_replica_exists(client: TavusClient, replica_id: str) -> None:
|
|
169
|
+
try:
|
|
170
|
+
await client.replicas.get(replica_id)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
from tavus_mcp.sdk.errors import TavusError
|
|
173
|
+
|
|
174
|
+
raise TavusError(
|
|
175
|
+
f"Replica {replica_id!r} not found in this Tavus environment: {exc}"
|
|
176
|
+
) from exc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _select_default_replica(
|
|
180
|
+
client: TavusClient, persona_id: str, inner_model: str | None
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
body: dict[str, Any] = {"persona_id": persona_id}
|
|
183
|
+
if inner_model:
|
|
184
|
+
body["model"] = inner_model
|
|
185
|
+
selection = await client.request(
|
|
186
|
+
"POST",
|
|
187
|
+
"/persona-builder/select-replica",
|
|
188
|
+
json=body,
|
|
189
|
+
timeout=PERSONA_BUILDER_LLM_TIMEOUT_S,
|
|
190
|
+
)
|
|
191
|
+
replica_id = selection.get("replica_id") if isinstance(selection, dict) else None
|
|
192
|
+
if not replica_id:
|
|
193
|
+
from tavus_mcp.sdk.errors import TavusError
|
|
194
|
+
|
|
195
|
+
raise TavusError("RQH did not return a default replica selection.")
|
|
196
|
+
return selection
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _run_build_loop(
|
|
200
|
+
*,
|
|
201
|
+
client: TavusClient,
|
|
202
|
+
builder_id: str,
|
|
203
|
+
prompt: str,
|
|
204
|
+
inner_model: str | None,
|
|
205
|
+
max_rounds: int,
|
|
206
|
+
answers: Sequence[str] | None,
|
|
207
|
+
answer_provider: AnswerProvider | None,
|
|
208
|
+
on_builder_reply: BuildProgress | None,
|
|
209
|
+
transcript: list[dict[str, Any]],
|
|
210
|
+
) -> None:
|
|
211
|
+
next_message = prompt
|
|
212
|
+
answer_index = 0
|
|
213
|
+
answers_used = 0
|
|
214
|
+
builder_turns = 0
|
|
215
|
+
initial_fanout_done = False
|
|
216
|
+
human_driven = answer_provider is not None or answers is not None
|
|
217
|
+
max_builder_turns = max_rounds + 1 if human_driven else max_rounds
|
|
218
|
+
while next_message and builder_turns < max_builder_turns:
|
|
219
|
+
if not next_message:
|
|
220
|
+
break
|
|
221
|
+
latest_creator_message = next_message
|
|
222
|
+
transcript.append({"role": "user", "text": next_message})
|
|
223
|
+
reply = await client.builders.chat(builder_id, next_message)
|
|
224
|
+
builder_turns += 1
|
|
225
|
+
transcript.append(
|
|
226
|
+
{
|
|
227
|
+
"role": "assistant",
|
|
228
|
+
"text": reply.get("text", ""),
|
|
229
|
+
"targets": list(reply.get("targets") or []),
|
|
230
|
+
"draft_ready": bool(reply.get("draft_ready", False)),
|
|
231
|
+
"suggestions": list(reply.get("suggestions") or []),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
if on_builder_reply is not None:
|
|
235
|
+
on_builder_reply(reply, min(builder_turns, max_rounds), transcript)
|
|
236
|
+
|
|
237
|
+
if reply.get("draft_ready"):
|
|
238
|
+
sections = _sections_for_ready_reply(reply, initial_fanout_done)
|
|
239
|
+
if sections:
|
|
240
|
+
await _apply_builder_sections(
|
|
241
|
+
client, builder_id, sections, latest_creator_message
|
|
242
|
+
)
|
|
243
|
+
transcript.append(
|
|
244
|
+
{
|
|
245
|
+
"role": "system",
|
|
246
|
+
"text": (
|
|
247
|
+
"applied "
|
|
248
|
+
+ ", ".join(sections)
|
|
249
|
+
+ f": {latest_creator_message}"
|
|
250
|
+
),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
initial_fanout_done = True
|
|
254
|
+
|
|
255
|
+
if _looks_like_preview_closer(reply):
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if human_driven:
|
|
259
|
+
if answers_used >= max_rounds:
|
|
260
|
+
break
|
|
261
|
+
if answer_provider is not None:
|
|
262
|
+
next_message = (
|
|
263
|
+
answer_provider(reply, min(builder_turns, max_rounds), transcript)
|
|
264
|
+
or ""
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
if answers is None or answer_index >= len(answers):
|
|
268
|
+
break
|
|
269
|
+
next_message = str(answers[answer_index]).strip()
|
|
270
|
+
answer_index += 1
|
|
271
|
+
if next_message:
|
|
272
|
+
answers_used += 1
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
if reply.get("draft_ready"):
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
decision = await _decide_next(
|
|
279
|
+
client=client,
|
|
280
|
+
prompt=prompt,
|
|
281
|
+
transcript=transcript,
|
|
282
|
+
latest=reply,
|
|
283
|
+
inner_model=inner_model,
|
|
284
|
+
)
|
|
285
|
+
applied_targets = await _apply_decision_target_updates(
|
|
286
|
+
client, builder_id, decision, transcript
|
|
287
|
+
)
|
|
288
|
+
done = bool(decision.get("action") == "done")
|
|
289
|
+
if done and not initial_fanout_done and not applied_targets:
|
|
290
|
+
await _apply_fallback_target_updates(client, builder_id, prompt, transcript)
|
|
291
|
+
initial_fanout_done = True
|
|
292
|
+
if done:
|
|
293
|
+
break
|
|
294
|
+
next_message = decision.get("next_message") or ""
|
|
295
|
+
|
|
296
|
+
if not initial_fanout_done:
|
|
297
|
+
await _apply_fallback_target_updates(client, builder_id, prompt, transcript)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _sections_for_ready_reply(
|
|
301
|
+
reply: dict[str, Any], initial_fanout_done: bool
|
|
302
|
+
) -> list[str]:
|
|
303
|
+
if not initial_fanout_done:
|
|
304
|
+
return list(BUILDER_SECTIONS)
|
|
305
|
+
raw_targets = reply.get("targets")
|
|
306
|
+
if not isinstance(raw_targets, list) or not raw_targets:
|
|
307
|
+
return list(BUILDER_SECTIONS)
|
|
308
|
+
sections = [target for target in raw_targets if target in BUILDER_SECTIONS]
|
|
309
|
+
return list(dict.fromkeys(sections))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _looks_like_preview_closer(reply: dict[str, Any]) -> bool:
|
|
313
|
+
text = str(reply.get("text") or "").strip().lower()
|
|
314
|
+
if not text:
|
|
315
|
+
return False
|
|
316
|
+
if "preview" not in text:
|
|
317
|
+
return False
|
|
318
|
+
return "?" not in text
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def _apply_builder_sections(
|
|
322
|
+
client: TavusClient, builder_id: str, sections: Sequence[str], message: str
|
|
323
|
+
) -> None:
|
|
324
|
+
for section in sections:
|
|
325
|
+
if section == "personality":
|
|
326
|
+
await client.builders.update_personality(
|
|
327
|
+
builder_id, message, persona_name=True, system_prompt=True
|
|
328
|
+
)
|
|
329
|
+
elif section == "greeting":
|
|
330
|
+
await client.builders.update_greeting(builder_id, message)
|
|
331
|
+
elif section == "objectives":
|
|
332
|
+
await client.builders.update_objectives(builder_id, message)
|
|
333
|
+
elif section == "guardrails":
|
|
334
|
+
await client.builders.update_guardrails(builder_id, message)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def _apply_decision_target_updates(
|
|
338
|
+
client: TavusClient,
|
|
339
|
+
builder_id: str,
|
|
340
|
+
decision: dict[str, Any],
|
|
341
|
+
transcript: list[dict[str, Any]],
|
|
342
|
+
) -> set[str]:
|
|
343
|
+
applied_targets: set[str] = set()
|
|
344
|
+
for update in _iter_target_updates(decision.get("target_updates")):
|
|
345
|
+
target = update.get("target")
|
|
346
|
+
message = update.get("message") or ""
|
|
347
|
+
if target not in BUILDER_TARGETS or not message:
|
|
348
|
+
continue
|
|
349
|
+
await _apply_target_update(client, builder_id, target, message)
|
|
350
|
+
applied_targets.add(target)
|
|
351
|
+
transcript.append({"role": "system", "text": f"applied {target}: {message}"})
|
|
352
|
+
return applied_targets
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def _apply_fallback_target_updates(
|
|
356
|
+
client: TavusClient,
|
|
357
|
+
builder_id: str,
|
|
358
|
+
prompt: str,
|
|
359
|
+
transcript: list[dict[str, Any]],
|
|
360
|
+
) -> None:
|
|
361
|
+
for target, message in _fallback_target_updates(prompt):
|
|
362
|
+
await _apply_target_update(client, builder_id, target, message)
|
|
363
|
+
transcript.append({"role": "system", "text": f"applied {target}: {message}"})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _iter_target_updates(raw: Any) -> list[dict[str, Any]]:
|
|
367
|
+
if isinstance(raw, dict):
|
|
368
|
+
raw = [raw]
|
|
369
|
+
if not isinstance(raw, list):
|
|
370
|
+
return []
|
|
371
|
+
return [item for item in raw if isinstance(item, dict)]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _fallback_target_updates(goal: str) -> list[tuple[str, str]]:
|
|
375
|
+
base = (
|
|
376
|
+
"The builder marked the draft ready without applying any structured "
|
|
377
|
+
"persona updates. Use this creator goal to produce the missing "
|
|
378
|
+
f"configuration:\n\n{goal}"
|
|
379
|
+
)
|
|
380
|
+
return [
|
|
381
|
+
(
|
|
382
|
+
"persona_name",
|
|
383
|
+
f"{base}\n\nCreate a short, specific persona title. Use title case. "
|
|
384
|
+
"Do not use placeholder names.",
|
|
385
|
+
),
|
|
386
|
+
(
|
|
387
|
+
"personality",
|
|
388
|
+
f"{base}\n\nCreate a complete, detailed system prompt with role, audience, "
|
|
389
|
+
"conversation style, workflow, boundaries, and success behavior.",
|
|
390
|
+
),
|
|
391
|
+
(
|
|
392
|
+
"objectives",
|
|
393
|
+
f"{base}\n\nCreate concise objectives that capture what the persona should "
|
|
394
|
+
"drive the conversation toward.",
|
|
395
|
+
),
|
|
396
|
+
(
|
|
397
|
+
"guardrails",
|
|
398
|
+
f"{base}\n\nCreate concise guardrails for anything the persona must avoid "
|
|
399
|
+
"or safely redirect.",
|
|
400
|
+
),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def _apply_target_update(
|
|
405
|
+
client: TavusClient, builder_id: str, target: str, message: str
|
|
406
|
+
) -> None:
|
|
407
|
+
if target == "persona_name":
|
|
408
|
+
await client.builders.update_personality(builder_id, message, persona_name=True)
|
|
409
|
+
elif target == "personality":
|
|
410
|
+
await client.builders.update_personality(
|
|
411
|
+
builder_id, message, persona_name=True, system_prompt=True
|
|
412
|
+
)
|
|
413
|
+
elif target == "greeting":
|
|
414
|
+
await client.builders.update_greeting(builder_id, message)
|
|
415
|
+
elif target == "objectives":
|
|
416
|
+
await client.builders.update_objectives(builder_id, message)
|
|
417
|
+
elif target == "guardrails":
|
|
418
|
+
await client.builders.update_guardrails(builder_id, message)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def _decide_next(
|
|
422
|
+
*,
|
|
423
|
+
client: TavusClient,
|
|
424
|
+
prompt: str,
|
|
425
|
+
transcript: list[dict[str, Any]],
|
|
426
|
+
latest: dict[str, Any],
|
|
427
|
+
inner_model: str | None,
|
|
428
|
+
) -> dict[str, Any]:
|
|
429
|
+
body: dict[str, Any] = {
|
|
430
|
+
"goal": prompt,
|
|
431
|
+
"transcript": transcript,
|
|
432
|
+
"latest_reply": latest,
|
|
433
|
+
}
|
|
434
|
+
if inner_model:
|
|
435
|
+
body["model"] = inner_model
|
|
436
|
+
return await client.request(
|
|
437
|
+
"POST",
|
|
438
|
+
"/persona-builder/llm/decide-next-action",
|
|
439
|
+
json=body,
|
|
440
|
+
timeout=PERSONA_BUILDER_LLM_TIMEOUT_S,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def _generate_probes(
|
|
445
|
+
client: TavusClient, spec: dict[str, Any], inner_model: str | None
|
|
446
|
+
) -> list[str]:
|
|
447
|
+
body: dict[str, Any] = {"persona_spec": spec}
|
|
448
|
+
if inner_model:
|
|
449
|
+
body["model"] = inner_model
|
|
450
|
+
response = await client.request(
|
|
451
|
+
"POST",
|
|
452
|
+
"/persona-builder/llm/generate-probes",
|
|
453
|
+
json=body,
|
|
454
|
+
timeout=PERSONA_BUILDER_LLM_TIMEOUT_S,
|
|
455
|
+
)
|
|
456
|
+
probes = [
|
|
457
|
+
str(p) for p in (response.get("probes") or []) if isinstance(p, str) and p.strip()
|
|
458
|
+
]
|
|
459
|
+
if not probes:
|
|
460
|
+
return ["Tell me about yourself.", "What can you help me with?"]
|
|
461
|
+
return probes
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def _judge_transcript(
|
|
465
|
+
client: TavusClient,
|
|
466
|
+
spec: dict[str, Any],
|
|
467
|
+
smoke_transcript: list[dict[str, Any]],
|
|
468
|
+
inner_model: str | None,
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
body: dict[str, Any] = {
|
|
471
|
+
"persona_spec": spec,
|
|
472
|
+
"smoke_transcript": smoke_transcript,
|
|
473
|
+
}
|
|
474
|
+
if inner_model:
|
|
475
|
+
body["model"] = inner_model
|
|
476
|
+
verdict = await client.request(
|
|
477
|
+
"POST",
|
|
478
|
+
"/persona-builder/llm/judge-transcript",
|
|
479
|
+
json=body,
|
|
480
|
+
timeout=PERSONA_BUILDER_LLM_TIMEOUT_S,
|
|
481
|
+
)
|
|
482
|
+
if not isinstance(verdict, dict) or "overall" not in verdict:
|
|
483
|
+
return {
|
|
484
|
+
"objectives": [],
|
|
485
|
+
"guardrails": [],
|
|
486
|
+
"knowledge_base": {"used": False, "evidence": "no verdict returned"},
|
|
487
|
+
"tools": [],
|
|
488
|
+
"overall": "partial",
|
|
489
|
+
"summary": "Judge did not return a structured verdict.",
|
|
490
|
+
}
|
|
491
|
+
return verdict
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _assemble_spec(client: TavusClient, persona_id: str) -> dict[str, Any]:
|
|
495
|
+
persona = await client.personas.get(persona_id, include_settings="true")
|
|
496
|
+
if not isinstance(persona, dict):
|
|
497
|
+
return {"persona_id": persona_id, "raw": persona}
|
|
498
|
+
spec: dict[str, Any] = {
|
|
499
|
+
"persona_id": persona_id,
|
|
500
|
+
"persona_name": persona.get("persona_name"),
|
|
501
|
+
"system_prompt": persona.get("system_prompt"),
|
|
502
|
+
"greeting": persona.get("greeting"),
|
|
503
|
+
"default_replica_id": persona.get("default_replica_id"),
|
|
504
|
+
}
|
|
505
|
+
objectives_id = persona.get("objectives_id")
|
|
506
|
+
guardrails_id = persona.get("guardrails_id")
|
|
507
|
+
document_ids = list(persona.get("document_ids") or [])
|
|
508
|
+
layers = persona.get("layers") or {}
|
|
509
|
+
llm_layer = layers.get("llm") if isinstance(layers, dict) else {}
|
|
510
|
+
tools_field: list[Any] = []
|
|
511
|
+
if isinstance(llm_layer, dict):
|
|
512
|
+
tools_field = list(llm_layer.get("tools") or [])
|
|
513
|
+
|
|
514
|
+
spec["objectives"] = await _safe_get(client.objectives, objectives_id)
|
|
515
|
+
spec["guardrails"] = await _safe_get(client.guardrails, guardrails_id)
|
|
516
|
+
documents: list[Any] = []
|
|
517
|
+
for did in document_ids:
|
|
518
|
+
doc = await _safe_get(client.documents, did)
|
|
519
|
+
if doc is not None:
|
|
520
|
+
documents.append(doc)
|
|
521
|
+
spec["documents"] = documents
|
|
522
|
+
spec["tools"] = tools_field
|
|
523
|
+
return spec
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def _safe_get(resource: Any, resource_id: str | None) -> Any:
|
|
527
|
+
if not resource_id:
|
|
528
|
+
return None
|
|
529
|
+
try:
|
|
530
|
+
return await resource.get(resource_id)
|
|
531
|
+
except Exception:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
async def _run_smoke_test(
|
|
536
|
+
client: TavusClient, persona_id: str, probes: list[str]
|
|
537
|
+
) -> list[dict[str, Any]]:
|
|
538
|
+
convo = await client.conversations.create({"persona_id": persona_id, "chat": True})
|
|
539
|
+
cid = str(convo.get("conversation_id") if isinstance(convo, dict) else convo)
|
|
540
|
+
transcript: list[dict[str, Any]] = []
|
|
541
|
+
try:
|
|
542
|
+
for probe in probes:
|
|
543
|
+
transcript.append({"role": "user", "text": probe})
|
|
544
|
+
try:
|
|
545
|
+
reply = await client.conversations.chat_turn(
|
|
546
|
+
cid, probe, timeout_s=CVI_SMOKE_TURN_TIMEOUT_S
|
|
547
|
+
)
|
|
548
|
+
transcript.append({"role": "assistant", "text": reply})
|
|
549
|
+
except TimeoutError as exc:
|
|
550
|
+
transcript.append(
|
|
551
|
+
{"role": "assistant", "text": "", "error": f"timeout: {exc}"}
|
|
552
|
+
)
|
|
553
|
+
finally:
|
|
554
|
+
try:
|
|
555
|
+
await client.conversations.end(cid)
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
return transcript
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _summarize_gaps(verdict: dict[str, Any]) -> str:
|
|
562
|
+
parts: list[str] = []
|
|
563
|
+
for obj in verdict.get("objectives") or []:
|
|
564
|
+
if not obj.get("satisfied"):
|
|
565
|
+
parts.append(
|
|
566
|
+
f"Objective '{obj.get('name')}' not satisfied: {obj.get('evidence')}"
|
|
567
|
+
)
|
|
568
|
+
for gr in verdict.get("guardrails") or []:
|
|
569
|
+
if not gr.get("held"):
|
|
570
|
+
parts.append(f"Guardrail '{gr.get('name')}' did not hold: {gr.get('evidence')}")
|
|
571
|
+
kb = verdict.get("knowledge_base") or {}
|
|
572
|
+
if kb and not kb.get("used", True):
|
|
573
|
+
parts.append(f"Knowledge base not used: {kb.get('evidence')}")
|
|
574
|
+
for tool in verdict.get("tools") or []:
|
|
575
|
+
if not tool.get("invoked"):
|
|
576
|
+
parts.append(f"Tool '{tool.get('name')}' not invoked: {tool.get('evidence')}")
|
|
577
|
+
if not parts:
|
|
578
|
+
parts.append(verdict.get("summary") or "Partial coverage; tighten the persona.")
|
|
579
|
+
return "\n".join(f"- {part}" for part in parts)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
async def _prepend_validation_directive(
|
|
583
|
+
client: TavusClient, persona_id: str, verdict: dict[str, Any]
|
|
584
|
+
) -> None:
|
|
585
|
+
spec = await _assemble_spec(client, persona_id)
|
|
586
|
+
prompt = str(spec.get("system_prompt") or "").strip()
|
|
587
|
+
directive = _validation_directive(verdict)
|
|
588
|
+
if not prompt or directive in prompt:
|
|
589
|
+
return
|
|
590
|
+
await client.personas.patch(
|
|
591
|
+
persona_id,
|
|
592
|
+
[{"op": "replace", "path": "/system_prompt", "value": f"{directive}\n\n{prompt}"}],
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _validation_directive(verdict: dict[str, Any]) -> str:
|
|
597
|
+
corrections = [
|
|
598
|
+
"- You are the published persona, not the persona builder. Never ask the user "
|
|
599
|
+
"what persona to create.",
|
|
600
|
+
"- If required information is missing, ask for it instead of giving generic "
|
|
601
|
+
"advice or jumping ahead.",
|
|
602
|
+
]
|
|
603
|
+
for obj in verdict.get("objectives") or []:
|
|
604
|
+
if not obj.get("satisfied"):
|
|
605
|
+
name = obj.get("name") or "unnamed_objective"
|
|
606
|
+
corrections.append(f"- Satisfy objective `{name}` before attempting later objectives.")
|
|
607
|
+
for guardrail in verdict.get("guardrails") or []:
|
|
608
|
+
if not guardrail.get("held"):
|
|
609
|
+
name = guardrail.get("name") or "unnamed_guardrail"
|
|
610
|
+
corrections.append(f"- Treat guardrail `{name}` as mandatory in every response.")
|
|
611
|
+
|
|
612
|
+
return (
|
|
613
|
+
"## Critical Runtime Corrections\n\n"
|
|
614
|
+
"These rules override any conflicting guidance. Before every response, "
|
|
615
|
+
"check the persona objectives and guardrails. If required information is "
|
|
616
|
+
"missing, ask for it instead of giving generic advice or jumping ahead.\n"
|
|
617
|
+
+ "\n".join(corrections)
|
|
618
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from tavus_mcp.sdk.client.http import TavusClient
|
|
7
|
+
from tavus_mcp.sdk.patch import allowed_patch_paths
|
|
8
|
+
from tavus_mcp.sdk.schemas.persona import PersonaPatchModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def describe_persona_options(client: TavusClient, persona_id: str) -> dict[str, Any]:
|
|
12
|
+
(
|
|
13
|
+
persona,
|
|
14
|
+
replicas,
|
|
15
|
+
voices,
|
|
16
|
+
guardrails,
|
|
17
|
+
objectives,
|
|
18
|
+
documents,
|
|
19
|
+
tools,
|
|
20
|
+
pronunciation_dictionaries,
|
|
21
|
+
attached_tools,
|
|
22
|
+
) = await asyncio.gather(
|
|
23
|
+
client.personas.get(persona_id, verbose="true", include_settings="true"),
|
|
24
|
+
client.replicas.list(limit=100, replica_type="system"),
|
|
25
|
+
client.voices.list(limit=100),
|
|
26
|
+
client.guardrails.list(limit=100),
|
|
27
|
+
client.objectives.list(limit=100),
|
|
28
|
+
client.documents.list(limit=100),
|
|
29
|
+
client.tools.list(limit=100),
|
|
30
|
+
client.pronunciation_dictionaries.list(limit=100),
|
|
31
|
+
client.personas.list_tools(persona_id),
|
|
32
|
+
)
|
|
33
|
+
return {
|
|
34
|
+
"persona": persona,
|
|
35
|
+
# First-class tools already attached to this persona.
|
|
36
|
+
"persona_attached_tools": _data(attached_tools),
|
|
37
|
+
"available": {
|
|
38
|
+
"replicas": _data(replicas),
|
|
39
|
+
"voices": _data(voices),
|
|
40
|
+
"guardrails": _data(guardrails),
|
|
41
|
+
"objectives": _data(objectives),
|
|
42
|
+
"documents": _data(documents),
|
|
43
|
+
# Grouped by trigger origin so vision/audio tools aren't mislabeled
|
|
44
|
+
# as llm — attach with tavus_persona_tools_attach.
|
|
45
|
+
"tools": _group_tools_by_origin(_data(tools)),
|
|
46
|
+
"pronunciation_dictionaries": _data(pronunciation_dictionaries),
|
|
47
|
+
},
|
|
48
|
+
"schemas": {
|
|
49
|
+
"persona_patch_model": PersonaPatchModel.model_json_schema(),
|
|
50
|
+
"patchable_paths": sorted(allowed_patch_paths()),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _data(value: Any) -> Any:
|
|
56
|
+
if isinstance(value, dict) and "data" in value:
|
|
57
|
+
return value["data"]
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _group_tools_by_origin(tools: Any) -> dict[str, list[Any]]:
|
|
62
|
+
grouped: dict[str, list[Any]] = {"llm": [], "vision": [], "audio": []}
|
|
63
|
+
if isinstance(tools, list):
|
|
64
|
+
for tool in tools:
|
|
65
|
+
origin = tool.get("origin", "llm") if isinstance(tool, dict) else "llm"
|
|
66
|
+
grouped.setdefault(origin, []).append(tool)
|
|
67
|
+
return grouped
|