Эх сурвалжийг харах

fix(outlook-report): realign payload contract and renderer with baseline fixtures

金逸霄 3 долоо хоног өмнө
parent
commit
061afbd566

+ 78 - 33
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java

@@ -12,12 +12,12 @@ import java.util.List;
 public record OutlookExamSprintReportPayload(
         @NotNull @Valid ReportMetadata reportMetadata,
         @NotNull @Valid ReadinessOverview readinessOverview,
-        @NotNull @Valid SyllabusMasteryProfile syllabusMasteryProfile,
-        @NotNull @Valid VocabularyProfile pastPaperVocabularyProfile,
-        @NotNull @Valid VocabularyProfile highFrequencyVocabularyProfile,
-        @NotEmpty List<@Valid VocabularyFrequencyBand> vocabularyFrequencyBands,
-        @NotEmpty List<@Valid SprintPlanOption> sprintPlanOptions,
-        @NotNull @Valid DiagnosticCaseStudy diagnosticCaseStudy) {
+        @NotNull @Valid SyllabusMasteryChart syllabusMasteryChart,
+        @NotNull @Valid PastPaperVocabularyChart pastPaperVocabularyChart,
+        @NotNull @Valid HighFrequencyVocabularyChart highFrequencyVocabularyChart,
+        @NotNull @Valid VocabularyFrequencyBandChart vocabularyFrequencyBandChart,
+        @NotNull @Valid FrequencyPlan frequencyPlan,
+        @NotNull @Valid ScoreImprovementCaseStudy scoreImprovementCaseStudy) {
 
     public record ReportMetadata(
             @NotBlank String reportVersionLabel,
@@ -34,45 +34,90 @@ public record OutlookExamSprintReportPayload(
             @Min(0) @Max(100) int readinessScore) {
     }
 
-    public record SyllabusMasteryProfile(
+    public record SyllabusMasteryChart(
+            @Min(0) int totalWordCount,
+            @Min(0) int masteredWordCount,
+            @Min(0) int unmasteredWordCount,
             @Min(0) @Max(100) int masteryPercent,
-            @NotBlank String diagnosis,
-            @NotBlank String recommendation,
-            @NotEmpty List<@Valid DimensionScore> dimensionScores) {
+            @NotBlank String summaryLabel,
+            @NotBlank String recommendation) {
+
+        public boolean hasConsistentWordCounts() {
+            return masteredWordCount + unmasteredWordCount == totalWordCount;
+        }
+
+        public boolean hasConsistentMasteryPercent() {
+            return totalWordCount == 0 || masteryPercent == Math.round((masteredWordCount * 100.0f) / totalWordCount);
+        }
     }
 
-    public record VocabularyProfile(
-            @Min(0) int masteredWordCount,
-            @Min(1) int totalWordCount,
-            @Min(0) @Max(100) int masteryPercent,
-            @NotBlank String diagnosis,
-            @NotBlank String recommendation,
-            List<@NotBlank String> sampleWords) {
+    public record PastPaperVocabularyChart(
+            @Min(0) int totalWordCount,
+            @Min(0) int unknownWordCountBeforeSprint,
+            @Min(0) int unknownWordCountAfterSprint,
+            @NotBlank String projectedScoreGainLabel,
+            @NotBlank String recommendation) {
+
+        public boolean hasValidUnknownWordCounts() {
+            return unknownWordCountBeforeSprint >= unknownWordCountAfterSprint;
+        }
+    }
+
+    public record HighFrequencyVocabularyChart(
+            @Min(0) @Max(100) int basicCorePercent,
+            @Min(0) @Max(100) int highScorePercent,
+            @NotBlank String highlightLabel) {
     }
 
-    public record DimensionScore(@NotBlank String label, @Min(0) @Max(100) int score) {
+    public record VocabularyFrequencyBandChart(
+            @NotEmpty List<@Valid VocabularyFrequencyBar> bars) {
+
+        public boolean hasExpectedBarCount() {
+            return bars != null && bars.size() == 3;
+        }
     }
 
-    public record VocabularyFrequencyBand(
+    public record VocabularyFrequencyBar(
             @NotBlank String bandLabel,
-            @Min(0) @Max(100) int masteryPercent,
-            @Min(0) @Max(100) int targetPercent) {
+            double currentValue,
+            @NotBlank String priorityLabel,
+            @NotBlank String themeColor) {
+    }
+
+    public record FrequencyPlan(
+            @NotEmpty List<@Valid FrequencyPlanCard> cards,
+            @NotBlank String recommendationTitle,
+            @NotBlank String recommendationSummary,
+            @NotEmpty List<@Valid PhaseSuggestion> phaseSuggestions) {
+
+        public boolean hasExpectedCadences() {
+            return cards != null && !cards.isEmpty();
+        }
     }
 
-    public record SprintPlanOption(
-            @NotBlank String planName,
-            @NotBlank String cadenceLabel,
-            String tagLabel,
-            @NotBlank String focus,
-            @NotEmpty List<@NotBlank String> actionItems,
-            @NotBlank String expectedOutcome) {
+    public record FrequencyPlanCard(
+            @Min(0) int cadencePerWeek,
+            @NotBlank String scoreGainLabel,
+            @Min(0) @Max(100) int winRatePercent,
+            boolean recommended,
+            String badgeLabel,
+            String emphasisIcon) {
     }
 
-    public record DiagnosticCaseStudy(
+    public record PhaseSuggestion(
             @NotBlank String title,
-            @NotBlank String context,
-            @NotBlank String diagnosis,
-            @NotBlank String strategy,
-            @NotBlank String keyTakeaway) {
+            @NotBlank String description) {
+    }
+
+    public record ScoreImprovementCaseStudy(
+            @NotBlank String headline,
+            @NotBlank String learnerName,
+            @NotBlank String studyPeriodLabel,
+            @Min(0) int memorizedWordCount,
+            @Min(0) int examHitWordCount,
+            double hitRatePercent,
+            @NotBlank String baselineScoreLabel,
+            @Min(0) int finalScore,
+            @Min(0) int scoreGain) {
     }
 }

+ 235 - 87
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java

@@ -39,24 +39,14 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     public String render(JsonNode payload, Instant generatedAt) {
         try {
             OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
-            OutlookExamSprintReportPayload.ReportMetadata metadata = reportPayload.reportMetadata();
             return loadTemplate()
-                    .replace("{{reportVersionLabel}}", escape(metadata.reportVersionLabel()))
-                    .replace("{{learnerName}}", escape(metadata.learnerName()))
-                    .replace("{{targetExamName}}", escape(metadata.targetExamName()))
-                    .replace("{{sprintPeriodLabel}}", escape(metadata.sprintPeriodLabel()))
-                    .replace("{{authorName}}", escape(metadata.authorName()))
-                    .replace("{{generatedAt}}", escape(DATE_TIME_FORMATTER.format(generatedAt)))
-                    .replace("{{summary}}", escape(reportPayload.readinessOverview().summary()))
-                    .replace("{{currentStage}}", escape(reportPayload.readinessOverview().currentStage()))
-                    .replace("{{keyInsight}}", escape(reportPayload.readinessOverview().keyInsight()))
-                    .replace("{{readinessScore}}", String.valueOf(reportPayload.readinessOverview().readinessScore()))
-                    .replace("{{syllabusMasteryProfile}}", renderSyllabusMasteryProfile(reportPayload.syllabusMasteryProfile()))
-                    .replace("{{pastPaperVocabularyProfile}}", renderVocabularyProfile("真题试卷词汇掌握情况", reportPayload.pastPaperVocabularyProfile()))
-                    .replace("{{highFrequencyVocabularyProfile}}", renderVocabularyProfile("常考词汇掌握情况", reportPayload.highFrequencyVocabularyProfile()))
-                    .replace("{{vocabularyFrequencyBands}}", renderVocabularyFrequencyBands(reportPayload.vocabularyFrequencyBands()))
-                    .replace("{{sprintPlanOptions}}", renderSprintPlanOptions(reportPayload.sprintPlanOptions()))
-                    .replace("{{diagnosticCaseStudy}}", renderDiagnosticCaseStudy(reportPayload.diagnosticCaseStudy()));
+                    .replace("{{reportIntroShell}}", renderReportIntroShell(reportPayload, generatedAt))
+                    .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
+                    .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
+                    .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
+                    .replace("{{vocabularyFrequencyBandChart}}", renderVocabularyFrequencyBandChart(reportPayload.vocabularyFrequencyBandChart()))
+                    .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.frequencyPlan()))
+                    .replace("{{scoreImprovementCaseStudy}}", renderScoreImprovementCaseStudy(reportPayload.scoreImprovementCaseStudy()));
         } catch (IOException exception) {
             throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
         } catch (Exception exception) {
@@ -70,101 +60,259 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         }
     }
 
-    private String renderSyllabusMasteryProfile(OutlookExamSprintReportPayload.SyllabusMasteryProfile profile) {
-        StringBuilder builder = new StringBuilder();
-        builder.append("<div class='card-title'>考纲词汇掌握情况</div>")
-                .append("<div class='card-body'>")
-                .append("<p><strong>掌握度:</strong>").append(profile.masteryPercent()).append("%</p>")
-                .append("<p><strong>诊断:</strong>").append(escape(profile.diagnosis())).append("</p>")
-                .append("<p><strong>建议:</strong>").append(escape(profile.recommendation())).append("</p>")
-                .append("<ul>");
-        for (OutlookExamSprintReportPayload.DimensionScore score : profile.dimensionScores()) {
-            builder.append("<li>")
-                    .append(escape(score.label()))
-                    .append(":")
-                    .append(score.score())
-                    .append("%</li>");
-        }
-        builder.append("</ul></div>");
-        return builder.toString();
+    private String renderReportIntroShell(OutlookExamSprintReportPayload payload, Instant generatedAt) {
+        OutlookExamSprintReportPayload.ReportMetadata metadata = payload.reportMetadata();
+        OutlookExamSprintReportPayload.ReadinessOverview readinessOverview = payload.readinessOverview();
+
+        return new StringBuilder()
+                .append("<div class='report-intro-shell'>")
+                .append("<div class='report-intro-meta'>")
+                .append("<span>报告版本:").append(escape(metadata.reportVersionLabel())).append("</span>")
+                .append("<span>学生:").append(escape(metadata.learnerName())).append("</span>")
+                .append("<span>目标考试:").append(escape(metadata.targetExamName())).append("</span>")
+                .append("<span>冲刺周期:").append(escape(metadata.sprintPeriodLabel())).append("</span>")
+                .append("<span>作者:").append(escape(metadata.authorName())).append("</span>")
+                .append("<span>生成时间:").append(escape(DATE_TIME_FORMATTER.format(generatedAt))).append("</span>")
+                .append("</div>")
+                .append("<p class='report-intro-summary'>").append(escape(readinessOverview.summary())).append("</p>")
+                .append("<p class='report-intro-insight'>核心观察:").append(escape(readinessOverview.keyInsight())).append("</p>")
+                .append("<p class='report-intro-summary'>当前阶段:").append(escape(readinessOverview.currentStage())).append(" · 备考得分 ")
+                .append(readinessOverview.readinessScore()).append("</p>")
+                .append("</div>")
+                .toString();
     }
 
-    private String renderVocabularyProfile(String title, OutlookExamSprintReportPayload.VocabularyProfile profile) {
-        StringBuilder builder = new StringBuilder();
-        builder.append("<div class='card-title'>").append(title).append("</div>")
-                .append("<div class='card-body'>")
-                .append("<p><strong>已掌握:</strong>").append(profile.masteredWordCount()).append(" / ")
-                .append(profile.totalWordCount()).append(" 词(").append(profile.masteryPercent()).append("%)</p>")
-                .append("<p><strong>诊断:</strong>").append(escape(profile.diagnosis())).append("</p>")
-                .append("<p><strong>建议:</strong>").append(escape(profile.recommendation())).append("</p>")
-                .append(renderSampleWords(profile.sampleWords()))
-                .append("</div>");
-        return builder.toString();
+    private String renderSyllabusMasteryChart(OutlookExamSprintReportPayload.SyllabusMasteryChart chart) {
+        int masteredArcPercent = Math.max(0, Math.min(100, chart.masteryPercent()));
+        int unmasteredArcPercent = 100 - masteredArcPercent;
+
+        return new StringBuilder()
+                .append("<div class='card-title'>考纲词汇掌握情况</div>")
+                .append("<div class='chart-shell'>")
+                .append("<svg class='syllabus-donut-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
+                .append("<circle class='chart-track' cx='110' cy='110' r='76'></circle>")
+                .append("<path class='donut-mastered-arc' d='")
+                .append(describeArc(110, 110, 76, -90, -90 + (masteredArcPercent * 3.6)))
+                .append("' stroke='#448aff' stroke-width='18' fill='none' stroke-linecap='round'/>")
+                .append("<path class='donut-unmastered-arc' d='")
+                .append(describeArc(110, 110, 76, -90 + (masteredArcPercent * 3.6), 270))
+                .append("' stroke='#e8eef7' stroke-width='18' fill='none' stroke-linecap='round'/>")
+                .append("<text class='chart-percent' x='110' y='106' text-anchor='middle'>")
+                .append(chart.masteryPercent()).append("%</text>")
+                .append("<text class='chart-caption' x='110' y='131' text-anchor='middle'>")
+                .append(escape(chart.summaryLabel())).append("</text>")
+                .append("</svg>")
+                .append("</div>")
+                .append("<div class='data-text'>")
+                .append("已掌握 ").append(chart.masteredWordCount()).append(" / ").append(chart.totalWordCount())
+                .append(" 词,未掌握 ").append(chart.unmasteredWordCount()).append(" 词。</div>")
+                .append("<p class='chart-note'>").append(escape(chart.recommendation())).append("</p>")
+                .toString();
     }
 
-    private String renderSampleWords(List<String> sampleWords) {
-        if (sampleWords == null || sampleWords.isEmpty()) {
-            return "";
-        }
+    private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
+        int totalHeight = barHeight(chart.totalWordCount(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
+        int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
 
-        StringBuilder builder = new StringBuilder("<div class='chip-group'>");
-        for (String sampleWord : sampleWords) {
-            builder.append("<span class='chip'>").append(escape(sampleWord)).append("</span>");
-        }
-        builder.append("</div>");
-        return builder.toString();
+        return new StringBuilder()
+                .append("<div class='card-title'>真题试卷词汇掌握情况</div>")
+                .append("<div class='chart-shell'>")
+                .append("<svg class='past-paper-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
+                .append(renderChartAxes(320, 220))
+                .append("<rect class='chart-column total-column' x='84' y='").append(180 - totalHeight)
+                .append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
+                .append("<rect class='chart-column unknown-column' x='180' y='").append(180 - unknownHeight)
+                .append("' width='56' height='").append(unknownHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
+                .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>总词量</text>")
+                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>未知词</text>")
+                .append("</svg>")
+                .append("</div>")
+                .append("<div class='data-text'>总词量 ").append(chart.totalWordCount())
+                .append(" 词,冲刺前未知 ").append(chart.unknownWordCountBeforeSprint())
+                .append(" 词,冲刺后未知 ").append(chart.unknownWordCountAfterSprint()).append(" 词。</div>")
+                .append("<p class='chart-note'>").append(escape(chart.projectedScoreGainLabel())).append(" · ")
+                .append(escape(chart.recommendation())).append("</p>")
+                .toString();
     }
 
-    private String renderVocabularyFrequencyBands(List<OutlookExamSprintReportPayload.VocabularyFrequencyBand> bands) {
-        StringBuilder builder = new StringBuilder("<div class='frequency-list'>");
-        for (OutlookExamSprintReportPayload.VocabularyFrequencyBand band : bands) {
-            builder.append("<div class='frequency-item'><strong>")
-                    .append(escape(band.bandLabel()))
-                    .append("</strong>:当前 ")
-                    .append(band.masteryPercent())
-                    .append("%,目标 ")
-                    .append(band.targetPercent())
-                    .append("%</div>");
+    private String renderHighFrequencyVocabularyChart(OutlookExamSprintReportPayload.HighFrequencyVocabularyChart chart) {
+        int basicHeight = barHeight(chart.basicCorePercent(), 100);
+        int highScoreHeight = barHeight(chart.highScorePercent(), 100);
+
+        return new StringBuilder()
+                .append("<div class='card-title'>常考词汇掌握情况</div>")
+                .append("<div class='chart-shell'>")
+                .append("<svg class='high-frequency-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
+                .append(renderChartAxes(320, 220))
+                .append("<rect class='chart-column basic-core-column' x='84' y='").append(180 - basicHeight)
+                .append("' width='56' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
+                .append("<rect class='chart-column high-score-column' x='180' y='").append(180 - highScoreHeight)
+                .append("' width='56' height='").append(highScoreHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
+                .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>基础核心</text>")
+                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>拉分词</text>")
+                .append("</svg>")
+                .append("</div>")
+                .append("<div class='data-text'>基础核心 ").append(chart.basicCorePercent()).append("% ,高分词 ")
+                .append(chart.highScorePercent()).append("%。</div>")
+                .append("<p class='chart-note'>").append(escape(chart.highlightLabel())).append("</p>")
+                .toString();
+    }
+
+    private String renderVocabularyFrequencyBandChart(OutlookExamSprintReportPayload.VocabularyFrequencyBandChart chart) {
+        List<OutlookExamSprintReportPayload.VocabularyFrequencyBar> bars = chart.bars();
+        int highest = bars.stream().mapToInt(bar -> (int) Math.ceil(bar.currentValue())).max().orElse(1);
+
+        StringBuilder builder = new StringBuilder();
+        builder.append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>");
+        builder.append(renderChartAxes(360, 220));
+
+        int[] xPositions = {70, 160, 250};
+        String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
+        for (int index = 0; index < Math.min(3, bars.size()); index++) {
+            OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
+            int height = barHeight((int) Math.round(bar.currentValue()), highest);
+            int x = xPositions[index];
+            builder.append("<rect class='chart-column ").append(columnClasses[index]).append("' x='")
+                    .append(x).append("' y='").append(180 - height).append("' width='54' height='")
+                    .append(height).append("' rx='8' ry='8' fill='").append(escape(bar.themeColor())).append("'/>")
+                    .append("<text class='donut-label-text' x='").append(x + 27).append("' y='198' text-anchor='middle'>")
+                    .append(escape(bar.bandLabel())).append("</text>")
+                    .append("<text class='chart-caption' x='").append(x + 27).append("' y='20' text-anchor='middle'>")
+                    .append(escape(bar.priorityLabel())).append("</text>");
         }
-        builder.append("</div>");
+
+        builder.append("</svg>");
         return builder.toString();
     }
 
-    private String renderSprintPlanOptions(List<OutlookExamSprintReportPayload.SprintPlanOption> options) {
+    private String renderStudySuggestionSection(OutlookExamSprintReportPayload.FrequencyPlan frequencyPlan) {
         StringBuilder builder = new StringBuilder();
-        for (OutlookExamSprintReportPayload.SprintPlanOption option : options) {
-            builder.append("<div class='plan-card'>")
-                    .append("<div class='plan-title'>")
-                    .append(escape(option.planName()));
-            if (option.tagLabel() != null && !option.tagLabel().isBlank()) {
-                builder.append(" <span class='tag'>").append(escape(option.tagLabel())).append("</span>");
+        builder.append("<div class='study-suggestion-shell'>")
+                .append("<div class='study-suggestion-intro'>")
+                .append("<h3>").append(escape(frequencyPlan.recommendationTitle())).append("</h3>")
+                .append("<p>").append(escape(frequencyPlan.recommendationSummary())).append("</p>")
+                .append("</div>")
+                .append("<div class='study-frequency-grid'>");
+
+        for (OutlookExamSprintReportPayload.FrequencyPlanCard card : frequencyPlan.cards()) {
+            builder.append("<div class='study-frequency-card")
+                    .append(card.recommended() ? " active" : "")
+                    .append("'>")
+                    .append("<div class='study-frequency-header'>")
+                    .append(escape(card.cadencePerWeek() + "套/周"));
+            if (card.emphasisIcon() != null && !card.emphasisIcon().isBlank()) {
+                builder.append("<span class='crown'>").append(escape(card.emphasisIcon())).append("</span>");
             }
             builder.append("</div>")
-                    .append("<div class='plan-cadence'>").append(escape(option.cadenceLabel())).append("</div>")
-                    .append("<p><strong>重点:</strong>").append(escape(option.focus())).append("</p>")
-                    .append("<ul>");
-            for (String actionItem : option.actionItems()) {
-                builder.append("<li>").append(escape(actionItem)).append("</li>");
+                    .append("<div class='study-frequency-progress'><span class='study-frequency-progress-fill' style='width:")
+                    .append(card.winRatePercent()).append("%'></span></div>")
+                    .append("<div class='study-frequency-data'>")
+                    .append("<span class='win-rate'>").append(escape(card.scoreGainLabel())).append("</span> · ")
+                    .append(card.winRatePercent()).append("%")
+                    .append("</div>");
+
+            if (card.badgeLabel() != null && !card.badgeLabel().isBlank()) {
+                builder.append("<div class='badge'>").append(escape(card.badgeLabel())).append("</div>");
             }
-            builder.append("</ul>")
-                    .append("<p><strong>预期:</strong>").append(escape(option.expectedOutcome())).append("</p>")
+
+            builder.append("</div>");
+        }
+
+        builder.append("</div>")
+                .append("<div class='study-strategy-note'><span class='study-strategy-label'>")
+                .append(escape(frequencyPlan.recommendationTitle()))
+                .append("</span>")
+                .append(escape(frequencyPlan.recommendationSummary()))
+                .append("</div>")
+                .append("<div class='study-stage-box'>");
+
+        for (OutlookExamSprintReportPayload.PhaseSuggestion suggestion : frequencyPlan.phaseSuggestions()) {
+            builder.append("<div class='study-stage-item'>")
+                    .append("<h4>").append(escape(suggestion.title())).append("</h4>")
+                    .append("<p>").append(escape(suggestion.description())).append("</p>")
                     .append("</div>");
         }
+
+        builder.append("</div></div>");
         return builder.toString();
     }
 
-    private String renderDiagnosticCaseStudy(OutlookExamSprintReportPayload.DiagnosticCaseStudy caseStudy) {
+    private String renderScoreImprovementCaseStudy(OutlookExamSprintReportPayload.ScoreImprovementCaseStudy caseStudy) {
         return new StringBuilder()
-                .append("<div class='case-card'>")
-                .append("<h3>").append(escape(caseStudy.title())).append("</h3>")
-                .append("<p><strong>背景:</strong>").append(escape(caseStudy.context())).append("</p>")
-                .append("<p><strong>诊断:</strong>").append(escape(caseStudy.diagnosis())).append("</p>")
-                .append("<p><strong>策略:</strong>").append(escape(caseStudy.strategy())).append("</p>")
-                .append("<p><strong>启示:</strong>").append(escape(caseStudy.keyTakeaway())).append("</p>")
+                .append("<div class='case-study-shell'>")
+                .append("<div class='case-study-visual'>")
+                .append("<svg class='case-study-visual-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
+                .append("<circle cx='130' cy='130' r='92' fill='#fff3e5' stroke='#f3d7bb' stroke-width='2'/>")
+                .append("<path d='M52 176 L104 124 L136 146 L204 82' fill='none' stroke='#ff7d00' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/>")
+                .append("<circle cx='52' cy='176' r='8' fill='#ff7d00'/>")
+                .append("<circle cx='104' cy='124' r='8' fill='#ff7d00'/>")
+                .append("<circle cx='136' cy='146' r='8' fill='#ff7d00'/>")
+                .append("<circle cx='204' cy='82' r='8' fill='#ff7d00'/>")
+                .append("<text x='130' y='214' text-anchor='middle' fill='#8a5d36' font-size='14'>")
+                .append(escape(caseStudy.baselineScoreLabel())).append(" → ").append(caseStudy.finalScore()).append("分</text>")
+                .append("</svg>")
+                .append("<div class='case-hit-rate-badge'><span class='case-hit-rate-value'>")
+                .append(caseStudy.hitRatePercent()).append("%</span><span class='case-hit-rate-label'>命中率</span></div>")
+                .append("</div>")
+                .append("<div class='case-info'>")
+                .append("<h3>").append(escape(caseStudy.headline())).append("</h3>")
+                .append(caseInfoSection("背景", caseStudy.studyPeriodLabel()))
+                .append(caseInfoSection("学生", caseStudy.learnerName()))
+                .append(caseInfoSection("记忆词汇", caseStudy.memorizedWordCount() + "词"))
+                .append(caseInfoSection("高考命中", caseStudy.examHitWordCount() + "词"))
+                .append(caseInfoSection("结果", caseStudy.baselineScoreLabel() + " · " + caseStudy.finalScore() + "分 · 提升分数:+" + caseStudy.scoreGain() + "分"))
                 .append("</div>")
+                .append("</div>")
+                .toString();
+    }
+
+    private StringBuilder caseInfoSection(String label, String value) {
+        return new StringBuilder()
+                .append("<div class='case-info-section'>")
+                .append("<div class='case-info-group'><span class='case-info-label'>")
+                .append(escape(label)).append(":</span><span class='case-info-value'>")
+                .append(escape(value)).append("</span></div>")
+                .append("</div>");
+    }
+
+    private String renderChartAxes(int width, int height) {
+        return new StringBuilder()
+                .append("<line class='chart-axis' x1='34' y1='18' x2='34' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
+                .append("<line class='chart-axis' x1='34' y1='180' x2='").append(width - 24).append("' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
                 .toString();
     }
 
+    private int barHeight(int value, int maxValue) {
+        if (maxValue <= 0) {
+            return 0;
+        }
+        double ratio = Math.max(0d, Math.min(1d, value / (double) maxValue));
+        return Math.max(16, (int) Math.round(ratio * 130));
+    }
+
+    private String describeArc(double centerX, double centerY, double radius, double startAngle, double endAngle) {
+        double startRadians = Math.toRadians(startAngle);
+        double endRadians = Math.toRadians(endAngle);
+        double startX = centerX + (radius * Math.cos(startRadians));
+        double startY = centerY + (radius * Math.sin(startRadians));
+        double endX = centerX + (radius * Math.cos(endRadians));
+        double endY = centerY + (radius * Math.sin(endRadians));
+        double largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
+        return new StringBuilder()
+                .append("M ").append(formatDouble(startX)).append(' ')
+                .append(formatDouble(startY)).append(' ')
+                .append("A ").append(formatDouble(radius)).append(' ')
+                .append(formatDouble(radius)).append(" 0 ")
+                .append((int) largeArcFlag).append(" 1 ")
+                .append(formatDouble(endX)).append(' ')
+                .append(formatDouble(endY))
+                .toString();
+    }
+
+    private String formatDouble(double value) {
+        return String.format(java.util.Locale.ROOT, "%.2f", value);
+    }
+
     private String escape(String value) {
         if (value == null) {
             return "";

+ 46 - 19
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java

@@ -395,12 +395,15 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         return OBJECT_MAPPER.readTree("""
                 {
                   "reportMetadata": {
+                    "reportVersionLabel": "2026 词汇展望报告",
                     "learnerName": "李同学",
                     "targetExamName": "春季高考英语",
-                    "sprintPeriodLabel": "30 天考前冲刺"
+                    "sprintPeriodLabel": "30 天考前冲刺",
+                    "authorName": "Ability Bot"
                   },
                   "readinessOverview": {
                     "summary": "基础较稳,具备短期冲刺提分空间。",
+                    "currentStage": "冲刺提升期",
                     "keyInsight": "核心观察:高频与常考词群是提分关键。"
                   },
                   "syllabusMasteryChart": {
@@ -408,6 +411,7 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                     "masteredWordCount": 2701,
                     "unmasteredWordCount": 1499,
                     "masteryPercent": 64,
+                    "summaryLabel": "考纲词汇掌握概览",
                     "recommendation": "优先补齐高考核心场景词。"
                   },
                   "pastPaperVocabularyChart": {
@@ -429,29 +433,52 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                       {"bandLabel": "低频词", "currentValue": 70.4, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
                     ]
                   },
-                  "studySuggestionSection": {
-                    "cadenceCards": [
-                      {"cadencePerWeek": 1, "scoreGainLabel": "+5分", "winRatePercent": 38, "recommended": false},
-                      {"cadencePerWeek": 2, "scoreGainLabel": "+10分", "winRatePercent": 55, "recommended": false},
-                      {"cadencePerWeek": 3, "scoreGainLabel": "+10分", "winRatePercent": 72, "recommended": true},
-                      {"cadencePerWeek": 5, "scoreGainLabel": "+20分", "winRatePercent": 88, "recommended": false}
+                  "frequencyPlan": {
+                    "cards": [
+                      {
+                        "cadencePerWeek": 1,
+                        "scoreGainLabel": "+5分",
+                        "winRatePercent": 38,
+                        "recommended": false,
+                        "badgeLabel": "稳健",
+                        "emphasisIcon": "①"
+                      },
+                      {
+                        "cadencePerWeek": 2,
+                        "scoreGainLabel": "+10分",
+                        "winRatePercent": 55,
+                        "recommended": false,
+                        "badgeLabel": "均衡",
+                        "emphasisIcon": "②"
+                      },
+                      {
+                        "cadencePerWeek": 3,
+                        "scoreGainLabel": "+15分",
+                        "winRatePercent": 72,
+                        "recommended": true,
+                        "badgeLabel": "推荐",
+                        "emphasisIcon": "③"
+                      },
+                      {
+                        "cadencePerWeek": 5,
+                        "scoreGainLabel": "+20分",
+                        "winRatePercent": 88,
+                        "recommended": false,
+                        "badgeLabel": "冲刺",
+                        "emphasisIcon": "④"
+                      }
                     ],
-                    "strategyProjection": {
-                      "recommendedCadenceLabel": "3套",
-                      "projectedScoreGainLabel": "15+10分",
-                      "overallWinRatePercent": 72
-                    },
-                    "halfMonthSprintAdvice": {
-                      "description": "按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。"
-                    },
-                    "halfHourReviewAdvice": {
-                      "description": "只复习已标记的核心词汇,不学新词;使用专属《压轴词》速记手册,保持记忆热度,考场直接见效。"
-                    }
+                    "recommendationTitle": "💡建议策略",
+                    "recommendationSummary": "7 天提分冲刺是首选节奏,按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。",
+                    "phaseSuggestions": [
+                      {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
+                      {"title": "考前半小时·临阵巩固期", "description": "结合真题词做循环巩固。"}
+                    ]
                   },
                   "scoreImprovementCaseStudy": {
                     "headline": "真实提分 · 效果可复制",
                     "learnerName": "王雷宇",
-                    "studyPeriodLabel": "考前3天短期突击",
+                    "studyPeriodLabel": "考前半个月·核心突击期",
                     "memorizedWordCount": 705,
                     "examHitWordCount": 237,
                     "hitRatePercent": 33.8,

+ 76 - 51
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

@@ -120,60 +120,85 @@ class ClasspathOutlookExamSprintReportRendererTest {
                     "keyInsight": "高频与常考词群是提分关键。",
                     "readinessScore": 72
                   },
-                  "syllabusMasteryProfile": {
-                    "masteryPercent": 78,
-                    "diagnosis": "考纲词覆盖较好。",
-                    "recommendation": "保持滚动复习。",
-                    "dimensionScores": [
-                      {"label": "识记", "score": 82},
-                      {"label": "应用", "score": 74}
-                    ]
+                  "syllabusMasteryChart": {
+                    "totalWordCount": 4200,
+                    "masteredWordCount": 2701,
+                    "unmasteredWordCount": 1499,
+                    "masteryPercent": 64,
+                    "summaryLabel": "考纲词汇掌握概览",
+                    "recommendation": "优先补齐高考核心场景词。"
+                  },
+                  "pastPaperVocabularyChart": {
+                    "totalWordCount": 961,
+                    "unknownWordCountBeforeSprint": 847,
+                    "unknownWordCountAfterSprint": 716,
+                    "projectedScoreGainLabel": "预计提分5-15分",
+                    "recommendation": "先压降真题生词占比。"
                   },
-                  "pastPaperVocabularyProfile": {
-                    "masteredWordCount": 420,
-                    "totalWordCount": 600,
-                    "masteryPercent": 70,
-                    "diagnosis": "真题词汇还需查漏补缺。",
-                    "recommendation": "优先扫清近三年高频词。",
-                    "sampleWords": ["abandon", "adapt", "assume"]
+                  "highFrequencyVocabularyChart": {
+                    "basicCorePercent": 62,
+                    "highScorePercent": 41,
+                    "highlightLabel": "拉分词是提分核心突破项"
                   },
-                  "highFrequencyVocabularyProfile": {
-                    "masteredWordCount": 320,
-                    "totalWordCount": 400,
-                    "masteryPercent": 80,
-                    "diagnosis": "常考词汇掌握情况良好。",
-                    "recommendation": "继续稳固高频词群。",
-                    "sampleWords": ["benefit", "capacity", "decline"]
+                  "vocabularyFrequencyBandChart": {
+                    "bars": [
+                      {"bandLabel": "高频词", "currentValue": 188.6, "priorityLabel": "优先学习", "themeColor": "#448aff"},
+                      {"bandLabel": "中频词", "currentValue": 154.5, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
+                      {"bandLabel": "低频词", "currentValue": 70.4, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
+                    ]
+                  },
+                  "frequencyPlan": {
+                    "cards": [
+                      {
+                        "cadencePerWeek": 1,
+                        "scoreGainLabel": "+5分",
+                        "winRatePercent": 38,
+                        "recommended": false,
+                        "badgeLabel": "稳健",
+                        "emphasisIcon": "①"
+                      },
+                      {
+                        "cadencePerWeek": 2,
+                        "scoreGainLabel": "+10分",
+                        "winRatePercent": 55,
+                        "recommended": false,
+                        "badgeLabel": "均衡",
+                        "emphasisIcon": "②"
+                      },
+                      {
+                        "cadencePerWeek": 3,
+                        "scoreGainLabel": "+15分",
+                        "winRatePercent": 72,
+                        "recommended": true,
+                        "badgeLabel": "推荐",
+                        "emphasisIcon": "③"
+                      },
+                      {
+                        "cadencePerWeek": 5,
+                        "scoreGainLabel": "+20分",
+                        "winRatePercent": 88,
+                        "recommended": false,
+                        "badgeLabel": "冲刺",
+                        "emphasisIcon": "④"
+                      }
+                    ],
+                    "recommendationTitle": "💡建议策略",
+                    "recommendationSummary": "7 天提分冲刺是首选节奏,按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。",
+                    "phaseSuggestions": [
+                      {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
+                      {"title": "考前半小时·临阵巩固期", "description": "结合真题词做循环巩固。"}
+                    ]
                   },
-                  "vocabularyFrequencyBands": [
-                    {"bandLabel": "高频词", "masteryPercent": 80, "targetPercent": 90},
-                    {"bandLabel": "中频词", "masteryPercent": 68, "targetPercent": 80},
-                    {"bandLabel": "低频词", "masteryPercent": 45, "targetPercent": 60}
-                  ],
-                  "sprintPlanOptions": [
-                    {
-                      "planName": "7 天提分冲刺",
-                      "cadenceLabel": "7 天",
-                      "tagLabel": "推荐",
-                      "focus": "高频词与真题词回收",
-                      "actionItems": ["晨读高频词", "午间错词复现", "晚间真题套练"],
-                      "expectedOutcome": "稳定拿下基础词汇题"
-                    },
-                    {
-                      "planName": "14 天均衡提升",
-                      "cadenceLabel": "14 天",
-                      "tagLabel": "稳妥",
-                      "focus": "词群与题型双线推进",
-                      "actionItems": ["两天一轮主题词", "隔天真题精练"],
-                      "expectedOutcome": "兼顾稳定性与提分空间"
-                    }
-                  ],
-                  "diagnosticCaseStudy": {
-                    "title": "上届学员案例",
-                    "context": "基础一般但执行力强。",
-                    "diagnosis": "高频词重复错误较多。",
-                    "strategy": "连续 10 天高频词闭环复习。",
-                    "keyTakeaway": "短周期高频复现可快速提分。"
+                  "scoreImprovementCaseStudy": {
+                    "headline": "真实提分 · 效果可复制",
+                    "learnerName": "王雷宇",
+                    "studyPeriodLabel": "考前半个月·核心突击期",
+                    "memorizedWordCount": 705,
+                    "examHitWordCount": 237,
+                    "hitRatePercent": 33.8,
+                    "baselineScoreLabel": "70分以下",
+                    "finalScore": 89,
+                    "scoreGain": 19
                   }
                 }
                 """);