DecisionRationaleReportController.java
package com.taxonomy.versioning.controller;
import com.taxonomy.architecture.decision.DecisionRationaleReport;
import com.taxonomy.architecture.decision.DecisionRationaleReportPlugin;
import com.taxonomy.architecture.decision.DecisionRationaleReportService;
import com.taxonomy.architecture.decision.DecisionRationaleScoreSemanticsAdapter;
import com.taxonomy.architecture.decision.DecisionReportTemplateHeaders;
import com.taxonomy.architecture.decision.DecisionRationaleReportService.DecisionAnalysisInput;
import com.taxonomy.architecture.report.ReportRendererRegistry;
import com.taxonomy.catalog.service.TaxonomyService;
import com.taxonomy.dto.AnalysisScoreDetail;
import com.taxonomy.dto.AnalysisScoreSemantics;
import com.taxonomy.dto.ProductCoverageGap;
import com.taxonomy.dto.TaxonomyDiscrepancy;
import com.taxonomy.dto.ViewContext;
import com.taxonomy.extension.api.report.ReportFormatDescriptor;
import com.taxonomy.extension.api.report.ReportRenderContext;
import com.taxonomy.extension.api.report.ReportRenderResult;
import com.taxonomy.extension.api.report.ReportRendererExtension;
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.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RestController;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* HTTP adapter for the hierarchical decision-rationale report extension family.
*
* <p>The controller owns the report-specific request and trusted runtime provenance.
* Format rendering is delegated exclusively to {@link ReportRendererRegistry}; adding
* another decision-report format therefore requires only another renderer extension.</p>
*/
@RestController
@RequestMapping("/api/decision-report")
@Tag(name = "Decision Rationale Report")
public class DecisionRationaleReportController {
private static final int MAX_SCORE_ENTRIES = 25_000;
private static final int MAX_REASON_ENTRIES = 25_000;
private static final int MAX_NODE_CODE_LENGTH = 256;
private static final int MAX_REQUIREMENT_LENGTH = 500_000;
private static final int MAX_REASON_LENGTH = 50_000;
private static final long MAX_TOTAL_REASON_CHARACTERS = 5_000_000L;
private static final int MAX_DISCREPANCIES = 10_000;
private static final int MAX_PRODUCT_COVERAGE_GAPS = 10_000;
private static final int MAX_PRODUCT_GAP_CANDIDATES = 25_000;
private static final int MAX_METADATA_LENGTH = 256;
private final DecisionRationaleReportService reportService;
private final ReportRendererRegistry reportRendererRegistry;
private final RepositoryStateService repositoryStateService;
private final WorkspaceResolver workspaceResolver;
private final DecisionRationaleScoreSemanticsAdapter scoreSemanticsAdapter;
private final TaxonomyService taxonomyService;
/** Backward-compatible constructor for focused unit tests. */
public DecisionRationaleReportController(
DecisionRationaleReportService reportService,
ReportRendererRegistry reportRendererRegistry,
RepositoryStateService repositoryStateService,
WorkspaceResolver workspaceResolver) {
this(reportService, reportRendererRegistry, repositoryStateService, workspaceResolver,
new DecisionRationaleScoreSemanticsAdapter(), null);
}
@Autowired
public DecisionRationaleReportController(
DecisionRationaleReportService reportService,
ReportRendererRegistry reportRendererRegistry,
RepositoryStateService repositoryStateService,
WorkspaceResolver workspaceResolver,
DecisionRationaleScoreSemanticsAdapter scoreSemanticsAdapter,
TaxonomyService taxonomyService) {
this.reportService = reportService;
this.reportRendererRegistry = reportRendererRegistry;
this.repositoryStateService = repositoryStateService;
this.workspaceResolver = workspaceResolver;
this.scoreSemanticsAdapter = scoreSemanticsAdapter;
this.taxonomyService = taxonomyService;
}
public record DecisionReportRequest(
Map<String, Integer> scores,
Map<String, Integer> rawScores,
Map<String, Integer> effectiveScores,
Map<String, AnalysisScoreDetail> scoreDetails,
Map<String, Integer> productSuitabilityScores,
Integer scoreSemanticsVersion,
Map<String, String> reasons,
String businessText,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies,
List<ProductCoverageGap> productCoverageGaps,
String language) {
/** Legacy request constructor retained for Java callers and validation tests. */
public DecisionReportRequest(
Map<String, Integer> scores,
Map<String, String> reasons,
String businessText,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies,
List<ProductCoverageGap> productCoverageGaps,
String language) {
this(scores, null, null, null, null, null, reasons, businessText, provider,
analysisStatus, discrepancies, productCoverageGaps, language);
}
}
@Operation(
summary = "List decision-rationale report formats",
description = "Returns formats registered through the report-renderer extension SPI.")
@GetMapping("/formats")
public List<ReportFormatDescriptor> listFormats() {
return reportRendererRegistry.listDescriptors(
DecisionRationaleReportPlugin.REPORT_TYPE_ID);
}
@Operation(
summary = "Export hierarchical decision rationale as DOCX",
description = "Creates a professional Word document with title page, executive summary, "
+ "one parent/children decision chapter per positive hierarchy decision, diagrams, "
+ "and account/version provenance in the footer.")
@ApiResponse(responseCode = "200", description = "DOCX report returned")
@ApiResponse(responseCode = "400", description = "Requirement or score evidence is missing, invalid or incomplete")
@PostMapping("/docx")
public ResponseEntity<byte[]> exportDocx(@RequestBody DecisionReportRequest request) {
return render(request, "docx");
}
@Operation(
summary = "Export hierarchical decision rationale as HTML",
description = "Creates a self-contained, print-ready HTML report with inline SVG diagrams.")
@ApiResponse(responseCode = "200", description = "HTML report returned")
@ApiResponse(responseCode = "400", description = "Requirement or score evidence is missing, invalid or incomplete")
@PostMapping("/html")
public ResponseEntity<byte[]> exportHtml(@RequestBody DecisionReportRequest request) {
return render(request, "html");
}
@Operation(
summary = "Export hierarchical decision rationale as JSON",
description = "Returns the format-neutral report model including all chapters and provenance.")
@ApiResponse(responseCode = "200", description = "Structured report returned")
@ApiResponse(responseCode = "400", description = "Requirement or score evidence is missing, invalid or incomplete")
@PostMapping("/json")
public ResponseEntity<byte[]> exportJson(@RequestBody DecisionReportRequest request) {
return render(request, "json");
}
private ResponseEntity<byte[]> render(
DecisionReportRequest request,
String formatId) {
if (!isValid(request)) {
return ResponseEntity.badRequest().build();
}
DecisionRationaleReport report = generate(request);
ReportRendererExtension renderer = reportRendererRegistry.getRequired(
DecisionRationaleReportPlugin.REPORT_TYPE_ID, formatId);
ReportFormatDescriptor format = renderer.descriptor();
ReportRenderResult rendered = renderer.render(ReportRenderContext.ofPayload(report));
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\""
+ DecisionRationaleReportPlugin.BASE_FILENAME + "."
+ format.fileExtension() + "\"")
.header("X-Taxonomy-Data-SHA256",
report.metadata().taxonomyDataFingerprintSha256())
.header("X-Taxonomy-Analysis-SHA256",
report.metadata().analysisSnapshotFingerprintSha256());
DecisionReportTemplateHeaders.apply(response, rendered);
return response
.contentType(MediaType.parseMediaType(format.contentType()))
.body(rendered.bytes());
}
private DecisionRationaleReport generate(DecisionReportRequest request) {
WorkspaceContext context = workspaceResolver.resolveCurrentContext();
String branch = repositoryStateService.resolveWorkspaceBranch(context.username());
ViewContext viewContext = repositoryStateService.getViewContext(
context.username(), branch, context);
Locale locale = resolveLocale(request.language());
AnalysisScoreSemantics.Derived scoreSemantics = resolveScoreSemantics(request);
Map<String, AnalysisScoreDetail> scoreDetails = scoreSemantics.scoreDetails();
Map<String, Integer> effectiveScores = scoreSemantics.effectiveScores();
Map<String, String> reasons = scoreSemanticsAdapter.enrichReasons(
request.reasons(), scoreDetails, locale);
DecisionAnalysisInput input = new DecisionAnalysisInput(
request.businessText(),
effectiveScores,
reasons,
request.provider(),
request.analysisStatus(),
request.discrepancies(),
request.productCoverageGaps(),
List.of(),
null,
scoreDetails);
DecisionRationaleReport report = reportService.generate(
input, context, viewContext, locale);
return scoreSemanticsAdapter.adapt(report, scoreDetails, locale);
}
private AnalysisScoreSemantics.Derived resolveScoreSemantics(
DecisionReportRequest request) {
// Validation requires an explicit raw map to cover every selected code.
// Never fill missing raw evidence with possibly weighted values from scores.
Map<String, Integer> suppliedRaw = request.rawScores() != null
? request.rawScores() : request.scores();
Map<String, Integer> authoritativeRaw = new LinkedHashMap<>();
request.scores().keySet().forEach(code -> authoritativeRaw.put(
code, suppliedRaw.get(code)));
return AnalysisScoreSemantics.derive(
authoritativeRaw,
taxonomyService == null ? List.of() : taxonomyService.getFingerprintTree());
}
private Locale resolveLocale(String language) {
if (language == null || language.isBlank()) {
return LocaleContextHolder.getLocale();
}
Locale locale = Locale.forLanguageTag(language.strip());
return locale.getLanguage().isBlank()
? LocaleContextHolder.getLocale() : locale;
}
private boolean isValid(DecisionReportRequest request) {
if (request == null
|| !boundedText(request.businessText(), MAX_REQUIREMENT_LENGTH, false)
|| request.scores() == null
|| request.scores().isEmpty()
|| request.scores().size() > MAX_SCORE_ENTRIES
|| !validScoreMap(request.scores())
|| !validOptionalScoreMap(request.rawScores())
|| (request.rawScores() != null
&& !request.rawScores().keySet().containsAll(request.scores().keySet()))
|| !validOptionalScoreMap(request.effectiveScores())
|| !validOptionalScoreMap(request.productSuitabilityScores())
|| !validScoreDetails(request.scoreDetails())
|| !boundedMetadata(request.provider())
|| !boundedMetadata(request.analysisStatus())
|| !boundedMetadata(request.language())
|| (request.scoreSemanticsVersion() != null
&& request.scoreSemanticsVersion() < 0)
|| (request.discrepancies() != null
&& request.discrepancies().size() > MAX_DISCREPANCIES)
|| (request.productCoverageGaps() != null
&& request.productCoverageGaps().size() > MAX_PRODUCT_COVERAGE_GAPS)) {
return false;
}
return validReasons(request.reasons())
&& validDiscrepancies(request.discrepancies())
&& validProductCoverageGaps(request.productCoverageGaps());
}
private boolean validOptionalScoreMap(Map<String, Integer> scores) {
return scores == null || (scores.size() <= MAX_SCORE_ENTRIES && validScoreMap(scores));
}
private boolean validScoreMap(Map<String, Integer> scores) {
return scores != null && scores.entrySet().stream().allMatch(entry ->
boundedNodeCode(entry.getKey())
&& entry.getValue() != null
&& entry.getValue() >= 0
&& entry.getValue() <= 100);
}
private boolean validScoreDetails(Map<String, AnalysisScoreDetail> details) {
if (details == null) {
return true;
}
if (details.size() > MAX_SCORE_ENTRIES) {
return false;
}
return details.entrySet().stream().allMatch(entry -> {
AnalysisScoreDetail detail = entry.getValue();
return boundedNodeCode(entry.getKey())
&& detail != null
&& entry.getKey().equals(detail.nodeCode())
&& detail.kind() != null
&& detail.rawScore() >= 0 && detail.rawScore() <= 100
&& detail.effectiveRelevance() >= 0
&& detail.effectiveRelevance() <= 100
&& (detail.parentCode() == null || boundedNodeCode(detail.parentCode()))
&& (detail.parentScore() == null
|| (detail.parentScore() >= 0 && detail.parentScore() <= 100));
});
}
private boolean validProductCoverageGaps(
List<ProductCoverageGap> productCoverageGaps) {
if (productCoverageGaps == null) {
return true;
}
int totalCandidates = 0;
Set<String> familyCodes = new HashSet<>();
for (ProductCoverageGap gap : productCoverageGaps) {
if (gap == null
|| !boundedNodeCode(gap.productFamilyCode())
|| !boundedText(gap.productFamilyName(), MAX_REASON_LENGTH, true)
|| gap.familyScore() < 0
|| gap.familyScore() > 100
|| !boundedText(gap.reason(), MAX_REASON_LENGTH, false)
|| gap.candidateCodes() == null
|| gap.candidateCodes().isEmpty()
|| !familyCodes.add(gap.productFamilyCode())) {
return false;
}
Set<String> candidateCodes = new HashSet<>();
for (String candidateCode : gap.candidateCodes()) {
totalCandidates++;
if (totalCandidates > MAX_PRODUCT_GAP_CANDIDATES
|| !boundedNodeCode(candidateCode)
|| !candidateCodes.add(candidateCode)) {
return false;
}
}
}
return true;
}
private boolean validDiscrepancies(
List<TaxonomyDiscrepancy> discrepancies) {
if (discrepancies == null) {
return true;
}
for (TaxonomyDiscrepancy discrepancy : discrepancies) {
if (discrepancy == null
|| !boundedText(discrepancy.parentCode(),
MAX_NODE_CODE_LENGTH, false)
|| discrepancy.expectedParentScore() < 0
|| discrepancy.expectedParentScore() > 100
|| discrepancy.actualChildSum() < 0) {
return false;
}
}
return true;
}
private boolean validReasons(Map<String, String> reasons) {
if (reasons == null) {
return true;
}
if (reasons.size() > MAX_REASON_ENTRIES) {
return false;
}
long totalCharacters = 0;
for (Map.Entry<String, String> entry : reasons.entrySet()) {
if (!boundedText(entry.getKey(), MAX_NODE_CODE_LENGTH, false)
|| !boundedText(entry.getValue(), MAX_REASON_LENGTH, true)) {
return false;
}
if (entry.getValue() != null) {
totalCharacters += entry.getValue().length();
if (totalCharacters > MAX_TOTAL_REASON_CHARACTERS) {
return false;
}
}
}
return true;
}
private boolean boundedNodeCode(String value) {
return boundedText(value, MAX_NODE_CODE_LENGTH, false)
&& value.equals(value.strip());
}
private boolean boundedMetadata(String value) {
return boundedText(value, MAX_METADATA_LENGTH, true);
}
private boolean boundedText(String value, int maxLength, boolean nullable) {
if (value == null) {
return nullable;
}
return value.length() <= maxLength && (nullable || !value.isBlank());
}
}