에이전트 루프와 코드 언어 지원, 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:
107
src/AxCopilot/Services/Agent/AgentCommandQueue.cs
Normal file
107
src/AxCopilot/Services/Agent/AgentCommandQueue.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ["배경", "목적", "범위"] },
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user