AnalysisApiController.java
package com.taxonomy.analysis.controller;
import com.taxonomy.analysis.usecase.AnalysisStreamEvent;
import com.taxonomy.analysis.usecase.AnalyzeNodeChildrenCommand;
import com.taxonomy.analysis.usecase.AnalyzeNodeChildrenResult;
import com.taxonomy.analysis.usecase.AnalyzeNodeChildrenUseCase;
import com.taxonomy.analysis.usecase.AnalyzeRequirementCommand;
import com.taxonomy.analysis.usecase.AnalyzeRequirementResult;
import com.taxonomy.analysis.usecase.AnalyzeRequirementUseCase;
import com.taxonomy.analysis.usecase.JustifyLeafCommand;
import com.taxonomy.analysis.usecase.JustifyLeafResult;
import com.taxonomy.analysis.usecase.JustifyLeafUseCase;
import com.taxonomy.analysis.usecase.StreamRequirementAnalysisCommand;
import com.taxonomy.analysis.usecase.StreamRequirementAnalysisUseCase;
import com.taxonomy.analysis.usecase.UnknownAnalysisProviderException;
import com.taxonomy.catalog.service.TaxonomyService;
import com.taxonomy.dto.AnalysisRequest;
import com.taxonomy.dto.AnalysisResult;
import com.taxonomy.versioning.service.RepositoryStateService;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicLong;
/**
* HTTP/SSE transport adapter for requirement analysis. Business orchestration is
* delegated to typed application use cases.
*/
@RestController
@RequestMapping("/api")
@Tag(name = "Analysis")
public class AnalysisApiController {
static final String ANALYSIS_OPERATION_ID_HEADER = "X-Analysis-Operation-Id";
private static final Logger log = LoggerFactory.getLogger(AnalysisApiController.class);
private final TaxonomyService taxonomyService;
private final ExecutorService analysisExecutor;
private final ObjectMapper objectMapper;
private final AnalyzeRequirementUseCase analyzeRequirementUseCase;
private final StreamRequirementAnalysisUseCase streamRequirementAnalysisUseCase;
private final AnalyzeNodeChildrenUseCase analyzeNodeChildrenUseCase;
private final JustifyLeafUseCase justifyLeafUseCase;
private final AnalysisSseEventMapper analysisSseEventMapper;
private final RepositoryStateService repositoryStateService;
private final WorkspaceResolver workspaceResolver;
private final MessageSource messageSource;
public AnalysisApiController(TaxonomyService taxonomyService,
ExecutorService analysisExecutor,
ObjectMapper objectMapper,
AnalyzeRequirementUseCase analyzeRequirementUseCase,
StreamRequirementAnalysisUseCase streamRequirementAnalysisUseCase,
AnalyzeNodeChildrenUseCase analyzeNodeChildrenUseCase,
JustifyLeafUseCase justifyLeafUseCase,
AnalysisSseEventMapper analysisSseEventMapper,
RepositoryStateService repositoryStateService,
WorkspaceResolver workspaceResolver,
MessageSource messageSource) {
this.taxonomyService = taxonomyService;
this.analysisExecutor = analysisExecutor;
this.objectMapper = objectMapper;
this.analyzeRequirementUseCase = analyzeRequirementUseCase;
this.streamRequirementAnalysisUseCase = streamRequirementAnalysisUseCase;
this.analyzeNodeChildrenUseCase = analyzeNodeChildrenUseCase;
this.justifyLeafUseCase = justifyLeafUseCase;
this.analysisSseEventMapper = analysisSseEventMapper;
this.repositoryStateService = repositoryStateService;
this.workspaceResolver = workspaceResolver;
this.messageSource = messageSource;
}
@Operation(summary = "Analyze business requirement",
description = "Analyzes a business requirement against the taxonomy using the configured LLM provider. Optionally includes an architecture view.",
tags = {"Analysis"})
@ApiResponse(responseCode = "200", description = "Analysis completed")
@ApiResponse(responseCode = "400", description = "Business text is blank or the provider is unknown")
@PostMapping("/analyze")
public ResponseEntity<AnalysisResult> analyze(@RequestBody AnalysisRequest request) {
ResponseEntity<AnalysisResult> guard = checkInitialized();
if (guard != null) return guard;
if (request == null || request.getBusinessText() == null
|| request.getBusinessText().isBlank()) {
return ResponseEntity.badRequest().build();
}
String operationId = newOperationId();
try {
String username = workspaceResolver.resolveCurrentUsername();
AnalyzeRequirementResult result = analyzeRequirementUseCase.analyze(
new AnalyzeRequirementCommand(
request.getBusinessText(),
request.isIncludeArchitectureView(),
request.getMaxArchitectureNodes(),
request.getProvider(),
username,
resolveWorkspaceContext(username)));
return ResponseEntity.ok()
.header(ANALYSIS_OPERATION_ID_HEADER, operationId)
.body(result.analysisResult());
} catch (UnknownAnalysisProviderException e) {
@SuppressWarnings("unchecked")
ResponseEntity<AnalysisResult> badProvider = (ResponseEntity<AnalysisResult>)
(ResponseEntity<?>) ResponseEntity.badRequest()
.header(ANALYSIS_OPERATION_ID_HEADER, operationId)
.body(Map.of(
"error", "Unknown provider: " + e.getProvider(),
"validProviders", e.getValidProviders()));
return badProvider;
}
}
@Operation(summary = "Streaming analysis (SSE)",
description = "Emits phase, scores, expanding, complete, and error events as the taxonomy is processed. Every event carries one operation ID and a monotonic sequence.",
tags = {"Analysis"})
@GetMapping(value = "/analyze-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter analyzeStream(
@Parameter(description = "Business requirement text to analyze")
@RequestParam String businessText,
@Parameter(description = "LLM provider override")
@RequestParam(required = false) String provider) {
SseEmitter emitter = new SseEmitter(120_000L);
String operationId = newOperationId();
AtomicLong eventSequence = new AtomicLong();
if (!taxonomyService.isInitialized()) {
sendEvent(emitter, operationId, eventSequence.incrementAndGet(), "error", Map.of(
"status", "ERROR",
"errorMessage", messageSource.getMessage(
"error.loading", null,
"Taxonomy data is still loading. Please wait.",
LocaleContextHolder.getLocale()),
"initStatus", taxonomyService.getInitStatus()));
emitter.complete();
return emitter;
}
if (businessText == null || businessText.isBlank()) {
sendEvent(emitter, operationId, eventSequence.incrementAndGet(), "error", Map.of(
"status", "ERROR",
"errorMessage", "businessText must not be blank"));
emitter.complete();
return emitter;
}
// Resolve and validate the exact tab-pinned workspace while the request
// context is still available. Native EventSource cannot send the custom
// header, so WorkspaceContextResolver accepts its equivalent query value.
String username = workspaceResolver.resolveCurrentUsername();
resolveWorkspaceContext(username);
StreamRequirementAnalysisCommand command = new StreamRequirementAnalysisCommand(
businessText, provider, LocaleContextHolder.getLocale());
analysisExecutor.execute(() -> {
try {
streamRequirementAnalysisUseCase.stream(command, event -> {
AnalysisSseEventMapper.MappedEvent mapped = analysisSseEventMapper.map(event);
sendEvent(emitter, operationId, eventSequence.incrementAndGet(),
mapped.name(), mapped.payload());
if (event instanceof AnalysisStreamEvent.Complete
|| event instanceof AnalysisStreamEvent.Error) {
emitter.complete();
}
});
} catch (UnknownAnalysisProviderException e) {
sendEvent(emitter, operationId, eventSequence.incrementAndGet(), "error", Map.of(
"status", "ERROR",
"errorMessage", "Unknown provider: " + e.getProvider()));
emitter.complete();
} catch (Exception e) {
log.error("Streaming analysis failed", e);
sendEvent(emitter, operationId, eventSequence.incrementAndGet(), "error", Map.of(
"status", "ERROR",
"errorMessage", "Analysis failed. Retry the operation or inspect administrator diagnostics."));
emitter.complete();
}
});
return emitter;
}
@Operation(summary = "Analyze single node children",
description = "Analyzes the children of a taxonomy node against a requirement",
tags = {"Analysis"})
@GetMapping("/analyze-node")
public ResponseEntity<AnalyzeNodeChildrenResult> analyzeNode(
@RequestParam String parentCode,
@RequestParam String businessText,
@RequestParam(defaultValue = "100") int parentScore) {
ResponseEntity<AnalyzeNodeChildrenResult> guard = checkInitialized();
if (guard != null) return guard;
try {
return ResponseEntity.ok(analyzeNodeChildrenUseCase.analyze(
new AnalyzeNodeChildrenCommand(parentCode, businessText, parentScore)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
public record JustifyLeafRequest(
String nodeCode,
String businessText,
Map<String, Integer> scores,
Map<String, String> reasons) {
}
@Operation(summary = "Generate leaf justification",
description = "Generates a traceable explanation for a leaf-node match",
tags = {"Analysis"})
@ApiResponse(responseCode = "200", description = "Justification generated")
@ApiResponse(responseCode = "400", description = "Request is invalid")
@ApiResponse(responseCode = "500", description = "Justification generation failed")
@PostMapping("/justify-leaf")
public ResponseEntity<?> justifyLeaf(@RequestBody JustifyLeafRequest request) {
ResponseEntity<JustifyLeafResult> guard = checkInitialized();
if (guard != null) return guard;
try {
JustifyLeafResult result = justifyLeafUseCase.justify(new JustifyLeafCommand(
request.nodeCode(), request.businessText(), request.scores(), request.reasons()));
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, e.getMessage());
problem.setTitle("Invalid leaf justification request");
problem.setType(URI.create("urn:taxonomy:problem:invalid-justification-request"));
return ResponseEntity.badRequest().body(problem);
} catch (Exception e) {
log.error("Leaf justification failed for node {}",
request != null ? request.nodeCode() : null, e);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"The leaf justification could not be generated. Retry the operation or inspect the administrator diagnostics.");
problem.setTitle("Leaf justification failed");
problem.setType(URI.create("urn:taxonomy:problem:justification-failed"));
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problem);
}
}
private void sendEvent(SseEmitter emitter,
String operationId,
long sequence,
String eventName,
Object data) {
try {
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("operationId", operationId);
envelope.put("sequence", sequence);
envelope.put("emittedAt", Instant.now().toString());
if (data instanceof Map<?, ?> dataMap) {
dataMap.forEach((key, value) -> envelope.put(String.valueOf(key), value));
} else {
envelope.put("data", data);
}
emitter.send(SseEmitter.event()
.id(operationId + ":" + sequence)
.name(eventName)
.data(objectMapper.writeValueAsString(envelope)));
} catch (IOException e) {
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
}
private String newOperationId() {
return UUID.randomUUID().toString();
}
private WorkspaceContext resolveWorkspaceContext(String username) {
try {
repositoryStateService.ensureWorkspaceState(username);
return workspaceResolver.resolveCurrentContext();
} catch (AccessDeniedException exception) {
// An explicit tab pin is an authorization boundary. Never turn a
// denied or foreign workspace into an implicit shared analysis.
throw exception;
} catch (Exception e) {
log.warn("Falling back to shared workspace context for user '{}' due to: {}",
username, e.toString(), e);
return WorkspaceContext.SHARED;
}
}
@SuppressWarnings("unchecked")
private <T> ResponseEntity<T> checkInitialized() {
if (!taxonomyService.isInitialized()) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", messageSource.getMessage(
"error.loading", null,
"Taxonomy data is still loading. Please wait.",
LocaleContextHolder.getLocale()));
body.put("status", taxonomyService.getInitStatus());
return (ResponseEntity<T>) ResponseEntity.status(503).body(body);
}
return null;
}
}