에이전트 루프와 코드 언어 지원, 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

@@ -0,0 +1,107 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace AxCopilot.Services.Agent;
public enum AgentQueuePriority
{
Now = 0,
Next = 1,
Later = 2,
}
public enum AgentCommandKind
{
Prompt,
Notification,
}
public sealed record AgentQueuedCommand(
long Sequence,
AgentCommandKind Kind,
AgentQueuePriority Priority,
string Content,
DateTime CreatedAt,
bool RequestInterrupt);
/// <summary>
/// 에이전트 실행 중 추가 입력과 내부 알림을 관리하는 경량 큐.
/// UI 구조는 유지하면서도 우선순위/종류를 명확히 구분해
/// 실행 중 메시지 유입을 안정적으로 처리하도록 돕습니다.
/// </summary>
public sealed class AgentCommandQueue
{
private readonly ConcurrentQueue<AgentQueuedCommand> _now = new();
private readonly ConcurrentQueue<AgentQueuedCommand> _next = new();
private readonly ConcurrentQueue<AgentQueuedCommand> _later = new();
private long _sequence;
public void EnqueuePrompt(string content, string priority = "next", bool requestInterrupt = false)
=> Enqueue(AgentCommandKind.Prompt, content, priority, requestInterrupt);
public void EnqueueNotification(string content, string priority = "later")
=> Enqueue(AgentCommandKind.Notification, content, priority, requestInterrupt: false);
public void Clear()
{
while (_now.TryDequeue(out _)) { }
while (_next.TryDequeue(out _)) { }
while (_later.TryDequeue(out _)) { }
}
public List<AgentQueuedCommand> DrainAll()
{
var items = new List<AgentQueuedCommand>();
DrainInto(_now, items);
DrainInto(_next, items);
DrainInto(_later, items);
return items
.OrderBy(x => x.Priority)
.ThenBy(x => x.Sequence)
.ToList();
}
private void Enqueue(AgentCommandKind kind, string content, string? priority, bool requestInterrupt)
{
if (string.IsNullOrWhiteSpace(content))
return;
var item = new AgentQueuedCommand(
Interlocked.Increment(ref _sequence),
kind,
NormalizePriority(priority),
content.Trim(),
DateTime.Now,
requestInterrupt);
switch (item.Priority)
{
case AgentQueuePriority.Now:
_now.Enqueue(item);
break;
case AgentQueuePriority.Later:
_later.Enqueue(item);
break;
default:
_next.Enqueue(item);
break;
}
}
private static void DrainInto(ConcurrentQueue<AgentQueuedCommand> queue, List<AgentQueuedCommand> target)
{
while (queue.TryDequeue(out var item))
target.Add(item);
}
private static AgentQueuePriority NormalizePriority(string? priority)
=> priority?.Trim().ToLowerInvariant() switch
{
"now" => AgentQueuePriority.Now,
"later" => AgentQueuePriority.Later,
_ => AgentQueuePriority.Next,
};
}

View File

@@ -36,14 +36,64 @@ public partial class AgentLoopService
};
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>실행 중 사용자 메시지 주입 큐 (Claude Code 스타일 mid-execution steering).</summary>
private readonly ConcurrentQueue<string> _pendingUserMessages = new();
/// <summary>실행 중 추가 입력/알림 큐. 우선순위와 종류를 함께 보존합니다.</summary>
private readonly AgentCommandQueue _pendingCommands = new();
/// <summary>실행 중인 에이전트 루프에 사용자 메시지를 주입합니다. 다음 LLM 호출 전에 반영됩니다.</summary>
public void InjectUserMessage(string message)
{
if (!string.IsNullOrWhiteSpace(message))
_pendingUserMessages.Enqueue(message);
_pendingCommands.EnqueuePrompt(
message,
IsRunning ? "now" : "next",
requestInterrupt: IsRunning);
}
private void DrainPendingCommands(List<ChatMessage> messages)
{
var drained = _pendingCommands.DrainAll();
if (drained.Count == 0)
return;
var interruptingPrompts = drained.Count(x => x.Kind == AgentCommandKind.Prompt && x.RequestInterrupt);
if (interruptingPrompts > 0)
{
messages.Add(new ChatMessage
{
Role = "system",
MetaKind = "queued_input_interrupt",
Content = $"[queued input] {interruptingPrompts} new prompt(s) arrived during execution. Prioritize the newest user direction before continuing.",
Timestamp = DateTime.Now,
});
}
foreach (var item in drained)
{
switch (item.Kind)
{
case AgentCommandKind.Notification:
messages.Add(new ChatMessage
{
Role = "system",
MetaKind = "queue_notification",
Content = item.Content,
Timestamp = item.CreatedAt,
});
EmitEvent(AgentEventType.Thinking, "", item.Content);
break;
default:
messages.Add(new ChatMessage
{
Role = "user",
MetaKind = item.RequestInterrupt ? "queued_prompt_interrupt" : "queued_prompt",
Content = item.Content,
Timestamp = item.CreatedAt,
});
EmitEvent(AgentEventType.UserMessage, "", item.Content);
break;
}
}
}
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
@@ -179,7 +229,7 @@ public partial class AgentLoopService
_currentRunId = Guid.NewGuid().ToString("N");
_docFallbackAttempted = false;
_documentPlanApproved = false;
while (_pendingUserMessages.TryDequeue(out _)) { } // 이전 실행의 잔여 메시지 제거
_pendingCommands.Clear(); // 이전 실행의 잔여 제거
var llm = _settings.Settings.Llm;
var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : 25;
var maxIterations = baseMax; // 동적 조정 가능
@@ -440,11 +490,7 @@ public partial class AgentLoopService
}
// ── 실행 중 사용자 메시지 주입 (Claude Code 스타일 steering) ──
while (_pendingUserMessages.TryDequeue(out var injectedMsg))
{
messages.Add(new ChatMessage { Role = "user", Content = injectedMsg });
EmitEvent(AgentEventType.UserMessage, "", injectedMsg);
}
DrainPendingCommands(messages);
var queryView = AgentQueryContextBuilder.Build(messages);
var queryMessages = queryView.Messages;
@@ -455,6 +501,7 @@ public partial class AgentLoopService
$"start={queryView.WindowStartIndex}, " +
$"pairs={queryView.PreservedToolPairCount}, " +
$"tool_result_budget={queryView.TruncatedToolResultCount}, " +
$"tool_result_preview_reuse={queryView.ReusedToolResultPreviewCount}, " +
$"tokens {queryView.TokensBeforeBudget}->{queryView.TokensAfterBudget}";
WorkflowLogService.LogTransition(
_conversationId,

View File

@@ -12,6 +12,7 @@ public sealed class AgentQueryContextWindowResult
public bool ToolPairExpanded { get; init; }
public int PreservedToolPairCount { get; init; }
public int TruncatedToolResultCount { get; init; }
public int ReusedToolResultPreviewCount { get; init; }
public int TokensBeforeBudget { get; init; }
public int TokensAfterBudget { get; init; }
}
@@ -38,6 +39,7 @@ public static class AgentQueryContextBuilder
ToolPairExpanded = false,
PreservedToolPairCount = 0,
TruncatedToolResultCount = 0,
ReusedToolResultPreviewCount = 0,
TokensBeforeBudget = 0,
TokensAfterBudget = 0,
};
@@ -65,7 +67,7 @@ public static class AgentQueryContextBuilder
InjectPostCompactContextMessage(windowMessages);
var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages);
var budgetResult = AgentToolResultBudget.Apply(windowMessages, ProtectedRecentNonSystemMessages);
var budgetResult = AgentToolResultBudget.Apply(windowMessages, ProtectedRecentNonSystemMessages, sourceMessages: sourceMessages);
var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages);
return new AgentQueryContextWindowResult
@@ -78,6 +80,7 @@ public static class AgentQueryContextBuilder
ToolPairExpanded = toolPairExpanded,
PreservedToolPairCount = preservedToolPairs,
TruncatedToolResultCount = budgetResult.TruncatedCount,
ReusedToolResultPreviewCount = budgetResult.ReusedPreviewCount,
TokensBeforeBudget = tokensBeforeBudget,
TokensAfterBudget = tokensAfterBudget,
};
@@ -130,6 +133,7 @@ public static class AgentQueryContextBuilder
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
QueryPreviewContent = source.QueryPreviewContent,
Images = source.Images?.Select(image => new ImageAttachment
{
Base64 = image.Base64,

View File

@@ -8,6 +8,7 @@ public sealed class AgentToolResultBudgetResult
public int TruncatedCount { get; set; }
public int ProcessedCount { get; set; }
public int TotalCharsBefore { get; set; }
public int ReusedPreviewCount { get; set; }
}
/// <summary>
@@ -22,9 +23,13 @@ public static class AgentToolResultBudget
List<ChatMessage> messages,
int protectedRecentNonSystemMessages,
int softCharLimit = DefaultSoftCharLimit,
int aggregateBudgetChars = DefaultAggregateBudgetChars)
int aggregateBudgetChars = DefaultAggregateBudgetChars,
IReadOnlyList<ChatMessage>? sourceMessages = null)
{
var result = new AgentToolResultBudgetResult();
var sourceById = sourceMessages?
.Where(message => !string.IsNullOrWhiteSpace(message.MsgId))
.ToDictionary(message => message.MsgId, StringComparer.OrdinalIgnoreCase);
var nonSystemIndexes = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
@@ -49,6 +54,15 @@ public static class AgentToolResultBudget
result.ProcessedCount++;
result.TotalCharsBefore += content.Length;
if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent))
{
messages[i] = CloneMessage(message, message.QueryPreviewContent);
result.TruncatedCount++;
result.ReusedPreviewCount++;
continue;
}
spentChars += content.Length;
if (content.Length <= softCharLimit && spentChars <= aggregateBudgetChars)
continue;
@@ -57,6 +71,14 @@ public static class AgentToolResultBudget
if (string.Equals(truncated, content, StringComparison.Ordinal))
continue;
message.QueryPreviewContent = truncated;
if (sourceById != null
&& sourceById.TryGetValue(message.MsgId, out var source)
&& string.IsNullOrWhiteSpace(source.QueryPreviewContent))
{
source.QueryPreviewContent = truncated;
}
messages[i] = CloneMessage(message, truncated);
result.TruncatedCount++;
}
@@ -147,6 +169,7 @@ public static class AgentToolResultBudget
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
QueryPreviewContent = source.QueryPreviewContent,
Images = source.Images?.Select(image => new ImageAttachment
{
Base64 = image.Base64,

View File

@@ -616,6 +616,15 @@ public class DocumentPlannerTool : IAgentTool
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
},
"presentation" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. Executive Summary", Level = 1, KeyPoints = ["핵심 메시지", "의사결정 포인트", "한 줄 권고안"] },
new() { Id = "sec-2", Heading = "2. Situation & Imperative", Level = 1, KeyPoints = ["배경", "왜 지금 중요한가", "문제 정의"] },
new() { Id = "sec-3", Heading = "3. Key Findings", Level = 1, KeyPoints = ["핵심 데이터", "주요 인사이트", "시사점"] },
new() { Id = "sec-4", Heading = "4. Options & Recommendation", Level = 1, KeyPoints = ["대안 비교", "추천안", "선택 근거"] },
new() { Id = "sec-5", Heading = "5. Implementation Roadmap", Level = 1, KeyPoints = ["단계", "일정", "책임 주체"] },
new() { Id = "sec-6", Heading = "6. Impact & Ask", Level = 1, KeyPoints = ["기대 효과", "핵심 KPI", "의사결정 요청 사항"] },
},
_ => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },

View File

@@ -22,6 +22,7 @@ public class LspTool : IAgentTool, IDisposable
"- action=\"prepare_call_hierarchy\": 현재 위치의 호출 계층 기준 심볼을 확인합니다\n" +
"- action=\"incoming_calls\": 현재 심볼을 호출하는 상위 호출자를 찾습니다\n" +
"- action=\"outgoing_calls\": 현재 심볼이 호출하는 하위 호출 대상을 찾습니다\n" +
$"지원 언어 서버: {Services.CodeLanguageCatalog.BuildLspSupportDescription()}\n" +
"line/character는 기본적으로 1-based 입력을 기대하며, 0-based 값도 호환 처리합니다.";
public ToolParameterSchema Parameters => new()
@@ -298,19 +299,7 @@ public class LspTool : IAgentTool, IDisposable
}
private static string? DetectLanguage(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".cs" => "csharp",
".ts" or ".tsx" => "typescript",
".js" or ".jsx" => "javascript",
".py" => "python",
".cpp" or ".cc" or ".cxx" or ".c" or ".h" or ".hpp" => "cpp",
".java" => "java",
_ => null
};
}
=> Services.CodeLanguageCatalog.DetectLspLanguageId(filePath);
private static int NormalizePosition(int value)
{

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)
{