Skip to content

Commit 0291bab

Browse files
authored
Merge pull request #159 from PublicisSapient/develop
Develop
2 parents a34b610 + 2a317e9 commit 0291bab

File tree

11 files changed

+543
-335
lines changed

11 files changed

+543
-335
lines changed

ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationService.java

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ public KpiMaturity calculateKpiMaturityForProject(ProjectInputDTO projectInput)
162162
return calculateKpiMaturity(projectInput, kpiElementList);
163163
}
164164

165-
private List<KpiElement> processAllKpiRequests(List<KpiRequest> kpiRequests, ProjectDeliveryMethodology projectDeliveryMethodology) {
166-
if(projectDeliveryMethodology == ProjectDeliveryMethodology.KANBAN) {
165+
private List<KpiElement> processAllKpiRequests(List<KpiRequest> kpiRequests,
166+
ProjectDeliveryMethodology projectDeliveryMethodology) {
167+
if (projectDeliveryMethodology == ProjectDeliveryMethodology.KANBAN) {
167168
return this.knowHOWClient.getKpiIntegrationValuesKanban(kpiRequests);
168169
}
169170
return this.knowHOWClient.getKpiIntegrationValues(kpiRequests);
@@ -326,39 +327,45 @@ private List<KpiRequest> constructKpiRequests(ProjectInputDTO projectInput) {
326327
List<KpiRequest> kpiRequests = new ArrayList<>();
327328

328329
Map<String, List<KpiMaster>> kpisGroupedBySource = this.kpisEligibleForMaturityCalculation.stream()
329-
.filter(kpiMaster -> kpiMaster.getKanban() == (projectInput.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN))
330+
.filter(kpiMaster -> kpiMaster
331+
.getKanban() == (projectInput.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN))
330332
.collect(Collectors.groupingBy(KpiMaster::getKpiSource));
331333

332334
for (Map.Entry<String, List<KpiMaster>> entry : kpisGroupedBySource.entrySet()) {
333335
KpiGranularity kpiGranularity = KpiGranularity.getByKpiXAxisLabel(entry.getValue().get(0).getXAxisLabel());
334336
switch (kpiGranularity) {
335-
case MONTH, WEEK, DAY -> kpiRequests.add(KpiRequest.builder()
336-
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
337-
.selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()),
338-
CommonConstant.DATE, List.of(KPI_GRANULARITY_WEEKS)))
339-
.ids(new String[]{String.valueOf(
340-
this.kpiMaturityCalculationConfig.getCalculationConfig().getDataPoints().getCount())})
341-
.level(projectInput.hierarchyLevel())
342-
.label(projectInput.hierarchyLevelId()).build());
343-
case SPRINT, ITERATION, PI -> kpiRequests.add(KpiRequest.builder()
344-
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
345-
.selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT,
346-
projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList(),
347-
CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId())))
348-
.ids(projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList()
349-
.toArray(String[]::new))
350-
.level(projectInput.sprints().get(0).hierarchyLevel())
351-
.label(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT).build());
352-
case NONE -> {
353-
if(projectInput.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN) {
354-
kpiRequests.add(KpiRequest.builder()
355-
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
356-
.selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()),
357-
CommonConstant.DATE, List.of(KPI_GRANULARITY_WEEKS)))
358-
.ids(new String[]{String.valueOf(
359-
this.kpiMaturityCalculationConfig.getCalculationConfig().getDataPoints().getCount())})
360-
.level(projectInput.hierarchyLevel()).label(projectInput.hierarchyLevelId()).build());
361-
} else {
337+
case MONTH, WEEK, DAY -> kpiRequests.add(KpiRequest.builder()
338+
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
339+
.selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()),
340+
CommonConstant.DATE, List.of(KPI_GRANULARITY_WEEKS)))
341+
.ids(new String[] { String.valueOf(
342+
this.kpiMaturityCalculationConfig.getCalculationConfig().getDataPoints().getCount()) })
343+
.level(projectInput.hierarchyLevel()).label(projectInput.hierarchyLevelId()).build());
344+
case SPRINT, ITERATION, PI -> {
345+
if (CollectionUtils.isNotEmpty(projectInput.sprints())) {
346+
kpiRequests.add(KpiRequest.builder()
347+
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
348+
.selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT,
349+
projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList(),
350+
CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId())))
351+
.ids(projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList()
352+
.toArray(String[]::new))
353+
.level(projectInput.sprints().get(0).hierarchyLevel())
354+
.label(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT).build());
355+
}
356+
}
357+
case NONE -> {
358+
if (projectInput.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN) {
359+
kpiRequests.add(KpiRequest.builder()
360+
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
361+
.selectedMap(
362+
Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()),
363+
CommonConstant.DATE, List.of(KPI_GRANULARITY_WEEKS)))
364+
.ids(new String[] { String.valueOf(this.kpiMaturityCalculationConfig.getCalculationConfig()
365+
.getDataPoints().getCount()) })
366+
.level(projectInput.hierarchyLevel()).label(projectInput.hierarchyLevelId()).build());
367+
} else {
368+
if (CollectionUtils.isNotEmpty(projectInput.sprints())) {
362369
kpiRequests.add(KpiRequest.builder()
363370
.kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList()))
364371
.selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT,
@@ -371,6 +378,7 @@ private List<KpiRequest> constructKpiRequests(ProjectInputDTO projectInput) {
371378
}
372379
}
373380
}
381+
}
374382
}
375383
return kpiRequests;
376384
}
@@ -381,7 +389,8 @@ private List<KpiRequest> constructKpiRequests(ProjectInputDTO projectInput) {
381389
* <p>
382390
* This method performs a multi-step filtering process:
383391
* <ol>
384-
* <li>Fetch KPIs supporting maturity calculation for SCRUM and KANBAN methodology</li>
392+
* <li>Fetch KPIs supporting maturity calculation for SCRUM and KANBAN
393+
* methodology</li>
385394
* <li>Map KPI categories from database configuration</li>
386395
* <li>Override categories with values from KpiCategoryMapping if available</li>
387396
* <li>Filter KPIs to include only those with configured category weights</li>
@@ -400,8 +409,7 @@ private List<KpiMaster> loadKpisEligibleForMaturityCalculation() {
400409
}
401410
return KpiMaster.builder().kpiId(kpiMasterProjection.getKpiId())
402411
.kpiName(kpiMasterProjection.getKpiName()).kpiCategory(kpiCategory.toLowerCase())
403-
.kpiSource(kpiMasterProjection.getKpiSource())
404-
.kanban(kpiMasterProjection.isKanban())
412+
.kpiSource(kpiMasterProjection.getKpiSource()).kanban(kpiMasterProjection.isKanban())
405413
.xAxisLabel(kpiMasterProjection.getxAxisLabel()).build();
406414
}).toList();
407415

ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProjectBatchService.java

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,21 @@ private static List<ProjectInputDTO> constructProjectInputDTOList(Page<ProjectBa
196196
projectInputDTOBuilder.deliveryMethodology(ProjectDeliveryMethodology.KANBAN)
197197
.sprints(List.of());
198198
} else {
199-
projectInputDTOBuilder.deliveryMethodology(ProjectDeliveryMethodology.SCRUM)
200-
.sprints(projectObjectIdSprintsMap.get(projectBasicConfig.getId()).stream()
201-
.map(sprintDetails -> SprintInputDTO.builder()
202-
.hierarchyLevel(sprintHierarchyLevel.getLevel())
203-
.hierarchyLevelId(sprintHierarchyLevel.getHierarchyLevelId())
204-
.name(sprintDetails.getSprintName()).nodeId(sprintDetails.getSprintID())
205-
.build())
206-
.toList());
199+
projectInputDTOBuilder.deliveryMethodology(ProjectDeliveryMethodology.SCRUM);
200+
if (CollectionUtils.isEmpty(projectObjectIdSprintsMap.get(projectBasicConfig.getId()))) {
201+
log.info("Scrum project with node id {} and name {} did not have any sprint data",
202+
projectBasicConfig.getProjectNodeId(), projectBasicConfig.getProjectName());
203+
projectInputDTOBuilder.sprints(Collections.emptyList());
204+
} else {
205+
projectInputDTOBuilder
206+
.sprints(projectObjectIdSprintsMap.get(projectBasicConfig.getId()).stream()
207+
.map(sprintDetails -> SprintInputDTO.builder()
208+
.hierarchyLevel(sprintHierarchyLevel.getLevel())
209+
.hierarchyLevelId(sprintHierarchyLevel.getHierarchyLevelId())
210+
.name(sprintDetails.getSprintName())
211+
.nodeId(sprintDetails.getSprintID()).build())
212+
.toList());
213+
}
207214
}
208215
return projectInputDTOBuilder.build();
209216
}).toList();

ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParser.java

Lines changed: 118 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,24 @@ public class BatchRecommendationResponseParser {
6161
*
6262
* @param response
6363
* ChatGenerationResponseDTO from AI Gateway
64-
* @return Optional containing parsed Recommendation, or empty if parsing fails
64+
* @return parsed and validated Recommendation object
6565
* @throws IllegalArgumentException
6666
* if response is null
67+
* @throws IllegalStateException
68+
* if response content is empty, JSON extraction fails, or required
69+
* fields missing
70+
* @throws Exception
71+
* if JSON parsing fails or other processing errors occur
6772
*/
68-
public Optional<Recommendation> parseRecommendation(ChatGenerationResponseDTO response) {
73+
public Recommendation parseRecommendation(ChatGenerationResponseDTO response) throws Exception {
6974
if (response == null) {
7075
throw new IllegalArgumentException("AI Gateway response cannot be null");
7176
}
7277

7378
// Validate response content is not null or empty
7479
String aiResponse = response.content();
7580
if (aiResponse == null || aiResponse.trim().isEmpty()) {
76-
log.error("{} AI Gateway returned null or empty response content",
77-
JobConstants.LOG_PREFIX_RECOMMENDATION);
78-
return Optional.empty();
81+
throw new IllegalStateException("AI Gateway returned empty response content");
7982
}
8083

8184
return parseRecommendationContent(aiResponse);
@@ -86,45 +89,83 @@ public Optional<Recommendation> parseRecommendation(ChatGenerationResponseDTO re
8689
*
8790
* @param aiResponse
8891
* JSON string from AI Gateway
89-
* @return Optional containing parsed Recommendation, or empty if parsing fails
92+
* @return parsed and validated Recommendation
93+
* @throws IllegalStateException
94+
* if content is empty, JSON extraction fails, or required fields
95+
* missing
96+
* @throws Exception
97+
* if JSON parsing fails
9098
*/
91-
private Optional<Recommendation> parseRecommendationContent(String aiResponse) {
92-
if (StringUtils.isBlank(aiResponse)) {
93-
log.error("{} AI response is empty, cannot parse recommendation",
94-
JobConstants.LOG_PREFIX_RECOMMENDATION);
95-
return Optional.empty();
99+
private Recommendation parseRecommendationContent(String aiResponse) throws Exception {
100+
String jsonContent = extractJsonContent(aiResponse);
101+
102+
if (StringUtils.isBlank(jsonContent) || EMPTY_JSON_OBJECT.equals(jsonContent)) {
103+
throw new IllegalStateException(
104+
"Failed to extract JSON from AI response - malformed markdown or missing JSON content");
96105
}
97106

98-
try {
99-
String jsonContent = extractJsonContent(aiResponse);
107+
JsonNode rootNode = objectMapper.readTree(jsonContent);
100108

101-
if (StringUtils.isBlank(jsonContent) || EMPTY_JSON_OBJECT.equals(jsonContent)) {
102-
log.error("{} Extracted JSON content is empty or invalid from AI response",
103-
JobConstants.LOG_PREFIX_RECOMMENDATION);
104-
return Optional.empty();
105-
}
106-
JsonNode rootNode = objectMapper.readTree(jsonContent);
109+
// Check for direct recommendation object with required non-empty fields
110+
if (getTextValue(rootNode, TITLE) != null && getTextValue(rootNode, DESCRIPTION) != null) {
111+
return parseAndValidateRecommendation(rootNode);
112+
}
107113

108-
// Check for direct recommendation object with required non-empty fields
109-
if (hasValidTextField(rootNode, TITLE) && hasValidTextField(rootNode, DESCRIPTION)) {
110-
return Optional.of(parseRecommendationNode(rootNode));
111-
}
114+
// Check for recommendations array
115+
JsonNode recommendationsArray = rootNode.get(RECOMMENDATIONS);
116+
if (recommendationsArray != null && recommendationsArray.isArray() && !recommendationsArray.isEmpty()) {
117+
return parseAndValidateRecommendation(recommendationsArray.get(0));
118+
}
112119

113-
// Check for recommendations array
114-
return Optional.ofNullable(rootNode.get(RECOMMENDATIONS)).filter(JsonNode::isArray)
115-
.filter(node -> !node.isEmpty()).map(node -> parseRecommendationNode(node.get(0)));
120+
// Missing required fields
121+
throw new IllegalStateException("AI response missing required fields: title and description must be non-empty");
122+
}
116123

117-
} catch (Exception e) {
118-
String preview = StringUtils.abbreviate(aiResponse, 100);
119-
log.error("{} Error parsing AI response JSON: {} - Response preview: {}",
120-
JobConstants.LOG_PREFIX_RECOMMENDATION, e.getMessage(), preview, e);
121-
return Optional.empty();
124+
/**
125+
* Parses and validates a recommendation node to ensure data quality.
126+
*
127+
* @param node
128+
* the JSON node to parse
129+
* @return validated Recommendation object
130+
* @throws IllegalStateException
131+
* if required fields are missing or invalid
132+
*/
133+
private Recommendation parseAndValidateRecommendation(JsonNode node) {
134+
Recommendation recommendation = parseRecommendationNode(node);
135+
136+
// Validate required text fields
137+
if (StringUtils.isBlank(recommendation.getTitle())) {
138+
throw new IllegalStateException(
139+
"AI response missing required field 'title' - recommendation must have non-empty title");
140+
}
141+
142+
if (StringUtils.isBlank(recommendation.getDescription())) {
143+
throw new IllegalStateException(
144+
"AI response missing required field 'description' - recommendation must have non-empty description");
145+
}
146+
147+
// Validate critical fields
148+
if (recommendation.getSeverity() == null) {
149+
throw new IllegalStateException(
150+
"AI response missing required field 'severity' - cannot determine recommendation priority");
151+
}
152+
153+
if (StringUtils.isBlank(recommendation.getTimeToValue())) {
154+
throw new IllegalStateException(
155+
"AI response missing required field 'timeToValue' - prompt requires timeline estimate based on severity and complexity");
156+
}
157+
158+
if (recommendation.getActionPlans() == null || recommendation.getActionPlans().isEmpty()) {
159+
throw new IllegalStateException(
160+
"AI response missing required field 'actionPlans' - recommendation must include actionable steps");
122161
}
162+
163+
return recommendation;
123164
}
124165

125166
/**
126167
* Extracts JSON content from AI response by removing markdown code blocks.
127-
* Handles responses wrapped in ```json``` markdown blocks.
168+
* Handles responses wrapped in ```json``` markdown blocks with fallback logic.
128169
*
129170
* @param aiResponse
130171
* the raw AI response string
@@ -135,15 +176,29 @@ private String extractJsonContent(String aiResponse) {
135176

136177
// Remove markdown code blocks if present
137178
if (content.startsWith(MARKDOWN_CODE_FENCE)) {
138-
content = StringUtils.substringBetween(content, "\n", MARKDOWN_CODE_FENCE);
139-
if (content == null) {
140-
return EMPTY_JSON_OBJECT;
179+
String extracted = StringUtils.substringBetween(content, "\n", MARKDOWN_CODE_FENCE);
180+
if (extracted == null) {
181+
log.warn("{} Malformed markdown fence - attempting fallback extraction",
182+
JobConstants.LOG_PREFIX_RECOMMENDATION);
183+
int firstNewline = content.indexOf('\n');
184+
if (firstNewline > 0 && firstNewline < content.length() - 1) {
185+
content = content.substring(firstNewline + 1).trim();
186+
} else {
187+
return EMPTY_JSON_OBJECT;
188+
}
189+
} else {
190+
content = extracted.trim();
141191
}
142192
}
143193

144194
// Find and extract JSON object starting from first {
145195
int jsonStart = content.indexOf(JSON_START_CHAR);
146-
return jsonStart >= 0 ? content.substring(jsonStart) : content;
196+
if (jsonStart < 0) {
197+
log.warn("{} No JSON object found in response", JobConstants.LOG_PREFIX_RECOMMENDATION);
198+
return EMPTY_JSON_OBJECT;
199+
}
200+
201+
return content.substring(jsonStart);
147202
}
148203

149204
/**
@@ -175,37 +230,43 @@ private Optional<Severity> parseSeverity(String severityStr) {
175230
try {
176231
return Optional.of(Severity.valueOf(severityStr));
177232
} catch (IllegalArgumentException e) {
178-
log.warn("{} Invalid severity value from AI response: {}. Saving as null.",
233+
log.debug("{} Invalid severity value '{}' in AI response - will be validated",
179234
JobConstants.LOG_PREFIX_RECOMMENDATION, severityStr);
180235
return Optional.empty();
181236
}
182237
}
183238

184239
/**
185-
* Parses action plans from JSON array node.
240+
* Parses action plans from JSON array node. Filters out action plans with empty
241+
* or blank title/description to ensure data quality.
242+
*
243+
* @param actionPlansNode
244+
* the JSON array node containing action plans
245+
* @return list of valid action plans, or null if none are valid
186246
*/
187247
private List<ActionPlan> parseActionPlans(JsonNode actionPlansNode) {
188248
List<ActionPlan> actionPlans = new ArrayList<>();
189-
actionPlansNode.forEach(actionNode -> {
190-
ActionPlan action = ActionPlan.builder().title(getTextValue(actionNode, TITLE))
191-
.description(getTextValue(actionNode, DESCRIPTION)).build();
192-
actionPlans.add(action);
193-
});
194-
return actionPlans;
195-
}
249+
int skippedCount = 0;
196250

197-
/**
198-
* Checks if JSON node has a valid non-empty text field.
199-
*
200-
* @param node
201-
* the JSON node to check
202-
* @param fieldName
203-
* the field name to check
204-
* @return true if field exists and has non-blank text
205-
*/
206-
private boolean hasValidTextField(JsonNode node, String fieldName) {
207-
return Optional.ofNullable(node.get(fieldName)).map(JsonNode::asText).filter(StringUtils::isNotBlank)
208-
.isPresent();
251+
for (JsonNode actionNode : actionPlansNode) {
252+
String title = getTextValue(actionNode, TITLE);
253+
String description = getTextValue(actionNode, DESCRIPTION);
254+
255+
// Only include action plans with both title and description
256+
if (StringUtils.isNotBlank(title) && StringUtils.isNotBlank(description)) {
257+
ActionPlan action = ActionPlan.builder().title(title).description(description).build();
258+
actionPlans.add(action);
259+
} else {
260+
skippedCount++;
261+
}
262+
}
263+
264+
if (skippedCount > 0) {
265+
log.debug("{} Filtered out {} action plan(s) with empty content from {} total",
266+
JobConstants.LOG_PREFIX_RECOMMENDATION, skippedCount, actionPlansNode.size());
267+
}
268+
269+
return actionPlans.isEmpty() ? null : actionPlans;
209270
}
210271

211272
/**

0 commit comments

Comments
 (0)