devforgeai 1.0.5 → 1.0.6

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 (131) hide show
  1. package/CLAUDE.md +120 -0
  2. package/package.json +9 -1
  3. package/src/CLAUDE.md +699 -0
  4. package/src/claude/scripts/README.md +396 -0
  5. package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
  6. package/src/claude/scripts/check-hooks-fast.sh +70 -0
  7. package/src/claude/scripts/devforgeai-validate +6 -0
  8. package/src/claude/scripts/devforgeai_cli/README.md +531 -0
  9. package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
  10. package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
  11. package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
  12. package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
  13. package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
  14. package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
  15. package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
  16. package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
  17. package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
  18. package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
  19. package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
  20. package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
  21. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
  22. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
  23. package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
  24. package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
  25. package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
  26. package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
  27. package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
  28. package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
  29. package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
  30. package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
  31. package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
  32. package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
  33. package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
  34. package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
  35. package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
  36. package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
  37. package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
  38. package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
  39. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
  40. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
  41. package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
  42. package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
  43. package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
  44. package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
  45. package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
  46. package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
  47. package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
  48. package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
  49. package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
  50. package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
  51. package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
  52. package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
  53. package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
  54. package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
  55. package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
  56. package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
  57. package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
  58. package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
  59. package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
  60. package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
  61. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
  62. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
  63. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
  64. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
  65. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
  66. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
  67. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
  68. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
  69. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
  70. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
  71. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
  72. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
  73. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
  74. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
  75. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
  76. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
  77. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
  78. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
  79. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
  80. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
  81. package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
  82. package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
  83. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
  84. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
  85. package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
  86. package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
  87. package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
  88. package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
  89. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
  90. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
  91. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
  92. package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
  93. package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
  94. package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
  95. package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
  96. package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
  97. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
  98. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
  99. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
  100. package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
  101. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
  102. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
  103. package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
  104. package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
  105. package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
  106. package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
  107. package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
  108. package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
  109. package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
  110. package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
  111. package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
  112. package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
  113. package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
  114. package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
  115. package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
  116. package/src/claude/scripts/install_hooks.sh +186 -0
  117. package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
  118. package/src/claude/scripts/migrate-ac-headers.sh +122 -0
  119. package/src/claude/scripts/plan_file_kb.sh +704 -0
  120. package/src/claude/scripts/requirements.txt +8 -0
  121. package/src/claude/scripts/session_catalog.sh +543 -0
  122. package/src/claude/scripts/setup.py +55 -0
  123. package/src/claude/scripts/start-devforgeai.sh +16 -0
  124. package/src/claude/scripts/statusline.sh +27 -0
  125. package/src/claude/scripts/validate_deferrals.py +344 -0
  126. package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
  127. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
  128. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
  129. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
  130. package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
  131. package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
@@ -0,0 +1,549 @@
1
+ """
2
+ Feedback Template Engine (STORY-010)
3
+
4
+ Implements template selection, field mapping, template rendering, and persistence.
5
+ - Template selection with priority chain (custom > operation+status > operation > fallback)
6
+ - Field mapping from conversation responses to template sections
7
+ - Template rendering with YAML frontmatter and markdown content
8
+ - File persistence with unique filenames (timestamp + UUID)
9
+ """
10
+
11
+ import re
12
+ import yaml
13
+ from pathlib import Path
14
+ from datetime import datetime
15
+ from datetime import timezone as dt_timezone
16
+ from uuid import uuid4
17
+ from typing import Dict, Any, Optional
18
+
19
+
20
+ # =============================================================================
21
+ # CONSTANTS
22
+ # =============================================================================
23
+
24
+ VALID_OPERATION_TYPES = {"command", "skill", "subagent", "workflow"}
25
+ VALID_STATUS_VALUES = {"passed", "failed", "partial"}
26
+ DEFAULT_RESPONSE_MESSAGE = "No response provided"
27
+ DEFAULT_TEMPLATE_SECTION_HEADER = "## Additional Feedback"
28
+ TEMPLATE_FILENAME_PATTERN = r"^(\w+-)*\w+\.md$"
29
+
30
+
31
+ # =============================================================================
32
+ # TEMPLATE SELECTION
33
+ # =============================================================================
34
+
35
+ def select_template(
36
+ operation_type: str,
37
+ status: str,
38
+ user_config: Optional[Dict[str, Any]] = None,
39
+ template_dir: Optional[str] = None
40
+ ) -> str:
41
+ """
42
+ Select template with priority chain.
43
+
44
+ Priority:
45
+ 1. Custom template (from user_config)
46
+ 2. Operation+Status specific (e.g., command-passed)
47
+ 3. Operation generic (e.g., command-generic)
48
+ 4. Fallback generic
49
+
50
+ Args:
51
+ operation_type: Operation type (command, skill, subagent, workflow)
52
+ status: Status (passed, failed, partial)
53
+ user_config: User configuration dict with custom template paths
54
+ template_dir: Directory containing template files
55
+
56
+ Returns:
57
+ Template content as string
58
+
59
+ Raises:
60
+ ValueError: If operation_type or status invalid
61
+ FileNotFoundError: If no templates found and no fallback available
62
+ """
63
+ # Validate inputs
64
+ if not operation_type or not isinstance(operation_type, str):
65
+ raise ValueError("operation_type must be non-empty string")
66
+ if not status or not isinstance(status, str):
67
+ raise ValueError("status must be non-empty string")
68
+
69
+ # Validate status value
70
+ if status.lower() not in VALID_STATUS_VALUES:
71
+ raise ValueError(
72
+ f"status must be one of {VALID_STATUS_VALUES}, got {status}"
73
+ )
74
+
75
+ if template_dir is None:
76
+ template_dir = "devforgeai/templates"
77
+
78
+ template_path = Path(template_dir)
79
+
80
+ # Priority 1: Check custom templates in user_config
81
+ if user_config and "templates" in user_config and "custom" in user_config["templates"]:
82
+ custom_templates = user_config["templates"]["custom"]
83
+ if operation_type.lower() in custom_templates:
84
+ custom_path = custom_templates[operation_type.lower()]
85
+ expanded_path = Path(custom_path).expanduser()
86
+ if expanded_path.exists():
87
+ return expanded_path.read_text(encoding="utf-8")
88
+
89
+ # Priority 2: Operation + Status specific (e.g., command-passed.md)
90
+ operation_status_file = template_path / f"{operation_type.lower()}-{status.lower()}.md"
91
+ if operation_status_file.exists():
92
+ template_content = operation_status_file.read_text(encoding="utf-8")
93
+ return _enhance_template_with_field_mappings(template_content)
94
+
95
+ # Priority 3: Operation generic (e.g., command-generic.md)
96
+ operation_generic_file = template_path / f"{operation_type.lower()}-generic.md"
97
+ if operation_generic_file.exists():
98
+ template_content = operation_generic_file.read_text(encoding="utf-8")
99
+ return _enhance_template_with_field_mappings(template_content)
100
+
101
+ # Priority 4: Fallback generic.md
102
+ generic_file = template_path / "generic.md"
103
+ if generic_file.exists():
104
+ template_content = generic_file.read_text(encoding="utf-8")
105
+ return _enhance_template_with_field_mappings(template_content)
106
+
107
+ # No templates found - check if this is a testing scenario
108
+ if not template_path.exists():
109
+ raise FileNotFoundError(
110
+ f"Template directory not found: {template_dir}"
111
+ )
112
+
113
+ # Directory exists but no matching templates found
114
+ all_templates = list(template_path.glob("*.md"))
115
+ if not all_templates:
116
+ # Empty template directory - raise error
117
+ raise FileNotFoundError(
118
+ f"No templates found in {template_dir}"
119
+ )
120
+
121
+ # Template directory has files but none match our criteria
122
+ # This shouldn't happen in normal flow, but might in tests
123
+ raise FileNotFoundError(
124
+ f"No template found for operation_type={operation_type}, status={status}"
125
+ )
126
+
127
+
128
+ def _validate_operation_type(operation_type: str) -> None:
129
+ """Validate operation type format."""
130
+ if not operation_type or not isinstance(operation_type, str):
131
+ raise ValueError("operation_type must be non-empty string")
132
+ if operation_type.lower() not in VALID_OPERATION_TYPES:
133
+ raise ValueError(
134
+ f"operation_type must be one of {VALID_OPERATION_TYPES}, got {operation_type}"
135
+ )
136
+
137
+
138
+ def _validate_status(status: str) -> None:
139
+ """Validate status format."""
140
+ if not status or not isinstance(status, str):
141
+ raise ValueError("status must be non-empty string")
142
+ if status.lower() not in VALID_STATUS_VALUES:
143
+ raise ValueError(
144
+ f"status must be one of {VALID_STATUS_VALUES}, got {status}"
145
+ )
146
+
147
+
148
+ def _extract_field_mappings_from_markdown(markdown_text: str) -> Dict[str, Any]:
149
+ """Extract field mappings from markdown section."""
150
+ field_mappings = {}
151
+
152
+ lines = markdown_text.split("\n")
153
+ current_field = None
154
+
155
+ for line in lines:
156
+ line = line.rstrip()
157
+
158
+ # Skip empty lines and markdown headers
159
+ if not line or line.startswith("#"):
160
+ continue
161
+
162
+ # Lines with colons at root level are field mappings
163
+ if ":" in line and not line.startswith(" "):
164
+ # Parse field: value format
165
+ parts = line.split(":", 1)
166
+ field_name = parts[0].strip()
167
+ current_field = field_name
168
+ field_mappings[field_name] = {}
169
+ elif line.startswith(" ") and current_field:
170
+ # Nested properties (question-id, section)
171
+ nested_line = line.strip()
172
+ if ":" in nested_line:
173
+ key, value = nested_line.split(":", 1)
174
+ key = key.strip()
175
+ value = value.strip().strip('"\'')
176
+
177
+ # Normalize key names
178
+ if key == "question-id":
179
+ key = "question_id"
180
+ elif key == "section":
181
+ key = "section"
182
+
183
+ field_mappings[current_field][key] = value
184
+
185
+ return field_mappings
186
+
187
+
188
+ def _enhance_template_with_field_mappings(template_content: str) -> str:
189
+ """
190
+ Enhance template by moving field_mappings from markdown to YAML frontmatter.
191
+
192
+ This enables templates to be read with just YAML extraction while
193
+ maintaining readable markdown documentation.
194
+ """
195
+ parts = template_content.split("---")
196
+ if len(parts) < 3:
197
+ # Not a valid template format, return as-is
198
+ return template_content
199
+
200
+ yaml_section = parts[1].strip()
201
+ markdown_section = "---".join(parts[2:])
202
+
203
+ # Extract field_mappings from markdown
204
+ field_mappings = _extract_field_mappings_from_markdown(markdown_section)
205
+
206
+ if not field_mappings:
207
+ # No field_mappings found, return template as-is
208
+ return template_content
209
+
210
+ # Add field_mappings to YAML section
211
+ # Parse existing YAML
212
+ try:
213
+ yaml_dict = yaml.safe_load(yaml_section) or {}
214
+ except yaml.YAMLError:
215
+ # If YAML parsing fails, return as-is
216
+ return template_content
217
+
218
+ # Add field_mappings to YAML dict
219
+ yaml_dict["field_mappings"] = field_mappings
220
+
221
+ # Regenerate YAML section with field_mappings
222
+ new_yaml = yaml.dump(yaml_dict, default_flow_style=False, allow_unicode=True).strip()
223
+
224
+ # Reconstruct template
225
+ return f"---\n{new_yaml}\n---\n{markdown_section}"
226
+
227
+
228
+ # =============================================================================
229
+ # FIELD MAPPING
230
+ # =============================================================================
231
+
232
+ def map_fields(
233
+ template_or_dict: Any,
234
+ conversation_responses: Dict[str, Any]
235
+ ) -> Dict[str, Any]:
236
+ """
237
+ Map conversation responses to template sections.
238
+
239
+ Maps field_mappings from template to conversation responses:
240
+ - For each field mapping, looks up question_id in responses
241
+ - Uses response value or "No response provided" default
242
+ - Collects unmapped responses in "## Additional Feedback" section
243
+
244
+ Args:
245
+ template_or_dict: Parsed template dict with field_mappings, or full template string
246
+ conversation_responses: Dict of question_id -> response value
247
+
248
+ Returns:
249
+ Dict of {section_header: response_text}
250
+
251
+ Raises:
252
+ ValueError: If template format invalid
253
+ """
254
+ # Support both string (full template) and dict (parsed template)
255
+ template_dict = template_or_dict
256
+
257
+ if isinstance(template_dict, str):
258
+ # Parse the full template string
259
+ template_dict = _parse_template_content(template_dict)
260
+
261
+ if not isinstance(template_dict, dict):
262
+ return {}
263
+
264
+ # Support both raw YAML dicts and parsed templates with field_mappings
265
+ field_mappings = template_dict.get("field_mappings")
266
+
267
+ if not field_mappings:
268
+ # If no field_mappings found, return empty dict
269
+ # The template may not have field_mappings defined (tests may pass raw YAML)
270
+ return {}
271
+
272
+ # Validate template structure
273
+ for field_name, mapping in field_mappings.items():
274
+ if not isinstance(mapping, dict):
275
+ raise ValueError(f"Field mapping for {field_name} must be dict")
276
+ if "question_id" not in mapping:
277
+ raise ValueError(f"Field mapping for {field_name} missing 'question_id'")
278
+ if "section" not in mapping:
279
+ raise ValueError(f"Field mapping for {field_name} missing 'section'")
280
+
281
+ question_id = mapping["question_id"]
282
+ section_header = mapping["section"]
283
+
284
+ if not question_id or not isinstance(question_id, str):
285
+ raise ValueError(f"question_id for {field_name} must be non-empty string")
286
+ if not section_header.startswith("##"):
287
+ raise ValueError(
288
+ f"section header for {field_name} must start with ##, got {section_header}"
289
+ )
290
+
291
+ # Map fields from responses
292
+ mapped_sections = {}
293
+ mapped_question_ids = set()
294
+
295
+ for field_name, mapping in field_mappings.items():
296
+ question_id = mapping["question_id"]
297
+ section_header = mapping["section"]
298
+
299
+ # Get response or use default
300
+ if question_id in conversation_responses:
301
+ response = conversation_responses[question_id]
302
+ mapped_question_ids.add(question_id)
303
+
304
+ # Handle None or empty string appropriately
305
+ if response is None:
306
+ response = DEFAULT_RESPONSE_MESSAGE
307
+ # Empty string is preserved as-is, not replaced with default
308
+ else:
309
+ response = DEFAULT_RESPONSE_MESSAGE
310
+
311
+ mapped_sections[section_header] = response
312
+
313
+ # Collect unmapped responses
314
+ unmapped = {}
315
+ for question_id, response in conversation_responses.items():
316
+ if question_id not in mapped_question_ids:
317
+ # Ignore system fields like sentiment_rating
318
+ if not question_id.startswith("sentiment_") and question_id != "additional_feedback":
319
+ unmapped[question_id] = response
320
+
321
+ # Add unmapped responses to Additional Feedback section
322
+ if unmapped:
323
+ additional_feedback_lines = []
324
+ for question_id, response in unmapped.items():
325
+ additional_feedback_lines.append(f"- **{question_id}**: {response}")
326
+
327
+ mapped_sections[DEFAULT_TEMPLATE_SECTION_HEADER] = "\n".join(additional_feedback_lines)
328
+
329
+ return mapped_sections
330
+
331
+
332
+ # =============================================================================
333
+ # TEMPLATE RENDERING
334
+ # =============================================================================
335
+
336
+ def render_template(
337
+ template_content: str,
338
+ responses: Dict[str, Any],
339
+ metadata: Dict[str, Any]
340
+ ) -> str:
341
+ """
342
+ Render template with responses and metadata.
343
+
344
+ Generates YAML frontmatter from metadata and assembles markdown sections.
345
+ Auto-populates Context, User Sentiment, and Actionable Insights sections.
346
+
347
+ Args:
348
+ template_content: Template string (YAML frontmatter + markdown)
349
+ responses: Conversation responses dict
350
+ metadata: Operation metadata (operation, type, status, timestamp, etc.)
351
+
352
+ Returns:
353
+ Rendered template string (YAML frontmatter + markdown)
354
+ """
355
+ # Parse template
356
+ template_dict = _parse_template_content(template_content)
357
+
358
+ # Map fields
359
+ mapped_sections = map_fields(template_dict, responses)
360
+
361
+ # Generate frontmatter (include both operation metadata and template metadata)
362
+ frontmatter_dict = dict(metadata) # Copy operation metadata
363
+ if "version" in template_dict:
364
+ frontmatter_dict["template_version"] = template_dict["version"]
365
+ if "template-id" in template_dict:
366
+ frontmatter_dict["template_id"] = template_dict["template-id"]
367
+
368
+ frontmatter = _generate_frontmatter(frontmatter_dict)
369
+
370
+ # Auto-generate Context section
371
+ context_section = _generate_context_section(metadata)
372
+ mapped_sections["## Context"] = context_section
373
+
374
+ # Auto-calculate User Sentiment
375
+ sentiment = _calculate_sentiment(responses)
376
+ mapped_sections["## User Sentiment"] = sentiment
377
+
378
+ # Extract actionable insights
379
+ suggestions_text = ""
380
+ for response in responses.values():
381
+ if isinstance(response, str) and any(word in response.lower() for word in ["should", "could", "needs"]):
382
+ suggestions_text = response
383
+ break
384
+
385
+ if suggestions_text:
386
+ insights = _extract_insights(suggestions_text)
387
+ if insights:
388
+ mapped_sections["## Actionable Insights"] = "\n".join(f"- {insight}" for insight in insights)
389
+ else:
390
+ mapped_sections["## Actionable Insights"] = "No specific actionable insights extracted."
391
+
392
+ # Assemble sections
393
+ sections_content = _assemble_sections(mapped_sections)
394
+
395
+ # Combine frontmatter and content
396
+ rendered = f"---\n{frontmatter}---\n\n{sections_content}"
397
+
398
+ return rendered
399
+
400
+
401
+ def _parse_template_content(template_content: str) -> Dict[str, Any]:
402
+ """Parse template content (YAML frontmatter + field mappings in markdown)."""
403
+ # Split on YAML delimiters
404
+ parts = template_content.split("---")
405
+ if len(parts) < 3:
406
+ raise ValueError("Template must contain YAML frontmatter (delimited by ---)")
407
+
408
+ # Parse YAML frontmatter
409
+ yaml_content = parts[1].strip()
410
+ template_dict = yaml.safe_load(yaml_content) or {}
411
+
412
+ # Parse field mappings from markdown section
413
+ markdown_section = "---".join(parts[2:]) if len(parts) > 2 else ""
414
+ field_mappings = _extract_field_mappings_from_markdown(markdown_section)
415
+
416
+ if field_mappings:
417
+ template_dict["field_mappings"] = field_mappings
418
+
419
+ return template_dict
420
+
421
+
422
+ def _generate_frontmatter(metadata: Dict[str, Any]) -> str:
423
+ """Generate YAML frontmatter from metadata dict."""
424
+ # Use yaml.dump to properly escape special characters
425
+ frontmatter_yaml = yaml.dump(metadata, default_flow_style=False, allow_unicode=True)
426
+ return frontmatter_yaml
427
+
428
+
429
+ def _generate_context_section(metadata: Dict[str, Any]) -> str:
430
+ """Auto-generate context section from metadata."""
431
+ context_lines = []
432
+
433
+ if "operation" in metadata:
434
+ context_lines.append(f"**Operation**: {metadata['operation']}")
435
+
436
+ if "type" in metadata:
437
+ context_lines.append(f"**Type**: {metadata['type']}")
438
+
439
+ if "status" in metadata:
440
+ context_lines.append(f"**Status**: {metadata['status']}")
441
+
442
+ if "timestamp" in metadata:
443
+ context_lines.append(f"**Timestamp**: {metadata['timestamp']}")
444
+
445
+ if "story_id" in metadata and metadata["story_id"]:
446
+ context_lines.append(f"**Story ID**: {metadata['story_id']}")
447
+
448
+ if "duration_seconds" in metadata:
449
+ context_lines.append(f"**Duration**: {metadata['duration_seconds']} seconds")
450
+
451
+ if "token_usage" in metadata:
452
+ context_lines.append(f"**Token Usage**: {metadata['token_usage']} tokens")
453
+
454
+ return "\n".join(context_lines) if context_lines else "No context available"
455
+
456
+
457
+ def _calculate_sentiment(responses: Dict[str, Any]) -> str:
458
+ """Calculate user sentiment from responses."""
459
+ # Look for sentiment_rating field
460
+ if "sentiment_rating" in responses:
461
+ rating = responses["sentiment_rating"]
462
+ if isinstance(rating, int):
463
+ if rating >= 4:
464
+ return f"Positive ({rating}/5)"
465
+ elif rating >= 3:
466
+ return f"Neutral ({rating}/5)"
467
+ else:
468
+ return f"Negative ({rating}/5)"
469
+
470
+ return "Neutral"
471
+
472
+
473
+ def _extract_insights(suggestions_text: str) -> list:
474
+ """Extract actionable insights from suggestions text."""
475
+ insights = []
476
+
477
+ # Look for sentences with action words
478
+ action_words = ["should", "could", "needs", "must", "recommend"]
479
+
480
+ for word in action_words:
481
+ pattern = rf"[^.!?]*\b{word}\b[^.!?]*[.!?]"
482
+ matches = re.findall(pattern, suggestions_text, re.IGNORECASE)
483
+ for match in matches:
484
+ insight = match.strip()
485
+ if insight and insight not in insights:
486
+ insights.append(insight)
487
+
488
+ return insights
489
+
490
+
491
+ def _assemble_sections(sections: Dict[str, Any]) -> str:
492
+ """Assemble markdown sections with headers."""
493
+ lines = []
494
+
495
+ for section_header, content in sections.items():
496
+ lines.append(f"{section_header}\n")
497
+
498
+ # Handle different content types
499
+ if isinstance(content, str):
500
+ lines.append(content)
501
+ elif isinstance(content, (int, float)):
502
+ lines.append(str(content))
503
+ elif isinstance(content, list):
504
+ lines.append("\n".join(content))
505
+ else:
506
+ lines.append(str(content))
507
+
508
+ lines.append("") # Blank line between sections
509
+
510
+ return "\n".join(lines)
511
+
512
+
513
+ # =============================================================================
514
+ # TEMPLATE PERSISTENCE
515
+ # =============================================================================
516
+
517
+ def save_rendered_template(
518
+ rendered_content: str,
519
+ operation_type: str,
520
+ output_dir: str = "devforgeai/feedback"
521
+ ) -> Path:
522
+ """
523
+ Save rendered template to file.
524
+
525
+ Generates unique filename: {timestamp}-{uuid}-retrospective.md
526
+ Creates output directory if needed.
527
+
528
+ Args:
529
+ rendered_content: Rendered template string
530
+ operation_type: Operation type (for directory organization)
531
+ output_dir: Output directory (default: devforgeai/feedback)
532
+
533
+ Returns:
534
+ Path to created file
535
+ """
536
+ # Create output directory with operation-type subdirectory
537
+ output_path = Path(output_dir) / operation_type
538
+ output_path.mkdir(parents=True, exist_ok=True)
539
+
540
+ # Generate unique filename (timestamp + UUID to prevent collisions)
541
+ timestamp = datetime.now(dt_timezone.utc).strftime("%Y%m%d-%H%M%S")
542
+ unique_id = str(uuid4())[:8] # Use first 8 chars of UUID
543
+ filename = f"{timestamp}-{unique_id}-retrospective.md"
544
+
545
+ # Write file
546
+ filepath = output_path / filename
547
+ filepath.write_text(rendered_content, encoding="utf-8")
548
+
549
+ return filepath