julee 0.1.2__py3-none-any.whl → 0.1.3__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.
Files changed (155) hide show
  1. julee/api/app.py +9 -8
  2. julee/api/dependencies.py +15 -15
  3. julee/api/requests.py +10 -9
  4. julee/api/responses.py +2 -1
  5. julee/api/routers/__init__.py +5 -5
  6. julee/api/routers/assembly_specifications.py +5 -4
  7. julee/api/routers/documents.py +1 -1
  8. julee/api/routers/knowledge_service_configs.py +4 -3
  9. julee/api/routers/knowledge_service_queries.py +7 -6
  10. julee/api/routers/system.py +4 -3
  11. julee/api/routers/workflows.py +4 -5
  12. julee/api/services/system_initialization.py +6 -6
  13. julee/api/tests/routers/test_assembly_specifications.py +4 -3
  14. julee/api/tests/routers/test_documents.py +5 -4
  15. julee/api/tests/routers/test_knowledge_service_configs.py +7 -6
  16. julee/api/tests/routers/test_knowledge_service_queries.py +4 -3
  17. julee/api/tests/routers/test_system.py +5 -4
  18. julee/api/tests/routers/test_workflows.py +5 -4
  19. julee/api/tests/test_app.py +5 -4
  20. julee/api/tests/test_dependencies.py +3 -2
  21. julee/api/tests/test_requests.py +2 -1
  22. julee/contrib/__init__.py +15 -0
  23. julee/contrib/polling/__init__.py +47 -0
  24. julee/contrib/polling/domain/__init__.py +17 -0
  25. julee/contrib/polling/domain/models/__init__.py +13 -0
  26. julee/contrib/polling/domain/models/polling_config.py +39 -0
  27. julee/contrib/polling/domain/services/__init__.py +11 -0
  28. julee/contrib/polling/domain/services/poller.py +39 -0
  29. julee/contrib/polling/infrastructure/__init__.py +15 -0
  30. julee/contrib/polling/infrastructure/services/__init__.py +12 -0
  31. julee/contrib/polling/infrastructure/services/polling/__init__.py +12 -0
  32. julee/contrib/polling/infrastructure/services/polling/http/__init__.py +12 -0
  33. julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +80 -0
  34. julee/contrib/polling/infrastructure/temporal/__init__.py +20 -0
  35. julee/contrib/polling/infrastructure/temporal/activities.py +42 -0
  36. julee/contrib/polling/infrastructure/temporal/activity_names.py +20 -0
  37. julee/contrib/polling/infrastructure/temporal/proxies.py +45 -0
  38. julee/contrib/polling/tests/__init__.py +6 -0
  39. julee/contrib/polling/tests/unit/__init__.py +6 -0
  40. julee/contrib/polling/tests/unit/infrastructure/__init__.py +7 -0
  41. julee/contrib/polling/tests/unit/infrastructure/services/__init__.py +6 -0
  42. julee/contrib/polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
  43. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
  44. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +163 -0
  45. julee/docs/__init__.py +5 -0
  46. julee/docs/sphinx_hcd/__init__.py +82 -0
  47. julee/docs/sphinx_hcd/accelerators.py +1078 -0
  48. julee/docs/sphinx_hcd/apps.py +499 -0
  49. julee/docs/sphinx_hcd/config.py +148 -0
  50. julee/docs/sphinx_hcd/epics.py +448 -0
  51. julee/docs/sphinx_hcd/integrations.py +306 -0
  52. julee/docs/sphinx_hcd/journeys.py +783 -0
  53. julee/docs/sphinx_hcd/personas.py +435 -0
  54. julee/docs/sphinx_hcd/stories.py +932 -0
  55. julee/docs/sphinx_hcd/utils.py +180 -0
  56. julee/domain/models/__init__.py +5 -6
  57. julee/domain/models/assembly/assembly.py +7 -7
  58. julee/domain/models/assembly/tests/factories.py +2 -1
  59. julee/domain/models/assembly/tests/test_assembly.py +16 -13
  60. julee/domain/models/assembly_specification/assembly_specification.py +11 -10
  61. julee/domain/models/assembly_specification/knowledge_service_query.py +7 -6
  62. julee/domain/models/assembly_specification/tests/factories.py +2 -1
  63. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +9 -6
  64. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +3 -1
  65. julee/domain/models/custom_fields/content_stream.py +3 -2
  66. julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -1
  67. julee/domain/models/document/document.py +12 -10
  68. julee/domain/models/document/tests/factories.py +3 -2
  69. julee/domain/models/document/tests/test_document.py +6 -3
  70. julee/domain/models/knowledge_service_config/knowledge_service_config.py +4 -4
  71. julee/domain/models/policy/__init__.py +4 -4
  72. julee/domain/models/policy/document_policy_validation.py +17 -17
  73. julee/domain/models/policy/policy.py +10 -10
  74. julee/domain/models/policy/tests/factories.py +2 -1
  75. julee/domain/models/policy/tests/test_document_policy_validation.py +3 -1
  76. julee/domain/models/policy/tests/test_policy.py +2 -1
  77. julee/domain/repositories/__init__.py +3 -3
  78. julee/domain/repositories/assembly.py +3 -1
  79. julee/domain/repositories/assembly_specification.py +2 -0
  80. julee/domain/repositories/base.py +5 -4
  81. julee/domain/repositories/document.py +3 -1
  82. julee/domain/repositories/document_policy_validation.py +3 -1
  83. julee/domain/repositories/knowledge_service_config.py +2 -0
  84. julee/domain/repositories/knowledge_service_query.py +1 -0
  85. julee/domain/repositories/policy.py +3 -1
  86. julee/domain/use_cases/decorators.py +3 -2
  87. julee/domain/use_cases/extract_assemble_data.py +13 -12
  88. julee/domain/use_cases/initialize_system_data.py +13 -13
  89. julee/domain/use_cases/tests/test_extract_assemble_data.py +10 -10
  90. julee/domain/use_cases/tests/test_initialize_system_data.py +2 -2
  91. julee/domain/use_cases/tests/test_validate_document.py +11 -11
  92. julee/domain/use_cases/validate_document.py +14 -14
  93. julee/maintenance/__init__.py +1 -0
  94. julee/maintenance/release.py +188 -0
  95. julee/repositories/memory/assembly.py +6 -5
  96. julee/repositories/memory/assembly_specification.py +8 -9
  97. julee/repositories/memory/base.py +12 -11
  98. julee/repositories/memory/document.py +8 -7
  99. julee/repositories/memory/document_policy_validation.py +7 -6
  100. julee/repositories/memory/knowledge_service_config.py +8 -7
  101. julee/repositories/memory/knowledge_service_query.py +8 -7
  102. julee/repositories/memory/policy.py +6 -5
  103. julee/repositories/memory/tests/test_document.py +6 -4
  104. julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
  105. julee/repositories/memory/tests/test_policy.py +2 -1
  106. julee/repositories/minio/assembly.py +4 -4
  107. julee/repositories/minio/assembly_specification.py +6 -8
  108. julee/repositories/minio/client.py +22 -25
  109. julee/repositories/minio/document.py +11 -11
  110. julee/repositories/minio/document_policy_validation.py +5 -5
  111. julee/repositories/minio/knowledge_service_config.py +6 -6
  112. julee/repositories/minio/knowledge_service_query.py +6 -9
  113. julee/repositories/minio/policy.py +4 -4
  114. julee/repositories/minio/tests/fake_client.py +11 -9
  115. julee/repositories/minio/tests/test_assembly.py +3 -1
  116. julee/repositories/minio/tests/test_assembly_specification.py +2 -1
  117. julee/repositories/minio/tests/test_client_protocol.py +5 -5
  118. julee/repositories/minio/tests/test_document.py +7 -6
  119. julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
  120. julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
  121. julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
  122. julee/repositories/minio/tests/test_policy.py +3 -1
  123. julee/repositories/temporal/activities.py +5 -5
  124. julee/repositories/temporal/proxies.py +5 -5
  125. julee/services/knowledge_service/__init__.py +1 -2
  126. julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
  127. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
  128. julee/services/knowledge_service/factory.py +8 -8
  129. julee/services/knowledge_service/knowledge_service.py +12 -14
  130. julee/services/knowledge_service/memory/knowledge_service.py +13 -12
  131. julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
  132. julee/services/knowledge_service/test_factory.py +11 -10
  133. julee/services/temporal/activities.py +10 -10
  134. julee/services/temporal/proxies.py +2 -2
  135. julee/util/domain.py +6 -6
  136. julee/util/repos/minio/file_storage.py +8 -9
  137. julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
  138. julee/util/repos/temporal/data_converter.py +6 -6
  139. julee/util/repos/temporal/minio_file_storage.py +1 -1
  140. julee/util/repos/temporal/proxies/file_storage.py +2 -3
  141. julee/util/repositories.py +4 -3
  142. julee/util/temporal/decorators.py +20 -18
  143. julee/util/tests/test_decorators.py +13 -15
  144. julee/util/validation/repository.py +3 -3
  145. julee/util/validation/type_guards.py +12 -11
  146. julee/worker.py +9 -8
  147. julee/workflows/__init__.py +2 -2
  148. julee/workflows/extract_assemble.py +2 -1
  149. julee/workflows/validate_document.py +3 -2
  150. {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/METADATA +2 -1
  151. julee-0.1.3.dist-info/RECORD +197 -0
  152. julee-0.1.2.dist-info/RECORD +0 -161
  153. {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
  154. {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
  155. {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,435 @@
1
+ """Sphinx extension for persona diagrams.
2
+
3
+ Generates PlantUML use case diagrams dynamically from epic and story data.
4
+
5
+ Provides directives:
6
+ - persona-diagram: Generate UML diagram for a single persona showing their epics
7
+ - persona-index-diagram: Generate UML diagram for staff or external persona groups
8
+ """
9
+
10
+ from collections import defaultdict
11
+ from docutils import nodes
12
+ from sphinx.util.docutils import SphinxDirective
13
+ from sphinx.util import logging
14
+
15
+ from .config import get_config
16
+ from .utils import normalize_name, slugify
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_epics_for_persona(persona_name: str, epic_registry: dict, story_registry: list) -> list[tuple[str, dict]]:
22
+ """Get epics that contain stories for a given persona.
23
+
24
+ Args:
25
+ persona_name: The persona name to match
26
+ epic_registry: Dict of epic_slug -> epic_data
27
+ story_registry: List of story dicts
28
+
29
+ Returns:
30
+ List of (epic_slug, epic_data) tuples for matching epics
31
+ """
32
+ persona_normalized = normalize_name(persona_name)
33
+
34
+ # Build lookup of story title -> persona
35
+ story_personas = {}
36
+ for story in story_registry:
37
+ story_personas[normalize_name(story['feature'])] = story['persona_normalized']
38
+
39
+ matching_epics = []
40
+ for slug, epic in epic_registry.items():
41
+ # Check if any story in this epic belongs to the persona
42
+ for story_title in epic.get('stories', []):
43
+ story_normalized = normalize_name(story_title)
44
+ if story_personas.get(story_normalized) == persona_normalized:
45
+ matching_epics.append((slug, epic))
46
+ break
47
+
48
+ return sorted(matching_epics, key=lambda x: x[0])
49
+
50
+
51
+ def get_apps_for_epic(epic: dict, story_registry: list) -> set[str]:
52
+ """Get the set of app slugs used by stories in an epic.
53
+
54
+ Args:
55
+ epic: Epic data dict with 'stories' list
56
+ story_registry: List of story dicts
57
+
58
+ Returns:
59
+ Set of app slug strings
60
+ """
61
+ apps = set()
62
+
63
+ # Build lookup of story title -> app
64
+ story_apps = {}
65
+ for story in story_registry:
66
+ story_apps[normalize_name(story['feature'])] = story['app']
67
+
68
+ for story_title in epic.get('stories', []):
69
+ story_normalized = normalize_name(story_title)
70
+ if story_normalized in story_apps:
71
+ apps.add(story_apps[story_normalized])
72
+
73
+ return apps
74
+
75
+
76
+ def get_apps_for_persona(persona_name: str, story_registry: list) -> set[str]:
77
+ """Get the set of app slugs used by a persona.
78
+
79
+ Args:
80
+ persona_name: The persona name to match
81
+ story_registry: List of story dicts
82
+
83
+ Returns:
84
+ Set of app slug strings
85
+ """
86
+ persona_normalized = normalize_name(persona_name)
87
+ apps = set()
88
+
89
+ for story in story_registry:
90
+ if story['persona_normalized'] == persona_normalized:
91
+ apps.add(story['app'])
92
+
93
+ return apps
94
+
95
+
96
+ def generate_persona_plantuml(persona_name: str, epics: list[tuple[str, dict]],
97
+ story_registry: list, app_registry: dict) -> str:
98
+ """Generate PlantUML for a single persona's use case diagram.
99
+
100
+ Args:
101
+ persona_name: Display name of the persona
102
+ epics: List of (epic_slug, epic_data) tuples
103
+ story_registry: List of story dicts
104
+ app_registry: Dict of app_slug -> app_data
105
+
106
+ Returns:
107
+ PlantUML source string
108
+ """
109
+ persona_id = slugify(persona_name).replace('-', '_')
110
+
111
+ lines = [
112
+ f"@startuml persona-{slugify(persona_name)}",
113
+ "left to right direction",
114
+ "skinparam actorStyle awesome",
115
+ "",
116
+ f'actor "{persona_name}" as {persona_id}',
117
+ "",
118
+ ]
119
+
120
+ # Collect all apps used by this persona's epics
121
+ all_apps = set()
122
+ epic_apps = {} # epic_slug -> set of apps
123
+
124
+ for epic_slug, epic in epics:
125
+ apps = get_apps_for_epic(epic, story_registry)
126
+ epic_apps[epic_slug] = apps
127
+ all_apps.update(apps)
128
+
129
+ # Generate component declarations for apps
130
+ for app_slug in sorted(all_apps):
131
+ app_id = app_slug.replace('-', '_')
132
+ app_name = app_registry.get(app_slug, {}).get('name', app_slug.replace('-', ' ').title())
133
+ lines.append(f'component "{app_name}" as {app_id}')
134
+
135
+ lines.append("")
136
+
137
+ # Generate usecase declarations for epics
138
+ for epic_slug, epic in epics:
139
+ epic_id = epic_slug.replace('-', '_')
140
+ epic_name = epic_slug.replace('-', ' ').title()
141
+ lines.append(f'usecase "{epic_name}" as {epic_id}')
142
+
143
+ lines.append("")
144
+
145
+ # Generate persona -> epic connections
146
+ for epic_slug, epic in epics:
147
+ epic_id = epic_slug.replace('-', '_')
148
+ lines.append(f"{persona_id} --> {epic_id}")
149
+
150
+ lines.append("")
151
+
152
+ # Generate epic -> app connections
153
+ for epic_slug, epic in epics:
154
+ epic_id = epic_slug.replace('-', '_')
155
+ for app_slug in sorted(epic_apps.get(epic_slug, [])):
156
+ app_id = app_slug.replace('-', '_')
157
+ lines.append(f"{epic_id} --> {app_id}")
158
+
159
+ lines.append("")
160
+ lines.append("@enduml")
161
+
162
+ return "\n".join(lines)
163
+
164
+
165
+ def generate_persona_index_plantuml(persona_type: str, personas: list[str],
166
+ epic_registry: dict, story_registry: list,
167
+ app_registry: dict) -> str:
168
+ """Generate PlantUML for a group of personas (staff or external).
169
+
170
+ Args:
171
+ persona_type: 'staff' or 'external'
172
+ personas: List of persona names in this group
173
+ epic_registry: Dict of epic_slug -> epic_data
174
+ story_registry: List of story dicts
175
+ app_registry: Dict of app_slug -> app_data
176
+
177
+ Returns:
178
+ PlantUML source string
179
+ """
180
+ lines = [
181
+ f"@startuml persona-{persona_type}",
182
+ "left to right direction",
183
+ "skinparam actorStyle awesome",
184
+ "",
185
+ ]
186
+
187
+ # Collect data for all personas
188
+ persona_epics = {} # persona -> list of epic slugs
189
+ all_apps = set()
190
+ epic_app_map = {} # epic_slug -> set of apps
191
+
192
+ for persona_name in personas:
193
+ epics = get_epics_for_persona(persona_name, epic_registry, story_registry)
194
+ persona_epics[persona_name] = [slug for slug, _ in epics]
195
+
196
+ for epic_slug, epic in epics:
197
+ if epic_slug not in epic_app_map:
198
+ apps = get_apps_for_epic(epic, story_registry)
199
+ epic_app_map[epic_slug] = apps
200
+ all_apps.update(apps)
201
+
202
+ # Generate actor declarations
203
+ for persona_name in sorted(personas):
204
+ persona_id = slugify(persona_name).replace('-', '_')
205
+ lines.append(f'actor "{persona_name}" as {persona_id}')
206
+
207
+ lines.append("")
208
+
209
+ # Generate component declarations for apps
210
+ for app_slug in sorted(all_apps):
211
+ app_id = app_slug.replace('-', '_')
212
+ app_name = app_registry.get(app_slug, {}).get('name', app_slug.replace('-', ' ').title())
213
+ lines.append(f'component "{app_name}" as {app_id}')
214
+
215
+ lines.append("")
216
+
217
+ # Collect unique epics across all personas in this group
218
+ all_epics = set()
219
+ for epic_slugs in persona_epics.values():
220
+ all_epics.update(epic_slugs)
221
+
222
+ # Generate usecase declarations for epics
223
+ for epic_slug in sorted(all_epics):
224
+ epic_id = epic_slug.replace('-', '_')
225
+ epic_name = epic_slug.replace('-', ' ').title()
226
+ lines.append(f'usecase "{epic_name}" as {epic_id}')
227
+
228
+ lines.append("")
229
+
230
+ # Generate persona -> epic connections
231
+ for persona_name in sorted(personas):
232
+ persona_id = slugify(persona_name).replace('-', '_')
233
+ for epic_slug in sorted(persona_epics.get(persona_name, [])):
234
+ epic_id = epic_slug.replace('-', '_')
235
+ lines.append(f"{persona_id} --> {epic_id}")
236
+
237
+ lines.append("")
238
+
239
+ # Generate epic -> app connections
240
+ for epic_slug in sorted(all_epics):
241
+ epic_id = epic_slug.replace('-', '_')
242
+ for app_slug in sorted(epic_app_map.get(epic_slug, [])):
243
+ app_id = app_slug.replace('-', '_')
244
+ lines.append(f"{epic_id} --> {app_id}")
245
+
246
+ lines.append("")
247
+ lines.append("@enduml")
248
+
249
+ return "\n".join(lines)
250
+
251
+
252
+ class PersonaDiagramDirective(SphinxDirective):
253
+ """Generate PlantUML use case diagram for a single persona.
254
+
255
+ Usage::
256
+
257
+ .. persona-diagram:: Pilot Manager
258
+
259
+ Generates a diagram showing:
260
+ - The persona as an actor
261
+ - Epics they participate in as use cases
262
+ - Apps they interact with as components
263
+ """
264
+
265
+ required_arguments = 1
266
+ final_argument_whitespace = True
267
+
268
+ def run(self):
269
+ persona_name = self.arguments[0]
270
+
271
+ # Return placeholder - actual rendering in doctree-resolved
272
+ node = PersonaDiagramPlaceholder()
273
+ node['persona'] = persona_name
274
+ return [node]
275
+
276
+
277
+ class PersonaDiagramPlaceholder(nodes.General, nodes.Element):
278
+ """Placeholder node for persona-diagram, replaced at doctree-resolved."""
279
+ pass
280
+
281
+
282
+ class PersonaIndexDiagramDirective(SphinxDirective):
283
+ """Generate PlantUML diagram for a group of personas.
284
+
285
+ Usage::
286
+
287
+ .. persona-index-diagram:: staff
288
+ .. persona-index-diagram:: customers
289
+ .. persona-index-diagram:: vendors
290
+
291
+ Groups are determined by the type field from app.yaml manifests.
292
+ Any value is accepted - the directive filters personas to those
293
+ using apps with a matching type.
294
+ """
295
+
296
+ required_arguments = 1
297
+ option_spec = {}
298
+
299
+ def run(self):
300
+ group_type = self.arguments[0].lower()
301
+
302
+ # Return placeholder - actual rendering in doctree-resolved
303
+ node = PersonaIndexDiagramPlaceholder()
304
+ node['group_type'] = group_type
305
+ return [node]
306
+
307
+
308
+ class PersonaIndexDiagramPlaceholder(nodes.General, nodes.Element):
309
+ """Placeholder node for persona-index-diagram, replaced at doctree-resolved."""
310
+ pass
311
+
312
+
313
+ def get_personas_by_app_type(story_registry: list, app_registry: dict) -> dict[str, set[str]]:
314
+ """Group personas by the type of apps they use.
315
+
316
+ Args:
317
+ story_registry: List of story dicts
318
+ app_registry: Dict of app_slug -> app_data
319
+
320
+ Returns:
321
+ Dict mapping app type strings to sets of persona names
322
+ """
323
+ personas_by_type = defaultdict(set)
324
+
325
+ for story in story_registry:
326
+ app_slug = story['app']
327
+ persona = story['persona']
328
+
329
+ if persona == 'unknown':
330
+ continue
331
+
332
+ app_data = app_registry.get(app_slug, {})
333
+ app_type = app_data.get('type', 'unknown').lower()
334
+
335
+ personas_by_type[app_type].add(persona)
336
+
337
+ return personas_by_type
338
+
339
+
340
+ def build_persona_diagram(persona_name: str, env, docname: str):
341
+ """Build the PlantUML diagram for a single persona."""
342
+ from . import stories, epics, apps
343
+ from sphinxcontrib.plantuml import plantuml
344
+ import os
345
+
346
+ story_registry = stories.get_story_registry()
347
+ epic_registry = epics.get_epic_registry(env)
348
+ app_registry = apps.get_app_registry()
349
+
350
+ # Get epics for this persona
351
+ persona_epics = get_epics_for_persona(persona_name, epic_registry, story_registry)
352
+
353
+ if not persona_epics:
354
+ para = nodes.paragraph()
355
+ para += nodes.emphasis(text=f"No epics found for persona '{persona_name}'")
356
+ return [para]
357
+
358
+ # Generate PlantUML
359
+ puml_source = generate_persona_plantuml(
360
+ persona_name, persona_epics, story_registry, app_registry
361
+ )
362
+
363
+ # Create plantuml node with required attributes
364
+ node = plantuml(puml_source)
365
+ node['uml'] = puml_source
366
+ node['incdir'] = os.path.dirname(docname)
367
+ node['filename'] = os.path.basename(docname)
368
+
369
+ return [node]
370
+
371
+
372
+ def build_persona_index_diagram(group_type: str, env, docname: str):
373
+ """Build the PlantUML diagram for a persona group."""
374
+ from . import stories, epics, apps
375
+ from sphinxcontrib.plantuml import plantuml
376
+ import os
377
+
378
+ story_registry = stories.get_story_registry()
379
+ epic_registry = epics.get_epic_registry(env)
380
+ app_registry = apps.get_app_registry()
381
+
382
+ # Get personas for this group type
383
+ personas_by_type = get_personas_by_app_type(story_registry, app_registry)
384
+ personas = sorted(personas_by_type.get(group_type, set()))
385
+
386
+ if not personas:
387
+ para = nodes.paragraph()
388
+ para += nodes.emphasis(text=f"No {group_type} personas found")
389
+ return [para]
390
+
391
+ # Generate PlantUML
392
+ puml_source = generate_persona_index_plantuml(
393
+ group_type, personas, epic_registry, story_registry, app_registry
394
+ )
395
+
396
+ # Create plantuml node with required attributes
397
+ node = plantuml(puml_source)
398
+ node['uml'] = puml_source
399
+ node['incdir'] = os.path.dirname(docname)
400
+ node['filename'] = os.path.basename(docname)
401
+
402
+ return [node]
403
+
404
+
405
+ def process_persona_placeholders(app, doctree, docname):
406
+ """Replace persona diagram placeholders with rendered content."""
407
+ env = app.env
408
+
409
+ # Process persona-diagram placeholders
410
+ for node in doctree.traverse(PersonaDiagramPlaceholder):
411
+ persona = node['persona']
412
+ content = build_persona_diagram(persona, env, docname)
413
+ node.replace_self(content)
414
+
415
+ # Process persona-index-diagram placeholders
416
+ for node in doctree.traverse(PersonaIndexDiagramPlaceholder):
417
+ group_type = node['group_type']
418
+ content = build_persona_index_diagram(group_type, env, docname)
419
+ node.replace_self(content)
420
+
421
+
422
+ def setup(app):
423
+ app.connect("doctree-resolved", process_persona_placeholders)
424
+
425
+ app.add_directive("persona-diagram", PersonaDiagramDirective)
426
+ app.add_directive("persona-index-diagram", PersonaIndexDiagramDirective)
427
+
428
+ app.add_node(PersonaDiagramPlaceholder)
429
+ app.add_node(PersonaIndexDiagramPlaceholder)
430
+
431
+ return {
432
+ "version": "1.0",
433
+ "parallel_read_safe": False,
434
+ "parallel_write_safe": True,
435
+ }