AnalysisApiController.java
package com.taxonomy.analysis.controller;
import com.taxonomy.dto.LlmCallDetail;
import com.taxonomy.dto.AnalysisRequest;
import com.taxonomy.dto.AnalysisResult;
import com.taxonomy.dto.RequirementArchitectureView;
import com.taxonomy.dto.TaxonomyDiscrepancy;
import com.taxonomy.dto.TaxonomyNodeDto;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.analysis.service.AnalysisEventCallback;
import com.taxonomy.analysis.service.LlmProvider;
import com.taxonomy.analysis.service.LlmService;
import com.taxonomy.analysis.service.AnalysisRelationGenerator;
import com.taxonomy.export.DiagramViewMetadata;
import com.taxonomy.preferences.PreferencesService;
import com.taxonomy.shared.config.ExportConfig;
import com.taxonomy.versioning.service.HypothesisService;
import com.taxonomy.versioning.service.RepositoryStateService;
import com.taxonomy.architecture.service.RequirementArchitectureViewService;
import com.taxonomy.catalog.service.TaxonomyService;
import com.taxonomy.workspace.service.WorkspaceResolver;
import tools.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
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 java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
@RestController
@RequestMapping("/api")
@Tag(name = "Analysis")
public class AnalysisApiController {
private final TaxonomyService taxonomyService;
private final LlmService llmService;
private final ExecutorService analysisExecutor;
private final ObjectMapper objectMapper;
private final RequirementArchitectureViewService architectureViewService;
private final AnalysisRelationGenerator analysisRelationGenerator;
private final HypothesisService hypothesisService;
private final RepositoryStateService repositoryStateService;
private final WorkspaceResolver workspaceResolver;
private final org.springframework.context.MessageSource messageSource;
private final PreferencesService preferencesService;
public AnalysisApiController(TaxonomyService taxonomyService,
LlmService llmService,
ExecutorService analysisExecutor,
ObjectMapper objectMapper,
RequirementArchitectureViewService architectureViewService,
AnalysisRelationGenerator analysisRelationGenerator,
HypothesisService hypothesisService,
RepositoryStateService repositoryStateService,
WorkspaceResolver workspaceResolver,
org.springframework.context.MessageSource messageSource,
PreferencesService preferencesService) {
this.taxonomyService = taxonomyService;
this.llmService = llmService;
this.analysisExecutor = analysisExecutor;
this.objectMapper = objectMapper;
this.architectureViewService = architectureViewService;
this.analysisRelationGenerator = analysisRelationGenerator;
this.hypothesisService = hypothesisService;
this.repositoryStateService = repositoryStateService;
this.workspaceResolver = workspaceResolver;
this.messageSource = messageSource;
this.preferencesService = preferencesService;
}
@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 missing")
@PostMapping("/analyze")
public ResponseEntity<AnalysisResult> analyze(@RequestBody AnalysisRequest request) {
ResponseEntity<AnalysisResult> guard = checkInitialized();
if (guard != null) return guard;
if (request.getBusinessText() == null || request.getBusinessText().isBlank()) {
return ResponseEntity.badRequest().build();
}
if (request.getProvider() != null && !request.getProvider().isBlank()) {
try {
llmService.setRequestProvider(
LlmProvider.valueOf(request.getProvider().toUpperCase()));
} catch (IllegalArgumentException e) {
@SuppressWarnings("unchecked")
ResponseEntity<AnalysisResult> badProvider = (ResponseEntity<AnalysisResult>)
(ResponseEntity<?>) ResponseEntity.badRequest().body(Map.of(
"error", "Unknown provider: " + request.getProvider(),
"validProviders", java.util.Arrays.toString(LlmProvider.values())));
return badProvider;
}
}
try {
AnalysisResult result = llmService.analyzeWithBudget(request.getBusinessText());
// Generate provisional relation hypotheses from scored nodes
if (result.getScores() != null) {
result.setProvisionalRelations(
analysisRelationGenerator.generate(result.getScores()));
// Persist hypotheses to database for later accept/reject via API
if (!result.getProvisionalRelations().isEmpty()) {
hypothesisService.persistFromAnalysis(result.getProvisionalRelations(), null);
}
}
if (request.isIncludeArchitectureView() && result.getScores() != null) {
RequirementArchitectureView archView = architectureViewService.build(
result.getScores(), request.getBusinessText(),
request.getMaxArchitectureNodes(),
result.getProvisionalRelations());
// Populate view metadata from the active diagram policy
DiagramViewMetadata meta = ExportConfig.resolveViewMetadata(preferencesService);
archView.setViewTitle(meta.viewTitle());
archView.setViewDescription(meta.viewDescription());
archView.setContainmentEnabled(meta.containmentEnabled());
archView.setActiveRules(meta.activeRules());
result.setArchitectureView(archView);
}
result.setViewContext(repositoryStateService.getViewContext(
workspaceResolver.resolveCurrentUsername(),
repositoryStateService.resolveWorkspaceBranch(
workspaceResolver.resolveCurrentUsername())));
return ResponseEntity.ok(result);
} finally {
llmService.clearRequestProvider();
}
}
/**
* Streaming analysis endpoint using Server-Sent Events (SSE).
* Emits {@code phase}, {@code scores}, {@code expanding}, {@code complete} and
* {@code error} events as the LLM processes the taxonomy level by level.
*/
@Operation(summary = "Streaming analysis (SSE)", description = "Server-Sent Events streaming analysis. Emits phase, scores, expanding, complete, and error events as the LLM processes the taxonomy level by level.", 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 (e.g. GEMINI, LOCAL_ONNX)") @RequestParam(required = false) String provider) {
SseEmitter emitter = new SseEmitter(120_000L);
if (!taxonomyService.isInitialized()) {
try {
emitter.send(SseEmitter.event()
.name("error")
.data(objectMapper.writeValueAsString(Map.of(
"status", "ERROR",
"errorMessage", messageSource.getMessage("error.loading", null,
"Taxonomy data is still loading. Please wait.",
org.springframework.context.i18n.LocaleContextHolder.getLocale()),
"initStatus", taxonomyService.getInitStatus()))));
} catch (Exception ignored) {
// client already disconnected
}
emitter.complete();
return emitter;
}
if (businessText == null || businessText.isBlank()) {
try {
emitter.send(SseEmitter.event()
.name("error")
.data("{\"status\":\"ERROR\",\"errorMessage\":\"businessText must not be blank\"}"));
} catch (IOException ignored) {
// client already disconnected
}
emitter.complete();
return emitter;
}
LlmProvider resolvedProvider = null;
if (provider != null && !provider.isBlank()) {
try {
resolvedProvider = LlmProvider.valueOf(provider.toUpperCase());
} catch (IllegalArgumentException e) {
try {
emitter.send(SseEmitter.event()
.name("error")
.data(objectMapper.writeValueAsString(Map.of(
"status", "ERROR",
"errorMessage", "Unknown provider: " + provider))));
} catch (Exception ignored) {
// client already disconnected
}
emitter.complete();
return emitter;
}
}
final LlmProvider providerOverride = resolvedProvider;
analysisExecutor.execute(() -> {
if (providerOverride != null) {
llmService.setRequestProvider(providerOverride);
}
try {
llmService.analyzeStreaming(businessText, new AnalysisEventCallback() {
@Override
public void onPhase(String message, int progressPercent) {
sendEvent(emitter, "phase",
Map.of("message", message, "progress", progressPercent));
}
@Override
public void onScores(Map<String, Integer> newScores, Map<String, String> reasons,
String description, LlmCallDetail detail) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("scores", newScores);
payload.put("reasons", reasons != null ? reasons : Map.of());
payload.put("description", description);
payload.put("message", description);
if (detail != null) {
payload.put("prompt", detail.getPrompt() != null ? detail.getPrompt() : "");
payload.put("rawResponse", detail.getRawResponse() != null ? detail.getRawResponse() : "");
payload.put("provider", detail.getProvider() != null ? detail.getProvider() : "");
payload.put("durationMs", detail.getDurationMs());
if (detail.getError() != null) {
payload.put("error", detail.getError());
}
}
sendEvent(emitter, "scores", payload);
}
@Override
public void onExpanding(String parentCode, List<String> childCodes) {
sendEvent(emitter, "expanding",
Map.of("parentCode", parentCode, "childCodes", childCodes));
}
@Override
public void onComplete(String status, Map<String, Integer> allScores,
List<String> warnings,
List<TaxonomyDiscrepancy> discrepancies) {
int matched = (int) allScores.values().stream()
.filter(v -> v > 0).count();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("status", status);
payload.put("totalScores", allScores);
payload.put("totalMatched", matched);
payload.put("warnings", warnings);
payload.put("discrepancies", discrepancies);
sendEvent(emitter, "complete", payload);
emitter.complete();
}
@Override
public void onError(String status, String errorMessage,
Map<String, Integer> partialScores,
List<String> warnings,
List<TaxonomyDiscrepancy> discrepancies) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("status", status);
payload.put("errorMessage", errorMessage);
payload.put("partialScores", partialScores);
payload.put("warnings", warnings);
payload.put("discrepancies", discrepancies);
sendEvent(emitter, "error", payload);
emitter.complete();
}
});
} catch (Exception e) {
emitter.completeWithError(e);
} finally {
llmService.clearRequestProvider();
}
});
return emitter;
}
private void sendEvent(SseEmitter emitter, String eventName, Object data) {
try {
emitter.send(SseEmitter.event()
.name(eventName)
.data(objectMapper.writeValueAsString(data)));
} catch (IOException e) {
// Client disconnected — complete silently
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
}
@Operation(summary = "Analyze single node children", description = "Analyzes the children of a specific taxonomy node against a business requirement", tags = {"Analysis"})
@GetMapping("/analyze-node")
public ResponseEntity<Map<String, Object>> analyzeNode(
@Parameter(description = "Parent taxonomy node code") @RequestParam String parentCode,
@Parameter(description = "Business requirement text") @RequestParam String businessText,
@Parameter(description = "Parent node's score (0-100); defaults to 100 for root-level nodes") @RequestParam(defaultValue = "100") int parentScore) {
ResponseEntity<Map<String, Object>> guard = checkInitialized();
if (guard != null) return guard;
if (businessText == null || businessText.isBlank()) {
return ResponseEntity.badRequest().build();
}
List<TaxonomyNode> children = taxonomyService.getChildrenOf(parentCode);
if (children.isEmpty()) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("scores", Map.of());
empty.put("prompt", "");
empty.put("rawResponse", "");
empty.put("provider", "");
empty.put("durationMs", 0);
return ResponseEntity.ok(empty);
}
LlmCallDetail detail = llmService.analyzeSingleBatchDetailed(businessText, children, parentScore);
Map<String, Object> result = new LinkedHashMap<>();
result.put("scores", detail.getScores());
result.put("reasons", detail.getReasons() != null ? detail.getReasons() : Map.of());
result.put("prompt", detail.getPrompt());
result.put("rawResponse", detail.getRawResponse());
result.put("provider", detail.getProvider());
result.put("durationMs", detail.getDurationMs());
result.put("error", detail.getError());
return ResponseEntity.ok(result);
}
/**
* Generates a leaf-node justification on demand.
* Collects the path from root to the leaf node and cross-references to other
* high-scoring nodes, then calls the LLM for a coherent summary.
*/
@Operation(summary = "Generate leaf justification", description = "Generates an explanatory justification for a leaf node match using the LLM", tags = {"Analysis"})
@PostMapping("/justify-leaf")
public ResponseEntity<Map<String, Object>> justifyLeaf(
@RequestBody Map<String, Object> body) {
ResponseEntity<Map<String, Object>> guard = checkInitialized();
if (guard != null) return guard;
String nodeCode = (String) body.get("nodeCode");
String businessText = (String) body.get("businessText");
if (nodeCode == null || nodeCode.isBlank() || businessText == null || businessText.isBlank()) {
return ResponseEntity.badRequest().build();
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> rawScores = body.get("scores") instanceof Map<?, ?>
? (Map<String, Object>) body.get("scores") : Map.of();
@SuppressWarnings("unchecked")
Map<String, String> allReasons = body.get("reasons") instanceof Map<?, ?>
? (Map<String, String>) body.get("reasons") : Map.of();
Map<String, Integer> allScores = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : rawScores.entrySet()) {
if (e.getValue() instanceof Number n) {
allScores.put(e.getKey(), n.intValue());
}
}
List<com.taxonomy.catalog.model.TaxonomyNode> pathNodes =
taxonomyService.getPathToRoot(nodeCode);
String justification = llmService.generateLeafJustification(
businessText, nodeCode, pathNodes, allScores, allReasons);
Map<String, Object> result = new LinkedHashMap<>();
result.put("nodeCode", nodeCode);
result.put("justification", justification);
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("nodeCode", nodeCode);
result.put("justification", "Error: " + e.getMessage());
return ResponseEntity.ok(result);
}
}
@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.",
org.springframework.context.i18n.LocaleContextHolder.getLocale()));
body.put("status", taxonomyService.getInitStatus());
return (ResponseEntity<T>) ResponseEntity.status(503).body(body);
}
return null;
}
}