@@ -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