devforgeai 1.0.5 → 1.0.7
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.
- package/CLAUDE.md +120 -0
- package/bin/devforgeai.js +0 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/hooks/phase-completion-gate.sh +0 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
- 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
|