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.
@@ -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