에이전트 루프와 코드 언어 지원, PPT 생성 품질을 함께 고도화

- AgentCommandQueue를 도입해 실행 중 추가 입력을 우선순위와 인터럽트 여부까지 포함해 처리하도록 정리함
- AgentToolResultBudget와 AgentQueryContextBuilder에 tool result preview 캐시를 연결해 긴 세션에서 축약 결과 재사용을 안정화함
- CodeLanguageCatalog를 추가해 코드 탭의 내장 언어 지원, 인덱싱 확장자, 시스템 프롬프트 언어 가이드, LSP 언어 판정을 한 카탈로그로 통합함
- 설정의 코드 탭에 지원 언어(LSP)와 코드 탭 기본 지원 언어를 명시적으로 표시하도록 보강함
- DocumentPlannerTool의 presentation 구조를 컨설팅형 스토리라인으로 정리하고, PptxSkill에 executive_summary/recommendation/roadmap/comparison/kpi_dashboard 레이아웃을 추가함
- pptx-creator 스킬을 AX native pptx_create 중심으로 재작성하고, 관련 회귀 테스트를 추가했으며 WorkspaceContextGeneratorTests의 nullable 경고도 정리함

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_impl\\ -p:IntermediateOutputPath=obj\\verify_impl\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|AgentCommandQueueTests|AgentToolResultBudgetTests|DocumentPlannerPresentationTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_impl_tests\\ -p:IntermediateOutputPath=obj\\verify_impl_tests\\
This commit is contained in:
2026-04-14 19:53:39 +09:00
parent 946c31e275
commit 0b6d60e959
23 changed files with 1837 additions and 746 deletions

View File

@@ -36,6 +36,10 @@ public class PptxSkill : IAgentTool
"section (chapter divider), two_column (title+left+right), " +
"table (title+real styled table), quote (styled quotation slide), blank, " +
"image_full (full-slide background image), " +
"executive_summary (consulting-style headline and takeaways), " +
"recommendation (single recommendation with rationale), " +
"roadmap (phase timeline), comparison (option comparison cards), " +
"kpi_dashboard (metric cards and takeaways), " +
"chart (native OpenXML chart — bar/line/pie with embedded data). " +
"Built-in themes (each controls BOTH colors AND layout/composition): " +
"professional (bar_left), modern (top_band+card), dark (center_bold), " +
@@ -72,10 +76,17 @@ public class PptxSkill : IAgentTool
Type = "array",
Description =
"Array of slide objects. Each slide: " +
"{\"layout\": \"title|content|section|two_column|table|quote|blank|image_full|chart\", " +
"{\"layout\": \"title|content|section|two_column|table|quote|blank|image_full|chart|executive_summary|recommendation|roadmap|comparison|kpi_dashboard\", " +
"\"title\": \"...\", \"subtitle\": \"...\", " +
"\"body\": \"bullet1\\nbullet2\\n - sub-bullet (IMPORTANT: write 5-8 detailed bullet points per slide to fill the space fully)\", " +
"\"headline\": \"single-message headline for the slide\", " +
"\"body\": \"3-5 concise evidence-backed bullets or short statements\", " +
"\"left\": \"...\", \"right\": \"...\", " +
"\"summary_points\": [\"key point 1\", \"key point 2\"], " +
"\"recommendation\": \"recommended action or decision\", " +
"\"next_steps\": [\"step 1\", \"step 2\"], " +
"\"phases\": [{\"title\":\"Phase 1\",\"detail\":\"Design\",\"timeline\":\"Q1\",\"owner\":\"PM\"}], " +
"\"options\": [{\"name\":\"Option A\",\"pros\":\"...\",\"cons\":\"...\",\"verdict\":\"Recommended\"}], " +
"\"kpis\": [{\"label\":\"Revenue\",\"value\":\"12%\",\"trend\":\"YoY\",\"note\":\"Top-line growth\"}], " +
"\"headers\": [\"col1\",\"col2\"], \"rows\": [[\"a\",\"b\"]], " +
"\"quote\": \"Quote text\", \"author\": \"Author\", " +
"\"image\": \"path/to/image.png (local file, embedded in slide; position: top_right|bottom_right|center|full|left_half|right_half; default: center)\", " +
@@ -818,6 +829,21 @@ public class PptxSkill : IAgentTool
case "chart":
BuildChartSlide(slidePart, shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "executive_summary":
BuildExecutiveSummarySlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "recommendation":
BuildRecommendationSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "roadmap":
BuildRoadmapSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "comparison":
BuildComparisonSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "kpi_dashboard":
BuildKpiDashboardSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "blank":
break;
default: // content
@@ -1432,6 +1458,334 @@ public class PptxSkill : IAgentTool
/// bar(세로 막대), line(꺾은선), pie(원형) 차트를 지원합니다.
/// ChartPart에 내장 스프레드시트 데이터를 포함하므로 PowerPoint에서 편집 가능합니다.
/// </summary>
private sealed record PresentationCardItem(string Title, string Primary, string Secondary, string Badge);
private static void BuildExecutiveSummarySlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 360000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
AddRect(t, ref id, 0, 0, W, 170000, c.Accent);
var title = CoalesceText(Str(s, "title"), "Executive Summary");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), title);
var summaryPoints = GetStringList(s, "summary_points");
if (summaryPoints.Count == 0)
summaryPoints = GetStringList(s, "body");
if (summaryPoints.Count == 0)
summaryPoints = ["핵심 메시지를 3개 이하로 정리", "가장 중요한 수치와 근거를 포함", "의사결정자가 다음 행동을 바로 이해할 수 있도록 구성"];
var recommendation = CoalesceText(Str(s, "recommendation"), Str(s, "subtitle"), "권고안과 기대효과를 한 문장으로 정리");
var kpis = GetStructuredItems(s, "kpis");
AddText(t, ref id, M, 220000, W - M * 2, 420000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 520000, W - M * 2, 760000, headline, 3200, c.Primary, bold: true, align: "l");
long leftX = M;
long topY = 1420000;
long gap = 220000;
long leftW = (W - M * 2 - gap) * 58 / 100;
long rightX = leftX + leftW + gap;
long rightW = W - M - rightX;
long topH = 2300000;
AddRoundedRect(t, ref id, leftX, topY, leftW, topH, c.BgAlt);
AddText(t, ref id, leftX + 120000, topY + 70000, leftW - 240000, 300000, "Key Takeaways", 1800, c.Primary, bold: true, align: "l");
AddBulletBody(t, ref id, leftX + 100000, topY + 360000, leftW - 200000, topH - 460000,
string.Join("\n", summaryPoints.Select(x => $"- {x}")), 1700, c.TextDark, c.Accent);
AddRoundedRect(t, ref id, rightX, topY, rightW, topH, c.Primary);
AddText(t, ref id, rightX + 120000, topY + 70000, rightW - 240000, 260000, "Recommendation", 1800, c.TextLight, bold: true, align: "l");
AddTextEx(t, ref id, rightX + 120000, topY + 420000, rightW - 240000, 1100000, recommendation, 2200, c.TextLight, bold: true, italic: false, align: "l");
var rationaleLines = GetStringList(s, "next_steps");
if (rationaleLines.Count == 0)
rationaleLines = GetStringList(s, "body").Take(2).ToList();
if (rationaleLines.Count > 0)
AddBulletBody(t, ref id, rightX + 100000, topY + 1500000, rightW - 200000, topH - 1620000,
string.Join("\n", rationaleLines.Select(x => $"- {x}")), 1500, c.TextLight, c.Accent);
if (kpis.Count > 0)
{
var metricCount = Math.Min(3, kpis.Count);
long cardY = topY + topH + 200000;
long cardGap = 180000;
long cardW = (W - M * 2 - cardGap * (metricCount - 1)) / metricCount;
long cardH = 1050000;
for (int i = 0; i < metricCount; i++)
{
var metric = kpis[i];
var cardX = M + i * (cardW + cardGap);
AddRoundedRect(t, ref id, cardX, cardY, cardW, cardH, c.BgAlt);
AddText(t, ref id, cardX + 90000, cardY + 70000, cardW - 180000, 220000, metric.Title, 1400, c.TextDark, bold: false, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 260000, cardW - 180000, 330000, CoalesceText(metric.Primary, "-"), 2600, c.Primary, bold: true, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 640000, cardW - 180000, 170000, CoalesceText(metric.Secondary, metric.Badge), 1300, c.Accent, bold: true, align: "l");
}
}
}
private static void BuildRecommendationSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 380000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "Recommendation");
var recommendation = CoalesceText(Str(s, "recommendation"), Str(s, "headline"), Str(s, "subtitle"), "권고안을 한 문장으로 제시");
var reasons = GetStringList(s, "summary_points");
if (reasons.Count == 0)
reasons = GetStringList(s, "body");
var nextSteps = GetStringList(s, "next_steps");
AddText(t, ref id, M, 220000, W - M * 2, 360000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 520000, W - M * 2, 420000, recommendation, 3000, c.Primary, bold: true, align: "l");
long mainY = 1220000;
long gap = 220000;
long leftW = (W - M * 2 - gap) * 52 / 100;
long rightX = M + leftW + gap;
long rightW = W - M - rightX;
long boxH = 2450000;
AddRoundedRect(t, ref id, M, mainY, leftW, boxH, c.Primary);
AddText(t, ref id, M + 120000, mainY + 80000, leftW - 240000, 240000, "Recommended Move", 1800, c.TextLight, bold: true, align: "l");
AddTextEx(t, ref id, M + 120000, mainY + 400000, leftW - 240000, 1200000, recommendation, 2400, c.TextLight, bold: true, italic: false, align: "l");
if (nextSteps.Count > 0)
{
AddText(t, ref id, M + 120000, mainY + 1780000, leftW - 240000, 180000, "Immediate Actions", 1600, c.Accent, bold: true, align: "l");
AddBulletBody(t, ref id, M + 100000, mainY + 1990000, leftW - 200000, boxH - 2090000,
string.Join("\n", nextSteps.Take(3).Select(x => $"- {x}")), 1450, c.TextLight, c.Accent);
}
AddRoundedRect(t, ref id, rightX, mainY, rightW, boxH, c.BgAlt);
AddText(t, ref id, rightX + 120000, mainY + 80000, rightW - 240000, 240000, "Why This Wins", 1800, c.Primary, bold: true, align: "l");
AddBulletBody(t, ref id, rightX + 100000, mainY + 360000, rightW - 200000, boxH - 480000,
string.Join("\n", reasons.Take(5).Select(x => $"- {x}")), 1600, c.TextDark, c.Accent);
}
private static void BuildRoadmapSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 360000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "Implementation Roadmap");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), "단계별 우선순위와 산출물을 한 눈에 정리");
var phases = GetStructuredItems(s, "phases");
if (phases.Count == 0)
{
phases =
[
new("Phase 1", "진단 및 설계", "0-30일", "PM"),
new("Phase 2", "구현 및 검증", "30-60일", "Delivery"),
new("Phase 3", "확산 및 운영정착", "60-90일", "Business")
];
}
AddText(t, ref id, M, 220000, W - M * 2, 320000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 500000, W - M * 2, 420000, headline, 2800, c.Primary, bold: true, align: "l");
AddRect(t, ref id, M, 1260000, W - M * 2, 12000, c.Accent);
var phaseCount = Math.Min(4, phases.Count);
long gap = 160000;
long laneY = 1540000;
long laneW = (W - M * 2 - gap * (phaseCount - 1)) / phaseCount;
long laneH = 2100000;
for (int i = 0; i < phaseCount; i++)
{
var phase = phases[i];
var laneX = M + i * (laneW + gap);
var fill = i % 2 == 0 ? c.BgAlt : c.Bg;
AddRoundedRect(t, ref id, laneX, laneY, laneW, laneH, fill);
AddRect(t, ref id, laneX, laneY, laneW, 160000, i % 2 == 0 ? c.Primary : c.Accent);
AddText(t, ref id, laneX + 90000, laneY + 220000, laneW - 180000, 240000, phase.Title, 1800, c.Primary, bold: true, align: "l");
AddText(t, ref id, laneX + 90000, laneY + 520000, laneW - 180000, 520000, CoalesceText(phase.Primary, "핵심 과업 정의"), 1500, c.TextDark, bold: false, align: "l");
AddText(t, ref id, laneX + 90000, laneY + 1200000, laneW - 180000, 180000, CoalesceText(phase.Secondary, "기간 미정"), 1350, c.Accent, bold: true, align: "l");
AddText(t, ref id, laneX + 90000, laneY + 1450000, laneW - 180000, 180000, CoalesceText(phase.Badge, "담당 미정"), 1250, c.TextDark, bold: false, align: "l");
}
}
private static void BuildComparisonSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 340000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "Option Comparison");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), "대안별 장단점과 권고안을 비교");
var options = GetStructuredItems(s, "options");
if (options.Count == 0)
{
options =
[
new("Option A", "빠른 적용", "확장성 제한", "Fastest"),
new("Option B", "균형 잡힌 투자", "의사결정 필요", "Recommended"),
new("Option C", "장기 최적화", "리드타임 길음", "Strategic")
];
}
AddText(t, ref id, M, 220000, W - M * 2, 320000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 500000, W - M * 2, 420000, headline, 2800, c.Primary, bold: true, align: "l");
var optionCount = Math.Min(3, options.Count);
long gap = 160000;
long cardY = 1360000;
long cardH = 2600000;
long cardW = (W - M * 2 - gap * (optionCount - 1)) / optionCount;
for (int i = 0; i < optionCount; i++)
{
var option = options[i];
var cardX = M + i * (cardW + gap);
AddRoundedRect(t, ref id, cardX, cardY, cardW, cardH, c.BgAlt);
AddRect(t, ref id, cardX, cardY, cardW, 140000, i == 1 ? c.Accent : c.Primary);
AddText(t, ref id, cardX + 90000, cardY + 220000, cardW - 180000, 240000, option.Title, 1800, c.Primary, bold: true, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 560000, cardW - 180000, 180000, "Pros", 1400, c.Accent, bold: true, align: "l");
AddTextEx(t, ref id, cardX + 90000, cardY + 760000, cardW - 180000, 520000, CoalesceText(option.Primary, "-"), 1450, c.TextDark, bold: false, italic: false, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 1480000, cardW - 180000, 180000, "Risks", 1400, c.Accent, bold: true, align: "l");
AddTextEx(t, ref id, cardX + 90000, cardY + 1680000, cardW - 180000, 460000, CoalesceText(option.Secondary, "-"), 1450, c.TextDark, bold: false, italic: false, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 2280000, cardW - 180000, 160000, CoalesceText(option.Badge, ""), 1300, c.Primary, bold: true, align: "l");
}
}
private static void BuildKpiDashboardSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 360000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "KPI Dashboard");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), "핵심 지표의 현재 상태와 시사점");
var metrics = GetStructuredItems(s, "kpis");
if (metrics.Count == 0)
{
metrics =
[
new("Revenue", "12%", "YoY", "Strong"),
new("Cost", "-8%", "vs plan", "Improving"),
new("NPS", "61", "survey", "Stable"),
new("Delivery", "94%", "SLA", "On track")
];
}
AddText(t, ref id, M, 220000, W - M * 2, 320000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 500000, W - M * 2, 420000, headline, 2800, c.Primary, bold: true, align: "l");
long cardTop = 1320000;
long gap = 180000;
long cardW = (W - M * 2 - gap) / 2;
long cardH = 1160000;
for (int i = 0; i < Math.Min(4, metrics.Count); i++)
{
var metric = metrics[i];
long row = i / 2;
long col = i % 2;
long x = M + col * (cardW + gap);
long y = cardTop + row * (cardH + 180000);
AddRoundedRect(t, ref id, x, y, cardW, cardH, c.BgAlt);
AddText(t, ref id, x + 100000, y + 80000, cardW - 200000, 180000, metric.Title, 1450, c.TextDark, bold: false, align: "l");
AddText(t, ref id, x + 100000, y + 280000, cardW - 200000, 340000, CoalesceText(metric.Primary, "-"), 2800, c.Primary, bold: true, align: "l");
AddText(t, ref id, x + 100000, y + 700000, cardW - 200000, 150000, CoalesceText(metric.Secondary, ""), 1300, c.Accent, bold: true, align: "l");
AddText(t, ref id, x + 100000, y + 900000, cardW - 200000, 140000, CoalesceText(metric.Badge, ""), 1200, c.TextDark, bold: false, align: "l");
}
var takeawayLines = GetStringList(s, "summary_points");
if (takeawayLines.Count == 0)
takeawayLines = GetStringList(s, "body");
if (takeawayLines.Count > 0)
{
AddRect(t, ref id, M, H - 650000, W - M * 2, 9000, c.Accent);
AddText(t, ref id, M, H - 560000, W - M * 2, 140000, "Implication", 1500, c.Primary, bold: true, align: "l");
AddTextEx(t, ref id, M, H - 380000, W - M * 2, 220000, takeawayLines[0], 1450, c.TextDark, bold: false, italic: false, align: "l");
}
}
private static List<string> GetStringList(JsonElement source, string propertyName)
{
if (!source.SafeTryGetProperty(propertyName, out var value))
return [];
if (value.ValueKind == JsonValueKind.Array)
{
return value.EnumerateArray()
.Select(item => item.ValueKind switch
{
JsonValueKind.String => item.SafeGetString() ?? string.Empty,
JsonValueKind.Object => CoalesceText(
FirstNonEmpty(item, "title", "name", "label", "value", "detail", "note"),
item.ToString()),
_ => item.ToString()
})
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
if (value.ValueKind == JsonValueKind.String)
{
return (value.SafeGetString() ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
return [];
}
private static List<PresentationCardItem> GetStructuredItems(JsonElement source, string propertyName)
{
var items = new List<PresentationCardItem>();
if (!source.SafeTryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array)
return items;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var text = item.SafeGetString() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(text))
items.Add(new PresentationCardItem(text, string.Empty, string.Empty, string.Empty));
continue;
}
if (item.ValueKind != JsonValueKind.Object)
continue;
items.Add(new PresentationCardItem(
CoalesceText(FirstNonEmpty(item, "title", "name", "label"), "Item"),
CoalesceText(FirstNonEmpty(item, "value", "detail", "pros", "timeline", "summary"), string.Empty),
CoalesceText(FirstNonEmpty(item, "note", "cons", "trend", "owner", "secondary"), string.Empty),
CoalesceText(FirstNonEmpty(item, "badge", "verdict", "status", "tag"), string.Empty)));
}
return items;
}
private static string? FirstNonEmpty(JsonElement source, params string[] keys)
{
foreach (var key in keys)
{
if (!source.SafeTryGetProperty(key, out var value))
continue;
var text = value.ValueKind switch
{
JsonValueKind.String => value.SafeGetString(),
JsonValueKind.Number => value.ToString(),
_ => null
};
if (!string.IsNullOrWhiteSpace(text))
return text;
}
return null;
}
private static string CoalesceText(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
private static void BuildChartSlide(SlidePart slidePart, ShapeTree tree, JsonElement s,
FullTheme theme, long W, long H, ref uint id)
{