- DeckPlanningService와 DeckQualityReviewService를 추가해 deck brief 정규화, consulting storyline 보강, 누락된 Executive Summary/Recommendation/Roadmap/Appendix 자동 보강, deck-level 품질 점수와 경고 계산을 지원합니다. - PptxSkill에 audience/objective/decision_ask/storyline 파라미터를 추가하고, issue_tree/before_after/decision_matrix/risk_heatmap/benefit_waterfall/operating_model/appendix_evidence 레이아웃을 네이티브 슬라이드 타입으로 정규화한 뒤 planning summary와 quality summary를 함께 반환하도록 보강했습니다. - pptx-creator 및 strategy-deck/board-update/pmo-steering/sales-review-deck/operating-model-deck 번들 스킬을 추가·정리하고, DeckPlanningServiceTests/DeckQualityReviewServiceTests/PptxSkillAutoRepairTests로 회귀 검증을 보강했습니다. - README.md와 docs/DEVELOPMENT.md에 2026-04-14 21:50, 22:00 (KST) 기준 변경 이력과 검증 결과를 반영했습니다. - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_ppt_phase3\ -p:IntermediateOutputPath=obj\verify_ppt_phase3\ (경고 0 / 오류 0) - 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter 'DeckPlanningServiceTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests' -p:OutputPath=bin\verify_ppt_phase3_tests\ -p:IntermediateOutputPath=obj\verify_ppt_phase3_tests\ (통과 5)
219 lines
8.9 KiB
C#
219 lines
8.9 KiB
C#
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
public enum DeckReviewSeverity
|
|
{
|
|
Info,
|
|
Warning,
|
|
Critical,
|
|
}
|
|
|
|
public sealed record DeckReviewIssue(string Message, DeckReviewSeverity Severity);
|
|
|
|
public sealed record DeckQualityReport(
|
|
int Score,
|
|
IReadOnlyList<string> Strengths,
|
|
IReadOnlyList<DeckReviewIssue> Issues,
|
|
IReadOnlyList<string> Storyline)
|
|
{
|
|
public string ToToolSummary()
|
|
{
|
|
var strengths = Strengths.Take(3).ToList();
|
|
var issues = Issues
|
|
.OrderByDescending(issue => issue.Severity)
|
|
.Take(3)
|
|
.Select(issue => issue.Message)
|
|
.ToList();
|
|
var parts = new List<string> { $"PPT quality {Score}/100" };
|
|
if (strengths.Count > 0)
|
|
parts.Add("Strengths: " + string.Join(", ", strengths));
|
|
if (issues.Count > 0)
|
|
parts.Add("Needs work: " + string.Join(", ", issues));
|
|
else
|
|
parts.Add("Needs work: none");
|
|
return string.Join(" | ", parts);
|
|
}
|
|
}
|
|
|
|
public static class DeckQualityReviewService
|
|
{
|
|
public static DeckQualityReport ReviewDeck(
|
|
string title,
|
|
JsonElement slides,
|
|
bool hasTemplate,
|
|
int autoRepairCount,
|
|
IEnumerable<string>? storyline = null)
|
|
{
|
|
var strengths = new List<string>();
|
|
var issues = new List<DeckReviewIssue>();
|
|
var storylineSteps = (storyline ?? []).Where(step => !string.IsNullOrWhiteSpace(step)).ToList();
|
|
const string KoreanAppendix = "\uBD80\uB85D";
|
|
if (slides.ValueKind != JsonValueKind.Array)
|
|
return new DeckQualityReport(40, strengths, [new("Slides must be an array.", DeckReviewSeverity.Critical)], storylineSteps);
|
|
var slidesList = slides.EnumerateArray().ToList();
|
|
var slideCount = slidesList.Count;
|
|
var titleSlides = 0;
|
|
var executiveSlides = 0;
|
|
var recommendationSlides = 0;
|
|
var roadmapSlides = 0;
|
|
var appendixSlides = 0;
|
|
var chartSlides = 0;
|
|
var tableSlides = 0;
|
|
var comparisonSlides = 0;
|
|
var textHeavySlides = 0;
|
|
var duplicateHeadlines = 0;
|
|
var placeholderCount = 0;
|
|
var headlines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var layoutCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var slide in slidesList)
|
|
{
|
|
var layout = ReadString(slide, "layout", "content");
|
|
var normalizedLayout = layout.ToLowerInvariant();
|
|
layoutCounts[normalizedLayout] = layoutCounts.GetValueOrDefault(normalizedLayout) + 1;
|
|
switch (normalizedLayout)
|
|
{
|
|
case "title":
|
|
titleSlides++;
|
|
break;
|
|
case "executive_summary":
|
|
executiveSlides++;
|
|
break;
|
|
case "recommendation":
|
|
recommendationSlides++;
|
|
break;
|
|
case "roadmap":
|
|
roadmapSlides++;
|
|
break;
|
|
case "comparison":
|
|
comparisonSlides++;
|
|
break;
|
|
case "chart":
|
|
chartSlides++;
|
|
break;
|
|
case "table":
|
|
tableSlides++;
|
|
break;
|
|
}
|
|
var titleText = ReadString(slide, "title");
|
|
if (titleText.Contains("appendix", StringComparison.OrdinalIgnoreCase) ||
|
|
titleText.Contains(KoreanAppendix, StringComparison.OrdinalIgnoreCase) ||
|
|
titleText.Contains("evidence", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
appendixSlides++;
|
|
}
|
|
var headline = ReadString(slide, "headline");
|
|
if (!string.IsNullOrWhiteSpace(headline) && !headlines.Add(headline.Trim()))
|
|
duplicateHeadlines++;
|
|
var bulletCount =
|
|
ReadStringList(slide, "summary_points").Count +
|
|
ReadStringList(slide, "next_steps").Count +
|
|
ReadStringList(slide, "body").Count;
|
|
var bodyText = ReadString(slide, "body");
|
|
var textLength = bodyText.Length +
|
|
headline.Length +
|
|
ReadString(slide, "recommendation").Length +
|
|
ReadString(slide, "left").Length +
|
|
ReadString(slide, "right").Length;
|
|
if (bulletCount >= 7 || textLength >= 420)
|
|
textHeavySlides++;
|
|
placeholderCount += CountPlaceholders(slide.GetRawText());
|
|
}
|
|
if (hasTemplate) strengths.Add("Uses template or branded theme");
|
|
if (titleSlides > 0) strengths.Add("Includes title slide");
|
|
if (executiveSlides > 0) strengths.Add("Includes Executive Summary");
|
|
if (recommendationSlides > 0) strengths.Add("Includes Recommendation");
|
|
if (roadmapSlides > 0) strengths.Add("Includes Roadmap");
|
|
if (chartSlides + tableSlides + comparisonSlides >= 2)
|
|
strengths.Add("Contains evidence-oriented comparison, table, or chart slides");
|
|
if (layoutCounts.Count >= 4)
|
|
strengths.Add($"Uses {layoutCounts.Count} distinct layouts");
|
|
if (autoRepairCount > 0)
|
|
strengths.Add($"Auto-repair applied {autoRepairCount} time(s)");
|
|
if (slideCount < 4)
|
|
issues.Add(new("Slide count may be too low for an executive deck.", DeckReviewSeverity.Warning));
|
|
if (titleSlides == 0)
|
|
issues.Add(new("Title slide is missing.", DeckReviewSeverity.Warning));
|
|
if (executiveSlides == 0)
|
|
issues.Add(new("Executive Summary slide is missing.", DeckReviewSeverity.Critical));
|
|
if (recommendationSlides == 0)
|
|
issues.Add(new("Recommendation or decision request slide is missing.", DeckReviewSeverity.Critical));
|
|
if (roadmapSlides == 0 && slideCount >= 5)
|
|
issues.Add(new("Execution roadmap slide is missing.", DeckReviewSeverity.Warning));
|
|
if (appendixSlides == 0 && slideCount >= 6)
|
|
issues.Add(new("Appendix or evidence slide is limited.", DeckReviewSeverity.Info));
|
|
if (duplicateHeadlines > 0)
|
|
issues.Add(new($"Found {duplicateHeadlines} duplicate headline(s).", DeckReviewSeverity.Warning));
|
|
if (textHeavySlides > Math.Max(1, slideCount / 2))
|
|
issues.Add(new("Too many slides are text-heavy.", DeckReviewSeverity.Warning));
|
|
if (chartSlides + tableSlides + comparisonSlides == 0 && slideCount >= 4)
|
|
issues.Add(new("Evidence slides such as charts, tables, or comparisons are limited.", DeckReviewSeverity.Warning));
|
|
if (placeholderCount > 0)
|
|
issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", DeckReviewSeverity.Critical));
|
|
var score = 70;
|
|
score += Math.Min(18, strengths.Count * 3);
|
|
score -= issues.Sum(issue => issue.Severity switch
|
|
{
|
|
DeckReviewSeverity.Critical => 12,
|
|
DeckReviewSeverity.Warning => 6,
|
|
_ => 2,
|
|
});
|
|
score = Math.Clamp(score, 30, 98);
|
|
return new DeckQualityReport(score, strengths, issues, storylineSteps);
|
|
}
|
|
private static string ReadString(JsonElement element, string propertyName, string fallback = "")
|
|
{
|
|
if (!element.SafeTryGetProperty(propertyName, out var value))
|
|
return fallback;
|
|
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.SafeGetString() ?? fallback,
|
|
JsonValueKind.Number => value.ToString(),
|
|
_ => fallback,
|
|
};
|
|
}
|
|
|
|
private static List<string> ReadStringList(JsonElement element, string propertyName)
|
|
{
|
|
if (!element.SafeTryGetProperty(propertyName, out var value))
|
|
return [];
|
|
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.Array => value.EnumerateArray()
|
|
.Select(item => item.ValueKind switch
|
|
{
|
|
JsonValueKind.String => item.SafeGetString() ?? string.Empty,
|
|
JsonValueKind.Number => item.ToString(),
|
|
JsonValueKind.Object => item.ToString(),
|
|
_ => string.Empty,
|
|
})
|
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
|
.ToList(),
|
|
JsonValueKind.String => (value.SafeGetString() ?? string.Empty)
|
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.ToList(),
|
|
_ => [],
|
|
};
|
|
}
|
|
|
|
private static int CountPlaceholders(string text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return 0;
|
|
|
|
var patterns = new[]
|
|
{
|
|
@"\bTBD\b",
|
|
@"\bTODO\b",
|
|
@"\[(placeholder|fill me|todo)\]",
|
|
@"lorem ipsum",
|
|
};
|
|
|
|
return patterns.Sum(pattern => Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count);
|
|
}
|
|
}
|
|
|