DecisionRationaleReportService.java
package com.taxonomy.architecture.decision;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ChildDecision;
import com.taxonomy.architecture.decision.DecisionRationaleReport.DecisionChapter;
import com.taxonomy.architecture.decision.DecisionRationaleReport.Disposition;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ExecutiveSummary;
import com.taxonomy.architecture.decision.DecisionRationaleReport.LeafCandidate;
import com.taxonomy.architecture.decision.DecisionRationaleReport.PathStep;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ReasonSource;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ReportMetadata;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ReportStatus;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.service.TaxonomyService;
import com.taxonomy.dto.AnalysisScoreDetail;
import com.taxonomy.dto.ProductCoverageGap;
import com.taxonomy.dto.TaxonomyDataFingerprint;
import com.taxonomy.dto.TaxonomyDiscrepancy;
import com.taxonomy.dto.TaxonomyNodeDto;
import com.taxonomy.dto.ViewContext;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Builds a traceable report from one completed or partially completed hierarchy analysis.
*
* <p>The service does not call an LLM while the report is generated. It reuses the original
* AI reasons captured during scoring, marks deterministic fallback text explicitly, and keeps
* all numeric values and hierarchy links immutable. Consequently, regenerating a report from
* the same analysis snapshot and taxonomy data produces the same decision content.</p>
*/
@Service
public class DecisionRationaleReportService {
private static final Comparator<TaxonomyNode> NODE_ORDER = Comparator
.comparing((TaxonomyNode node) -> node.getSortOrder() == null
? Integer.MAX_VALUE : node.getSortOrder())
.thenComparing(TaxonomyNode::getCode, Comparator.nullsLast(String::compareTo));
private final TaxonomyService taxonomyService;
private final TaxonomyCatalogueMetadataService catalogueMetadataService;
private final DecisionReportBuildMetadataService buildMetadataService;
private final String reportTimeZone;
public DecisionRationaleReportService(
TaxonomyService taxonomyService,
TaxonomyCatalogueMetadataService catalogueMetadataService,
DecisionReportBuildMetadataService buildMetadataService,
@Value("${taxonomy.report.time-zone:Europe/Berlin}") String reportTimeZone) {
this.taxonomyService = taxonomyService;
this.catalogueMetadataService = catalogueMetadataService;
this.buildMetadataService = buildMetadataService;
this.reportTimeZone = normalized(reportTimeZone, "Europe/Berlin");
}
/** Input captured from the analysis session or an immutable portfolio snapshot. */
public record DecisionAnalysisInput(
String businessText,
Map<String, Integer> scores,
Map<String, String> reasons,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies,
List<ProductCoverageGap> productCoverageGaps,
List<TaxonomyNodeDto> taxonomyTree,
AnalysisSnapshotProvenance snapshotProvenance,
Map<String, AnalysisScoreDetail> scoreDetails) {
public DecisionAnalysisInput {
scores = scores == null ? Map.of() : Map.copyOf(scores);
reasons = reasons == null ? Map.of() : Map.copyOf(reasons);
discrepancies = discrepancies == null ? List.of() : List.copyOf(discrepancies);
productCoverageGaps = productCoverageGaps == null
? List.of() : List.copyOf(productCoverageGaps);
taxonomyTree = taxonomyTree == null ? List.of() : List.copyOf(taxonomyTree);
scoreDetails = scoreDetails == null ? Map.of() : Map.copyOf(scoreDetails);
}
/** Backward-compatible full input without typed score evidence. */
public DecisionAnalysisInput(
String businessText,
Map<String, Integer> scores,
Map<String, String> reasons,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies,
List<ProductCoverageGap> productCoverageGaps,
List<TaxonomyNodeDto> taxonomyTree,
AnalysisSnapshotProvenance snapshotProvenance) {
this(businessText, scores, reasons, provider, analysisStatus, discrepancies,
productCoverageGaps, taxonomyTree, snapshotProvenance, Map.of());
}
/** Backward-compatible ad-hoc analysis input using the currently loaded hierarchy. */
public DecisionAnalysisInput(
String businessText,
Map<String, Integer> scores,
Map<String, String> reasons,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies) {
this(businessText, scores, reasons, provider, analysisStatus, discrepancies,
List.of(), List.of(), null, Map.of());
}
/** Ad-hoc analysis input including explicit concrete-product coverage evidence. */
public DecisionAnalysisInput(
String businessText,
Map<String, Integer> scores,
Map<String, String> reasons,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies,
List<ProductCoverageGap> productCoverageGaps) {
this(businessText, scores, reasons, provider, analysisStatus, discrepancies,
productCoverageGaps, List.of(), null, Map.of());
}
/** Backward-compatible immutable-snapshot input without product-gap evidence. */
public DecisionAnalysisInput(
String businessText,
Map<String, Integer> scores,
Map<String, String> reasons,
String provider,
String analysisStatus,
List<TaxonomyDiscrepancy> discrepancies,
List<TaxonomyNodeDto> taxonomyTree,
AnalysisSnapshotProvenance snapshotProvenance) {
this(businessText, scores, reasons, provider, analysisStatus, discrepancies,
List.of(), taxonomyTree, snapshotProvenance, Map.of());
}
}
/** Immutable evidence copied from a persisted requirement-analysis snapshot. */
public record AnalysisSnapshotProvenance(
String snapshotId,
Long projectId,
Long requirementId,
Long requirementVersionId,
Integer requirementVersionNumber,
Instant createdAt,
String createdBy,
String modelName,
String taxonomyFingerprintSha256,
String promptFingerprintSha256) {
}
@Transactional(readOnly = true)
public DecisionRationaleReport generate(
DecisionAnalysisInput input,
WorkspaceContext workspaceContext,
ViewContext viewContext,
Locale locale) {
Objects.requireNonNull(input, "input must not be null");
Objects.requireNonNull(workspaceContext, "workspaceContext must not be null");
Locale effectiveLocale = locale == null ? Locale.ENGLISH : locale;
boolean german = "de".equalsIgnoreCase(effectiveLocale.getLanguage());
Map<String, Integer> scores = sanitizeScores(input.scores());
Map<String, String> reasons = sanitizeReasons(input.reasons());
HierarchyData hierarchy = input.taxonomyTree().isEmpty()
? currentHierarchy()
: hierarchyFromSnapshot(input.taxonomyTree());
List<TaxonomyNode> roots = hierarchy.roots();
Map<String, List<TaxonomyNode>> childrenMap = hierarchy.childrenMap();
Map<String, TaxonomyNode> nodesByCode = indexNodes(roots, childrenMap);
List<TaxonomyNode> hierarchyOrder = preOrder(roots, childrenMap);
List<ProductCoverageGap> productCoverageGaps = validateProductCoverageGaps(
input.productCoverageGaps(), nodesByCode, childrenMap, scores);
Completeness completeness = assessCompleteness(
roots, hierarchyOrder, childrenMap, scores, productCoverageGaps);
List<DecisionChapter> chapters = buildChapters(
hierarchyOrder, childrenMap, scores, reasons, german);
List<LeafCandidate> leaves = findLeadingLeaves(
hierarchyOrder, childrenMap, scores, reasons, german);
LeafCandidate leadingLeaf = leaves.isEmpty() ? null : leaves.get(0);
List<PathStep> leadingPath = leadingLeaf == null
? List.of()
: buildPath(leadingLeaf.code(), nodesByCode, scores, reasons, german);
List<String> warnings = new ArrayList<>(buildWarnings(
input, viewContext, completeness, scores, reasons, leaves,
nodesByCode.keySet(), childrenMap, productCoverageGaps, german));
Instant generatedAt = Instant.now();
TaxonomyCatalogueMetadataService.CatalogueMetadata catalogue =
catalogueMetadataService.getMetadata();
DecisionReportBuildMetadataService.BuildMetadata buildMetadata =
buildMetadataService.current();
List<TaxonomyNodeDto> fingerprintTree = input.taxonomyTree().isEmpty()
? taxonomyService.toFingerprintTree(nodesByCode.values()) : input.taxonomyTree();
String actualDataFingerprint = TaxonomyDataFingerprint.sha256(fingerprintTree);
String analysisSnapshotFingerprint = fingerprintAnalysis(
input, scores, reasons, productCoverageGaps);
if (input.snapshotProvenance() != null
&& input.snapshotProvenance().taxonomyFingerprintSha256() != null
&& !input.snapshotProvenance().taxonomyFingerprintSha256().isBlank()
&& !TaxonomyDataFingerprint.matchesRecorded(
input.snapshotProvenance().taxonomyFingerprintSha256(),
fingerprintTree)) {
warnings.add(german
? "Der im Analysesnapshot gespeicherte Taxonomie-Fingerabdruck stimmt nicht mit der eingefrorenen Hierarchie im Analyse-Payload überein. Der Bericht bleibt nachvollziehbar, muss aber fachlich und technisch geprüft werden."
: "The taxonomy fingerprint stored in the analysis snapshot does not match the frozen hierarchy in the analysis payload. The report remains traceable but requires technical and substantive review.");
}
ReportStatus reportStatus = determineStatus(
completeness.complete(), input.analysisStatus(), leaves, warnings,
input.discrepancies());
int suppliedReasonCount = (int) reasons.values().stream()
.filter(reason -> reason != null && !reason.isBlank())
.count();
int positiveNodeCount = (int) scores.values().stream().filter(value -> value > 0).count();
String branch = firstNonBlank(
viewContext != null ? viewContext.basedOnBranch() : null,
workspaceContext.currentBranch(),
"unknown");
String basedOnCommit = viewContext != null
? normalized(viewContext.basedOnCommit(), "unknown") : "unknown";
boolean immutableSnapshot = !input.taxonomyTree().isEmpty();
String recordedTaxonomyFingerprint = snapshotValue(
input, AnalysisSnapshotProvenance::taxonomyFingerprintSha256, "unknown");
String catalogueFile = immutableSnapshot
? "analysisPayload.tree · snapshot "
+ snapshotValue(input, AnalysisSnapshotProvenance::snapshotId, "unknown")
: catalogue.filename();
String dataVersion = immutableSnapshot
? "snapshot fingerprint " + abbreviateHash(recordedTaxonomyFingerprint)
: catalogue.version();
String catalogueResourceFingerprint = immutableSnapshot
? "not persisted separately in the historical snapshot"
: catalogue.sha256();
String dataSource = immutableSnapshot
? (german ? "Unveränderlicher Anforderungs-Analysesnapshot"
: "Immutable requirement analysis snapshot")
: catalogue.source();
ReportMetadata metadata = new ReportMetadata(
generatedAt,
normalized(workspaceContext.username(), "system"),
buildMetadata.version(),
buildMetadata.commit(),
catalogueFile,
dataVersion,
catalogueResourceFingerprint,
actualDataFingerprint,
analysisSnapshotFingerprint,
dataSource,
nodesByCode.size(),
roots.size(),
workspaceContext.repositoryId(),
normalized(workspaceContext.workspaceId(), german ? "Zentral" : "Central"),
branch,
basedOnCommit,
viewContext != null ? viewContext.commitTimestamp() : null,
viewContext != null && viewContext.projectionStale(),
viewContext != null && viewContext.indexStale(),
normalized(input.provider(), "unknown"),
normalized(input.analysisStatus(), "UNKNOWN"),
snapshotValue(input, AnalysisSnapshotProvenance::modelName, "unknown"),
snapshotValue(input, AnalysisSnapshotProvenance::snapshotId, "ad-hoc"),
input.snapshotProvenance() != null ? input.snapshotProvenance().projectId() : null,
input.snapshotProvenance() != null ? input.snapshotProvenance().requirementId() : null,
input.snapshotProvenance() != null ? input.snapshotProvenance().requirementVersionId() : null,
input.snapshotProvenance() != null ? input.snapshotProvenance().requirementVersionNumber() : null,
input.snapshotProvenance() != null ? input.snapshotProvenance().createdAt() : null,
snapshotValue(input, AnalysisSnapshotProvenance::createdBy, "unknown"),
recordedTaxonomyFingerprint,
snapshotValue(input, AnalysisSnapshotProvenance::promptFingerprintSha256, "unknown"),
immutableSnapshot,
reportTimeZone,
suppliedReasonCount,
scores.size(),
positiveNodeCount,
completeness.percent());
ExecutiveSummary executiveSummary = new ExecutiveSummary(
leadingLeaf,
leadingPath,
conciseConclusion(leadingLeaf, leadingPath, german),
methodologyNote(german));
return new DecisionRationaleReport(
german ? "Hierarchischer Entscheidungs- und Begründungsbericht"
: "Hierarchical Decision Rationale Report",
effectiveLocale.toLanguageTag(),
normalized(input.businessText(), ""),
reportStatus,
metadata,
executiveSummary,
chapters,
leaves,
warnings,
productCoverageGaps,
input.discrepancies(),
viewContext);
}
private HierarchyData currentHierarchy() {
List<TaxonomyNode> roots = new ArrayList<>(taxonomyService.getRootNodes());
roots.sort(NODE_ORDER);
return new HierarchyData(
List.copyOf(roots),
Map.copyOf(copyAndSort(taxonomyService.getChildrenMap())));
}
/**
* Reconstructs transient domain nodes from the hierarchy frozen inside an immutable
* {@link com.taxonomy.dto.AnalysisResult}. No current catalogue row is consulted.
*/
private HierarchyData hierarchyFromSnapshot(List<TaxonomyNodeDto> tree) {
if (tree == null || tree.isEmpty()) {
throw new IllegalArgumentException(
"The analysis snapshot does not contain a frozen taxonomy hierarchy");
}
List<TaxonomyNode> roots = new ArrayList<>();
Map<String, List<TaxonomyNode>> childrenMap = new LinkedHashMap<>();
Set<String> codes = new LinkedHashSet<>();
for (TaxonomyNodeDto rootDto : tree) {
TaxonomyNode root = copySnapshotNode(rootDto, null, childrenMap, codes);
if (root != null) {
roots.add(root);
}
}
roots.sort(NODE_ORDER);
return new HierarchyData(List.copyOf(roots), immutableChildrenMap(childrenMap));
}
private TaxonomyNode copySnapshotNode(
TaxonomyNodeDto dto,
String inheritedParentCode,
Map<String, List<TaxonomyNode>> childrenMap,
Set<String> codes) {
if (dto == null || dto.getCode() == null || dto.getCode().isBlank()) {
return null;
}
String code = dto.getCode().strip();
if (!codes.add(code)) {
throw new IllegalArgumentException(
"The frozen taxonomy hierarchy contains duplicate node code " + code);
}
TaxonomyNode node = new TaxonomyNode();
node.setCode(code);
node.setUuid(dto.getUuid());
node.setNameEn(dto.getNameEn());
node.setNameDe(dto.getNameDe());
node.setDescriptionEn(dto.getDescriptionEn());
node.setDescriptionDe(dto.getDescriptionDe());
node.setParentCode(firstNonBlank(dto.getParentCode(), inheritedParentCode));
node.setTaxonomyRoot(dto.getTaxonomyRoot());
node.setLevel(dto.getLevel());
node.setDataset(dto.getDataset());
node.setExternalId(dto.getExternalId());
node.setSource(dto.getSource());
node.setReference(dto.getReference());
node.setSortOrder(dto.getSortOrder());
node.setState(dto.getState());
List<TaxonomyNode> children = new ArrayList<>();
for (TaxonomyNodeDto childDto : dto.getChildren() == null
? List.<TaxonomyNodeDto>of() : dto.getChildren()) {
TaxonomyNode child = copySnapshotNode(childDto, code, childrenMap, codes);
if (child != null) {
children.add(child);
}
}
children.sort(NODE_ORDER);
if (!children.isEmpty()) {
childrenMap.put(code, List.copyOf(children));
}
return node;
}
private Map<String, List<TaxonomyNode>> immutableChildrenMap(
Map<String, List<TaxonomyNode>> source) {
Map<String, List<TaxonomyNode>> immutable = new LinkedHashMap<>();
source.forEach((code, children) -> immutable.put(code, List.copyOf(children)));
return Map.copyOf(immutable);
}
private String snapshotValue(
DecisionAnalysisInput input,
java.util.function.Function<AnalysisSnapshotProvenance, String> accessor,
String fallback) {
if (input.snapshotProvenance() == null) {
return fallback;
}
return normalized(accessor.apply(input.snapshotProvenance()), fallback);
}
private Map<String, Integer> sanitizeScores(Map<String, Integer> source) {
Map<String, Integer> result = new LinkedHashMap<>();
if (source == null) {
return result;
}
source.forEach((code, value) -> {
if (code == null || code.isBlank() || value == null) {
return;
}
if (value < 0 || value > 100) {
throw new IllegalArgumentException(
"Score for node " + code + " must be between 0 and 100");
}
result.put(code.strip(), value);
});
return result;
}
private Map<String, String> sanitizeReasons(Map<String, String> source) {
Map<String, String> result = new HashMap<>();
if (source == null) {
return result;
}
source.forEach((code, reason) -> {
if (code != null && !code.isBlank() && reason != null && !reason.isBlank()) {
result.put(code.strip(), reason.strip());
}
});
return result;
}
private List<ProductCoverageGap> validateProductCoverageGaps(
List<ProductCoverageGap> source,
Map<String, TaxonomyNode> nodesByCode,
Map<String, List<TaxonomyNode>> childrenMap,
Map<String, Integer> scores) {
if (source == null || source.isEmpty()) {
return List.of();
}
List<ProductCoverageGap> ordered = new ArrayList<>(source);
if (ordered.stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("Product coverage gaps must not contain null entries");
}
ordered.sort(Comparator.comparing(
ProductCoverageGap::productFamilyCode,
Comparator.nullsLast(String::compareTo)));
Map<String, ProductCoverageGap> validated = new LinkedHashMap<>();
for (ProductCoverageGap gap : ordered) {
if (gap.productFamilyCode() == null || gap.productFamilyCode().isBlank()) {
throw new IllegalArgumentException("Product coverage gap has no product family code");
}
String familyCode = gap.productFamilyCode().strip();
TaxonomyNode family = nodesByCode.get(familyCode);
if (family == null) {
throw new IllegalArgumentException(
"Product coverage gap references unknown family " + familyCode);
}
if (gap.familyScore() < 0 || gap.familyScore() > 100) {
throw new IllegalArgumentException(
"Product coverage gap family score must be between 0 and 100 for "
+ familyCode);
}
Integer storedFamilyScore = scores.get(familyCode);
if (storedFamilyScore == null || storedFamilyScore != gap.familyScore()) {
throw new IllegalArgumentException(
"Product coverage gap family score does not match analysis score for "
+ familyCode);
}
if (gap.reason() == null || gap.reason().isBlank()) {
throw new IllegalArgumentException(
"Product coverage gap has no reason for " + familyCode);
}
if (gap.candidateCodes() == null || gap.candidateCodes().isEmpty()) {
throw new IllegalArgumentException(
"Product coverage gap has no evaluated product candidates for "
+ familyCode);
}
Set<String> candidateSet = new LinkedHashSet<>();
for (String rawCode : gap.candidateCodes()) {
if (rawCode == null || rawCode.isBlank()) {
throw new IllegalArgumentException(
"Product coverage gap contains a blank candidate for " + familyCode);
}
String candidateCode = rawCode.strip();
if (!candidateSet.add(candidateCode)) {
throw new IllegalArgumentException(
"Product coverage gap contains duplicate candidate "
+ candidateCode + " for " + familyCode);
}
}
List<String> candidateCodes = candidateSet.stream().sorted().toList();
Set<String> directChildCodes = childrenMap
.getOrDefault(familyCode, List.of()).stream()
.map(TaxonomyNode::getCode)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (!directChildCodes.containsAll(candidateCodes)) {
throw new IllegalArgumentException(
"Product coverage gap candidates are not direct children of "
+ familyCode);
}
if (candidateCodes.stream().anyMatch(code ->
!scores.containsKey(code) || scores.get(code) != 0)) {
throw new IllegalArgumentException(
"Product coverage gap candidates must be explicitly evaluated at 0 for "
+ familyCode);
}
ProductCoverageGap normalizedGap = new ProductCoverageGap(
familyCode,
firstNonBlank(gap.productFamilyName(), family.getName(), familyCode),
gap.familyScore(),
candidateCodes,
gap.reason().strip());
if (validated.putIfAbsent(familyCode, normalizedGap) != null) {
throw new IllegalArgumentException(
"Duplicate product coverage gap for family " + familyCode);
}
}
return List.copyOf(validated.values());
}
private Map<String, List<TaxonomyNode>> copyAndSort(
Map<String, List<TaxonomyNode>> original) {
Map<String, List<TaxonomyNode>> result = new HashMap<>();
if (original == null) {
return result;
}
original.forEach((parentCode, children) -> {
List<TaxonomyNode> sorted = new ArrayList<>(children == null ? List.of() : children);
sorted.sort(NODE_ORDER);
result.put(parentCode, List.copyOf(sorted));
});
return result;
}
private Map<String, TaxonomyNode> indexNodes(
List<TaxonomyNode> roots,
Map<String, List<TaxonomyNode>> childrenMap) {
Map<String, TaxonomyNode> result = new LinkedHashMap<>();
roots.forEach(node -> result.put(node.getCode(), node));
childrenMap.values().forEach(children -> children.forEach(
node -> result.putIfAbsent(node.getCode(), node)));
return result;
}
private List<TaxonomyNode> preOrder(
List<TaxonomyNode> roots,
Map<String, List<TaxonomyNode>> childrenMap) {
List<TaxonomyNode> ordered = new ArrayList<>();
Set<String> visited = new LinkedHashSet<>();
for (TaxonomyNode root : roots) {
visit(root, childrenMap, visited, ordered);
}
return ordered;
}
private void visit(
TaxonomyNode node,
Map<String, List<TaxonomyNode>> childrenMap,
Set<String> visited,
List<TaxonomyNode> ordered) {
if (node == null || node.getCode() == null || !visited.add(node.getCode())) {
return;
}
ordered.add(node);
for (TaxonomyNode child : childrenMap.getOrDefault(node.getCode(), List.of())) {
visit(child, childrenMap, visited, ordered);
}
}
private Completeness assessCompleteness(
List<TaxonomyNode> roots,
List<TaxonomyNode> hierarchyOrder,
Map<String, List<TaxonomyNode>> childrenMap,
Map<String, Integer> scores,
List<ProductCoverageGap> productCoverageGaps) {
Set<String> expectedCodes = new LinkedHashSet<>();
roots.stream().map(TaxonomyNode::getCode).forEach(expectedCodes::add);
List<String> incompleteParents = new ArrayList<>();
List<String> unresolvedParents = new ArrayList<>();
for (TaxonomyNode node : hierarchyOrder) {
List<TaxonomyNode> children = childrenMap.getOrDefault(node.getCode(), List.of());
if (children.isEmpty()) {
continue;
}
Integer score = scores.get(node.getCode());
List<String> childCodes = children.stream().map(TaxonomyNode::getCode).toList();
boolean anyPositiveChild = childCodes.stream()
.anyMatch(code -> scores.getOrDefault(code, 0) > 0);
// A positive child cannot form a traceable decision when its parent was not
// evaluated positively. Keep the chapter visible, but mark the snapshot incomplete.
if (anyPositiveChild && (score == null || score <= 0)) {
expectedCodes.add(node.getCode());
expectedCodes.addAll(childCodes);
incompleteParents.add(node.getCode());
continue;
}
if (score == null || score <= 0) {
continue;
}
expectedCodes.addAll(childCodes);
boolean allEvaluated = childCodes.stream().allMatch(scores::containsKey);
if (!allEvaluated) {
incompleteParents.add(node.getCode());
} else if (!anyPositiveChild
&& !hasResolvedProductCoverageGap(
node.getCode(), score, childCodes, scores, productCoverageGaps)) {
unresolvedParents.add(node.getCode());
}
}
long evaluatedExpected = expectedCodes.stream().filter(scores::containsKey).count();
double percent = expectedCodes.isEmpty()
? 100.0
: roundOneDecimal(evaluatedExpected * 100.0 / expectedCodes.size());
return new Completeness(
incompleteParents.isEmpty()
&& unresolvedParents.isEmpty()
&& evaluatedExpected == expectedCodes.size(),
percent,
List.copyOf(incompleteParents),
List.copyOf(unresolvedParents));
}
private boolean hasResolvedProductCoverageGap(
String familyCode,
Integer familyScore,
List<String> directChildCodes,
Map<String, Integer> scores,
List<ProductCoverageGap> productCoverageGaps) {
return productCoverageGaps.stream().anyMatch(gap ->
familyCode.equals(gap.productFamilyCode())
&& familyScore != null
&& familyScore == gap.familyScore()
&& !gap.candidateCodes().isEmpty()
&& directChildCodes.containsAll(gap.candidateCodes())
&& gap.candidateCodes().stream().allMatch(code ->
scores.containsKey(code) && scores.get(code) == 0));
}
private List<DecisionChapter> buildChapters(
List<TaxonomyNode> hierarchyOrder,
Map<String, List<TaxonomyNode>> childrenMap,
Map<String, Integer> scores,
Map<String, String> reasons,
boolean german) {
List<DecisionChapter> chapters = new ArrayList<>();
int chapterNumber = 1;
for (TaxonomyNode parent : hierarchyOrder) {
List<TaxonomyNode> children = childrenMap.getOrDefault(parent.getCode(), List.of());
if (children.isEmpty()
|| children.stream().noneMatch(child -> scores.getOrDefault(child.getCode(), 0) > 0)) {
continue;
}
List<String> missingChildCodes = children.stream()
.map(TaxonomyNode::getCode)
.filter(code -> !scores.containsKey(code))
.toList();
Integer parentScore = scores.get(parent.getCode());
int highestChildScore = children.stream()
.map(TaxonomyNode::getCode)
.map(scores::get)
.filter(Objects::nonNull)
.mapToInt(Integer::intValue)
.max()
.orElse(0);
List<TaxonomyNode> rankingOrder = children.stream()
.filter(child -> scores.getOrDefault(child.getCode(), 0) > 0)
.sorted(Comparator
.comparingInt((TaxonomyNode child) -> scores.get(child.getCode())).reversed()
.thenComparing(NODE_ORDER))
.toList();
Map<String, Integer> ranks = new HashMap<>();
int previousScore = Integer.MIN_VALUE;
int previousRank = 0;
for (int index = 0; index < rankingOrder.size(); index++) {
TaxonomyNode child = rankingOrder.get(index);
int childScore = scores.get(child.getCode());
int rank = childScore == previousScore ? previousRank : index + 1;
ranks.put(child.getCode(), rank);
previousScore = childScore;
previousRank = rank;
}
List<ChildDecision> childDecisions = new ArrayList<>();
for (TaxonomyNode child : children) {
Integer score = scores.get(child.getCode());
boolean leaf = childrenMap.getOrDefault(child.getCode(), List.of()).isEmpty();
Disposition disposition;
if (score == null) {
disposition = Disposition.NOT_EVALUATED;
} else if (score == 0) {
disposition = Disposition.REJECTED;
} else if (leaf) {
disposition = Disposition.LEAF_CANDIDATE;
} else {
disposition = Disposition.CONTINUED;
}
Reason reason = reasonFor(child, score, reasons, german);
childDecisions.add(new ChildDecision(
child.getCode(),
displayName(child, german),
displayDescription(child, german),
score,
localShare(score, parentScore),
score != null && score > 0 ? ranks.get(child.getCode()) : null,
score != null && score > 0 && score == highestChildScore,
disposition,
reason.text(),
reason.source(),
leaf));
}
childDecisions.sort(Comparator
.comparing((ChildDecision child) -> child.absoluteScore() == null)
.thenComparing((ChildDecision child) -> child.absoluteScore() == null
? Integer.MIN_VALUE : child.absoluteScore(), Comparator.reverseOrder())
.thenComparing(ChildDecision::code));
chapters.add(new DecisionChapter(
chapterNumber++,
parent.getCode(),
displayName(parent, german),
displayDescription(parent, german),
parentScore,
parent.getLevel(),
missingChildCodes.isEmpty(),
decisionSummary(parent, childDecisions, german),
comparativeRationale(parent, parentScore, childDecisions, german),
childDecisions,
missingChildCodes));
}
return List.copyOf(chapters);
}
private List<LeafCandidate> findLeadingLeaves(
List<TaxonomyNode> hierarchyOrder,
Map<String, List<TaxonomyNode>> childrenMap,
Map<String, Integer> scores,
Map<String, String> reasons,
boolean german) {
List<LeafCandidate> leaves = hierarchyOrder.stream()
.filter(node -> childrenMap.getOrDefault(node.getCode(), List.of()).isEmpty())
.filter(node -> scores.getOrDefault(node.getCode(), 0) > 0)
.map(node -> {
Reason reason = reasonFor(node, scores.get(node.getCode()), reasons, german);
List<TaxonomyNode> path = pathToRoot(node.getCode(), hierarchyOrder);
return new LeafCandidate(
node.getCode(),
displayName(node, german),
scores.get(node.getCode()),
normalized(node.getTaxonomyRoot(), rootFromCode(node.getCode())),
node.getLevel(),
path.stream().map(TaxonomyNode::getCode).collect(Collectors.joining(" → ")),
reason.text(),
reason.source());
})
.sorted(Comparator
.comparingInt(LeafCandidate::score).reversed()
.thenComparing(Comparator.comparingInt(LeafCandidate::depth).reversed())
.thenComparing(LeafCandidate::code))
.limit(20)
.toList();
return List.copyOf(leaves);
}
/**
* Builds a path without issuing one query per level. The supplied hierarchy list contains
* all current nodes, so parent links can be followed deterministically in memory.
*/
private List<TaxonomyNode> pathToRoot(String code, Collection<TaxonomyNode> nodes) {
Map<String, TaxonomyNode> byCode = nodes.stream()
.filter(node -> node.getCode() != null)
.collect(Collectors.toMap(
TaxonomyNode::getCode,
node -> node,
(left, right) -> left,
LinkedHashMap::new));
List<TaxonomyNode> reversed = new ArrayList<>();
Set<String> visited = new LinkedHashSet<>();
TaxonomyNode current = byCode.get(code);
while (current != null && visited.add(current.getCode())) {
reversed.add(current);
String parentCode = current.getParentCode();
current = parentCode == null ? null : byCode.get(parentCode);
}
java.util.Collections.reverse(reversed);
return List.copyOf(reversed);
}
private List<PathStep> buildPath(
String leafCode,
Map<String, TaxonomyNode> nodesByCode,
Map<String, Integer> scores,
Map<String, String> reasons,
boolean german) {
List<TaxonomyNode> path = pathToRoot(leafCode, nodesByCode.values());
List<PathStep> result = new ArrayList<>();
for (int index = 0; index < path.size(); index++) {
TaxonomyNode node = path.get(index);
Integer score = scores.get(node.getCode());
Integer parentScore = index == 0 ? null : scores.get(path.get(index - 1).getCode());
Reason reason = reasonFor(node, score, reasons, german);
result.add(new PathStep(
index + 1,
node.getCode(),
displayName(node, german),
score,
index == 0 ? null : localShare(score, parentScore),
reason.text(),
reason.source()));
}
return List.copyOf(result);
}
private List<String> buildWarnings(
DecisionAnalysisInput input,
ViewContext viewContext,
Completeness completeness,
Map<String, Integer> scores,
Map<String, String> reasons,
List<LeafCandidate> leaves,
Set<String> knownNodeCodes,
Map<String, List<TaxonomyNode>> childrenMap,
List<ProductCoverageGap> productCoverageGaps,
boolean german) {
List<String> warnings = new ArrayList<>();
if (input.snapshotProvenance() == null) {
warnings.add(german
? "Dieser Bericht wurde aus dem aktuellen Analysezustand erzeugt und ist nicht an einen unveränderlichen Projekt-Analysesnapshot gebunden. Für einen formalen Entscheidungsnachweis sollte der snapshotbasierte Export verwendet werden."
: "This report was generated from the current analysis state and is not bound to an immutable project-analysis snapshot. Use the snapshot-backed export for formal decision evidence.");
}
List<String> unknownCodes = scores.keySet().stream()
.filter(code -> !knownNodeCodes.contains(code))
.sorted()
.toList();
if (!unknownCodes.isEmpty()) {
warnings.add(german
? "Bewertungen verweisen auf Knoten, die im aktuell geladenen Taxonomiedatenbestand nicht vorkommen: "
+ String.join(", ", unknownCodes) + "."
: "Scores refer to nodes that do not exist in the currently loaded taxonomy data: "
+ String.join(", ", unknownCodes) + ".");
}
if (!completeness.incompleteParents().isEmpty()) {
warnings.add(german
? "Die Analyse ist unvollständig. Bei folgenden positiven Vaterknoten wurden nicht alle direkten Kinder bewertet: "
+ String.join(", ", completeness.incompleteParents()) + "."
: "The analysis is incomplete. Not all direct children were evaluated for these positive parent nodes: "
+ String.join(", ", completeness.incompleteParents()) + ".");
}
if (!completeness.unresolvedParents().isEmpty()) {
warnings.add(german
? "Positive Vaterknoten ohne positiv bewertetes direktes Kind: "
+ String.join(", ", completeness.unresolvedParents()) + "."
: "Positive parent nodes without a positively scored direct child: "
+ String.join(", ", completeness.unresolvedParents()) + ".");
}
if (leaves.isEmpty()) {
warnings.add(german
? "Es wurde kein positiv bewerteter tatsächlicher Blattknoten ermittelt."
: "No positively scored actual leaf node was identified.");
}
long missingPositiveReasons = scores.entrySet().stream()
.filter(entry -> entry.getValue() != null && entry.getValue() > 0)
.filter(entry -> !reasons.containsKey(entry.getKey()))
.count();
if (missingPositiveReasons > 0) {
warnings.add(german
? "Für " + missingPositiveReasons
+ " positiv bewertete Knoten lag keine ursprüngliche KI-Einzelbegründung vor; diese Stellen sind im Bericht als deterministische Herleitung gekennzeichnet."
: "No original AI scoring reason was available for " + missingPositiveReasons
+ " positively scored nodes; those passages are marked as deterministic derivations.");
}
List<String> inconsistentBudgets = new ArrayList<>();
for (Map.Entry<String, List<TaxonomyNode>> entry : childrenMap.entrySet()) {
Integer parentScore = scores.get(entry.getKey());
if (parentScore == null || parentScore <= 0) {
continue;
}
List<TaxonomyNode> budgetChildren = entry.getValue().stream()
.filter(child -> {
AnalysisScoreDetail detail = input.scoreDetails().get(child.getCode());
return detail == null || !detail.isProductSuitability();
})
.toList();
if (budgetChildren.isEmpty()) {
continue;
}
boolean allEvaluated = budgetChildren.stream()
.map(TaxonomyNode::getCode)
.allMatch(scores::containsKey);
if (!allEvaluated) {
continue;
}
int childTotal = budgetChildren.stream()
.map(TaxonomyNode::getCode)
.mapToInt(code -> scores.getOrDefault(code, 0))
.sum();
if (childTotal != 0 && childTotal != parentScore) {
inconsistentBudgets.add(entry.getKey() + " (" + childTotal + "% / "
+ parentScore + "%)");
}
}
if (!inconsistentBudgets.isEmpty()) {
warnings.add(german
? "Bei folgenden Vaterknoten entspricht die Summe der vollständig bewerteten direkten Kinder nicht dem gespeicherten Vaterbudget (Kindersumme / Vaterwert): "
+ String.join(", ", inconsistentBudgets) + "."
: "For the following parent nodes, the sum of fully evaluated direct children does not match the stored parent budget (child sum / parent score): "
+ String.join(", ", inconsistentBudgets) + ".");
}
for (ProductCoverageGap gap : productCoverageGaps) {
String candidates = String.join(", ", gap.candidateCodes());
warnings.add(german
? "Produktabdeckungslücke für " + gap.productFamilyCode() + " ("
+ gap.productFamilyName() + ", " + gap.familyScore()
+ " %): Keines der " + gap.candidateCodes().size()
+ " vollständig bewerteten Katalogprodukte erreichte den Eignungsschwellenwert. Kandidaten: "
+ candidates + "."
: "Product coverage gap for " + gap.productFamilyCode() + " ("
+ gap.productFamilyName() + ", " + gap.familyScore()
+ "%): None of the " + gap.candidateCodes().size()
+ " fully evaluated catalogue products reached the suitability threshold. Candidates: "
+ candidates + ".");
}
if (input.analysisStatus() != null
&& !"SUCCESS".equalsIgnoreCase(input.analysisStatus())) {
warnings.add(german
? "Der Analysezustand lautet " + input.analysisStatus()
+ "; das Ergebnis darf nicht ohne Prüfung als vollständig behandelt werden."
: "The analysis status is " + input.analysisStatus()
+ "; the result must not be treated as complete without review.");
}
if (viewContext != null && viewContext.projectionStale()) {
warnings.add(german
? "Die zugrunde liegende relationale Projektion war bei der Berichterzeugung als veraltet markiert."
: "The underlying relational projection was marked stale when the report was generated.");
}
if (viewContext != null && viewContext.indexStale()) {
warnings.add(german
? "Der zugrunde liegende Suchindex war bei der Berichterzeugung als veraltet markiert."
: "The underlying search index was marked stale when the report was generated.");
}
if (input.discrepancies() != null && !input.discrepancies().isEmpty()) {
warnings.add(german
? "Die Analyse enthält " + input.discrepancies().size()
+ " dokumentierte Abweichung(en) zwischen Rohverteilung und Vaterbudget."
: "The analysis contains " + input.discrepancies().size()
+ " documented discrepancy/discrepancies between the raw distribution and parent budget.");
}
return List.copyOf(warnings);
}
private ReportStatus determineStatus(
boolean complete,
String analysisStatus,
List<LeafCandidate> leaves,
List<String> warnings,
List<TaxonomyDiscrepancy> discrepancies) {
boolean analysisSucceeded = "SUCCESS".equalsIgnoreCase(analysisStatus);
if (leaves.isEmpty()) {
return complete && analysisSucceeded
? ReportStatus.NO_RESULT : ReportStatus.DRAFT_INCOMPLETE;
}
if (!complete || !analysisSucceeded) {
return ReportStatus.DRAFT_INCOMPLETE;
}
if (!warnings.isEmpty() || (discrepancies != null && !discrepancies.isEmpty())) {
return ReportStatus.FINAL_WITH_WARNINGS;
}
return ReportStatus.FINAL;
}
private String decisionSummary(
TaxonomyNode parent,
List<ChildDecision> children,
boolean german) {
List<ChildDecision> positive = children.stream()
.filter(child -> child.absoluteScore() != null && child.absoluteScore() > 0)
.toList();
List<ChildDecision> leaders = positive.stream().filter(ChildDecision::leadingSibling).toList();
String allPositive = positive.stream()
.map(child -> child.code() + " (" + child.absoluteScore() + " %)")
.collect(Collectors.joining(", "));
String leaderText = leaders.stream()
.map(child -> child.code() + " (" + child.absoluteScore() + " %)")
.collect(Collectors.joining(", "));
if (german) {
return "Vom Vaterknoten " + parent.getCode() + " werden die positiven Pfade "
+ allPositive + " weitergeführt. Innerhalb dieser Geschwistergruppe führt "
+ leaderText + ".";
}
return "From parent node " + parent.getCode() + ", the positive paths "
+ allPositive + " are continued. Within this sibling group, "
+ leaderText + " leads.";
}
private String comparativeRationale(
TaxonomyNode parent,
Integer parentScore,
List<ChildDecision> children,
boolean german) {
List<ChildDecision> positive = children.stream()
.filter(child -> child.absoluteScore() != null && child.absoluteScore() > 0)
.toList();
List<ChildDecision> leaders = positive.stream().filter(ChildDecision::leadingSibling).toList();
long rejected = children.stream()
.filter(child -> child.disposition() == Disposition.REJECTED)
.count();
long missing = children.stream()
.filter(child -> child.disposition() == Disposition.NOT_EVALUATED)
.count();
String leaderReasons = leaders.stream()
.map(child -> child.code() + ": " + child.reason())
.collect(Collectors.joining(" "));
if (german) {
StringBuilder text = new StringBuilder();
text.append("Der Vaterknoten ").append(parent.getCode())
.append(" trägt ").append(scoreText(parentScore, true))
.append(" der anforderungsbezogenen Relevanz in diese Entscheidung. ")
.append("Die höchste direkte Kinderbewertung entfällt auf ")
.append(leaders.stream()
.map(child -> child.code() + " mit " + child.absoluteScore() + " %")
.collect(Collectors.joining(" und ")))
.append(". ").append(leaderReasons);
if (positive.size() > leaders.size()) {
text.append(" Weitere positive Kinder werden entsprechend ihrer niedrigeren Werte als nachgeordnete, aber weiterhin relevante Pfade dokumentiert.");
}
if (rejected > 0) {
text.append(" ").append(rejected)
.append(" direkt bewertete Alternative(n) erhielten 0 % und wurden nicht weiter verfolgt.");
}
if (missing > 0) {
text.append(" Für ").append(missing)
.append(" direkte Kinder fehlt eine Bewertung; daraus wird ausdrücklich keine Ablehnung abgeleitet.");
}
return text.toString();
}
StringBuilder text = new StringBuilder();
text.append("Parent node ").append(parent.getCode())
.append(" carries ").append(scoreText(parentScore, false))
.append(" of requirement-related relevance into this decision. ")
.append("The highest direct child score belongs to ")
.append(leaders.stream()
.map(child -> child.code() + " at " + child.absoluteScore() + "%")
.collect(Collectors.joining(" and ")))
.append(". ").append(leaderReasons);
if (positive.size() > leaders.size()) {
text.append(" Other positive children remain documented as subordinate but relevant paths according to their lower scores.");
}
if (rejected > 0) {
text.append(" ").append(rejected)
.append(" directly evaluated alternative(s) scored 0% and were not pursued.");
}
if (missing > 0) {
text.append(" A score is missing for ").append(missing)
.append(" direct child/children; this is explicitly not interpreted as rejection.");
}
return text.toString();
}
private String conciseConclusion(
LeafCandidate leadingLeaf,
List<PathStep> path,
boolean german) {
if (leadingLeaf == null) {
return german
? "Aus den vorliegenden Bewertungen konnte kein positiv bewerteter tatsächlicher Blattknoten abgeleitet werden."
: "No positively scored actual leaf node could be derived from the available evaluations.";
}
String pathText = path.stream().map(PathStep::code).collect(Collectors.joining(" → "));
if (german) {
return "Der am höchsten bewertete tatsächliche Blattknoten ist "
+ leadingLeaf.code() + " – " + leadingLeaf.title() + " mit "
+ leadingLeaf.score() + " %. Der dokumentierte Entscheidungspfad lautet: "
+ pathText + ".";
}
return "The highest-rated actual leaf node is " + leadingLeaf.code() + " – "
+ leadingLeaf.title() + " at " + leadingLeaf.score()
+ "%. The documented decision path is: " + pathText + ".";
}
private String methodologyNote(boolean german) {
if (german) {
return "Die Prozentwerte sind Relevanz- und Zuordnungswerte, keine statistischen Wahrscheinlichkeiten. "
+ "Wurzelbereiche werden unabhängig auf 0–100 bewertet; unterhalb eines Vaterknotens dokumentiert der lokale Anteil die Verteilung innerhalb seiner direkten Kinder. "
+ "Der Bericht übernimmt ursprüngliche KI-Begründungen unverändert und kennzeichnet deterministische Ersatztexte ausdrücklich.";
}
return "Percentages are relevance and allocation scores, not statistical probabilities. "
+ "Root areas are rated independently on 0–100; below a parent, the local share documents the distribution among its direct children. "
+ "The report preserves original AI reasons and explicitly marks deterministic fallback text.";
}
private Reason reasonFor(
TaxonomyNode node,
Integer score,
Map<String, String> reasons,
boolean german) {
String reason = reasons.get(node.getCode());
if (reason != null && !reason.isBlank()) {
return new Reason(reason, ReasonSource.AI_SCORING);
}
if (score == null) {
return new Reason(
german
? "Nicht bewertet. Aus der fehlenden Bewertung darf keine fachliche Ablehnung abgeleitet werden."
: "Not evaluated. A missing score must not be interpreted as substantive rejection.",
ReasonSource.MISSING);
}
if (score == 0) {
return new Reason(
german
? "Der Knoten wurde bewertet, erhielt 0 % und wurde deshalb in diesem Pfad nicht weiter verfolgt."
: "The node was evaluated, scored 0%, and was therefore not pursued in this path.",
ReasonSource.DETERMINISTIC_TRACE);
}
return new Reason(
german
? "Für diesen positiven Bewertungswert lag keine separate KI-Einzelbegründung vor. Die Aufnahme in den Bericht folgt ausschließlich aus dem gespeicherten Wert und der Taxonomiehierarchie."
: "No separate AI reason was available for this positive score. Inclusion in the report follows only from the stored value and taxonomy hierarchy.",
ReasonSource.DETERMINISTIC_TRACE);
}
private Double localShare(Integer childScore, Integer parentScore) {
if (childScore == null || parentScore == null || parentScore <= 0) {
return null;
}
return roundOneDecimal(childScore * 100.0 / parentScore);
}
private String displayName(TaxonomyNode node, boolean german) {
if (german && node.getNameDe() != null && !node.getNameDe().isBlank()) {
return node.getNameDe().strip();
}
return normalized(node.getNameEn(), node.getCode());
}
private String displayDescription(TaxonomyNode node, boolean german) {
if (german && node.getDescriptionDe() != null && !node.getDescriptionDe().isBlank()) {
return node.getDescriptionDe().strip();
}
return normalized(node.getDescriptionEn(), "");
}
private String fingerprintAnalysis(
DecisionAnalysisInput input,
Map<String, Integer> scores,
Map<String, String> reasons,
List<ProductCoverageGap> productCoverageGaps) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
updateDigest(digest, "requirement", normalized(input.businessText(), ""));
updateDigest(digest, "provider", normalized(input.provider(), "unknown"));
updateDigest(digest, "status", normalized(input.analysisStatus(), "UNKNOWN"));
if (input.snapshotProvenance() != null) {
updateDigest(digest, "snapshot-id",
normalized(input.snapshotProvenance().snapshotId(), ""));
updateDigest(digest, "snapshot-created-at",
String.valueOf(input.snapshotProvenance().createdAt()));
updateDigest(digest, "snapshot-created-by",
normalized(input.snapshotProvenance().createdBy(), ""));
updateDigest(digest, "taxonomy-fingerprint",
normalized(input.snapshotProvenance().taxonomyFingerprintSha256(), ""));
updateDigest(digest, "prompt-fingerprint",
normalized(input.snapshotProvenance().promptFingerprintSha256(), ""));
}
scores.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> updateDigest(
digest, "score:" + entry.getKey(), String.valueOf(entry.getValue())));
reasons.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> updateDigest(
digest, "reason:" + entry.getKey(), entry.getValue()));
for (int index = 0; index < input.discrepancies().size(); index++) {
updateDigest(digest, "discrepancy:" + index,
String.valueOf(input.discrepancies().get(index)));
}
for (ProductCoverageGap gap : productCoverageGaps) {
updateDigest(digest, "product-gap:" + gap.productFamilyCode(),
gap.familyScore() + "|" + String.join(",", gap.candidateCodes())
+ "|" + gap.reason());
}
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
return "unavailable";
}
}
private void updateDigest(MessageDigest digest, String key, String value) {
digest.update(key.getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0x1f);
digest.update(normalized(value, "").getBytes(StandardCharsets.UTF_8));
digest.update((byte) '\n');
}
private String rootFromCode(String code) {
if (code == null || code.isBlank()) {
return "unknown";
}
int separator = code.indexOf('-');
return separator > 0 ? code.substring(0, separator) : code;
}
private String abbreviateHash(String value) {
if (value == null || value.isBlank()) {
return "unknown";
}
String normalized = value.strip();
return normalized.length() <= 16 ? normalized : normalized.substring(0, 16);
}
private String scoreText(Integer score, boolean german) {
if (score == null) {
return german ? "keinen gespeicherten Wert" : "no stored score";
}
return score + (german ? " %" : "%");
}
private double roundOneDecimal(double value) {
return Math.round(value * 10.0) / 10.0;
}
private String value(Object value) {
return value == null ? "" : String.valueOf(value);
}
private String normalized(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.strip();
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.strip();
}
}
return "";
}
private record HierarchyData(
List<TaxonomyNode> roots,
Map<String, List<TaxonomyNode>> childrenMap) {
}
private record Completeness(
boolean complete,
double percent,
List<String> incompleteParents,
List<String> unresolvedParents) {
}
private record Reason(String text, ReasonSource source) {
}
}