julee 0.1.5__py3-none-any.whl → 0.1.6__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 (105) hide show
  1. julee/docs/sphinx_hcd/__init__.py +146 -13
  2. julee/docs/sphinx_hcd/domain/__init__.py +5 -0
  3. julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
  4. julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
  5. julee/docs/sphinx_hcd/domain/models/app.py +151 -0
  6. julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
  7. julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
  8. julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
  9. julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
  10. julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
  11. julee/docs/sphinx_hcd/domain/models/story.py +128 -0
  12. julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
  13. julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
  14. julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
  15. julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
  16. julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
  17. julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
  18. julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
  19. julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
  20. julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
  21. julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
  22. julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
  23. julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
  24. julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
  25. julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
  26. julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
  27. julee/docs/sphinx_hcd/parsers/ast.py +150 -0
  28. julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
  29. julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
  30. julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
  31. julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
  32. julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
  33. julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
  34. julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
  35. julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
  36. julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
  37. julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
  38. julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
  39. julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
  40. julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
  41. julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
  42. julee/docs/sphinx_hcd/sphinx/context.py +163 -0
  43. julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
  44. julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
  45. julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
  46. julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
  47. julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
  48. julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
  49. julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
  50. julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
  51. julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
  52. julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
  53. julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
  54. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
  55. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
  56. julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
  57. julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
  58. julee/docs/sphinx_hcd/tests/__init__.py +9 -0
  59. julee/docs/sphinx_hcd/tests/conftest.py +6 -0
  60. julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
  61. julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
  62. julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
  63. julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
  64. julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
  65. julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
  66. julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
  67. julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
  68. julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
  69. julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
  70. julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
  71. julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
  72. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
  73. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
  74. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
  75. julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
  76. julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
  77. julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
  78. julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
  79. julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
  80. julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
  81. julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
  82. julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
  83. julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
  84. julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
  85. julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
  86. julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
  87. julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
  88. julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
  89. julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
  90. julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
  91. julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
  92. julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
  93. julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
  94. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/METADATA +2 -1
  95. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/RECORD +98 -13
  96. julee/docs/sphinx_hcd/accelerators.py +0 -1175
  97. julee/docs/sphinx_hcd/apps.py +0 -518
  98. julee/docs/sphinx_hcd/epics.py +0 -453
  99. julee/docs/sphinx_hcd/integrations.py +0 -310
  100. julee/docs/sphinx_hcd/journeys.py +0 -797
  101. julee/docs/sphinx_hcd/personas.py +0 -457
  102. julee/docs/sphinx_hcd/stories.py +0 -960
  103. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
  104. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
  105. {julee-0.1.5.dist-info → julee-0.1.6.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)