Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs
lacvet 8571a83ed0 PPT 생성 고도화 3차를 반영하고 deck planning·quality gate를 추가
- 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)
2026-04-14 22:01:41 +09:00

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);
}
}