julee 0.1.1__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 (157) 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 +14 -9
  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 +12 -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 +33 -16
  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 +23 -13
  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 +26 -15
  93. julee/maintenance/__init__.py +1 -0
  94. julee/maintenance/release.py +188 -0
  95. julee/repositories/__init__.py +4 -1
  96. julee/repositories/memory/assembly.py +6 -5
  97. julee/repositories/memory/assembly_specification.py +9 -9
  98. julee/repositories/memory/base.py +12 -11
  99. julee/repositories/memory/document.py +8 -7
  100. julee/repositories/memory/document_policy_validation.py +7 -6
  101. julee/repositories/memory/knowledge_service_config.py +9 -7
  102. julee/repositories/memory/knowledge_service_query.py +9 -7
  103. julee/repositories/memory/policy.py +6 -5
  104. julee/repositories/memory/tests/test_document.py +6 -4
  105. julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
  106. julee/repositories/memory/tests/test_policy.py +2 -1
  107. julee/repositories/minio/assembly.py +4 -4
  108. julee/repositories/minio/assembly_specification.py +6 -8
  109. julee/repositories/minio/client.py +22 -25
  110. julee/repositories/minio/document.py +11 -11
  111. julee/repositories/minio/document_policy_validation.py +5 -5
  112. julee/repositories/minio/knowledge_service_config.py +8 -8
  113. julee/repositories/minio/knowledge_service_query.py +6 -9
  114. julee/repositories/minio/policy.py +4 -4
  115. julee/repositories/minio/tests/fake_client.py +11 -9
  116. julee/repositories/minio/tests/test_assembly.py +3 -1
  117. julee/repositories/minio/tests/test_assembly_specification.py +2 -1
  118. julee/repositories/minio/tests/test_client_protocol.py +5 -5
  119. julee/repositories/minio/tests/test_document.py +7 -6
  120. julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
  121. julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
  122. julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
  123. julee/repositories/minio/tests/test_policy.py +3 -1
  124. julee/repositories/temporal/activities.py +5 -5
  125. julee/repositories/temporal/proxies.py +5 -5
  126. julee/services/knowledge_service/__init__.py +1 -2
  127. julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
  128. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
  129. julee/services/knowledge_service/factory.py +8 -8
  130. julee/services/knowledge_service/knowledge_service.py +22 -18
  131. julee/services/knowledge_service/memory/knowledge_service.py +13 -12
  132. julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
  133. julee/services/knowledge_service/test_factory.py +11 -10
  134. julee/services/temporal/activities.py +10 -10
  135. julee/services/temporal/proxies.py +2 -2
  136. julee/util/domain.py +6 -6
  137. julee/util/repos/minio/file_storage.py +8 -9
  138. julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
  139. julee/util/repos/temporal/data_converter.py +6 -6
  140. julee/util/repos/temporal/minio_file_storage.py +1 -1
  141. julee/util/repos/temporal/proxies/file_storage.py +2 -3
  142. julee/util/repositories.py +6 -7
  143. julee/util/temporal/activities.py +1 -1
  144. julee/util/temporal/decorators.py +28 -33
  145. julee/util/tests/test_decorators.py +13 -15
  146. julee/util/validation/repository.py +3 -3
  147. julee/util/validation/type_guards.py +12 -11
  148. julee/worker.py +9 -8
  149. julee/workflows/__init__.py +2 -2
  150. julee/workflows/extract_assemble.py +2 -1
  151. julee/workflows/validate_document.py +3 -2
  152. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/METADATA +4 -1
  153. julee-0.1.3.dist-info/RECORD +197 -0
  154. julee-0.1.1.dist-info/RECORD +0 -161
  155. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
  156. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
  157. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,783 @@
1
+ """Sphinx extension for defining and cross-referencing user journeys.
2
+
3
+ A journey represents a persona's path through the system to achieve a goal.
4
+ Each journey captures the user's motivation, the value they receive, and
5
+ the sequence of steps (stories/epics) they follow.
6
+
7
+ Provides directives:
8
+ - define-journey: Define a journey with persona, intent, outcome, and steps
9
+ - step-story: Reference a story as a journey step
10
+ - step-epic: Reference an epic as a journey step
11
+ - step-phase: Optional grouping label for steps
12
+ - journey-index: Render index of all journeys
13
+ - journey-dependency-graph: Generate PlantUML graph of journey dependencies
14
+ - journeys-for-persona: List journeys for a specific persona
15
+ """
16
+
17
+ import re
18
+ from docutils import nodes
19
+ from docutils.parsers.rst import directives
20
+ from sphinx.util.docutils import SphinxDirective
21
+ from sphinx.util import logging
22
+
23
+ from .config import get_config
24
+ from .utils import normalize_name, slugify, path_to_root, parse_list_option, parse_csv_option
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def get_journey_registry(env):
30
+ """Get or create the journey registry on the environment."""
31
+ if not hasattr(env, 'journey_registry'):
32
+ env.journey_registry = {}
33
+ return env.journey_registry
34
+
35
+
36
+ def get_current_journey(env):
37
+ """Get or create the current journey tracker on the environment."""
38
+ if not hasattr(env, 'journey_current'):
39
+ env.journey_current = {}
40
+ return env.journey_current
41
+
42
+
43
+ class DefineJourneyDirective(SphinxDirective):
44
+ """Define a journey with persona, intent, outcome, and metadata.
45
+
46
+ Options:
47
+ :persona: (required) The persona undertaking this journey.
48
+
49
+ :intent: The user's underlying motivation - why they care about this
50
+ journey. Answers "what does the persona want?" Focus on their
51
+ goal, not the system's features. Renders as:
52
+ "The [Persona] wants to [intent]."
53
+
54
+ :outcome: The business value delivered when the journey succeeds.
55
+ Answers "what does success look like?" Focus on measurable or
56
+ observable results, not activities. Renders as:
57
+ "Success means [outcome]."
58
+
59
+ :depends-on: Comma-separated list of journey slugs that must be
60
+ completed before this journey makes sense.
61
+
62
+ :preconditions: Bullet list of conditions that must be true before
63
+ starting this journey.
64
+
65
+ :postconditions: Bullet list of conditions that will be true after
66
+ completing this journey.
67
+
68
+ Content:
69
+ The directive body describes *what* the persona does (activities).
70
+ If :intent: is provided, this becomes supplementary description.
71
+ If :intent: is omitted, this becomes the goal (backward compatibility).
72
+
73
+ Example::
74
+
75
+ .. define-journey:: build-vocabulary
76
+ :persona: Knowledge Curator
77
+ :intent: Ensure consistent terminology across RBA programs
78
+ :outcome: Semantic interoperability enabling automated compliance mapping
79
+ :depends-on: operate-pipelines
80
+ :preconditions:
81
+ - Source materials available
82
+ - SME accessible
83
+ :postconditions:
84
+ - SVC published and versioned
85
+
86
+ Create a Sustainable Vocabulary Catalog from source materials.
87
+ """
88
+
89
+ required_arguments = 1 # journey slug
90
+ has_content = True
91
+ option_spec = {
92
+ 'persona': directives.unchanged_required,
93
+ 'intent': directives.unchanged,
94
+ 'outcome': directives.unchanged,
95
+ 'depends-on': directives.unchanged,
96
+ 'preconditions': directives.unchanged,
97
+ 'postconditions': directives.unchanged,
98
+ }
99
+
100
+ def run(self):
101
+ from . import stories
102
+
103
+ config = get_config()
104
+ journey_slug = self.arguments[0]
105
+ docname = self.env.docname
106
+
107
+ # Parse options
108
+ persona = self.options.get('persona', '').strip()
109
+ intent = self.options.get('intent', '').strip()
110
+ outcome = self.options.get('outcome', '').strip()
111
+ depends_on = parse_csv_option(self.options.get('depends-on', ''))
112
+ preconditions = parse_list_option(self.options.get('preconditions', ''))
113
+ postconditions = parse_list_option(self.options.get('postconditions', ''))
114
+
115
+ # Goal is the directive content (what they do)
116
+ goal = '\n'.join(self.content).strip()
117
+
118
+ # Register the journey in environment
119
+ journey_registry = get_journey_registry(self.env)
120
+ current_journey = get_current_journey(self.env)
121
+
122
+ journey_data = {
123
+ 'slug': journey_slug,
124
+ 'persona': persona,
125
+ 'persona_normalized': normalize_name(persona),
126
+ 'intent': intent,
127
+ 'outcome': outcome,
128
+ 'goal': goal,
129
+ 'depends_on': depends_on,
130
+ 'preconditions': preconditions,
131
+ 'postconditions': postconditions,
132
+ 'steps': [], # Will be populated by step-story/step-epic/step-phase
133
+ 'docname': docname,
134
+ }
135
+ journey_registry[journey_slug] = journey_data
136
+ current_journey[docname] = journey_slug
137
+
138
+ # Build output nodes
139
+ result_nodes = []
140
+
141
+ # Import story extension to get known personas
142
+ _known_personas = stories.get_known_personas()
143
+
144
+ # Calculate paths
145
+ prefix = path_to_root(docname)
146
+
147
+ # Intent and outcome as single paragraph:
148
+ # "The [Persona] wants to [intent]. Success means [outcome]."
149
+ if persona and intent:
150
+ intro_para = nodes.paragraph()
151
+ intro_para += nodes.Text("The ")
152
+
153
+ persona_slug = slugify(persona)
154
+ persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
155
+ persona_valid = normalize_name(persona) in _known_personas
156
+
157
+ if persona_valid:
158
+ persona_ref = nodes.reference("", "", refuri=persona_path)
159
+ persona_ref += nodes.Text(persona)
160
+ intro_para += persona_ref
161
+ else:
162
+ intro_para += nodes.Text(persona)
163
+
164
+ intro_para += nodes.Text(" wants to ")
165
+ # Lowercase first letter of intent to flow as sentence
166
+ intent_text = intent[0].lower() + intent[1:] if intent else ""
167
+ intro_para += nodes.Text(intent_text + ".")
168
+
169
+ # Append outcome to same paragraph if present
170
+ if outcome:
171
+ intro_para += nodes.Text(" Success means ")
172
+ outcome_text = outcome[0].lower() + outcome[1:] if outcome else ""
173
+ intro_para += nodes.Text(outcome_text + ".")
174
+
175
+ result_nodes.append(intro_para)
176
+
177
+ # Goal paragraph (what they do) - only if intent not provided (backward compat)
178
+ if goal and not intent:
179
+ # Fall back to old format if no intent specified
180
+ intro_para = nodes.paragraph()
181
+ intro_para += nodes.Text("The goal of the ")
182
+
183
+ persona_slug = slugify(persona)
184
+ persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
185
+ persona_valid = normalize_name(persona) in _known_personas
186
+
187
+ if persona_valid:
188
+ persona_ref = nodes.reference("", "", refuri=persona_path)
189
+ persona_ref += nodes.Text(persona)
190
+ intro_para += persona_ref
191
+ else:
192
+ intro_para += nodes.Text(persona)
193
+
194
+ intro_para += nodes.Text(" is to ")
195
+ goal_text = goal[0].lower() + goal[1:] if goal else ""
196
+ intro_para += nodes.Text(goal_text)
197
+ result_nodes.append(intro_para)
198
+ elif goal and intent:
199
+ # If intent provided, goal becomes activity description
200
+ goal_para = nodes.paragraph()
201
+ goal_para += nodes.Text(goal)
202
+ result_nodes.append(goal_para)
203
+
204
+ # Add a placeholder for steps (will be filled in doctree-resolved)
205
+ steps_placeholder = nodes.container()
206
+ steps_placeholder['classes'].append('journey-steps-placeholder')
207
+ steps_placeholder['journey_slug'] = journey_slug
208
+ result_nodes.append(steps_placeholder)
209
+
210
+ return result_nodes
211
+
212
+
213
+ class StepStoryDirective(SphinxDirective):
214
+ """Reference a story as a journey step.
215
+
216
+ Usage::
217
+
218
+ .. step-story:: Upload Scheme Documentation
219
+ """
220
+
221
+ required_arguments = 1
222
+ final_argument_whitespace = True
223
+
224
+ def run(self):
225
+ story_title = self.arguments[0]
226
+ docname = self.env.docname
227
+
228
+ # Add to current journey's steps
229
+ journey_registry = get_journey_registry(self.env)
230
+ current_journey = get_current_journey(self.env)
231
+
232
+ journey_slug = current_journey.get(docname)
233
+ if journey_slug and journey_slug in journey_registry:
234
+ journey_registry[journey_slug]['steps'].append({
235
+ 'type': 'story',
236
+ 'ref': story_title,
237
+ })
238
+
239
+ # Return empty - rendering happens in doctree-resolved
240
+ return []
241
+
242
+
243
+ class StepEpicDirective(SphinxDirective):
244
+ """Reference an epic as a journey step.
245
+
246
+ Usage::
247
+
248
+ .. step-epic:: vocabulary-management
249
+ """
250
+
251
+ required_arguments = 1
252
+
253
+ def run(self):
254
+ epic_slug = self.arguments[0]
255
+ docname = self.env.docname
256
+
257
+ # Add to current journey's steps
258
+ journey_registry = get_journey_registry(self.env)
259
+ current_journey = get_current_journey(self.env)
260
+
261
+ journey_slug = current_journey.get(docname)
262
+ if journey_slug and journey_slug in journey_registry:
263
+ journey_registry[journey_slug]['steps'].append({
264
+ 'type': 'epic',
265
+ 'ref': epic_slug,
266
+ })
267
+
268
+ # Return empty - rendering happens in doctree-resolved
269
+ return []
270
+
271
+
272
+ class StepPhaseDirective(SphinxDirective):
273
+ """Optional grouping label for journey steps.
274
+
275
+ Usage::
276
+
277
+ .. step-phase:: Upload Sources
278
+
279
+ Add reference materials to the knowledge base.
280
+ """
281
+
282
+ required_arguments = 1
283
+ final_argument_whitespace = True
284
+ has_content = True
285
+
286
+ def run(self):
287
+ phase_title = self.arguments[0]
288
+ docname = self.env.docname
289
+ description = '\n'.join(self.content).strip()
290
+
291
+ # Add to current journey's steps
292
+ journey_registry = get_journey_registry(self.env)
293
+ current_journey = get_current_journey(self.env)
294
+
295
+ journey_slug = current_journey.get(docname)
296
+ if journey_slug and journey_slug in journey_registry:
297
+ journey_registry[journey_slug]['steps'].append({
298
+ 'type': 'phase',
299
+ 'ref': phase_title,
300
+ 'description': description,
301
+ })
302
+
303
+ # Return empty - rendering happens in doctree-resolved
304
+ return []
305
+
306
+
307
+ class JourneyIndexDirective(SphinxDirective):
308
+ """Render index of all journeys.
309
+
310
+ Usage::
311
+
312
+ .. journey-index::
313
+ """
314
+
315
+ def run(self):
316
+ journey_registry = get_journey_registry(self.env)
317
+
318
+ if not journey_registry:
319
+ para = nodes.paragraph()
320
+ para += nodes.emphasis(text="No journeys defined")
321
+ return [para]
322
+
323
+ docname = self.env.docname
324
+
325
+ result_nodes = []
326
+ bullet_list = nodes.bullet_list()
327
+
328
+ for slug in sorted(journey_registry.keys()):
329
+ journey = journey_registry[slug]
330
+
331
+ item = nodes.list_item()
332
+ para = nodes.paragraph()
333
+
334
+ # Link to journey
335
+ journey_path = f"{slug}.html"
336
+ journey_ref = nodes.reference("", "", refuri=journey_path)
337
+ journey_ref += nodes.strong(text=slug.replace("-", " ").title())
338
+ para += journey_ref
339
+
340
+ # Persona in parentheses
341
+ if journey['persona']:
342
+ para += nodes.Text(f" ({journey['persona']})")
343
+
344
+ item += para
345
+
346
+ # Intent as sub-paragraph (preferred), fall back to goal
347
+ display_text = journey.get('intent') or journey.get('goal', '')
348
+ if display_text:
349
+ desc_para = nodes.paragraph()
350
+ # Truncate if too long
351
+ if len(display_text) > 100:
352
+ display_text = display_text[:100] + "..."
353
+ desc_para += nodes.Text(display_text)
354
+ item += desc_para
355
+
356
+ bullet_list += item
357
+
358
+ result_nodes.append(bullet_list)
359
+ return result_nodes
360
+
361
+
362
+ class JourneyDependencyGraphPlaceholder(nodes.General, nodes.Element):
363
+ """Placeholder node for journey dependency graph, replaced at doctree-resolved."""
364
+ pass
365
+
366
+
367
+ class JourneyDependencyGraphDirective(SphinxDirective):
368
+ """Generate a PlantUML dependency graph from journey dependencies.
369
+
370
+ Usage::
371
+
372
+ .. journey-dependency-graph::
373
+ """
374
+
375
+ def run(self):
376
+ # Return a placeholder that will be replaced in doctree-resolved
377
+ # when all journeys have been registered
378
+ node = JourneyDependencyGraphPlaceholder()
379
+ return [node]
380
+
381
+
382
+ def build_dependency_graph_node(env):
383
+ """Build the PlantUML node for the journey dependency graph."""
384
+ from sphinxcontrib.plantuml import plantuml
385
+
386
+ journey_registry = get_journey_registry(env)
387
+
388
+ if not journey_registry:
389
+ para = nodes.paragraph()
390
+ para += nodes.emphasis(text="No journeys defined")
391
+ return para
392
+
393
+ # Build PlantUML content
394
+ lines = [
395
+ "@startuml",
396
+ "skinparam componentStyle rectangle",
397
+ "skinparam defaultTextAlignment center",
398
+ "",
399
+ ]
400
+
401
+ # Add all journeys as components
402
+ for slug in sorted(journey_registry.keys()):
403
+ title = slug.replace("-", " ").title()
404
+ lines.append(f'[{title}] as {slug.replace("-", "_")}')
405
+
406
+ lines.append("")
407
+
408
+ # Add dependency arrows (A depends on B => A --> B)
409
+ for slug, journey in sorted(journey_registry.items()):
410
+ for dep in journey['depends_on']:
411
+ if dep in journey_registry:
412
+ from_id = slug.replace("-", "_")
413
+ to_id = dep.replace("-", "_")
414
+ lines.append(f"{from_id} --> {to_id}")
415
+
416
+ lines.append("")
417
+ lines.append("@enduml")
418
+
419
+ puml_content = "\n".join(lines)
420
+
421
+ # Use the sphinxcontrib.plantuml extension's node type
422
+ puml_node = plantuml(puml_content)
423
+ puml_node['uml'] = puml_content
424
+ puml_node['incdir'] = ''
425
+ puml_node['filename'] = 'journey-dependency-graph'
426
+
427
+ return puml_node
428
+
429
+
430
+ def process_dependency_graph_placeholder(app, doctree, docname):
431
+ """Replace dependency graph placeholder with actual PlantUML node.
432
+
433
+ Uses doctree-resolved event (fires after ALL documents read) to ensure
434
+ the journey registry is complete when building the graph.
435
+ """
436
+ env = app.env
437
+
438
+ for node in doctree.traverse(JourneyDependencyGraphPlaceholder):
439
+ puml_node = build_dependency_graph_node(env)
440
+ node.replace_self(puml_node)
441
+
442
+
443
+ class JourneysForPersonaDirective(SphinxDirective):
444
+ """List journeys for a specific persona.
445
+
446
+ Usage::
447
+
448
+ .. journeys-for-persona:: Analyst
449
+ """
450
+
451
+ required_arguments = 1
452
+ final_argument_whitespace = True
453
+
454
+ def run(self):
455
+ config = get_config()
456
+ persona_arg = self.arguments[0]
457
+ persona_normalized = normalize_name(persona_arg)
458
+ docname = self.env.docname
459
+
460
+ journey_registry = get_journey_registry(self.env)
461
+
462
+ # Find journeys for this persona
463
+ journeys = [j for j in journey_registry.values()
464
+ if j['persona_normalized'] == persona_normalized]
465
+
466
+ if not journeys:
467
+ para = nodes.paragraph()
468
+ para += nodes.emphasis(text=f"No journeys found for persona '{persona_arg}'")
469
+ return [para]
470
+
471
+ prefix = path_to_root(docname)
472
+
473
+ bullet_list = nodes.bullet_list()
474
+
475
+ for journey in sorted(journeys, key=lambda j: j['slug']):
476
+ item = nodes.list_item()
477
+ para = nodes.paragraph()
478
+
479
+ journey_path = f"{prefix}{config.get_doc_path('journeys')}/{journey['slug']}.html"
480
+ journey_ref = nodes.reference("", "", refuri=journey_path)
481
+ journey_ref += nodes.Text(journey['slug'].replace("-", " ").title())
482
+ para += journey_ref
483
+
484
+ item += para
485
+ bullet_list += item
486
+
487
+ return [bullet_list]
488
+
489
+
490
+ def clear_journey_state(app, env, docname):
491
+ """Clear journey state when a document is re-read."""
492
+ current_journey = get_current_journey(env)
493
+ journey_registry = get_journey_registry(env)
494
+
495
+ if docname in current_journey:
496
+ del current_journey[docname]
497
+
498
+ # Remove journeys defined in this document
499
+ to_remove = [slug for slug, j in journey_registry.items()
500
+ if j['docname'] == docname]
501
+ for slug in to_remove:
502
+ del journey_registry[slug]
503
+
504
+
505
+ def validate_journeys(app, env):
506
+ """Validate journey references after all documents are read."""
507
+ from . import stories
508
+
509
+ journey_registry = get_journey_registry(env)
510
+ _story_registry = stories.get_story_registry()
511
+ _known_personas = stories.get_known_personas()
512
+
513
+ story_titles = {normalize_name(s['feature']) for s in _story_registry}
514
+
515
+ for slug, journey in journey_registry.items():
516
+ # Validate persona
517
+ if journey['persona'] and journey['persona_normalized'] not in _known_personas:
518
+ logger.warning(
519
+ f"Journey '{slug}' references unknown persona: '{journey['persona']}'"
520
+ )
521
+
522
+ # Validate depends-on journeys
523
+ for dep in journey['depends_on']:
524
+ if dep not in journey_registry:
525
+ logger.warning(
526
+ f"Journey '{slug}' references unknown dependency: '{dep}'"
527
+ )
528
+
529
+ # Validate story steps
530
+ for step in journey['steps']:
531
+ if step['type'] == 'story':
532
+ if normalize_name(step['ref']) not in story_titles:
533
+ logger.warning(
534
+ f"Journey '{slug}' references unknown story: '{step['ref']}'"
535
+ )
536
+
537
+
538
+ def build_story_node(story_title: str, docname: str):
539
+ """Build a paragraph node for a story reference."""
540
+ from . import stories
541
+
542
+ config = get_config()
543
+ _story_registry = stories.get_story_registry()
544
+ _known_apps = stories.get_known_apps()
545
+
546
+ # Find the story
547
+ story_normalized = normalize_name(story_title)
548
+ story = None
549
+ for s in _story_registry:
550
+ if normalize_name(s["feature"]) == story_normalized:
551
+ story = s
552
+ break
553
+
554
+ para = nodes.paragraph()
555
+
556
+ if story:
557
+ # Create link to story
558
+ para += stories.make_story_reference(story, docname, story["feature"])
559
+
560
+ # Add app in parentheses
561
+ prefix = path_to_root(docname)
562
+ app_path = f"{prefix}{config.get_doc_path('applications')}/{story['app']}.html"
563
+ app_valid = story["app_normalized"] in _known_apps
564
+
565
+ para += nodes.Text(" (")
566
+ if app_valid:
567
+ app_ref = nodes.reference("", "", refuri=app_path)
568
+ app_ref += nodes.Text(story["app"].replace("-", " ").title())
569
+ para += app_ref
570
+ else:
571
+ para += nodes.Text(story["app"].replace("-", " ").title())
572
+ para += nodes.Text(")")
573
+ else:
574
+ # Story not found - show warning style
575
+ para += nodes.problematic(text=f"{story_title} [not found]")
576
+
577
+ return para
578
+
579
+
580
+ def build_epic_node(epic_slug: str, docname: str):
581
+ """Build a paragraph node for an epic reference."""
582
+ config = get_config()
583
+ prefix = path_to_root(docname)
584
+ epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic_slug}.html"
585
+
586
+ para = nodes.paragraph()
587
+ epic_ref = nodes.reference("", "", refuri=epic_path)
588
+ epic_ref += nodes.Text(epic_slug.replace("-", " ").title())
589
+ para += epic_ref
590
+ para += nodes.Text(" (epic)")
591
+
592
+ return para
593
+
594
+
595
+ def render_journey_steps(journey: dict, docname: str):
596
+ """Render journey steps as a numbered list with phases grouping stories."""
597
+ steps = journey['steps']
598
+ if not steps:
599
+ return None
600
+
601
+ # Group steps: phases contain subsequent stories/epics until next phase
602
+ phases = []
603
+ current_phase = None
604
+
605
+ for step in steps:
606
+ if step['type'] == 'phase':
607
+ # Start a new phase
608
+ current_phase = {
609
+ 'title': step['ref'],
610
+ 'description': step.get('description', ''),
611
+ 'items': []
612
+ }
613
+ phases.append(current_phase)
614
+ elif step['type'] in ('story', 'epic'):
615
+ if current_phase is None:
616
+ # Stories before any phase - create implicit phase
617
+ current_phase = {
618
+ 'title': None,
619
+ 'description': '',
620
+ 'items': []
621
+ }
622
+ phases.append(current_phase)
623
+ current_phase['items'].append(step)
624
+
625
+ # Build enumerated list
626
+ enum_list = nodes.enumerated_list()
627
+ enum_list['enumtype'] = 'arabic'
628
+
629
+ for phase in phases:
630
+ list_item = nodes.list_item()
631
+
632
+ # Phase header paragraph: "Title — Description" or just items if no title
633
+ if phase['title']:
634
+ header_para = nodes.paragraph()
635
+ header_para += nodes.strong(text=phase['title'])
636
+ if phase['description']:
637
+ header_para += nodes.Text(" — ")
638
+ header_para += nodes.Text(phase['description'])
639
+ list_item += header_para
640
+
641
+ # Nested bullet list for stories/epics
642
+ if phase['items']:
643
+ bullet_list = nodes.bullet_list()
644
+ for item in phase['items']:
645
+ bullet_item = nodes.list_item()
646
+ if item['type'] == 'story':
647
+ bullet_item += build_story_node(item['ref'], docname)
648
+ elif item['type'] == 'epic':
649
+ bullet_item += build_epic_node(item['ref'], docname)
650
+ bullet_list += bullet_item
651
+ list_item += bullet_list
652
+
653
+ enum_list += list_item
654
+
655
+ return enum_list
656
+
657
+
658
+ def make_labelled_list(term: str, items: list, env, docname: str = None, item_type: str = 'text'):
659
+ """Create a labelled bullet list with term as heading.
660
+
661
+ Uses 'simple' class on bullet list for compact vertical spacing.
662
+
663
+ item_type can be 'text' (plain text) or 'journey' (journey links).
664
+ """
665
+ config = get_config()
666
+ journey_registry = get_journey_registry(env)
667
+ container = nodes.container()
668
+
669
+ # Term as bold paragraph
670
+ term_para = nodes.paragraph()
671
+ term_para += nodes.strong(text=term)
672
+ container += term_para
673
+
674
+ # Bullet list
675
+ bullet_list = nodes.bullet_list()
676
+
677
+ for item in items:
678
+ list_item = nodes.list_item()
679
+ # Use inline container for content to avoid paragraph gaps
680
+ inline = nodes.inline()
681
+
682
+ if item_type == 'journey':
683
+ related_slug = item
684
+ related_path = f"{related_slug}.html"
685
+ if related_slug in journey_registry:
686
+ ref = nodes.reference("", "", refuri=related_path)
687
+ ref += nodes.Text(related_slug.replace("-", " ").title())
688
+ inline += ref
689
+ else:
690
+ inline += nodes.Text(related_slug.replace("-", " ").title())
691
+ inline += nodes.emphasis(text=" [not found]")
692
+ else:
693
+ inline += nodes.Text(item)
694
+
695
+ list_item += inline
696
+ bullet_list += list_item
697
+
698
+ container += bullet_list
699
+
700
+ return container
701
+
702
+
703
+ def process_journey_steps(app, doctree):
704
+ """Replace journey steps placeholder with rendered steps.
705
+
706
+ Uses doctree-read event (fires after all directives parsed, before pickle)
707
+ to ensure modifications are preserved in both HTML and LaTeX builds.
708
+ """
709
+ env = app.env
710
+ docname = env.docname # Available during doctree-read
711
+ current_journey = get_current_journey(env)
712
+ journey_registry = get_journey_registry(env)
713
+
714
+ journey_slug = current_journey.get(docname)
715
+ if not journey_slug or journey_slug not in journey_registry:
716
+ return
717
+
718
+ journey = journey_registry[journey_slug]
719
+
720
+ # Find and replace the steps placeholder
721
+ for node in doctree.traverse(nodes.container):
722
+ if 'journey-steps-placeholder' in node.get('classes', []):
723
+ steps_node = render_journey_steps(journey, docname)
724
+ if steps_node:
725
+ node.replace_self(steps_node)
726
+ else:
727
+ node.replace_self([])
728
+ break
729
+
730
+ # Add preconditions if present (after steps)
731
+ if journey['preconditions']:
732
+ doctree += make_labelled_list(
733
+ "Preconditions", journey['preconditions'], env
734
+ )
735
+
736
+ # Add postconditions if present
737
+ if journey['postconditions']:
738
+ doctree += make_labelled_list(
739
+ "Postconditions", journey['postconditions'], env
740
+ )
741
+
742
+ # Add depends-on journeys if present
743
+ if journey['depends_on']:
744
+ doctree += make_labelled_list(
745
+ "Depends On", journey['depends_on'], env, docname, item_type='journey'
746
+ )
747
+
748
+ # Add depended-on-by journeys (inferred from other journeys' depends_on)
749
+ depended_on_by = [
750
+ j['slug'] for j in journey_registry.values()
751
+ if journey_slug in j['depends_on']
752
+ ]
753
+ if depended_on_by:
754
+ doctree += make_labelled_list(
755
+ "Depended On By", sorted(depended_on_by), env, docname, item_type='journey'
756
+ )
757
+
758
+
759
+ def setup(app):
760
+ app.connect("env-purge-doc", clear_journey_state)
761
+ app.connect("env-check-consistency", validate_journeys)
762
+ # Use doctree-read (not doctree-resolved) so modifications persist in pickled
763
+ # doctrees for both HTML and LaTeX builders
764
+ app.connect("doctree-read", process_journey_steps)
765
+ # Dependency graph uses doctree-resolved (fires after ALL docs read)
766
+ # so journey_registry is complete when building the graph
767
+ app.connect("doctree-resolved", process_dependency_graph_placeholder)
768
+
769
+ app.add_directive("define-journey", DefineJourneyDirective)
770
+ app.add_directive("step-story", StepStoryDirective)
771
+ app.add_directive("step-epic", StepEpicDirective)
772
+ app.add_directive("step-phase", StepPhaseDirective)
773
+ app.add_directive("journey-index", JourneyIndexDirective)
774
+ app.add_directive("journey-dependency-graph", JourneyDependencyGraphDirective)
775
+ app.add_directive("journeys-for-persona", JourneysForPersonaDirective)
776
+
777
+ app.add_node(JourneyDependencyGraphPlaceholder)
778
+
779
+ return {
780
+ "version": "1.0",
781
+ "parallel_read_safe": False, # Uses global state
782
+ "parallel_write_safe": True,
783
+ }