julee 0.1.5__py3-none-any.whl → 0.1.7__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.
- julee/__init__.py +1 -1
- julee/contrib/polling/apps/worker/pipelines.py +3 -1
- julee/contrib/polling/tests/unit/apps/worker/test_pipelines.py +3 -0
- julee/docs/sphinx_hcd/__init__.py +146 -13
- julee/docs/sphinx_hcd/domain/__init__.py +5 -0
- julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
- julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
- julee/docs/sphinx_hcd/domain/models/app.py +151 -0
- julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
- julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
- julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
- julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
- julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
- julee/docs/sphinx_hcd/domain/models/story.py +128 -0
- julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
- julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
- julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
- julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
- julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
- julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
- julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
- julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
- julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
- julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
- julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
- julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
- julee/docs/sphinx_hcd/parsers/ast.py +150 -0
- julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
- julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
- julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
- julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
- julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
- julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
- julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
- julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
- julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
- julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
- julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
- julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
- julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
- julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
- julee/docs/sphinx_hcd/sphinx/context.py +163 -0
- julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
- julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
- julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
- julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
- julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
- julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
- julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
- julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
- julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
- julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
- julee/docs/sphinx_hcd/tests/__init__.py +9 -0
- julee/docs/sphinx_hcd/tests/conftest.py +6 -0
- julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
- julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
- julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
- julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
- julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
- julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
- julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
- julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
- julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
- julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
- julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
- julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
- julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
- julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
- julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
- {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/METADATA +2 -1
- {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/RECORD +101 -16
- julee/docs/sphinx_hcd/accelerators.py +0 -1175
- julee/docs/sphinx_hcd/apps.py +0 -518
- julee/docs/sphinx_hcd/epics.py +0 -453
- julee/docs/sphinx_hcd/integrations.py +0 -310
- julee/docs/sphinx_hcd/journeys.py +0 -797
- julee/docs/sphinx_hcd/personas.py +0 -457
- julee/docs/sphinx_hcd/stories.py +0 -960
- {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/WHEEL +0 -0
- {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Epic domain model.
|
|
2
|
+
|
|
3
|
+
Represents an epic in the HCD documentation system.
|
|
4
|
+
Epics are defined via RST directives and group related stories together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
from ...utils import normalize_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Epic(BaseModel):
|
|
13
|
+
"""Epic entity.
|
|
14
|
+
|
|
15
|
+
An epic represents a collection of related stories that together
|
|
16
|
+
deliver a larger piece of functionality or business value.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
slug: URL-safe identifier (e.g., "credential-creation")
|
|
20
|
+
description: Human-readable description of the epic
|
|
21
|
+
story_refs: List of story feature titles in this epic
|
|
22
|
+
docname: RST document name (for incremental builds)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
slug: str
|
|
26
|
+
description: str = ""
|
|
27
|
+
story_refs: list[str] = Field(default_factory=list)
|
|
28
|
+
docname: str = ""
|
|
29
|
+
|
|
30
|
+
@field_validator("slug", mode="before")
|
|
31
|
+
@classmethod
|
|
32
|
+
def validate_slug(cls, v: str) -> str:
|
|
33
|
+
"""Validate slug is not empty."""
|
|
34
|
+
if not v or not v.strip():
|
|
35
|
+
raise ValueError("slug cannot be empty")
|
|
36
|
+
return v.strip()
|
|
37
|
+
|
|
38
|
+
def add_story(self, story_title: str) -> None:
|
|
39
|
+
"""Add a story reference to this epic.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
story_title: Feature title of the story to add
|
|
43
|
+
"""
|
|
44
|
+
self.story_refs.append(story_title)
|
|
45
|
+
|
|
46
|
+
def has_story(self, story_title: str) -> bool:
|
|
47
|
+
"""Check if this epic contains a specific story.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
story_title: Feature title to check (case-insensitive)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if the story is in this epic
|
|
54
|
+
"""
|
|
55
|
+
story_normalized = normalize_name(story_title)
|
|
56
|
+
return any(normalize_name(ref) == story_normalized for ref in self.story_refs)
|
|
57
|
+
|
|
58
|
+
def get_story_refs_normalized(self) -> list[str]:
|
|
59
|
+
"""Get normalized story references.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of normalized story titles
|
|
63
|
+
"""
|
|
64
|
+
return [normalize_name(ref) for ref in self.story_refs]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def display_title(self) -> str:
|
|
68
|
+
"""Get formatted title for display."""
|
|
69
|
+
return self.slug.replace("-", " ").title()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def story_count(self) -> int:
|
|
73
|
+
"""Get number of stories in this epic."""
|
|
74
|
+
return len(self.story_refs)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def has_stories(self) -> bool:
|
|
78
|
+
"""Check if epic has any stories."""
|
|
79
|
+
return len(self.story_refs) > 0
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Integration domain model.
|
|
2
|
+
|
|
3
|
+
Represents an integration module in the HCD documentation system.
|
|
4
|
+
Integrations are defined via YAML manifests in integrations/*/integration.yaml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
from ...utils import normalize_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Direction(str, Enum):
|
|
15
|
+
"""Integration data flow direction."""
|
|
16
|
+
|
|
17
|
+
INBOUND = "inbound"
|
|
18
|
+
OUTBOUND = "outbound"
|
|
19
|
+
BIDIRECTIONAL = "bidirectional"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_string(cls, value: str) -> "Direction":
|
|
23
|
+
"""Convert string to Direction, defaulting to BIDIRECTIONAL."""
|
|
24
|
+
try:
|
|
25
|
+
return cls(value.lower())
|
|
26
|
+
except ValueError:
|
|
27
|
+
return cls.BIDIRECTIONAL
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self) -> str:
|
|
31
|
+
"""Get human-readable label."""
|
|
32
|
+
labels = {
|
|
33
|
+
Direction.INBOUND: "Inbound (data source)",
|
|
34
|
+
Direction.OUTBOUND: "Outbound (data sink)",
|
|
35
|
+
Direction.BIDIRECTIONAL: "Bidirectional",
|
|
36
|
+
}
|
|
37
|
+
return labels.get(self, str(self.value))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExternalDependency(BaseModel):
|
|
41
|
+
"""External system that an integration depends on.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
name: Display name of the external system
|
|
45
|
+
url: Optional URL for documentation or reference
|
|
46
|
+
description: Optional brief description
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
url: str | None = None
|
|
51
|
+
description: str = ""
|
|
52
|
+
|
|
53
|
+
@field_validator("name", mode="before")
|
|
54
|
+
@classmethod
|
|
55
|
+
def validate_name(cls, v: str) -> str:
|
|
56
|
+
"""Validate name is not empty."""
|
|
57
|
+
if not v or not v.strip():
|
|
58
|
+
raise ValueError("name cannot be empty")
|
|
59
|
+
return v.strip()
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict) -> "ExternalDependency":
|
|
63
|
+
"""Create from dictionary (YAML parsed data).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
data: Dictionary with name, url, description keys
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ExternalDependency instance
|
|
70
|
+
"""
|
|
71
|
+
return cls(
|
|
72
|
+
name=data.get("name", ""),
|
|
73
|
+
url=data.get("url"),
|
|
74
|
+
description=data.get("description", ""),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Integration(BaseModel):
|
|
79
|
+
"""Integration module entity.
|
|
80
|
+
|
|
81
|
+
Integrations represent connections to external systems, defining
|
|
82
|
+
data flow direction and external dependencies.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
slug: URL-safe identifier (e.g., "pilot-data-collection")
|
|
86
|
+
module: Python module name (e.g., "pilot_data_collection")
|
|
87
|
+
name: Display name
|
|
88
|
+
description: Human-readable description
|
|
89
|
+
direction: Data flow direction
|
|
90
|
+
depends_on: List of external dependencies
|
|
91
|
+
manifest_path: Path to the integration.yaml file
|
|
92
|
+
name_normalized: Lowercase name for matching
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
slug: str
|
|
96
|
+
module: str
|
|
97
|
+
name: str
|
|
98
|
+
description: str = ""
|
|
99
|
+
direction: Direction = Direction.BIDIRECTIONAL
|
|
100
|
+
depends_on: list[ExternalDependency] = Field(default_factory=list)
|
|
101
|
+
manifest_path: str = ""
|
|
102
|
+
name_normalized: str = ""
|
|
103
|
+
|
|
104
|
+
@field_validator("slug", mode="before")
|
|
105
|
+
@classmethod
|
|
106
|
+
def validate_slug(cls, v: str) -> str:
|
|
107
|
+
"""Validate slug is not empty."""
|
|
108
|
+
if not v or not v.strip():
|
|
109
|
+
raise ValueError("slug cannot be empty")
|
|
110
|
+
return v.strip()
|
|
111
|
+
|
|
112
|
+
@field_validator("module", mode="before")
|
|
113
|
+
@classmethod
|
|
114
|
+
def validate_module(cls, v: str) -> str:
|
|
115
|
+
"""Validate module is not empty."""
|
|
116
|
+
if not v or not v.strip():
|
|
117
|
+
raise ValueError("module cannot be empty")
|
|
118
|
+
return v.strip()
|
|
119
|
+
|
|
120
|
+
@field_validator("name", mode="before")
|
|
121
|
+
@classmethod
|
|
122
|
+
def validate_name(cls, v: str) -> str:
|
|
123
|
+
"""Validate name is not empty."""
|
|
124
|
+
if not v or not v.strip():
|
|
125
|
+
raise ValueError("name cannot be empty")
|
|
126
|
+
return v.strip()
|
|
127
|
+
|
|
128
|
+
@field_validator("name_normalized", mode="before")
|
|
129
|
+
@classmethod
|
|
130
|
+
def compute_name_normalized(cls, v: str, info) -> str:
|
|
131
|
+
"""Compute normalized name from name if not provided."""
|
|
132
|
+
if v:
|
|
133
|
+
return v
|
|
134
|
+
name = info.data.get("name", "")
|
|
135
|
+
return normalize_name(name) if name else ""
|
|
136
|
+
|
|
137
|
+
def model_post_init(self, __context) -> None:
|
|
138
|
+
"""Ensure normalized fields are computed after init."""
|
|
139
|
+
if not self.name_normalized and self.name:
|
|
140
|
+
object.__setattr__(self, "name_normalized", normalize_name(self.name))
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_manifest(
|
|
144
|
+
cls,
|
|
145
|
+
module_name: str,
|
|
146
|
+
manifest: dict,
|
|
147
|
+
manifest_path: str,
|
|
148
|
+
) -> "Integration":
|
|
149
|
+
"""Create an Integration from a parsed YAML manifest.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
module_name: Module directory name
|
|
153
|
+
manifest: Parsed YAML content
|
|
154
|
+
manifest_path: Path to the manifest file
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Integration instance
|
|
158
|
+
"""
|
|
159
|
+
slug = manifest.get("slug", module_name.replace("_", "-"))
|
|
160
|
+
name = manifest.get("name", slug.replace("-", " ").title())
|
|
161
|
+
direction = Direction.from_string(manifest.get("direction", "bidirectional"))
|
|
162
|
+
|
|
163
|
+
# Parse depends_on list
|
|
164
|
+
depends_on_raw = manifest.get("depends_on", [])
|
|
165
|
+
depends_on = [
|
|
166
|
+
(
|
|
167
|
+
ExternalDependency.from_dict(dep)
|
|
168
|
+
if isinstance(dep, dict)
|
|
169
|
+
else ExternalDependency(name=str(dep))
|
|
170
|
+
)
|
|
171
|
+
for dep in depends_on_raw
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
return cls(
|
|
175
|
+
slug=slug,
|
|
176
|
+
module=module_name,
|
|
177
|
+
name=name,
|
|
178
|
+
description=manifest.get("description", "").strip(),
|
|
179
|
+
direction=direction,
|
|
180
|
+
depends_on=depends_on,
|
|
181
|
+
manifest_path=manifest_path,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def matches_direction(self, direction: Direction | str) -> bool:
|
|
185
|
+
"""Check if this integration matches the given direction.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
direction: Direction enum or string to match
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if integration matches the direction
|
|
192
|
+
"""
|
|
193
|
+
if isinstance(direction, str):
|
|
194
|
+
direction = Direction.from_string(direction)
|
|
195
|
+
return self.direction == direction
|
|
196
|
+
|
|
197
|
+
def matches_name(self, name: str) -> bool:
|
|
198
|
+
"""Check if this integration matches the given name (case-insensitive).
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
name: Name to match against
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
True if normalized names match
|
|
205
|
+
"""
|
|
206
|
+
return self.name_normalized == normalize_name(name)
|
|
207
|
+
|
|
208
|
+
def has_dependency(self, dep_name: str) -> bool:
|
|
209
|
+
"""Check if this integration has a specific dependency.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
dep_name: Dependency name to check (case-insensitive)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if dependency exists
|
|
216
|
+
"""
|
|
217
|
+
dep_normalized = normalize_name(dep_name)
|
|
218
|
+
return any(
|
|
219
|
+
normalize_name(dep.name) == dep_normalized for dep in self.depends_on
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def direction_label(self) -> str:
|
|
224
|
+
"""Get human-readable direction label."""
|
|
225
|
+
return self.direction.label
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def module_path(self) -> str:
|
|
229
|
+
"""Get full module path for display."""
|
|
230
|
+
return f"integrations.{self.module}"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Journey domain model.
|
|
2
|
+
|
|
3
|
+
Represents a user journey in the HCD documentation system.
|
|
4
|
+
Journeys are defined via RST directives and track a persona's path
|
|
5
|
+
through the system to achieve a goal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from ...utils import normalize_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StepType(str, Enum):
|
|
16
|
+
"""Type of journey step."""
|
|
17
|
+
|
|
18
|
+
STORY = "story"
|
|
19
|
+
EPIC = "epic"
|
|
20
|
+
PHASE = "phase"
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_string(cls, value: str) -> "StepType":
|
|
24
|
+
"""Convert string to StepType."""
|
|
25
|
+
try:
|
|
26
|
+
return cls(value.lower())
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise ValueError(f"Invalid step type: {value}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JourneyStep(BaseModel):
|
|
32
|
+
"""A step within a journey.
|
|
33
|
+
|
|
34
|
+
Steps can be stories (feature references), epics (epic references),
|
|
35
|
+
or phases (grouping labels for subsequent steps).
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
step_type: The type of step (story, epic, phase)
|
|
39
|
+
ref: Reference identifier (story title, epic slug, or phase title)
|
|
40
|
+
description: Optional description (primarily for phases)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
step_type: StepType
|
|
44
|
+
ref: str
|
|
45
|
+
description: str = ""
|
|
46
|
+
|
|
47
|
+
@field_validator("ref", mode="before")
|
|
48
|
+
@classmethod
|
|
49
|
+
def validate_ref(cls, v: str) -> str:
|
|
50
|
+
"""Validate ref is not empty."""
|
|
51
|
+
if not v or not v.strip():
|
|
52
|
+
raise ValueError("ref cannot be empty")
|
|
53
|
+
return v.strip()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def story(cls, title: str) -> "JourneyStep":
|
|
57
|
+
"""Create a story step.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
title: Story feature title
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
JourneyStep with type STORY
|
|
64
|
+
"""
|
|
65
|
+
return cls(step_type=StepType.STORY, ref=title)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def epic(cls, slug: str) -> "JourneyStep":
|
|
69
|
+
"""Create an epic step.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
slug: Epic slug
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
JourneyStep with type EPIC
|
|
76
|
+
"""
|
|
77
|
+
return cls(step_type=StepType.EPIC, ref=slug)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def phase(cls, title: str, description: str = "") -> "JourneyStep":
|
|
81
|
+
"""Create a phase step.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
title: Phase title
|
|
85
|
+
description: Optional phase description
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
JourneyStep with type PHASE
|
|
89
|
+
"""
|
|
90
|
+
return cls(step_type=StepType.PHASE, ref=title, description=description)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_story(self) -> bool:
|
|
94
|
+
"""Check if this is a story step."""
|
|
95
|
+
return self.step_type == StepType.STORY
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_epic(self) -> bool:
|
|
99
|
+
"""Check if this is an epic step."""
|
|
100
|
+
return self.step_type == StepType.EPIC
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_phase(self) -> bool:
|
|
104
|
+
"""Check if this is a phase step."""
|
|
105
|
+
return self.step_type == StepType.PHASE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Journey(BaseModel):
|
|
109
|
+
"""User journey entity.
|
|
110
|
+
|
|
111
|
+
A journey represents a persona's path through the system to achieve
|
|
112
|
+
a goal. It captures the user's motivation, the value delivered, and
|
|
113
|
+
the sequence of steps they follow.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
slug: URL-safe identifier (e.g., "build-vocabulary")
|
|
117
|
+
persona: The persona undertaking this journey
|
|
118
|
+
persona_normalized: Lowercase persona for matching
|
|
119
|
+
intent: What the persona wants (their motivation)
|
|
120
|
+
outcome: What success looks like (business value)
|
|
121
|
+
goal: Activity description (what they do)
|
|
122
|
+
depends_on: Journey slugs that must be completed first
|
|
123
|
+
steps: Sequence of journey steps
|
|
124
|
+
preconditions: Conditions that must be true before starting
|
|
125
|
+
postconditions: Conditions that will be true after completion
|
|
126
|
+
docname: RST document name (for incremental builds)
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
slug: str
|
|
130
|
+
persona: str = ""
|
|
131
|
+
persona_normalized: str = ""
|
|
132
|
+
intent: str = ""
|
|
133
|
+
outcome: str = ""
|
|
134
|
+
goal: str = ""
|
|
135
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
136
|
+
steps: list[JourneyStep] = Field(default_factory=list)
|
|
137
|
+
preconditions: list[str] = Field(default_factory=list)
|
|
138
|
+
postconditions: list[str] = Field(default_factory=list)
|
|
139
|
+
docname: str = ""
|
|
140
|
+
|
|
141
|
+
@field_validator("slug", mode="before")
|
|
142
|
+
@classmethod
|
|
143
|
+
def validate_slug(cls, v: str) -> str:
|
|
144
|
+
"""Validate slug is not empty."""
|
|
145
|
+
if not v or not v.strip():
|
|
146
|
+
raise ValueError("slug cannot be empty")
|
|
147
|
+
return v.strip()
|
|
148
|
+
|
|
149
|
+
@field_validator("persona_normalized", mode="before")
|
|
150
|
+
@classmethod
|
|
151
|
+
def compute_persona_normalized(cls, v: str, info) -> str:
|
|
152
|
+
"""Compute normalized persona from persona if not provided."""
|
|
153
|
+
if v:
|
|
154
|
+
return v
|
|
155
|
+
persona = info.data.get("persona", "")
|
|
156
|
+
return normalize_name(persona) if persona else ""
|
|
157
|
+
|
|
158
|
+
def model_post_init(self, __context) -> None:
|
|
159
|
+
"""Ensure normalized fields are computed after init."""
|
|
160
|
+
if not self.persona_normalized and self.persona:
|
|
161
|
+
object.__setattr__(self, "persona_normalized", normalize_name(self.persona))
|
|
162
|
+
|
|
163
|
+
def matches_persona(self, persona_name: str) -> bool:
|
|
164
|
+
"""Check if this journey matches the given persona (case-insensitive).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
persona_name: Persona name to match against
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if normalized names match
|
|
171
|
+
"""
|
|
172
|
+
return self.persona_normalized == normalize_name(persona_name)
|
|
173
|
+
|
|
174
|
+
def has_dependency(self, journey_slug: str) -> bool:
|
|
175
|
+
"""Check if this journey depends on another journey.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
journey_slug: Slug of potential dependency
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True if this journey depends on the given journey
|
|
182
|
+
"""
|
|
183
|
+
return journey_slug in self.depends_on
|
|
184
|
+
|
|
185
|
+
def add_step(self, step: JourneyStep) -> None:
|
|
186
|
+
"""Add a step to this journey.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
step: JourneyStep to add
|
|
190
|
+
"""
|
|
191
|
+
self.steps.append(step)
|
|
192
|
+
|
|
193
|
+
def get_story_refs(self) -> list[str]:
|
|
194
|
+
"""Get all story references from steps.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of story titles referenced in steps
|
|
198
|
+
"""
|
|
199
|
+
return [step.ref for step in self.steps if step.is_story]
|
|
200
|
+
|
|
201
|
+
def get_epic_refs(self) -> list[str]:
|
|
202
|
+
"""Get all epic references from steps.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of epic slugs referenced in steps
|
|
206
|
+
"""
|
|
207
|
+
return [step.ref for step in self.steps if step.is_epic]
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def display_title(self) -> str:
|
|
211
|
+
"""Get formatted title for display."""
|
|
212
|
+
return self.slug.replace("-", " ").title()
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def has_steps(self) -> bool:
|
|
216
|
+
"""Check if journey has any steps."""
|
|
217
|
+
return len(self.steps) > 0
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def step_count(self) -> int:
|
|
221
|
+
"""Get number of steps."""
|
|
222
|
+
return len(self.steps)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Persona domain model.
|
|
2
|
+
|
|
3
|
+
Represents a persona derived from story data in the HCD documentation system.
|
|
4
|
+
Personas are not defined directly but are extracted from user stories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, computed_field, field_validator
|
|
8
|
+
|
|
9
|
+
from ...utils import normalize_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Persona(BaseModel):
|
|
13
|
+
"""Persona entity.
|
|
14
|
+
|
|
15
|
+
A persona represents a type of user who interacts with the system.
|
|
16
|
+
Personas are derived from user stories - they are the "As a..." in
|
|
17
|
+
"As a [persona], I want to...".
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
name: Display name of the persona (e.g., "Knowledge Curator")
|
|
21
|
+
app_slugs: List of app slugs this persona uses
|
|
22
|
+
epic_slugs: List of epic slugs containing stories for this persona
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
app_slugs: list[str] = Field(default_factory=list)
|
|
27
|
+
epic_slugs: list[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@field_validator("name", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_name(cls, v: str) -> str:
|
|
32
|
+
"""Validate name is not empty."""
|
|
33
|
+
if not v or not v.strip():
|
|
34
|
+
raise ValueError("name cannot be empty")
|
|
35
|
+
return v.strip()
|
|
36
|
+
|
|
37
|
+
@computed_field
|
|
38
|
+
@property
|
|
39
|
+
def normalized_name(self) -> str:
|
|
40
|
+
"""Get normalized name for matching."""
|
|
41
|
+
return normalize_name(self.name)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def display_name(self) -> str:
|
|
45
|
+
"""Get formatted name for display (same as name)."""
|
|
46
|
+
return self.name
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def app_count(self) -> int:
|
|
50
|
+
"""Get number of apps this persona uses."""
|
|
51
|
+
return len(self.app_slugs)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def epic_count(self) -> int:
|
|
55
|
+
"""Get number of epics this persona participates in."""
|
|
56
|
+
return len(self.epic_slugs)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def has_apps(self) -> bool:
|
|
60
|
+
"""Check if persona uses any apps."""
|
|
61
|
+
return len(self.app_slugs) > 0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_epics(self) -> bool:
|
|
65
|
+
"""Check if persona participates in any epics."""
|
|
66
|
+
return len(self.epic_slugs) > 0
|
|
67
|
+
|
|
68
|
+
def uses_app(self, app_slug: str) -> bool:
|
|
69
|
+
"""Check if persona uses a specific app.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
app_slug: App slug to check
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if persona uses this app
|
|
76
|
+
"""
|
|
77
|
+
return app_slug in self.app_slugs
|
|
78
|
+
|
|
79
|
+
def participates_in_epic(self, epic_slug: str) -> bool:
|
|
80
|
+
"""Check if persona participates in a specific epic.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
epic_slug: Epic slug to check
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if persona has stories in this epic
|
|
87
|
+
"""
|
|
88
|
+
return epic_slug in self.epic_slugs
|
|
89
|
+
|
|
90
|
+
def add_app(self, app_slug: str) -> None:
|
|
91
|
+
"""Add an app to this persona's app list.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
app_slug: App slug to add (duplicates ignored)
|
|
95
|
+
"""
|
|
96
|
+
if app_slug not in self.app_slugs:
|
|
97
|
+
self.app_slugs.append(app_slug)
|
|
98
|
+
|
|
99
|
+
def add_epic(self, epic_slug: str) -> None:
|
|
100
|
+
"""Add an epic to this persona's epic list.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
epic_slug: Epic slug to add (duplicates ignored)
|
|
104
|
+
"""
|
|
105
|
+
if epic_slug not in self.epic_slugs:
|
|
106
|
+
self.epic_slugs.append(epic_slug)
|