arkaos 3.1.0 → 3.3.0

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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.1.0
1
+ 3.3.0
@@ -27,6 +27,8 @@ _VALID_FIELDS: tuple[str, ...] = (
27
27
  "mental_models",
28
28
  "frameworks",
29
29
  "expertise_domains",
30
+ "communication_avoid",
31
+ "key_quotes",
30
32
  )
31
33
  _MAX_COUNT = 12
32
34
  _DEFAULT_COUNT = 5
@@ -40,6 +42,23 @@ _FIELD_LABELS: dict[str, str] = {
40
42
  "mental_models": "mental models",
41
43
  "frameworks": "frameworks",
42
44
  "expertise_domains": "expertise domains",
45
+ "communication_avoid": "phrases this profile should AVOID using",
46
+ "key_quotes": "verbatim or paraphrased key quotes",
47
+ }
48
+
49
+ # Field-specific length hints — different fields want different item shapes.
50
+ _FIELD_LENGTH_HINT: dict[str, str] = {
51
+ "mental_models": "Return a JSON array of short strings (2-5 words each).",
52
+ "frameworks": "Return a JSON array of short strings (2-5 words each).",
53
+ "expertise_domains": "Return a JSON array of short strings (2-5 words each).",
54
+ "communication_avoid": (
55
+ "Return a JSON array of short phrases (2-6 words each) that the "
56
+ "profile would never say or write."
57
+ ),
58
+ "key_quotes": (
59
+ "Return a JSON array of full sentences (8-25 words each), each "
60
+ "phrased as if the person said it. No attribution prefixes."
61
+ ),
43
62
  }
44
63
 
45
64
 
@@ -94,10 +113,8 @@ def _build_prompt(field: str, context: dict, count: int) -> str:
94
113
  + ", ".join(current)
95
114
  + "."
96
115
  )
97
- lines.append(
98
- "Return a JSON array of short strings (2-5 words each). "
99
- "No explanations, no numbering, no surrounding object."
100
- )
116
+ lines.append(_FIELD_LENGTH_HINT[field])
117
+ lines.append("No explanations, no numbering, no surrounding object.")
101
118
  return "\n".join(lines)
102
119
 
103
120
 
@@ -0,0 +1,111 @@
1
+ """AI-powered persona draft from a free-text description (PR83a v3.3.0).
2
+
3
+ Sibling to `core/personas/builder.PersonaBuilder` but does NOT require
4
+ indexed content. Useful when:
5
+
6
+ - The operator wants to model a persona quickly without ingesting sources
7
+ - A YouTuber / author isn't yet in the knowledge base
8
+ - The persona is a synthetic archetype rather than a real person
9
+
10
+ Reuses the same JSON schema and parsing as the vector-driven builder so
11
+ the resulting Persona is interchangeable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+
20
+ from core.personas.builder import (
21
+ _PERSONA_SYSTEM_PROMPT,
22
+ _extract_json_object,
23
+ )
24
+ from core.personas.schema import (
25
+ Persona,
26
+ PersonaBigFive,
27
+ PersonaCommunication,
28
+ PersonaDISC,
29
+ PersonaEnneagram,
30
+ )
31
+ from core.runtime.llm_provider import LLMProvider, LLMUnavailable, get_llm_provider
32
+
33
+
34
+ _DESCRIPTION_MIN_CHARS = 20
35
+
36
+
37
+ class PersonaDraftError(RuntimeError):
38
+ """LLM produced unusable output or could not be reached."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PersonaDraftResult:
43
+ persona: Persona
44
+ provider_name: str
45
+ raw_response: str
46
+
47
+
48
+ def draft_persona_from_description(
49
+ description: str,
50
+ *,
51
+ name: str,
52
+ source_label: str = "",
53
+ provider: LLMProvider | None = None,
54
+ ) -> PersonaDraftResult:
55
+ """Generate a Persona draft from a free-text description."""
56
+ description = (description or "").strip()
57
+ if not name or not name.strip():
58
+ raise PersonaDraftError("name must not be empty")
59
+ if len(description) < _DESCRIPTION_MIN_CHARS:
60
+ raise PersonaDraftError(
61
+ f"description must be at least {_DESCRIPTION_MIN_CHARS} characters"
62
+ )
63
+ llm = provider or get_llm_provider()
64
+ prompt = _build_prompt(name.strip(), description)
65
+ try:
66
+ resp = llm.complete(prompt, max_tokens=3000, system=_PERSONA_SYSTEM_PROMPT)
67
+ except LLMUnavailable as exc:
68
+ raise PersonaDraftError(str(exc)) from exc
69
+ persona = _parse(name.strip(), source_label.strip() or name.strip(), resp.text)
70
+ return PersonaDraftResult(
71
+ persona=persona, provider_name=llm.name(), raw_response=resp.text,
72
+ )
73
+
74
+
75
+ def _build_prompt(name: str, description: str) -> str:
76
+ return (
77
+ f"Person: {name}\n\n"
78
+ f"Description provided by the operator:\n{description}\n\n"
79
+ "Build the persona purely from the description above. If a field is "
80
+ "not implied, choose the closest neutral default rather than "
81
+ "fabricating. NEVER invent quotes — leave key_quotes empty if no "
82
+ "quotes are present in the description."
83
+ )
84
+
85
+
86
+ def _parse(name: str, source_label: str, raw: str) -> Persona:
87
+ data = _extract_json_object(raw)
88
+ if data is None:
89
+ raise PersonaDraftError(
90
+ f"LLM did not return a JSON object; raw response: {raw[:200]!r}"
91
+ )
92
+ try:
93
+ return Persona(
94
+ id=str(uuid.uuid4()),
95
+ name=name,
96
+ title=str(data.get("title") or ""),
97
+ tagline=str(data.get("tagline") or ""),
98
+ source=source_label,
99
+ disc=PersonaDISC(**(data.get("disc") or {})),
100
+ enneagram=PersonaEnneagram(**(data.get("enneagram") or {})),
101
+ big_five=PersonaBigFive(**(data.get("big_five") or {})),
102
+ mbti=str(data.get("mbti") or "").upper() or "INTJ",
103
+ mental_models=[str(x) for x in (data.get("mental_models") or [])],
104
+ expertise_domains=[str(x) for x in (data.get("expertise_domains") or [])],
105
+ frameworks=[str(x) for x in (data.get("frameworks") or [])],
106
+ key_quotes=[str(x) for x in (data.get("key_quotes") or [])],
107
+ communication=PersonaCommunication(**(data.get("communication") or {})),
108
+ created_at=datetime.now(timezone.utc).isoformat(),
109
+ )
110
+ except (TypeError, ValueError) as exc:
111
+ raise PersonaDraftError(f"persona schema mismatch: {exc}") from exc
@@ -111,7 +111,8 @@ function csvToList(value: string): string[] {
111
111
  }
112
112
 
113
113
  // PR81 v2.99.0 — AI list-field suggester.
114
- type SuggestField = 'mental_models_primary' | 'frameworks' | 'expertise_domains'
114
+ // PR82c v3.2.0 extended with 'communication_avoid'.
115
+ type SuggestField = 'mental_models_primary' | 'frameworks' | 'expertise_domains' | 'communication_avoid'
115
116
  const suggestingField = ref<SuggestField | null>(null)
116
117
 
117
118
  async function suggest(field: SuggestField) {
@@ -122,7 +123,9 @@ async function suggest(field: SuggestField) {
122
123
  ? draft.value.mental_models.primary
123
124
  : field === 'frameworks'
124
125
  ? draft.value.frameworks
125
- : draft.value.expertise_domains
126
+ : field === 'expertise_domains'
127
+ ? draft.value.expertise_domains
128
+ : draft.value.communication.avoid
126
129
  suggestingField.value = field
127
130
  try {
128
131
  const res = await $fetch<{
@@ -159,8 +162,10 @@ async function suggest(field: SuggestField) {
159
162
  draft.value.mental_models.primary = merged
160
163
  } else if (field === 'frameworks') {
161
164
  draft.value.frameworks = merged
162
- } else {
165
+ } else if (field === 'expertise_domains') {
163
166
  draft.value.expertise_domains = merged
167
+ } else {
168
+ draft.value.communication.avoid = merged
164
169
  }
165
170
  markDirty()
166
171
  toast.add({
@@ -437,6 +442,18 @@ const vocabOptions = [
437
442
  </UFormField>
438
443
  </div>
439
444
  <UFormField label="Avoid (phrases)" help="comma-separated">
445
+ <template #hint>
446
+ <UButton
447
+ label="Suggest with AI"
448
+ icon="i-lucide-sparkles"
449
+ size="xs"
450
+ color="primary"
451
+ variant="soft"
452
+ :loading="suggestingField === 'communication_avoid'"
453
+ :disabled="suggestingField !== null"
454
+ @click="suggest('communication_avoid')"
455
+ />
456
+ </template>
440
457
  <UInput
441
458
  :model-value="listToCsv(draft.communication.avoid)"
442
459
  @update:model-value="(v: string) => { if (draft) { draft.communication.avoid = csvToList(v); markDirty() } }"
@@ -37,6 +37,12 @@ const sourceLineCount = computed(() =>
37
37
  .length,
38
38
  )
39
39
 
40
+ // PR83a v3.3.0 — Mode 3: build from a free-text description (no chunks).
41
+ type Mode = 'sources' | 'existing' | 'description'
42
+ const mode = ref<Mode>('sources')
43
+ const description = ref('')
44
+ const descriptionLength = computed(() => description.value.trim().length)
45
+
40
46
  // ─── Step 2 state ────────────────────────────────────────────────────────
41
47
  const ingestJobs = ref<Array<{
42
48
  source: string
@@ -81,7 +87,14 @@ const tierOptions = [
81
87
 
82
88
 
83
89
  async function startIngest() {
84
- if (skipIngest.value) {
90
+ if (mode.value === 'description') {
91
+ // PR83a — no ingest, no chunks. Build directly from description.
92
+ if (descriptionLength.value < 20 || !name.value.trim()) return
93
+ step.value = 3
94
+ await runDescriptionBuild()
95
+ return
96
+ }
97
+ if (mode.value === 'existing' || skipIngest.value) {
85
98
  // Jump straight to step 3 — operator says content is already indexed.
86
99
  step.value = 3
87
100
  await runBuild()
@@ -166,6 +179,36 @@ onBeforeUnmount(() => {
166
179
  // ─── Step 3: build the persona draft ────────────────────────────────────
167
180
 
168
181
 
182
+ async function runDescriptionBuild() {
183
+ building.value = true
184
+ buildError.value = null
185
+ draft.value = null
186
+ try {
187
+ const res = await $fetch<{ persona: Persona, provider_name: string, error?: string }>(
188
+ `${apiBase}/api/personas/draft`,
189
+ {
190
+ method: 'POST',
191
+ body: {
192
+ name: name.value.trim(),
193
+ description: description.value.trim(),
194
+ source_label: sourceLabel.value.trim() || name.value.trim(),
195
+ },
196
+ },
197
+ )
198
+ if ('error' in res && typeof (res as any).error === 'string') {
199
+ throw new Error((res as any).error)
200
+ }
201
+ draft.value = res.persona
202
+ chunksUsed.value = 0
203
+ step.value = 4
204
+ } catch (err) {
205
+ buildError.value = err instanceof Error ? err.message : 'unknown error'
206
+ } finally {
207
+ building.value = false
208
+ }
209
+ }
210
+
211
+
169
212
  async function runBuild() {
170
213
  building.value = true
171
214
  buildError.value = null
@@ -308,7 +351,28 @@ function backToStep1() {
308
351
  />
309
352
  </UFormField>
310
353
 
354
+ <UFormField label="How should we generate this persona?">
355
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-2">
356
+ <button
357
+ v-for="m in ([
358
+ { key: 'sources', title: 'Ingest sources', desc: 'YouTube, articles, PDFs — best fidelity' },
359
+ { key: 'existing', title: 'Existing chunks', desc: 'Use what is already indexed' },
360
+ { key: 'description', title: 'From description', desc: 'No sources — pure description' },
361
+ ] as const)"
362
+ :key="m.key"
363
+ type="button"
364
+ class="text-left rounded-lg border p-3 transition-colors"
365
+ :class="mode === m.key ? 'border-primary bg-primary/5' : 'border-default hover:border-primary/40'"
366
+ @click="mode = m.key"
367
+ >
368
+ <p class="text-sm font-semibold">{{ m.title }}</p>
369
+ <p class="text-xs text-muted mt-1">{{ m.desc }}</p>
370
+ </button>
371
+ </div>
372
+ </UFormField>
373
+
311
374
  <UFormField
375
+ v-if="mode === 'sources'"
312
376
  label="Sources (one URL per line)"
313
377
  help="YouTube videos, articles, PDFs, blog posts about this person. The builder will search the indexed chunks and synthesise their behavioural DNA. Up to 50 sources per batch."
314
378
  >
@@ -317,28 +381,56 @@ function backToStep1() {
317
381
  :rows="6"
318
382
  placeholder="https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
319
383
  class="w-full font-mono text-sm"
320
- :disabled="skipIngest"
321
384
  />
322
385
  </UFormField>
323
386
 
324
- <div class="flex items-center justify-between text-xs text-muted">
325
- <span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
326
- <UCheckbox
327
- v-model="skipIngest"
328
- label="Skip ingest — content for this person is already in the knowledge base"
387
+ <UFormField
388
+ v-else-if="mode === 'description'"
389
+ label="Description"
390
+ help="Plain-text description of the person — their style, beliefs, what they do, how they talk. The LLM uses this verbatim. Minimum 20 characters."
391
+ >
392
+ <UTextarea
393
+ v-model="description"
394
+ :rows="6"
395
+ placeholder="A direct-response copywriter who treats offers as the only true growth lever. Punchy, allergic to fluff. Loves Hormozi-style hooks."
396
+ class="w-full"
329
397
  />
398
+ </UFormField>
399
+
400
+ <div
401
+ v-if="mode === 'sources'"
402
+ class="flex items-center justify-between text-xs text-muted"
403
+ >
404
+ <span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
405
+ </div>
406
+
407
+ <div v-else-if="mode === 'existing'" class="text-xs text-muted">
408
+ We will search the vector DB for chunks tagged with this name and synthesise from what we find. Make sure you've ingested content for this person first.
409
+ </div>
410
+
411
+ <div v-else-if="mode === 'description'" class="text-xs text-muted">
412
+ {{ descriptionLength }} character{{ descriptionLength === 1 ? '' : 's' }} ·
413
+ {{ descriptionLength >= 20 ? 'ready' : `${20 - descriptionLength} more needed` }}
330
414
  </div>
331
415
 
332
416
  <div class="flex justify-end gap-2 pt-4">
333
417
  <UButton
334
- :label="skipIngest ? 'Generate from existing knowledge' : `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`"
418
+ :label="(
419
+ mode === 'sources' ? `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`
420
+ : mode === 'existing' ? 'Generate from existing knowledge'
421
+ : 'Generate from description'
422
+ )"
335
423
  icon="i-lucide-arrow-right"
336
- :disabled="!name.trim() || (!skipIngest && sourceLineCount === 0) || sourceLineCount > 50"
424
+ :disabled="(
425
+ !name.trim()
426
+ || (mode === 'sources' && (sourceLineCount === 0 || sourceLineCount > 50))
427
+ || (mode === 'description' && descriptionLength < 20)
428
+ )"
337
429
  size="md"
338
430
  @click="startIngest"
339
431
  />
340
432
  </div>
341
- <p v-if="sourceLineCount > 50" class="text-xs text-red-400">
433
+ <p v-if="mode === 'sources' && sourceLineCount > 50" class="text-xs text-red-400">
342
434
  Over the 50-source cap. Trim the list before continuing.
343
435
  </p>
344
436
  </div>
@@ -202,7 +202,8 @@ function csvToList(value: string): string[] {
202
202
  }
203
203
 
204
204
  // PR81 suggest wiring — three list fields.
205
- type SuggestField = 'mental_models' | 'frameworks' | 'expertise_domains'
205
+ // PR82c v3.2.0 extended with 'communication_avoid'.
206
+ type SuggestField = 'mental_models' | 'frameworks' | 'expertise_domains' | 'communication_avoid'
206
207
  const suggestingField = ref<SuggestField | null>(null)
207
208
 
208
209
  async function suggest(field: SuggestField) {
@@ -211,7 +212,9 @@ async function suggest(field: SuggestField) {
211
212
  ? draft.value.mental_models_primary
212
213
  : field === 'frameworks'
213
214
  ? draft.value.frameworks
214
- : draft.value.expertise_domains
215
+ : field === 'expertise_domains'
216
+ ? draft.value.expertise_domains
217
+ : draft.value.comm_avoid
215
218
  if (!draft.value.name.trim() || !draft.value.role.trim()) {
216
219
  toast.add({
217
220
  title: 'Add a name and role first',
@@ -250,7 +253,8 @@ async function suggest(field: SuggestField) {
250
253
  const merged = [...current, ...additions]
251
254
  if (field === 'mental_models') draft.value.mental_models_primary = merged
252
255
  else if (field === 'frameworks') draft.value.frameworks = merged
253
- else draft.value.expertise_domains = merged
256
+ else if (field === 'expertise_domains') draft.value.expertise_domains = merged
257
+ else draft.value.comm_avoid = merged
254
258
  toast.add({
255
259
  title: `Added ${additions.length} suggestion${additions.length === 1 ? '' : 's'}`,
256
260
  description: `via ${res.provider_name}`,
@@ -553,6 +557,18 @@ const bigFiveKeys = ['openness', 'conscientiousness', 'extraversion', 'agreeable
553
557
  </UFormField>
554
558
  </div>
555
559
  <UFormField label="Avoid (phrases)" help="comma-separated">
560
+ <template #hint>
561
+ <UButton
562
+ label="Suggest with AI"
563
+ icon="i-lucide-sparkles"
564
+ size="xs"
565
+ color="primary"
566
+ variant="soft"
567
+ :loading="suggestingField === 'communication_avoid'"
568
+ :disabled="suggestingField !== null"
569
+ @click="suggest('communication_avoid')"
570
+ />
571
+ </template>
556
572
  <UInput
557
573
  :model-value="listToCsv(draft.comm_avoid)"
558
574
  @update:model-value="(v: string) => { draft.comm_avoid = csvToList(v) }"
@@ -222,12 +222,16 @@ function csvToList(value: string): string[] {
222
222
  }
223
223
 
224
224
  // PR81 v2.99.0 — AI list-field suggester for personas.
225
- type SuggestField = 'mental_models' | 'frameworks' | 'expertise_domains'
225
+ // PR82c v3.2.0 extended with 'communication_avoid' and 'key_quotes'.
226
+ type SuggestField = 'mental_models' | 'frameworks' | 'expertise_domains' | 'communication_avoid' | 'key_quotes'
226
227
  const suggestingField = ref<SuggestField | null>(null)
227
228
 
228
229
  async function suggest(field: SuggestField) {
229
230
  if (!draft.value || !detail.value) return
230
- const current = (draft.value as any)[field] as string[]
231
+ const current
232
+ = field === 'communication_avoid'
233
+ ? (draft.value.communication.avoid ?? [])
234
+ : ((draft.value as any)[field] as string[] ?? [])
231
235
  suggestingField.value = field
232
236
  try {
233
237
  const res = await $fetch<{
@@ -258,7 +262,12 @@ async function suggest(field: SuggestField) {
258
262
  })
259
263
  return
260
264
  }
261
- ;(draft.value as any)[field] = [...current, ...additions]
265
+ const merged = [...current, ...additions]
266
+ if (field === 'communication_avoid') {
267
+ draft.value.communication.avoid = merged
268
+ } else {
269
+ ;(draft.value as any)[field] = merged
270
+ }
262
271
  markDirty()
263
272
  toast.add({
264
273
  title: `Added ${additions.length} suggestion${additions.length === 1 ? '' : 's'}`,
@@ -751,6 +760,52 @@ const vocabOptions = [
751
760
  <USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" @update:model-value="markDirty" />
752
761
  </UFormField>
753
762
  </div>
763
+ <UFormField label="Avoid (phrases)" help="comma-separated">
764
+ <template #hint>
765
+ <UButton
766
+ label="Suggest with AI"
767
+ icon="i-lucide-sparkles"
768
+ size="xs"
769
+ color="primary"
770
+ variant="soft"
771
+ :loading="suggestingField === 'communication_avoid'"
772
+ :disabled="suggestingField !== null"
773
+ @click="suggest('communication_avoid')"
774
+ />
775
+ </template>
776
+ <UInput
777
+ :model-value="listToCsv(draft.communication.avoid)"
778
+ @update:model-value="(v: string) => { if (draft) { draft.communication.avoid = csvToList(v); markDirty() } }"
779
+ class="w-full"
780
+ />
781
+ </UFormField>
782
+ </section>
783
+
784
+ <section class="space-y-3">
785
+ <div class="flex items-center justify-between">
786
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Key quotes</h3>
787
+ <UButton
788
+ label="Suggest with AI"
789
+ icon="i-lucide-sparkles"
790
+ size="xs"
791
+ color="primary"
792
+ variant="soft"
793
+ :loading="suggestingField === 'key_quotes'"
794
+ :disabled="suggestingField !== null"
795
+ @click="suggest('key_quotes')"
796
+ />
797
+ </div>
798
+ <UTextarea
799
+ :model-value="(draft.key_quotes ?? []).join('\n')"
800
+ :rows="4"
801
+ placeholder="One quote per line. Verbatim or paraphrased."
802
+ @update:model-value="(v: string) => { if (draft) { draft.key_quotes = v.split('\n').map((q) => q.trim()).filter(Boolean); markDirty() } }"
803
+ class="w-full"
804
+ />
805
+ <p class="text-xs text-muted">
806
+ {{ (draft.key_quotes ?? []).length }} quote{{ (draft.key_quotes ?? []).length === 1 ? '' : 's' }}.
807
+ One per line.
808
+ </p>
754
809
  </section>
755
810
  </div>
756
811
 
@@ -209,9 +209,11 @@ export interface Persona {
209
209
  mental_models: string[]
210
210
  expertise_domains: string[]
211
211
  frameworks: string[]
212
+ key_quotes?: string[]
212
213
  communication: {
213
214
  tone: string
214
215
  vocabulary_level: string
216
+ avoid?: string[]
215
217
  }
216
218
  cloned_to_agents: string[]
217
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.1.0"
3
+ version = "3.3.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1827,6 +1827,43 @@ def _build_agent_yaml(
1827
1827
  return payload
1828
1828
 
1829
1829
 
1830
+ # --- AI persona draft from description (PR83a v3.3.0) ---
1831
+
1832
+ @app.post("/api/personas/draft")
1833
+ def personas_draft(body: dict):
1834
+ """Generate a Persona draft from a free-text description (no vector DB).
1835
+
1836
+ Body: {
1837
+ "description": "...", # min 20 chars
1838
+ "name": "Alex Carter", # required
1839
+ "source_label": "..." # optional
1840
+ }
1841
+ Returns: {"persona": {...}, "provider_name": "..."}
1842
+
1843
+ Sibling to /api/personas/build (which requires indexed chunks). Useful
1844
+ when the operator wants a quick draft without ingesting sources first.
1845
+ The result is NOT saved — operator reviews + POSTs to /api/personas.
1846
+ """
1847
+ from core.personas.description_drafter import (
1848
+ PersonaDraftError,
1849
+ draft_persona_from_description,
1850
+ )
1851
+
1852
+ description = (body.get("description") or "").strip()
1853
+ name = (body.get("name") or "").strip()
1854
+ source_label = (body.get("source_label") or "").strip()
1855
+ try:
1856
+ res = draft_persona_from_description(
1857
+ description, name=name, source_label=source_label,
1858
+ )
1859
+ except PersonaDraftError as exc:
1860
+ return {"error": str(exc)}
1861
+ return {
1862
+ "persona": res.persona.model_dump(),
1863
+ "provider_name": res.provider_name,
1864
+ }
1865
+
1866
+
1830
1867
  # --- AI agent draft from description (PR82b v3.1.0) ---
1831
1868
 
1832
1869
  @app.post("/api/agents/draft")