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)
This commit is contained in:
806
src/AxCopilot/Services/Agent/DeckPlanningService.cs
Normal file
806
src/AxCopilot/Services/Agent/DeckPlanningService.cs
Normal file
@@ -0,0 +1,806 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class DeckPreparationResult : IDisposable
|
||||
{
|
||||
public required JsonDocument SlidesDocument { get; init; }
|
||||
public required IReadOnlyList<string> Storyline { get; init; }
|
||||
public required int AutoRepairCount { get; init; }
|
||||
public string? Audience { get; init; }
|
||||
public string? Objective { get; init; }
|
||||
public string? DecisionAsk { get; init; }
|
||||
public string? SuggestedTheme { get; init; }
|
||||
public bool InjectedTitleSlide { get; init; }
|
||||
public bool InjectedExecutiveSummary { get; init; }
|
||||
public bool InjectedRecommendation { get; init; }
|
||||
public bool InjectedRoadmap { get; init; }
|
||||
public bool InjectedAppendix { get; init; }
|
||||
|
||||
public JsonElement Slides => SlidesDocument.RootElement;
|
||||
|
||||
public string ToToolSummary()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(Audience))
|
||||
parts.Add($"Audience: {Audience}");
|
||||
if (!string.IsNullOrWhiteSpace(Objective))
|
||||
parts.Add($"Objective: {Objective}");
|
||||
if (!string.IsNullOrWhiteSpace(DecisionAsk))
|
||||
parts.Add($"Ask: {DecisionAsk}");
|
||||
var injected = new List<string>();
|
||||
if (InjectedTitleSlide) injected.Add("Title");
|
||||
if (InjectedExecutiveSummary) injected.Add("Executive Summary");
|
||||
if (InjectedRecommendation) injected.Add("Recommendation");
|
||||
if (InjectedRoadmap) injected.Add("Roadmap");
|
||||
if (InjectedAppendix) injected.Add("Appendix");
|
||||
if (injected.Count > 0)
|
||||
parts.Add("Added: " + string.Join(", ", injected));
|
||||
if (AutoRepairCount > 0)
|
||||
parts.Add($"Auto-repair: {AutoRepairCount}");
|
||||
if (Storyline.Count > 0)
|
||||
parts.Add("Storyline: " + string.Join(" -> ", Storyline.Take(6)));
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
SlidesDocument.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeckPlanningService
|
||||
{
|
||||
public static DeckPreparationResult Prepare(
|
||||
JsonElement args,
|
||||
JsonElement slides,
|
||||
string presentationTitle)
|
||||
{
|
||||
var audience = ReadString(args, "audience");
|
||||
var objective = ReadString(args, "objective");
|
||||
var decisionAsk = ReadString(args, "decision_ask");
|
||||
var storylineHint = ReadHintList(args, "storyline", "storyline_hint", "deck_outline");
|
||||
|
||||
var slideNodes = ParseSlides(slides);
|
||||
var autoRepairCount = 0;
|
||||
|
||||
for (var i = 0; i < slideNodes.Count; i++)
|
||||
autoRepairCount += NormalizeSlide(slideNodes[i], i, presentationTitle, objective, decisionAsk);
|
||||
|
||||
var hasTitle = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "title", StringComparison.OrdinalIgnoreCase));
|
||||
var hasExecutiveSummary = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "executive_summary", StringComparison.OrdinalIgnoreCase));
|
||||
var hasRecommendation = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "recommendation", StringComparison.OrdinalIgnoreCase));
|
||||
var hasRoadmap = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "roadmap", StringComparison.OrdinalIgnoreCase));
|
||||
var hasAppendix = slideNodes.Any(slide =>
|
||||
{
|
||||
var title = ReadString(slide, "title");
|
||||
return title.Contains("appendix", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("\uBD80\uB85D", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("evidence", StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
var injectedTitle = false;
|
||||
var injectedExecutiveSummary = false;
|
||||
var injectedRecommendation = false;
|
||||
var injectedRoadmap = false;
|
||||
var injectedAppendix = false;
|
||||
|
||||
if (!hasTitle && slideNodes.Count > 0)
|
||||
{
|
||||
slideNodes.Insert(0, BuildTitleSlide(presentationTitle, audience, objective));
|
||||
injectedTitle = true;
|
||||
autoRepairCount++;
|
||||
}
|
||||
|
||||
if (!hasExecutiveSummary)
|
||||
{
|
||||
var insertAt = injectedTitle || (slideNodes.Count > 0 && string.Equals(ReadString(slideNodes[0], "layout"), "title", StringComparison.OrdinalIgnoreCase))
|
||||
? 1
|
||||
: 0;
|
||||
slideNodes.Insert(insertAt, BuildExecutiveSummarySlide(slideNodes, objective, decisionAsk));
|
||||
injectedExecutiveSummary = true;
|
||||
autoRepairCount++;
|
||||
}
|
||||
|
||||
if (!hasRecommendation && slideNodes.Count >= 3)
|
||||
{
|
||||
slideNodes.Add(BuildRecommendationSlide(slideNodes, objective, decisionAsk));
|
||||
injectedRecommendation = true;
|
||||
autoRepairCount++;
|
||||
}
|
||||
|
||||
if (!hasRoadmap && slideNodes.Count >= 4)
|
||||
{
|
||||
slideNodes.Add(BuildRoadmapSlide(decisionAsk));
|
||||
injectedRoadmap = true;
|
||||
autoRepairCount++;
|
||||
}
|
||||
|
||||
if (!hasAppendix && slideNodes.Count >= 6)
|
||||
{
|
||||
slideNodes.Add(BuildAppendixSlide(slideNodes));
|
||||
injectedAppendix = true;
|
||||
autoRepairCount++;
|
||||
}
|
||||
|
||||
for (var i = 0; i < slideNodes.Count; i++)
|
||||
autoRepairCount += NormalizeSlide(slideNodes[i], i, presentationTitle, objective, decisionAsk);
|
||||
|
||||
var storyline = BuildStoryline(slideNodes, storylineHint);
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(ToJsonArray(slideNodes));
|
||||
var document = JsonDocument.Parse(json);
|
||||
|
||||
return new DeckPreparationResult
|
||||
{
|
||||
SlidesDocument = document,
|
||||
Storyline = storyline,
|
||||
AutoRepairCount = autoRepairCount,
|
||||
Audience = audience,
|
||||
Objective = objective,
|
||||
DecisionAsk = decisionAsk,
|
||||
SuggestedTheme = SuggestTheme(objective, audience),
|
||||
InjectedTitleSlide = injectedTitle,
|
||||
InjectedExecutiveSummary = injectedExecutiveSummary,
|
||||
InjectedRecommendation = injectedRecommendation,
|
||||
InjectedRoadmap = injectedRoadmap,
|
||||
InjectedAppendix = injectedAppendix,
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeSlide(
|
||||
JsonObject slide,
|
||||
int slideIndex,
|
||||
string presentationTitle,
|
||||
string? objective,
|
||||
string? decisionAsk)
|
||||
{
|
||||
var repairs = 0;
|
||||
var layout = ReadString(slide, "layout");
|
||||
if (string.IsNullOrWhiteSpace(layout))
|
||||
{
|
||||
slide["layout"] = "content";
|
||||
layout = "content";
|
||||
repairs++;
|
||||
}
|
||||
|
||||
switch (layout.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "issue_tree":
|
||||
slide["layout"] = "two_column";
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Issue Tree");
|
||||
slide["left"] = ToMultiline(slide, "issues", "left", "body");
|
||||
slide["right"] = ToMultiline(slide, "implications", "next_steps", "right");
|
||||
repairs++;
|
||||
break;
|
||||
|
||||
case "before_after":
|
||||
slide["layout"] = "two_column";
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Before / After");
|
||||
slide["left"] = Coalesce(ToMultiline(slide, "before", "current_state", "left"), "Before state");
|
||||
slide["right"] = Coalesce(ToMultiline(slide, "after", "future_state", "right"), "After state");
|
||||
repairs++;
|
||||
break;
|
||||
|
||||
case "decision_matrix":
|
||||
slide["layout"] = "comparison";
|
||||
if (!slide.ContainsKey("options") && slide.TryGetPropertyValue("choices", out var choicesNode))
|
||||
slide["options"] = choicesNode?.DeepClone();
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Decision Matrix");
|
||||
repairs++;
|
||||
break;
|
||||
|
||||
case "risk_heatmap":
|
||||
slide["layout"] = "table";
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Risk Overview");
|
||||
EnsureRiskRows(slide);
|
||||
repairs++;
|
||||
break;
|
||||
|
||||
case "benefit_waterfall":
|
||||
slide["layout"] = "chart";
|
||||
slide["chart_type"] = "bar";
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Benefit Waterfall");
|
||||
EnsureChartFromBenefits(slide);
|
||||
repairs++;
|
||||
break;
|
||||
|
||||
case "operating_model":
|
||||
slide["layout"] = "two_column";
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Operating Model");
|
||||
slide["left"] = Coalesce(ToMultiline(slide, "current_state", "capabilities", "left"), "Current model");
|
||||
slide["right"] = Coalesce(ToMultiline(slide, "target_state", "changes", "right"), "Target model");
|
||||
repairs++;
|
||||
break;
|
||||
|
||||
case "appendix_evidence":
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Appendix");
|
||||
if (slide.TryGetPropertyValue("evidence", out var evidenceNode) && evidenceNode is JsonArray evidence && evidence.Count > 0)
|
||||
{
|
||||
slide["layout"] = "table";
|
||||
slide["headers"] = new JsonArray("Evidence", "Detail", "Source");
|
||||
slide["rows"] = BuildEvidenceRows(evidence);
|
||||
}
|
||||
else
|
||||
{
|
||||
slide["layout"] = "content";
|
||||
slide["body"] = ToMultiline(slide, "summary_points", "body");
|
||||
}
|
||||
repairs++;
|
||||
break;
|
||||
}
|
||||
|
||||
repairs += ClampArray(slide, "summary_points", 4);
|
||||
repairs += ClampArray(slide, "next_steps", 3);
|
||||
repairs += ClampArray(slide, "options", 3);
|
||||
repairs += ClampArray(slide, "kpis", 4);
|
||||
repairs += ClampArray(slide, "phases", 4);
|
||||
repairs += ClampArray(slide, "rows", 6);
|
||||
|
||||
var normalizedLayout = ReadString(slide, "layout").ToLowerInvariant();
|
||||
if (normalizedLayout is "content" or "title" or "section" or "two_column" or "quote")
|
||||
repairs += NormalizeMultilineText(slide, normalizedLayout);
|
||||
|
||||
if (normalizedLayout == "chart" && !HasChartData(slide))
|
||||
{
|
||||
slide["layout"] = "comparison";
|
||||
slide["title"] = Coalesce(ReadString(slide, "title"), "Decision Comparison");
|
||||
slide["headline"] = Coalesce(ReadString(slide, "headline"), Coalesce(objective, "?듭떖 ??덉쓣 鍮꾧탳?⑸땲??"));
|
||||
EnsureFallbackOptions(slide);
|
||||
repairs++;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ReadString(slide, "headline")) &&
|
||||
normalizedLayout is "executive_summary" or "comparison" or "recommendation" or "roadmap" or "kpi_dashboard")
|
||||
{
|
||||
slide["headline"] = BuildHeadline(slide, slideIndex, presentationTitle, objective, decisionAsk);
|
||||
repairs++;
|
||||
}
|
||||
|
||||
if (normalizedLayout == "recommendation" && string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
|
||||
{
|
||||
slide["recommendation"] = Coalesce(decisionAsk, objective, "利됱떆 ?ㅽ뻾??沅뚭퀬?덉쓣 ??臾몄옣?쇰줈 ?쒖떆?⑸땲??");
|
||||
repairs++;
|
||||
}
|
||||
|
||||
if (normalizedLayout == "title" && string.IsNullOrWhiteSpace(ReadString(slide, "subtitle")))
|
||||
{
|
||||
slide["subtitle"] = Coalesce(objective, decisionAsk, "?듭떖 硫붿떆吏? ?섏궗寃곗젙 ?ъ씤?몃? ?뺣━?⑸땲??");
|
||||
repairs++;
|
||||
}
|
||||
|
||||
return repairs;
|
||||
}
|
||||
|
||||
private static int NormalizeMultilineText(JsonObject slide, string normalizedLayout)
|
||||
{
|
||||
var repairs = 0;
|
||||
if (normalizedLayout == "content")
|
||||
{
|
||||
var lines = ReadTextLines(slide, "body");
|
||||
if (lines.Count > 0)
|
||||
{
|
||||
var compact = string.Join("\n", lines.Take(5).Select(TrimBullet));
|
||||
if (!string.Equals(compact, ReadString(slide, "body"), StringComparison.Ordinal))
|
||||
{
|
||||
slide["body"] = compact;
|
||||
repairs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedLayout == "two_column")
|
||||
{
|
||||
foreach (var key in new[] { "left", "right" })
|
||||
{
|
||||
var lines = ReadTextLines(slide, key);
|
||||
if (lines.Count == 0)
|
||||
continue;
|
||||
|
||||
var compact = string.Join("\n", lines.Take(5).Select(TrimBullet));
|
||||
if (!string.Equals(compact, ReadString(slide, key), StringComparison.Ordinal))
|
||||
{
|
||||
slide[key] = compact;
|
||||
repairs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return repairs;
|
||||
}
|
||||
|
||||
private static JsonObject BuildTitleSlide(string title, string? audience, string? objective)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["layout"] = "title",
|
||||
["title"] = Coalesce(title, "Presentation"),
|
||||
["subtitle"] = Coalesce(objective, audience, "Core message and decision ask"),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildExecutiveSummarySlide(IReadOnlyList<JsonObject> slides, string? objective, string? decisionAsk)
|
||||
{
|
||||
var findings = ExtractEvidenceLines(slides, 3);
|
||||
var kpis = ExtractMetrics(slides, 3);
|
||||
return new JsonObject
|
||||
{
|
||||
["layout"] = "executive_summary",
|
||||
["title"] = "Executive Summary",
|
||||
["headline"] = Coalesce(decisionAsk, objective, "?듭떖 ?ㅽ뻾?덉쓣 ?곗꽑 ?곸슜?섎㈃ ?④린 ?깃낵? 以묒옣湲??뺤옣?깆쓣 ?숈떆???뺣낫?????덉뒿?덈떎."),
|
||||
["summary_points"] = ToJsonArray(findings.Count > 0
|
||||
? findings
|
||||
: ["?듭떖 ?댁뒋? ?ㅽ뻾 ?곗꽑?쒖쐞瑜????덉뿉 ?뺣━?덉뒿?덈떎.", "利됱떆 ?ㅽ뻾 怨쇱젣? 以묎린 怨쇱젣瑜?援щ텇?덉뒿?덈떎.", "寃쎌쁺吏??섏궗寃곗젙???꾪븳 ?듭떖 洹쇨굅瑜??뺤텞?덉뒿?덈떎."]),
|
||||
["recommendation"] = Coalesce(decisionAsk, "?곗꽑?쒖쐞媛 ?믪? 媛쒖꽑?덉쓣 癒쇱? ?ㅽ뻾?섍퀬, 蹂묓뻾 怨쇱젣???④퀎?곸쑝濡??뺤옣?⑸땲??"),
|
||||
["kpis"] = ToMetricArray(kpis),
|
||||
["next_steps"] = ToJsonArray(new[]
|
||||
{
|
||||
"?듭떖 ?섏궗寃곗젙 1嫄댁쓣 ?뺤젙?⑸땲??",
|
||||
"1李??ㅽ뻾 踰붿쐞? 梨낆엫 二쇱껜瑜??뺥빀?덈떎.",
|
||||
"?깃낵 異붿쟻 KPI瑜??붽컙 ?⑥쐞濡?由щ럭?⑸땲??",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildRecommendationSlide(IReadOnlyList<JsonObject> slides, string? objective, string? decisionAsk)
|
||||
{
|
||||
var reasons = ExtractEvidenceLines(slides, 4);
|
||||
return new JsonObject
|
||||
{
|
||||
["layout"] = "recommendation",
|
||||
["title"] = "Recommendation",
|
||||
["headline"] = Coalesce(decisionAsk, objective, "媛???④낵媛 ???ㅽ뻾?덉쓣 癒쇱? ?좏깮?댁빞 ?⑸땲??"),
|
||||
["recommendation"] = Coalesce(decisionAsk, "媛移섍? ?믪? ?곗꽑 怨쇱젣??吏묒쨷?섍퀬, ??⑥쑉 ??ぉ? 異뺤냼 ?먮뒗 ?⑥닚?뷀빀?덈떎."),
|
||||
["summary_points"] = ToJsonArray(reasons.Count > 0
|
||||
? reasons
|
||||
: ["?④린 ?④낵? ?ㅽ뻾 媛?μ꽦???④퍡 怨좊젮?덉뒿?덈떎.", "由ъ뒪???鍮??④낵媛 ?믪? ??덉쓣 ?곗꽑?덉뒿?덈떎.", "?섏궗寃곗젙 ?댄썑 諛붾줈 ?ㅽ뻾 媛?ν븳 ?≪뀡???꾩텧?덉뒿?덈떎."]),
|
||||
["next_steps"] = ToJsonArray(new[]
|
||||
{
|
||||
"?뱀씤 踰붿쐞瑜??뺤젙?⑸땲??",
|
||||
"梨낆엫?먯? ?쇱젙 湲곗??좎쓣 ?ㅼ젙?⑸땲??",
|
||||
"珥덇린 ?깃낵瑜?2~4二??⑥쐞濡??먭??⑸땲??",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildRoadmapSlide(string? decisionAsk)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["layout"] = "roadmap",
|
||||
["title"] = "Implementation Roadmap",
|
||||
["headline"] = Coalesce(decisionAsk, "?④퀎蹂??곗꽑?쒖쐞? ?곗텧臾쇱쓣 湲곗??쇰줈 ?ㅽ뻾?⑸땲??"),
|
||||
["phases"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["title"] = "Phase 1",
|
||||
["detail"] = "Diagnose and lock the phase-1 scope",
|
||||
["timeline"] = "0-30 days",
|
||||
["owner"] = "PM / Business",
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["title"] = "Phase 2",
|
||||
["detail"] = "Build and validate early outcomes",
|
||||
["timeline"] = "30-60 days",
|
||||
["owner"] = "Delivery",
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["title"] = "Phase 3",
|
||||
["detail"] = "Scale and stabilize the operating model",
|
||||
["timeline"] = "60-90 days",
|
||||
["owner"] = "Business / Ops",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildAppendixSlide(IReadOnlyList<JsonObject> slides)
|
||||
{
|
||||
var lines = ExtractEvidenceLines(slides, 4);
|
||||
return new JsonObject
|
||||
{
|
||||
["layout"] = "content",
|
||||
["title"] = "Appendix & Evidence",
|
||||
["body"] = string.Join("\n", (lines.Count > 0 ? lines : new List<string>
|
||||
{
|
||||
"洹쇨굅 ?곗씠?곗? ?곸꽭 ?댁꽍??遺濡앹쑝濡??뺣━?⑸땲??",
|
||||
"?듭떖 ?섏튂??異쒖쿂? 怨꾩궛 湲곗???遺?고빀?덈떎.",
|
||||
"?ㅽ뻾 ?④퀎?먯꽌 李멸퀬???곸꽭 硫붾え瑜??ы븿?⑸땲??",
|
||||
}).Select(line => "- " + TrimBullet(line))),
|
||||
};
|
||||
}
|
||||
|
||||
private static void EnsureRiskRows(JsonObject slide)
|
||||
{
|
||||
slide["headers"] ??= new JsonArray("Risk", "Impact", "Likelihood", "Mitigation");
|
||||
if (slide.ContainsKey("rows"))
|
||||
return;
|
||||
|
||||
if (slide.TryGetPropertyValue("risks", out var risksNode) && risksNode is JsonArray risks && risks.Count > 0)
|
||||
{
|
||||
var rows = new JsonArray();
|
||||
foreach (var risk in risks.OfType<JsonObject>().Take(6))
|
||||
{
|
||||
rows.Add(new JsonArray(
|
||||
Coalesce(ReadString(risk, "title"), ReadString(risk, "risk"), "Key risk"),
|
||||
Coalesce(ReadString(risk, "impact"), "Medium"),
|
||||
Coalesce(ReadString(risk, "likelihood"), "Medium"),
|
||||
Coalesce(ReadString(risk, "mitigation"), ReadString(risk, "action"), "Mitigation required")));
|
||||
}
|
||||
slide["rows"] = rows;
|
||||
return;
|
||||
}
|
||||
|
||||
slide["rows"] = new JsonArray
|
||||
{
|
||||
new JsonArray("Execution delay", "High", "Medium", "Lock owners and weekly steering"),
|
||||
new JsonArray("Budget overrun", "Medium", "Medium", "Stage-gate investment decisions"),
|
||||
new JsonArray("Change adoption", "High", "Low", "Sponsor-led change management"),
|
||||
};
|
||||
}
|
||||
|
||||
private static void EnsureChartFromBenefits(JsonObject slide)
|
||||
{
|
||||
if (slide.ContainsKey("chart_labels") && slide.ContainsKey("chart_values"))
|
||||
return;
|
||||
|
||||
if (slide.TryGetPropertyValue("benefits", out var benefitsNode) && benefitsNode is JsonArray benefits && benefits.Count > 0)
|
||||
{
|
||||
var labels = new JsonArray();
|
||||
var values = new JsonArray();
|
||||
foreach (var benefit in benefits.OfType<JsonObject>().Take(6))
|
||||
{
|
||||
labels.Add(Coalesce(ReadString(benefit, "label"), ReadString(benefit, "title"), "Benefit"));
|
||||
values.Add(ReadDouble(benefit, "value", 0));
|
||||
}
|
||||
slide["chart_labels"] = labels;
|
||||
slide["chart_values"] = values;
|
||||
slide["chart_series_name"] ??= "Benefit";
|
||||
return;
|
||||
}
|
||||
|
||||
slide["chart_labels"] = new JsonArray("Revenue uplift", "Cost reduction", "Retention");
|
||||
slide["chart_values"] = new JsonArray(12, 8, 6);
|
||||
slide["chart_series_name"] ??= "Impact";
|
||||
}
|
||||
|
||||
private static void EnsureFallbackOptions(JsonObject slide)
|
||||
{
|
||||
if (slide.ContainsKey("options"))
|
||||
return;
|
||||
|
||||
slide["options"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "Option A",
|
||||
["pros"] = "鍮좊Ⅸ ?곸슜",
|
||||
["cons"] = "?뺤옣???쒗븳",
|
||||
["verdict"] = "Fastest",
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "Option B",
|
||||
["pros"] = "?④낵? ?ㅽ뻾?깆쓽 洹좏삎",
|
||||
["cons"] = "珥덇린 ?뺣젹 ?꾩슂",
|
||||
["verdict"] = "Recommended",
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "Option C",
|
||||
["pros"] = "High upside",
|
||||
["cons"] = "由щ뱶???湲몄쓬",
|
||||
["verdict"] = "Strategic",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasChartData(JsonObject slide)
|
||||
{
|
||||
return slide.TryGetPropertyValue("chart_labels", out var labelsNode) &&
|
||||
slide.TryGetPropertyValue("chart_values", out var valuesNode) &&
|
||||
labelsNode is JsonArray labels &&
|
||||
valuesNode is JsonArray values &&
|
||||
labels.Count > 0 &&
|
||||
values.Count > 0;
|
||||
}
|
||||
|
||||
private static int ClampArray(JsonObject slide, string propertyName, int maxCount)
|
||||
{
|
||||
if (!slide.TryGetPropertyValue(propertyName, out var node) || node is not JsonArray array || array.Count <= maxCount)
|
||||
return 0;
|
||||
|
||||
while (array.Count > maxCount)
|
||||
array.RemoveAt(array.Count - 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static List<string> BuildStoryline(IReadOnlyList<JsonObject> slides, IReadOnlyList<string> hint)
|
||||
{
|
||||
if (hint.Count > 0)
|
||||
return hint.ToList();
|
||||
|
||||
return slides
|
||||
.Select(slide => ReadString(slide, "title"))
|
||||
.Where(title => !string.IsNullOrWhiteSpace(title))
|
||||
.Take(8)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? SuggestTheme(string? objective, string? audience)
|
||||
{
|
||||
var source = (objective + " " + audience).Trim();
|
||||
if (source.Contains("board", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("?꾩썝", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("executive", StringComparison.OrdinalIgnoreCase))
|
||||
return "corporate";
|
||||
|
||||
if (source.Contains("finance", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("?щТ", StringComparison.OrdinalIgnoreCase))
|
||||
return "slate";
|
||||
|
||||
if (source.Contains("strategy", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("?꾨왂", StringComparison.OrdinalIgnoreCase))
|
||||
return "professional";
|
||||
|
||||
if (source.Contains("training", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("援먯쑁", StringComparison.OrdinalIgnoreCase))
|
||||
return "modern";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<JsonObject> ParseSlides(JsonElement slides)
|
||||
{
|
||||
var results = new List<JsonObject>();
|
||||
if (slides.ValueKind != JsonValueKind.Array)
|
||||
return results;
|
||||
|
||||
foreach (var slide in slides.EnumerateArray())
|
||||
{
|
||||
var node = JsonNode.Parse(slide.GetRawText()) as JsonObject;
|
||||
if (node != null)
|
||||
results.Add(node);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static JsonArray ToJsonArray(IEnumerable<JsonObject> slides)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var slide in slides)
|
||||
array.Add(slide);
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray ToJsonArray(IEnumerable<string> values)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var value in values)
|
||||
array.Add(value);
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray ToMetricArray(IReadOnlyList<(string Label, string Value, string Trend, string Note)> metrics)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["label"] = metric.Label,
|
||||
["value"] = metric.Value,
|
||||
["trend"] = metric.Trend,
|
||||
["note"] = metric.Note,
|
||||
});
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildEvidenceRows(JsonArray evidence)
|
||||
{
|
||||
var rows = new JsonArray();
|
||||
foreach (var item in evidence.OfType<JsonObject>().Take(6))
|
||||
{
|
||||
rows.Add(new JsonArray(
|
||||
Coalesce(ReadString(item, "title"), ReadString(item, "label"), "Evidence"),
|
||||
Coalesce(ReadString(item, "detail"), ReadString(item, "summary"), ReadString(item, "value"), "-"),
|
||||
Coalesce(ReadString(item, "source"), ReadString(item, "note"), "-")));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<string> ExtractEvidenceLines(IReadOnlyList<JsonObject> slides, int maxCount)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var slide in slides)
|
||||
{
|
||||
foreach (var line in ReadTextLines(slide, "summary_points"))
|
||||
{
|
||||
if (lines.Count >= maxCount)
|
||||
return lines;
|
||||
lines.Add(TrimBullet(line));
|
||||
}
|
||||
|
||||
foreach (var line in ReadTextLines(slide, "body"))
|
||||
{
|
||||
if (lines.Count >= maxCount)
|
||||
return lines;
|
||||
lines.Add(TrimBullet(line));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static List<(string Label, string Value, string Trend, string Note)> ExtractMetrics(IReadOnlyList<JsonObject> slides, int maxCount)
|
||||
{
|
||||
var metrics = new List<(string Label, string Value, string Trend, string Note)>();
|
||||
foreach (var slide in slides)
|
||||
{
|
||||
if (!slide.TryGetPropertyValue("kpis", out var node) || node is not JsonArray items)
|
||||
continue;
|
||||
|
||||
foreach (var item in items.OfType<JsonObject>())
|
||||
{
|
||||
metrics.Add((
|
||||
Coalesce(ReadString(item, "label"), ReadString(item, "title"), "Metric"),
|
||||
Coalesce(ReadString(item, "value"), "-"),
|
||||
Coalesce(ReadString(item, "trend"), string.Empty),
|
||||
Coalesce(ReadString(item, "note"), string.Empty)));
|
||||
|
||||
if (metrics.Count >= maxCount)
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics.Count == 0)
|
||||
{
|
||||
metrics.Add(("Value", "Impact", "Near term", "Priority"));
|
||||
metrics.Add(("Risk", "Managed", "Execution", "Tracked"));
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private static string BuildHeadline(JsonObject slide, int slideIndex, string presentationTitle, string? objective, string? decisionAsk)
|
||||
{
|
||||
var title = ReadString(slide, "title");
|
||||
var recommendation = ReadString(slide, "recommendation");
|
||||
var bodyLine = ReadTextLines(slide, "body").FirstOrDefault();
|
||||
|
||||
return Coalesce(
|
||||
recommendation,
|
||||
bodyLine,
|
||||
objective,
|
||||
decisionAsk,
|
||||
title,
|
||||
slideIndex == 0 ? presentationTitle : "?듭떖 硫붿떆吏瑜???臾몄옣?쇰줈 ?뺣━?⑸땲??");
|
||||
}
|
||||
|
||||
private static List<string> ReadHintList(JsonElement args, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!args.SafeTryGetProperty(key, out var value))
|
||||
continue;
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return value.EnumerateArray()
|
||||
.Select(item => item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? string.Empty : string.Empty)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return (value.SafeGetString() ?? string.Empty)
|
||||
.Split(new[] { '\n', '/', '>', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static List<string> ReadTextLines(JsonObject slide, string propertyName)
|
||||
{
|
||||
if (!slide.TryGetPropertyValue(propertyName, out var node) || node == null)
|
||||
return [];
|
||||
|
||||
return node switch
|
||||
{
|
||||
JsonArray array => array
|
||||
.Select(item => item?.ToJsonString())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => UnquoteJson(item ?? string.Empty))
|
||||
.Select(TrimBullet)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToList(),
|
||||
JsonValue => (node.ToJsonString().Trim('"'))
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(TrimBullet)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToList(),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToMultiline(JsonObject slide, params string[] propertyNames)
|
||||
{
|
||||
foreach (var propertyName in propertyNames)
|
||||
{
|
||||
var lines = ReadTextLines(slide, propertyName);
|
||||
if (lines.Count > 0)
|
||||
return string.Join("\n", lines.Take(5));
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.SafeTryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.SafeGetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string ReadString(JsonObject obj, string propertyName)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(propertyName, out var node) || node == null)
|
||||
return string.Empty;
|
||||
|
||||
return node switch
|
||||
{
|
||||
JsonValue value => UnquoteJson(value.ToJsonString()),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private static double ReadDouble(JsonObject obj, string propertyName, double fallback)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(propertyName, out var node) || node == null)
|
||||
return fallback;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<double>(node.ToJsonString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Coalesce(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string TrimBullet(string line)
|
||||
{
|
||||
return line.Trim().TrimStart('-', '*', '\u2022', ' ', '\t');
|
||||
}
|
||||
|
||||
private static string UnquoteJson(string value)
|
||||
{
|
||||
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string>(value) ?? string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value.Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
218
src/AxCopilot/Services/Agent/DeckQualityReviewService.cs
Normal file
218
src/AxCopilot/Services/Agent/DeckQualityReviewService.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -155,6 +155,27 @@ public class PptxSkill : IAgentTool
|
||||
Description = "Slide aspect ratio. Default: widescreen (16:9)",
|
||||
Enum = ["widescreen", "standard"]
|
||||
},
|
||||
["audience"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Target audience such as executives, steering committee, PMO, sales leadership, or team leads.",
|
||||
},
|
||||
["objective"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Presentation objective such as strategy recommendation, board update, PMO steering, operating model proposal, or KPI review.",
|
||||
},
|
||||
["decision_ask"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "The exact decision or approval being requested from the audience.",
|
||||
},
|
||||
["storyline"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Optional storyline or ordered section hints.",
|
||||
Items = new() { Type = "string" }
|
||||
},
|
||||
},
|
||||
Required = ["slides"]
|
||||
};
|
||||
@@ -506,9 +527,15 @@ public class PptxSkill : IAgentTool
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
|
||||
}
|
||||
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "basic100" : "basic100";
|
||||
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
|
||||
var hasExplicitTheme = args.SafeTryGetProperty("theme", out var th) &&
|
||||
!string.IsNullOrWhiteSpace(th.SafeGetString());
|
||||
var theme = hasExplicitTheme ? th.SafeGetString() ?? "basic100" : "basic100";
|
||||
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
|
||||
var cloneAll = args.SafeTryGetProperty("clone_slides", out var cloneEl) && cloneEl.ValueKind == JsonValueKind.True;
|
||||
var hasThemeFile = args.SafeTryGetProperty("theme_file", out var themeFileEl) &&
|
||||
!string.IsNullOrWhiteSpace(themeFileEl.SafeGetString());
|
||||
var hasExplicitTemplate = args.SafeTryGetProperty("template", out var templateArgEl) &&
|
||||
!string.IsNullOrWhiteSpace(templateArgEl.SafeGetString());
|
||||
|
||||
// 전체 복제용 텍스트 교체 맵
|
||||
Dictionary<string, string>? globalReplacements = null;
|
||||
@@ -526,6 +553,20 @@ public class PptxSkill : IAgentTool
|
||||
if (!hasSlidesArray && !cloneAll)
|
||||
return ToolResult.Fail("slides 배열이 필요합니다.");
|
||||
|
||||
using var preparedDeck = hasSlidesArray
|
||||
? DeckPlanningService.Prepare(args, slidesEl, presTitle)
|
||||
: null;
|
||||
|
||||
if (preparedDeck != null)
|
||||
{
|
||||
slidesEl = preparedDeck.Slides;
|
||||
if (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile &&
|
||||
!string.IsNullOrWhiteSpace(preparedDeck.SuggestedTheme))
|
||||
{
|
||||
theme = preparedDeck.SuggestedTheme!;
|
||||
}
|
||||
}
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -923,9 +964,23 @@ public class PptxSkill : IAgentTool
|
||||
: templatePptxPath != null
|
||||
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
||||
: theme;
|
||||
return ToolResult.Ok(
|
||||
$"✅ PPTX 생성 완료: {fullPath} ({slideCount_final}슬라이드, {themeLabel}, {(isWide ? "16:9" : "4:3")})",
|
||||
fullPath);
|
||||
var deckReview = hasSlidesArray
|
||||
? DeckQualityReviewService.ReviewDeck(
|
||||
presTitle,
|
||||
slidesEl,
|
||||
!string.IsNullOrWhiteSpace(templateName) || templatePptxPath != null,
|
||||
preparedDeck?.AutoRepairCount ?? 0,
|
||||
preparedDeck?.Storyline)
|
||||
: null;
|
||||
var outputParts = new List<string>
|
||||
{
|
||||
$"PPTX created: {fullPath} ({slideCount_final} slides, {themeLabel}, {(isWide ? "16:9" : "4:3")})"
|
||||
};
|
||||
if (preparedDeck != null)
|
||||
outputParts.Add(preparedDeck.ToToolSummary());
|
||||
if (deckReview != null)
|
||||
outputParts.Add(deckReview.ToToolSummary());
|
||||
return ToolResult.Ok(string.Join("\n", outputParts), fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user