DecisionRationaleHtmlRenderer.java
package com.taxonomy.architecture.decision;
import com.taxonomy.architecture.decision.DecisionChapterDiagramRenderer.DiagramPanel;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ChildDecision;
import com.taxonomy.architecture.decision.DecisionRationaleReport.DecisionChapter;
import com.taxonomy.architecture.decision.DecisionRationaleReport.LeafCandidate;
import com.taxonomy.architecture.decision.DecisionRationaleReport.PathStep;
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 org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
/** Produces a self-contained, print-ready HTML decision report. */
@Component
@SuppressWarnings("serial")
public class DecisionRationaleHtmlRenderer implements ReportRendererExtension {
private static final ReportFormatDescriptor DESCRIPTOR = new ReportFormatDescriptor(
"html", "HTML", "html", "text/html; charset=UTF-8", false);
private final DecisionChapterDiagramRenderer diagramRenderer;
public DecisionRationaleHtmlRenderer(DecisionChapterDiagramRenderer diagramRenderer) {
this.diagramRenderer = diagramRenderer;
}
@Override
public String reportTypeId() {
return DecisionRationaleReportPlugin.REPORT_TYPE_ID;
}
@Override
public Class<?> reportModelType() {
return DecisionRationaleReport.class;
}
@Override
public ReportFormatDescriptor descriptor() {
return DESCRIPTOR;
}
@Override
public ReportRenderResult render(ReportRenderContext context) {
return new ReportRenderResult(render(
context.payloadAs(DecisionRationaleReport.class))
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public String render(DecisionRationaleReport report) {
DecisionReportLabels labels = new DecisionReportLabels(report.languageTag());
StringBuilder html = new StringBuilder(64_000);
html.append("<!doctype html><html lang=\"")
.append(attr(report.languageTag())).append("\"><head><meta charset=\"utf-8\">")
.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">")
.append("<title>").append(text(report.title())).append("</title>")
.append(styles(report, labels))
.append("</head><body>");
html.append("<footer class=\"running-footer\"><span>")
.append(text(footerText(report, labels)))
.append("</span><span class=\"page-counter\"></span></footer>");
renderTitlePage(html, report, labels);
renderExecutiveSummary(html, report, labels);
renderChapters(html, report, labels);
renderAppendix(html, report, labels);
html.append("</body></html>");
return html.toString();
}
private void renderTitlePage(
StringBuilder html,
DecisionRationaleReport report,
DecisionReportLabels labels) {
DecisionRationaleReport.ReportMetadata metadata = report.metadata();
html.append("<section class=\"title-page\">")
.append("<div class=\"brand-rule\"></div>")
.append("<div class=\"eyebrow\">TAXONOMY · DECISION EVIDENCE</div>")
.append("<h1>").append(text(report.title())).append("</h1>")
.append("<p class=\"subtitle\">").append(text(labels.subtitle())).append("</p>")
.append("<div class=\"status status-")
.append(attr(report.status().name().toLowerCase(Locale.ROOT)))
.append("\">").append(text(statusLabel(report, labels))).append("</div>")
.append("<div class=\"requirement-card\"><h2>")
.append(text(labels.requirement())).append("</h2><p>")
.append(text(report.requirement())).append("</p></div>")
.append("<div class=\"metadata-grid\">");
metadataItem(html, labels.dataVersion(), metadata.taxonomyDataVersion());
metadataItem(html, labels.sourceFile(), metadata.taxonomyCatalogueFile());
metadataItem(html, labels.dataDigest(), shortHash(metadata.taxonomyDataFingerprintSha256()));
metadataItem(html, labels.hierarchyEvidence(), metadata.hierarchyFromImmutableSnapshot()
? labels.frozenHierarchy() : labels.liveHierarchy());
metadataItem(html, labels.nodes(), metadata.taxonomyNodeCount() + " / " + metadata.taxonomyRootCount());
metadataItem(html, labels.analysisSnapshot(), metadata.analysisSnapshotId());
metadataItem(html, labels.requirementVersion(), requirementVersion(report));
metadataItem(html, labels.analysisProvider(), metadata.analysisProvider());
metadataItem(html, labels.analysisModel(), metadata.analysisModel());
metadataItem(html, labels.analysisCreatedAt(),
formatInstant(metadata.analysisCreatedAt(), metadata.reportTimeZone()));
metadataItem(html, labels.analysisCreatedBy(), metadata.analysisCreatedBy());
metadataItem(html, labels.generatedAt(), formatInstant(metadata.generatedAt(), metadata.reportTimeZone()));
metadataItem(html, labels.generatedBy(), metadata.generatedBy());
metadataItem(html, labels.applicationVersion(), metadata.taxonomyApplicationVersion());
metadataItem(html, labels.basedOnCommit(), shortHash(metadata.basedOnCommit()));
html.append("</div><div class=\"classification-note\">")
.append(text(labels.methodology())).append(": ")
.append(text(report.executiveSummary().methodologyNote()))
.append("</div></section>");
}
private void renderExecutiveSummary(
StringBuilder html,
DecisionRationaleReport report,
DecisionReportLabels labels) {
html.append("<section class=\"report-section executive\"><div class=\"section-kicker\">01</div><h2>")
.append(text(labels.executiveSummary())).append("</h2>")
.append("<p class=\"lead\">")
.append(text(report.executiveSummary().conciseConclusion())).append("</p>");
LeafCandidate leading = report.executiveSummary().leadingLeaf();
if (leading != null) {
html.append("<div class=\"leading-leaf\"><div><span class=\"small-label\">")
.append(text(labels.leadingLeaf())).append("</span><strong>")
.append(text(leading.code())).append(" · ").append(text(leading.title()))
.append("</strong><span>").append(text(leading.taxonomyRoot()))
.append("</span></div><div class=\"score-orb\">")
.append(leading.score()).append("<small>%</small></div></div>");
}
html.append("<h3>").append(text(labels.highestPath())).append("</h3>")
.append("<table class=\"path-table\"><thead><tr><th>")
.append(text(labels.step())).append("</th><th>")
.append(text(labels.node())).append("</th><th>")
.append(text(labels.score())).append("</th><th>")
.append(text(labels.localShare())).append("</th><th>")
.append(text(labels.rationale())).append("</th><th>")
.append(text(labels.reasonSource())).append("</th></tr></thead><tbody>");
for (PathStep step : report.executiveSummary().path()) {
html.append("<tr><td>").append(step.position()).append("</td><td><code>")
.append(text(step.code())).append("</code><br><span class=\"muted\">")
.append(text(step.title())).append("</span></td><td>")
.append(score(step.absoluteScore())).append("</td><td>")
.append(percent(step.localSharePercent())).append("</td><td>")
.append(text(step.reason())).append("</td><td>")
.append(text(reasonSource(step.reasonSource(), labels))).append("</td></tr>");
}
html.append("</tbody></table>");
if (!report.warnings().isEmpty()) {
html.append("<div class=\"warning-box\"><h3>")
.append(text(labels.warnings())).append("</h3><ul>");
report.warnings().forEach(warning -> html.append("<li>")
.append(text(warning)).append("</li>"));
html.append("</ul></div>");
}
html.append("</section>");
}
private void renderChapters(
StringBuilder html,
DecisionRationaleReport report,
DecisionReportLabels labels) {
html.append("<section class=\"report-section chapter-intro\"><div class=\"section-kicker\">02</div><h2>")
.append(text(labels.decisionChapters())).append("</h2><p>")
.append(text(labels.german()
? "Jedes folgende Kapitel entspricht genau einem Vaterknoten, dessen direkte Kindknoten mindestens einen von Null verschiedenen Bewertungswert enthalten."
: "Each following chapter corresponds to one parent node whose direct children contain at least one non-zero score."))
.append("</p></section>");
for (DecisionChapter chapter : report.chapters()) {
html.append("<section class=\"report-section decision-chapter\"><div class=\"chapter-number\">")
.append(chapter.number()).append("</div><div class=\"chapter-heading\"><div><span class=\"small-label\">")
.append(text(labels.parentNode())).append("</span><h2><code>")
.append(text(chapter.parentCode())).append("</code> · ")
.append(text(chapter.parentTitle())).append("</h2></div><div class=\"parent-score\">")
.append(score(chapter.parentScore())).append("</div></div>");
if (chapter.parentDescription() != null && !chapter.parentDescription().isBlank()) {
html.append("<p class=\"parent-description\">")
.append(text(chapter.parentDescription())).append("</p>");
}
List<DiagramPanel> panels = diagramRenderer.render(chapter, report.languageTag());
for (DiagramPanel panel : panels) {
html.append("<figure class=\"decision-diagram\">")
.append(panel.svg())
.append("<figcaption>")
.append(text(labels.parentNode() + " " + chapter.parentCode()))
.append(panel.panelCount() > 1
? " · " + panel.panelNumber() + "/" + panel.panelCount() : "")
.append("</figcaption></figure>");
}
html.append("<div class=\"rationale-grid\"><div><h3>")
.append(text(labels.decisionResult())).append("</h3><p>")
.append(text(chapter.decisionSummary())).append("</p></div><div><h3>")
.append(text(labels.comparison())).append("</h3><p>")
.append(text(chapter.comparativeRationale())).append("</p></div></div>")
.append("<h3>").append(text(labels.alternatives())).append("</h3>")
.append("<table><thead><tr><th>").append(text(labels.rank()))
.append("</th><th>").append(text(labels.node()))
.append("</th><th>").append(text(labels.score()))
.append("</th><th>").append(text(labels.localShare()))
.append("</th><th>").append(text(labels.disposition()))
.append("</th><th>").append(text(labels.rationale()))
.append("</th><th>").append(text(labels.reasonSource()))
.append("</th></tr></thead><tbody>");
for (ChildDecision child : chapter.children()) {
html.append("<tr class=\"").append(rowClass(child)).append("\"><td>")
.append(child.rank() == null ? "—" : child.rank()).append("</td><td><code>")
.append(text(child.code())).append("</code><br><span class=\"muted\">")
.append(text(child.title())).append("</span></td><td>")
.append(score(child.absoluteScore())).append("</td><td>")
.append(percent(child.localSharePercent())).append("</td><td>")
.append(text(disposition(child, labels))).append("</td><td>")
.append(text(child.reason())).append("</td><td>")
.append(text(reasonSource(child.reasonSource(), labels))).append("</td></tr>");
}
html.append("</tbody></table>");
if (!chapter.complete()) {
html.append("<div class=\"warning-box compact\">")
.append(text(labels.german()
? "Nicht bewertete direkte Kinder: "
: "Unevaluated direct children: "))
.append(text(String.join(", ", chapter.missingChildCodes())))
.append("</div>");
}
html.append("</section>");
}
}
private void renderAppendix(
StringBuilder html,
DecisionRationaleReport report,
DecisionReportLabels labels) {
html.append("<section class=\"report-section appendix\"><div class=\"section-kicker\">03</div><h2>")
.append(text(labels.appendix())).append("</h2><h3>")
.append(text(labels.leadingLeaves())).append("</h3>")
.append("<table><thead><tr><th>").append(text(labels.rank()))
.append("</th><th>").append(text(labels.node()))
.append("</th><th>").append(text(labels.score()))
.append("</th><th>").append(text(labels.taxonomyRoot()))
.append("</th><th>").append(text(labels.hierarchyPath()))
.append("</th><th>").append(text(labels.rationale()))
.append("</th></tr></thead><tbody>");
int rank = 1;
for (LeafCandidate leaf : report.leadingLeaves()) {
html.append("<tr><td>").append(rank++).append("</td><td><code>")
.append(text(leaf.code())).append("</code><br><span class=\"muted\">")
.append(text(leaf.title())).append("</span></td><td>")
.append(leaf.score()).append(" %</td><td>")
.append(text(leaf.taxonomyRoot())).append("</td><td>")
.append(text(leaf.hierarchyPath())).append("</td><td>")
.append(text(leaf.reason())).append("</td></tr>");
}
html.append("</tbody></table><h3>").append(text(labels.taxonomyBasis()))
.append("</h3><dl class=\"evidence-list\">");
evidence(html, labels.catalogue(), report.metadata().taxonomyDataSource());
evidence(html, labels.sourceFile(), report.metadata().taxonomyCatalogueFile());
evidence(html, labels.sourceDigest(), report.metadata().taxonomyCatalogueResourceSha256());
evidence(html, labels.dataDigest(), report.metadata().taxonomyDataFingerprintSha256());
evidence(html, labels.recordedTaxonomyDigest(),
report.metadata().recordedTaxonomyFingerprintSha256());
evidence(html, labels.promptDigest(), report.metadata().promptFingerprintSha256());
evidence(html, labels.analysisDigest(), report.metadata().analysisSnapshotFingerprintSha256());
evidence(html, labels.analysisSnapshot(), report.metadata().analysisSnapshotId());
evidence(html, labels.requirementVersion(), requirementVersion(report));
evidence(html, labels.analysisCreatedAt(),
formatInstant(report.metadata().analysisCreatedAt(), report.metadata().reportTimeZone()));
evidence(html, labels.analysisCreatedBy(), report.metadata().analysisCreatedBy());
evidence(html, labels.repository(), report.metadata().repositoryId());
evidence(html, labels.workspace(), report.metadata().workspaceId());
evidence(html, labels.branch(), report.metadata().branch());
evidence(html, labels.basedOnCommit(), report.metadata().basedOnCommit());
evidence(html, labels.analysisProvider(), report.metadata().analysisProvider());
evidence(html, labels.analysisModel(), report.metadata().analysisModel());
evidence(html, labels.analysisStatus(), report.metadata().analysisStatus());
html.append("</dl><h3>").append(text(labels.methodology())).append("</h3><p>")
.append(text(report.executiveSummary().methodologyNote())).append("</p>");
if (!report.discrepancies().isEmpty()) {
html.append("<h3>").append(text(labels.german()
? "Dokumentierte Bewertungsabweichungen"
: "Documented scoring discrepancies"))
.append("</h3><pre>")
.append(text(report.discrepancies().toString())).append("</pre>");
}
html.append("</section>");
}
private String styles(DecisionRationaleReport report, DecisionReportLabels labels) {
String pageCounter = "\"" + cssString(labels.page() + " ")
+ "\" counter(page) \" " + cssString(labels.of())
+ " \" counter(pages)";
return """
<style>
:root{--navy:#0b1f33;--teal:#007a78;--teal-light:#e1f4f3;--blue-light:#e9f0f7;--ink:#26303a;--muted:#64707c;--line:#cdd5dc;--paper:#fff;--warning:#fff4d6;--warning-border:#b98200}
*{box-sizing:border-box}html{background:#eef1f4}body{margin:0 auto;max-width:1120px;background:var(--paper);color:var(--ink);font:16px/1.5 "Segoe UI",Arial,sans-serif;box-shadow:0 0 30px rgba(11,31,51,.12)}
.running-footer{position:fixed;left:0;right:0;bottom:0;height:34px;padding:8px 18mm;border-top:1px solid var(--line);background:#fff;color:var(--muted);font-size:10px;display:flex;justify-content:space-between;z-index:20}.page-counter:after{content:""}
.title-page,.report-section{padding:20mm 18mm 24mm}.title-page{min-height:285mm;position:relative;page-break-after:always;background:linear-gradient(150deg,#fff 0,#fff 70%,#edf7f6 100%)}
.brand-rule{height:8px;background:linear-gradient(90deg,var(--navy) 0 72%,var(--teal) 72%);margin-bottom:36px}.eyebrow,.section-kicker,.small-label{color:var(--teal);font-weight:700;letter-spacing:.12em;text-transform:uppercase;font-size:12px}
h1{font-size:44px;line-height:1.12;color:var(--navy);max-width:800px;margin:18px 0}.subtitle{font-size:21px;color:var(--muted);max-width:760px}.status{display:inline-block;margin-top:18px;padding:8px 14px;border-radius:999px;font-weight:700;background:var(--teal-light);color:var(--teal)}.status-draft_incomplete,.status-no_result{background:var(--warning);color:#745400}
.requirement-card{margin-top:42px;padding:20px 24px;border-left:6px solid var(--teal);background:#f6f8fa}.requirement-card h2{margin:0 0 8px;font-size:15px;color:var(--teal);text-transform:uppercase;letter-spacing:.08em}.requirement-card p{font-size:20px;margin:0;white-space:pre-wrap}
.metadata-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:30px}.metadata-item{background:#fff;padding:13px 16px}.metadata-item span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.05em}.metadata-item strong{display:block;margin-top:3px;overflow-wrap:anywhere}.classification-note{position:absolute;left:18mm;right:18mm;bottom:22mm;color:var(--muted);font-size:11px}
.report-section{page-break-before:auto}.decision-chapter{page-break-before:always}.section-kicker{margin-bottom:5px}.report-section>h2{font-size:32px;color:var(--navy);margin:0 0 20px}.report-section h3{color:var(--navy);margin:26px 0 10px}.lead{font-size:20px;color:var(--navy)}
.leading-leaf{display:flex;justify-content:space-between;align-items:center;background:var(--navy);color:#fff;padding:22px 26px;border-radius:8px;margin:24px 0}.leading-leaf strong,.leading-leaf span{display:block}.leading-leaf .small-label{color:#8de0dd}.score-orb{width:92px;height:92px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--teal);font-size:35px;font-weight:800}.score-orb small{font-size:18px;margin-left:2px}
table{width:100%;border-collapse:collapse;margin:12px 0 24px;font-size:13px;page-break-inside:auto}thead{display:table-header-group}tr{page-break-inside:avoid}th{background:var(--navy);color:#fff;text-align:left;padding:9px 8px;font-weight:650}td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}tbody tr:nth-child(even){background:#f7f9fa}code{font-family:"Cascadia Mono",Consolas,monospace;color:var(--navy);font-weight:700}.muted{color:var(--muted)}
.warning-box{background:var(--warning);border-left:5px solid var(--warning-border);padding:14px 18px;margin:22px 0}.warning-box h3{margin-top:0}.warning-box.compact{font-size:13px}.chapter-number{color:var(--teal);font-weight:800;font-size:14px}.chapter-heading{display:flex;align-items:flex-start;justify-content:space-between;border-bottom:3px solid var(--navy);padding-bottom:12px}.chapter-heading h2{margin:4px 0 0;color:var(--navy);font-size:29px}.parent-score{background:var(--teal);color:#fff;padding:10px 16px;border-radius:6px;font-weight:800;font-size:24px;white-space:nowrap}.parent-description{color:var(--muted)}
.decision-diagram{margin:24px 0;page-break-inside:avoid}.decision-diagram svg{width:100%;height:auto;border:1px solid var(--line);border-radius:6px;background:#fff}.decision-diagram figcaption{font-size:11px;color:var(--muted);text-align:right}.rationale-grid{display:grid;grid-template-columns:1fr 1.3fr;gap:22px}.rationale-grid>div{background:#f6f8fa;padding:14px 18px;border-top:4px solid var(--teal)}.rationale-grid h3{margin-top:0}.row-leading{background:var(--teal-light)!important}.row-rejected{color:var(--muted)}.row-missing{font-style:italic;color:var(--muted)}
.evidence-list{display:grid;grid-template-columns:210px 1fr;border-top:1px solid var(--line)}.evidence-list dt,.evidence-list dd{margin:0;padding:8px;border-bottom:1px solid var(--line)}.evidence-list dt{font-weight:700;color:var(--navy)}.evidence-list dd{overflow-wrap:anywhere}pre{white-space:pre-wrap;background:#f6f8fa;padding:12px;border:1px solid var(--line)}
@media(max-width:760px){body{box-shadow:none}.title-page,.report-section{padding:28px 20px}.metadata-grid,.rationale-grid{grid-template-columns:1fr}.chapter-heading{display:block}.parent-score{display:inline-block;margin-top:12px}.running-footer{display:none}}
@media print{html,body{background:#fff;box-shadow:none;max-width:none}.running-footer{position:fixed}.page-counter:after{content:PAGE_COUNTER}@page{size:A4;margin:14mm 12mm 18mm}.title-page,.report-section{padding:12mm 10mm 18mm}.title-page{min-height:255mm}.classification-note{left:10mm;right:10mm;bottom:16mm}}
</style>
""".replace("PAGE_COUNTER", pageCounter);
}
private void metadataItem(StringBuilder html, String label, String value) {
html.append("<div class=\"metadata-item\"><span>").append(text(label))
.append("</span><strong>").append(text(value)).append("</strong></div>");
}
private void evidence(StringBuilder html, String label, String value) {
html.append("<dt>").append(text(label)).append("</dt><dd>")
.append(text(value)).append("</dd>");
}
private String statusLabel(DecisionRationaleReport report, DecisionReportLabels labels) {
return switch (report.status()) {
case FINAL -> labels.finalStatus();
case FINAL_WITH_WARNINGS -> labels.finalWarnings();
case DRAFT_INCOMPLETE -> labels.draft();
case NO_RESULT -> labels.noResult();
};
}
private String disposition(ChildDecision child, DecisionReportLabels labels) {
return switch (child.disposition()) {
case CONTINUED -> labels.continued();
case LEAF_CANDIDATE -> labels.leafCandidate();
case REJECTED -> labels.rejected();
case NOT_EVALUATED -> labels.notEvaluated();
};
}
private String reasonSource(
DecisionRationaleReport.ReasonSource source,
DecisionReportLabels labels) {
if (source == null) {
return labels.missingReason();
}
return switch (source) {
case AI_SCORING -> labels.aiReason();
case DETERMINISTIC_TRACE -> labels.deterministicReason();
case MISSING -> labels.missingReason();
};
}
private String rowClass(ChildDecision child) {
if (child.leadingSibling()) {
return "row-leading";
}
return switch (child.disposition()) {
case REJECTED -> "row-rejected";
case NOT_EVALUATED -> "row-missing";
default -> "";
};
}
private String requirementVersion(DecisionRationaleReport report) {
if (report.metadata().requirementVersionNumber() == null) {
return "—";
}
StringBuilder value = new StringBuilder("v")
.append(report.metadata().requirementVersionNumber());
if (report.metadata().requirementId() != null) {
value.append(" · requirement ").append(report.metadata().requirementId());
}
if (report.metadata().projectId() != null) {
value.append(" · project ").append(report.metadata().projectId());
}
return value.toString();
}
private String footerText(DecisionRationaleReport report, DecisionReportLabels labels) {
return labels.generatedNotice() + " · "
+ formatInstant(report.metadata().generatedAt(), report.metadata().reportTimeZone())
+ " · " + report.metadata().generatedBy()
+ " · Taxonomy " + report.metadata().taxonomyApplicationVersion()
+ " · " + report.metadata().taxonomyDataVersion();
}
private String formatInstant(Instant instant, String zone) {
if (instant == null) {
return "—";
}
ZoneId zoneId;
try {
zoneId = ZoneId.of(zone);
} catch (Exception ignored) {
zoneId = ZoneOffset.UTC;
}
return DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss z", Locale.GERMANY)
.withZone(zoneId)
.format(instant);
}
private String score(Integer value) {
return value == null ? "—" : value + " %";
}
private String percent(Double value) {
if (value == null) {
return "—";
}
return Math.abs(value - Math.rint(value)) < 0.05
? String.format(Locale.ROOT, "%.0f %%", value)
: String.format(Locale.ROOT, "%.1f %%", value);
}
private String shortHash(String value) {
if (value == null || value.isBlank()) {
return "—";
}
return value.length() <= 16 ? value : value.substring(0, 16) + "…";
}
private String text(String value) {
if (value == null) {
return "";
}
return value.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
private String cssString(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\A " );
}
private String attr(String value) {
return text(value);
}
}