refactor(assistant): extract JSON response parsing into AssistantResponseParser

Cluster D from the structural analysis: parsePlan/parseBlockDraft/
parseConnections and their JSON-extraction helpers (readJsonObject,
extractJsonObject/OrArray, tryExtractBalancedJson, isLikelyNoConnectionsText)
had zero field dependencies - a pure string/JSON parsing layer.

- New AssistantResponseParser holds the parsing logic and the
  LENIENT_ASSISTANT_MAPPER it needs
- AssistantFlowPlan, AssistantBlockPlan, AssistantContainerPlan,
  PlanOperation, AssistantConfiguredBlockDraft, AssistantConnectionDraft,
  ParsedPlan, ParsedBlockDraft, ParsedConnections promoted from private
  to package-private nested types in FlowAssistantService so the new
  parser (and future extractions) can share them without duplication
- parseConnectionsOrInferSequential stays in FlowAssistantService since
  it also calls inferSequentialConnections (connection-resolution
  cluster, not yet extracted)

3175 -> 2988 lines (-187, now under 3000). Behavior-preserving: pure
extraction, no logic changes.

Verified with `mvn test`: 462 tests, 0 failures, 0 errors.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-02 10:12:50 +02:00
parent 4544f0c52d
commit 76aa3fa71d
2 changed files with 229 additions and 201 deletions

View File

@ -0,0 +1,215 @@
package it.cnr.isti.workflow.manager.assistant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import it.cnr.isti.workflow.manager.app.ObjectMapperHolder;
import it.cnr.isti.workflow.manager.assistant.FlowAssistantService.AssistantConfiguredBlockDraft;
import it.cnr.isti.workflow.manager.assistant.FlowAssistantService.AssistantConnectionDraft;
import it.cnr.isti.workflow.manager.assistant.FlowAssistantService.AssistantFlowPlan;
import it.cnr.isti.workflow.manager.assistant.FlowAssistantService.ParsedBlockDraft;
import it.cnr.isti.workflow.manager.assistant.FlowAssistantService.ParsedConnections;
import it.cnr.isti.workflow.manager.assistant.FlowAssistantService.ParsedPlan;
final class AssistantResponseParser {
private static final ObjectMapper LENIENT_ASSISTANT_MAPPER = JsonMapper.builder()
.enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES)
.enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
.build();
private AssistantResponseParser() {
}
static ParsedPlan parsePlan(String rawResponse) {
try {
JsonNode root = readJsonObject(rawResponse);
JsonNode planNode = root.has("plan") ? root.get("plan") : root;
AssistantFlowPlan plan = ObjectMapperHolder.mapper.treeToValue(planNode, AssistantFlowPlan.class);
return new ParsedPlan(plan, AssistantTextSupport.textOrEmpty(root.path("rationale")));
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid plan payload: " + e.getMessage());
}
}
static ParsedBlockDraft parseBlockDraft(String rawResponse) {
try {
JsonNode root = readJsonObject(rawResponse);
JsonNode blockNode = root.has("block") ? root.get("block") : root;
AssistantConfiguredBlockDraft block = new AssistantConfiguredBlockDraft(
AssistantTextSupport.textOrNull(blockNode.path("blockId")),
AssistantTextSupport.textOrNull(blockNode.path("name")),
blockNode.path("config"));
if (block.blockId() == null || block.config().isMissingNode()) {
throw new IllegalArgumentException("Missing blockId or config");
}
return new ParsedBlockDraft(block, AssistantTextSupport.textOrEmpty(root.path("rationale")));
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid block configuration payload: " + e.getMessage());
}
}
static ParsedConnections parseConnections(String rawResponse) {
try {
JsonNode root = readJsonObjectOrArray(rawResponse);
if (root.isArray()) {
List<AssistantConnectionDraft> arrayConnections = new ArrayList<>();
for (JsonNode node : root) {
arrayConnections.add(ObjectMapperHolder.mapper.treeToValue(node, AssistantConnectionDraft.class));
}
return new ParsedConnections(arrayConnections, "");
}
JsonNode connectionsNode = root.has("connections") ? root.get("connections") : root.path("connections");
List<AssistantConnectionDraft> connections = new ArrayList<>();
if (connectionsNode.isArray()) {
for (JsonNode node : connectionsNode) {
connections.add(ObjectMapperHolder.mapper.treeToValue(node, AssistantConnectionDraft.class));
}
}
return new ParsedConnections(connections, AssistantTextSupport.textOrEmpty(root.path("rationale")));
} catch (IllegalArgumentException e) {
if (isLikelyNoConnectionsText(rawResponse)) {
return new ParsedConnections(List.of(), rawResponse == null ? "" : rawResponse.trim());
}
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid connections payload: " + e.getMessage());
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid connections payload: " + e.getMessage());
}
}
static JsonNode readJsonObject(String rawResponse) {
String json = extractJsonObject(rawResponse);
try {
return ObjectMapperHolder.mapper.readTree(json);
} catch (Exception e) {
try {
return LENIENT_ASSISTANT_MAPPER.readTree(json);
} catch (Exception ignored) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
static JsonNode readJsonObjectOrArray(String rawResponse) {
String json = extractJsonObjectOrArray(rawResponse);
try {
return ObjectMapperHolder.mapper.readTree(json);
} catch (Exception e) {
try {
return LENIENT_ASSISTANT_MAPPER.readTree(json);
} catch (Exception ignored) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
static String extractJsonObject(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
throw new IllegalArgumentException("Empty assistant response");
}
String trimmed = rawResponse.trim();
boolean sawObjectStart = false;
for (int i = 0; i < trimmed.length(); i++) {
if (trimmed.charAt(i) != '{') {
continue;
}
sawObjectStart = true;
String candidate = tryExtractBalancedJson(trimmed, i);
if (candidate != null) {
return candidate;
}
}
if (!sawObjectStart) {
throw new IllegalArgumentException("No JSON object found in assistant response");
}
throw new IllegalArgumentException("Incomplete JSON object found in assistant response");
}
static String extractJsonObjectOrArray(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
throw new IllegalArgumentException("Empty assistant response");
}
String trimmed = rawResponse.trim();
boolean sawJsonStart = false;
for (int i = 0; i < trimmed.length(); i++) {
char current = trimmed.charAt(i);
if (current != '{' && current != '[') {
continue;
}
sawJsonStart = true;
String candidate = tryExtractBalancedJson(trimmed, i);
if (candidate != null) {
return candidate;
}
}
if (!sawJsonStart) {
throw new IllegalArgumentException("No JSON object found in assistant response");
}
throw new IllegalArgumentException("Incomplete JSON object found in assistant response");
}
static String tryExtractBalancedJson(String text, int start) {
int objectDepth = 0;
int arrayDepth = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < text.length(); i++) {
char current = text.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (current == '\\' && inString) {
escaped = true;
continue;
}
if (current == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (current == '{') {
objectDepth++;
} else if (current == '}') {
objectDepth--;
} else if (current == '[') {
arrayDepth++;
} else if (current == ']') {
arrayDepth--;
}
if (objectDepth == 0 && arrayDepth == 0) {
return text.substring(start, i + 1);
}
}
return null;
}
static boolean isLikelyNoConnectionsText(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
return false;
}
String normalized = rawResponse.trim().toLowerCase(Locale.ROOT);
return normalized.contains("no connection")
|| normalized.contains("no connections")
|| normalized.contains("none needed")
|| normalized.contains("nessuna connessione");
}
}

View File

@ -24,10 +24,8 @@ import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@ -157,11 +155,6 @@ public class FlowAssistantService {
ValidationErrorCode.EXECUTION_DEADLOCK);
private static final long DEFAULT_RETRY_BASE_DELAY_MILLIS = 120L;
private static final long DEFAULT_RETRY_MAX_DELAY_MILLIS = 800L;
private static final ObjectMapper LENIENT_ASSISTANT_MAPPER = JsonMapper.builder()
.enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES)
.enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
.build();
@FunctionalInterface
public interface ProgressListener {
@ -176,14 +169,14 @@ public class FlowAssistantService {
private static final ProgressListener NOOP_PROGRESS = (phase, message) -> {
};
private record AssistantFlowPlan(String name, String description, List<AssistantBlockPlan> blocks,
record AssistantFlowPlan(String name, String description, List<AssistantBlockPlan> blocks,
List<AssistantContainerPlan> containers) {
private AssistantFlowPlan(String name, String description, List<AssistantBlockPlan> blocks) {
AssistantFlowPlan(String name, String description, List<AssistantBlockPlan> blocks) {
this(name, description, blocks, List.of());
}
}
private record AssistantBlockPlan(String blockId, String blockType, String purpose, String operation) {
record AssistantBlockPlan(String blockId, String blockType, String purpose, String operation) {
}
/**
@ -204,34 +197,34 @@ public class FlowAssistantService {
* names the open non-multiple body input that receives the guard's feedback each iteration -
* required when the body exposes more than one open input, inferred when it exposes exactly one.
*/
private record AssistantContainerPlan(String containerId, String containerType, String purpose, String operation,
record AssistantContainerPlan(String containerId, String containerType, String purpose, String operation,
String iterationInput, String guardCondition, Integer maxIterations, String feedbackInput,
List<AssistantBlockPlan> blocks) {
}
private enum PlanOperation {
enum PlanOperation {
KEEP,
ADD,
UPDATE,
REMOVE
}
private record AssistantConfiguredBlockDraft(String blockId, String name, JsonNode config) {
record AssistantConfiguredBlockDraft(String blockId, String name, JsonNode config) {
}
private record AssistantConnectionDraft(String fromBlockId, String fromOutput, String toBlockId, String toInput) {
record AssistantConnectionDraft(String fromBlockId, String fromOutput, String toBlockId, String toInput) {
}
private record ParsedPlan(AssistantFlowPlan plan, String rationale) {
record ParsedPlan(AssistantFlowPlan plan, String rationale) {
}
private record ParsedBlockDraft(AssistantConfiguredBlockDraft block, String rationale) {
record ParsedBlockDraft(AssistantConfiguredBlockDraft block, String rationale) {
}
private record ConfiguredBlockResult(ParsedBlockDraft parsedBlock, Block<?> block) {
}
private record ParsedConnections(List<AssistantConnectionDraft> connections, String rationale) {
record ParsedConnections(List<AssistantConnectionDraft> connections, String rationale) {
}
private record ConfiguredBlockSummary(String blockId, String blockType, String name, String purpose, List<String> inputs,
@ -488,7 +481,7 @@ public class FlowAssistantService {
mcpServerCatalogEntries());
parsedPlan = invokeStructuredAndValidate(provider, authorization, planningModelFor(mode, phaseModels),
phaseModels.repairModel(), planPrompt, "plan", rawResponse -> {
ParsedPlan plan = parsePlan(rawResponse);
ParsedPlan plan = AssistantResponseParser.parsePlan(rawResponse);
AssistantFlowPlan normalizedPlan = validateAndNormalizePlan(plan.plan(), mode, userPrompt, currentFlow,
errors, catalogByType.keySet());
return new ParsedPlan(normalizedPlan, plan.rationale());
@ -543,7 +536,7 @@ public class FlowAssistantService {
ConfiguredBlockResult configuredBlock = invokeStructuredAndValidate(provider, authorization,
jsonModelFor(mode, phaseModels), phaseModels.repairModel(), blockPrompt,
"block configuration for " + blockPlan.blockId(), rawResponse -> {
ParsedBlockDraft parsedBlock = parseBlockDraft(rawResponse);
ParsedBlockDraft parsedBlock = AssistantResponseParser.parseBlockDraft(rawResponse);
AssistantConfiguredBlockDraft normalizedDraft = normalizeBlockDraft(blockPlan,
parsedBlock.block());
Block<?> newBlock = buildBlock(descriptor, blockPlan, normalizedDraft, flowProvider, flowModel,
@ -745,7 +738,7 @@ public class FlowAssistantService {
jsonModelFor(mode, phaseModels), phaseModels.repairModel(), blockPrompt,
"block configuration for " + blockPlan.blockId() + " in container " + containerPlan.containerId(),
rawResponse -> {
ParsedBlockDraft parsedBlock = parseBlockDraft(rawResponse);
ParsedBlockDraft parsedBlock = AssistantResponseParser.parseBlockDraft(rawResponse);
AssistantConfiguredBlockDraft normalizedDraft = normalizeBlockDraft(blockPlan, parsedBlock.block());
Block<?> newBlock = buildBlock(descriptor, blockPlan, normalizedDraft, flowProvider, flowModel,
innerRequiresSharedMemory, currentBlockIndex, blockPlanCount);
@ -1041,7 +1034,7 @@ public class FlowAssistantService {
private ParsedConnections parseConnectionsOrInferSequential(String rawResponse, List<Block<?>> assembledBlocks) {
try {
return parseConnections(rawResponse);
return AssistantResponseParser.parseConnections(rawResponse);
} catch (ResponseStatusException e) {
if (e.getReason() == null || !e.getReason().contains("invalid connections payload")) {
throw e;
@ -2289,91 +2282,6 @@ public class FlowAssistantService {
}
}
private ParsedPlan parsePlan(String rawResponse) {
try {
JsonNode root = readJsonObject(rawResponse);
JsonNode planNode = root.has("plan") ? root.get("plan") : root;
AssistantFlowPlan plan = ObjectMapperHolder.mapper.treeToValue(planNode, AssistantFlowPlan.class);
return new ParsedPlan(plan, AssistantTextSupport.textOrEmpty(root.path("rationale")));
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid plan payload: " + e.getMessage());
}
}
private ParsedBlockDraft parseBlockDraft(String rawResponse) {
try {
JsonNode root = readJsonObject(rawResponse);
JsonNode blockNode = root.has("block") ? root.get("block") : root;
AssistantConfiguredBlockDraft block = new AssistantConfiguredBlockDraft(
AssistantTextSupport.textOrNull(blockNode.path("blockId")),
AssistantTextSupport.textOrNull(blockNode.path("name")),
blockNode.path("config"));
if (block.blockId() == null || block.config().isMissingNode()) {
throw new IllegalArgumentException("Missing blockId or config");
}
return new ParsedBlockDraft(block, AssistantTextSupport.textOrEmpty(root.path("rationale")));
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid block configuration payload: " + e.getMessage());
}
}
private ParsedConnections parseConnections(String rawResponse) {
try {
JsonNode root = readJsonObjectOrArray(rawResponse);
if (root.isArray()) {
List<AssistantConnectionDraft> arrayConnections = new ArrayList<>();
for (JsonNode node : root) {
arrayConnections.add(ObjectMapperHolder.mapper.treeToValue(node, AssistantConnectionDraft.class));
}
return new ParsedConnections(arrayConnections, "");
}
JsonNode connectionsNode = root.has("connections") ? root.get("connections") : root.path("connections");
List<AssistantConnectionDraft> connections = new ArrayList<>();
if (connectionsNode.isArray()) {
for (JsonNode node : connectionsNode) {
connections.add(ObjectMapperHolder.mapper.treeToValue(node, AssistantConnectionDraft.class));
}
}
return new ParsedConnections(connections, AssistantTextSupport.textOrEmpty(root.path("rationale")));
} catch (IllegalArgumentException e) {
if (isLikelyNoConnectionsText(rawResponse)) {
return new ParsedConnections(List.of(), rawResponse == null ? "" : rawResponse.trim());
}
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid connections payload: " + e.getMessage());
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Assistant returned an invalid connections payload: " + e.getMessage());
}
}
private JsonNode readJsonObject(String rawResponse) {
String json = extractJsonObject(rawResponse);
try {
return ObjectMapperHolder.mapper.readTree(json);
} catch (Exception e) {
try {
return LENIENT_ASSISTANT_MAPPER.readTree(json);
} catch (Exception ignored) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
private JsonNode readJsonObjectOrArray(String rawResponse) {
String json = extractJsonObjectOrArray(rawResponse);
try {
return ObjectMapperHolder.mapper.readTree(json);
} catch (Exception e) {
try {
return LENIENT_ASSISTANT_MAPPER.readTree(json);
} catch (Exception ignored) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
private AssistantFlowPlan validateAndNormalizePlan(AssistantFlowPlan plan, OperationMode mode, String userPrompt,
FlowCreateRequest currentFlow, List<ValidationError> errors,
@ -2856,101 +2764,6 @@ public class FlowAssistantService {
return new ValidationError("flow", null, violation.getPropertyPath().toString(), violation.getMessage());
}
private String extractJsonObject(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
throw new IllegalArgumentException("Empty assistant response");
}
String trimmed = rawResponse.trim();
boolean sawObjectStart = false;
for (int i = 0; i < trimmed.length(); i++) {
if (trimmed.charAt(i) != '{') {
continue;
}
sawObjectStart = true;
String candidate = tryExtractBalancedJson(trimmed, i);
if (candidate != null) {
return candidate;
}
}
if (!sawObjectStart) {
throw new IllegalArgumentException("No JSON object found in assistant response");
}
throw new IllegalArgumentException("Incomplete JSON object found in assistant response");
}
private String extractJsonObjectOrArray(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
throw new IllegalArgumentException("Empty assistant response");
}
String trimmed = rawResponse.trim();
boolean sawJsonStart = false;
for (int i = 0; i < trimmed.length(); i++) {
char current = trimmed.charAt(i);
if (current != '{' && current != '[') {
continue;
}
sawJsonStart = true;
String candidate = tryExtractBalancedJson(trimmed, i);
if (candidate != null) {
return candidate;
}
}
if (!sawJsonStart) {
throw new IllegalArgumentException("No JSON object found in assistant response");
}
throw new IllegalArgumentException("Incomplete JSON object found in assistant response");
}
private String tryExtractBalancedJson(String text, int start) {
int objectDepth = 0;
int arrayDepth = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < text.length(); i++) {
char current = text.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (current == '\\' && inString) {
escaped = true;
continue;
}
if (current == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (current == '{') {
objectDepth++;
} else if (current == '}') {
objectDepth--;
} else if (current == '[') {
arrayDepth++;
} else if (current == ']') {
arrayDepth--;
}
if (objectDepth == 0 && arrayDepth == 0) {
return text.substring(start, i + 1);
}
}
return null;
}
private boolean isLikelyNoConnectionsText(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
return false;
}
String normalized = rawResponse.trim().toLowerCase(Locale.ROOT);
return normalized.contains("no connection")
|| normalized.contains("no connections")
|| normalized.contains("none needed")
|| normalized.contains("nessuna connessione");
}
private ObjectNode llmDescriptorNode(String provider, String model) {
ObjectNode llmDescriptor = ObjectMapperHolder.mapper.createObjectNode();