에이전트 루프와 코드 언어 지원, 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:
34
src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs
Normal file
34
src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AgentCommandQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public void DrainAll_ShouldRespectPriorityThenSequence()
|
||||
{
|
||||
var queue = new AgentCommandQueue();
|
||||
queue.EnqueuePrompt("later", "later");
|
||||
queue.EnqueuePrompt("next", "next");
|
||||
queue.EnqueuePrompt("now", "now", requestInterrupt: true);
|
||||
|
||||
var drained = queue.DrainAll();
|
||||
|
||||
drained.Select(x => x.Content).Should().Equal("now", "next", "later");
|
||||
drained[0].RequestInterrupt.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_ShouldRemoveQueuedItems()
|
||||
{
|
||||
var queue = new AgentCommandQueue();
|
||||
queue.EnqueuePrompt("first");
|
||||
queue.EnqueueNotification("note");
|
||||
|
||||
queue.Clear();
|
||||
|
||||
queue.DrainAll().Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
58
src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs
Normal file
58
src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AgentToolResultBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_ShouldPersistPreviewToSourceAndReuseItOnNextQueryView()
|
||||
{
|
||||
var longContent = new string('A', 1400);
|
||||
var sourceMessages = new List<ChatMessage>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MsgId = "tool-1",
|
||||
Role = "user",
|
||||
Content = $$"""{"type":"tool_result","tool_use_id":"call-1","tool_name":"file_read","content":"{{longContent}}"}"""
|
||||
},
|
||||
new()
|
||||
{
|
||||
MsgId = "tail-1",
|
||||
Role = "assistant",
|
||||
Content = "recent tail"
|
||||
}
|
||||
};
|
||||
|
||||
var firstWindow = sourceMessages.Select(message => new ChatMessage
|
||||
{
|
||||
MsgId = message.MsgId,
|
||||
Role = message.Role,
|
||||
Content = message.Content,
|
||||
QueryPreviewContent = message.QueryPreviewContent,
|
||||
Timestamp = message.Timestamp
|
||||
}).ToList();
|
||||
|
||||
var first = AgentToolResultBudget.Apply(firstWindow, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages);
|
||||
|
||||
sourceMessages[0].QueryPreviewContent.Should().NotBeNullOrWhiteSpace();
|
||||
first.TruncatedCount.Should().Be(1);
|
||||
|
||||
var secondWindow = sourceMessages.Select(message => new ChatMessage
|
||||
{
|
||||
MsgId = message.MsgId,
|
||||
Role = message.Role,
|
||||
Content = message.Content,
|
||||
QueryPreviewContent = message.QueryPreviewContent,
|
||||
Timestamp = message.Timestamp
|
||||
}).ToList();
|
||||
|
||||
var second = AgentToolResultBudget.Apply(secondWindow, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages);
|
||||
|
||||
second.ReusedPreviewCount.Should().Be(1);
|
||||
secondWindow[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent);
|
||||
}
|
||||
}
|
||||
38
src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs
Normal file
38
src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using AxCopilot.Services;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class CodeLanguageCatalogTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(".cs", "csharp")]
|
||||
[InlineData(".py", "python")]
|
||||
[InlineData(".java", "java")]
|
||||
[InlineData(".go", null)]
|
||||
[InlineData(".unknown", null)]
|
||||
public void DetectLspLanguageId_ShouldMatchCatalog(string extension, string? expected)
|
||||
{
|
||||
CodeLanguageCatalog.DetectLspLanguageId($"sample{extension}").Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".go", true)]
|
||||
[InlineData(".rs", true)]
|
||||
[InlineData(".sql", true)]
|
||||
[InlineData(".foo", false)]
|
||||
public void IsCodeLikeFile_ShouldReflectBuiltInSupport(string extension, bool expected)
|
||||
{
|
||||
CodeLanguageCatalog.IsCodeLikeFile(extension).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportDescriptions_ShouldContainBroadStaticSupportAndFocusedLspSupport()
|
||||
{
|
||||
CodeLanguageCatalog.BuildStaticSupportDescription().Should().Contain("Go");
|
||||
CodeLanguageCatalog.BuildStaticSupportDescription().Should().Contain("Rust");
|
||||
CodeLanguageCatalog.BuildLspSupportDescription().Should().Contain("C# (.NET)");
|
||||
CodeLanguageCatalog.BuildLspSupportDescription().Should().NotContain("Go");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class DocumentPlannerPresentationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ForPresentation_ShouldReturnConsultingStoryline()
|
||||
{
|
||||
var tool = new DocumentPlannerTool();
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = Path.GetTempPath(),
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"topic": "2026 사업 전략 제안",
|
||||
"document_type": "presentation",
|
||||
"target_pages": 6
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("Executive Summary");
|
||||
result.Output.Should().Contain("Situation & Imperative");
|
||||
result.Output.Should().Contain("Options & Recommendation");
|
||||
result.Output.Should().Contain("Implementation Roadmap");
|
||||
result.Output.Should().Contain("Impact & Ask");
|
||||
}
|
||||
}
|
||||
118
src/AxCopilot.Tests/Services/PptxSkillConsultingDeckTests.cs
Normal file
118
src/AxCopilot.Tests/Services/PptxSkillConsultingDeckTests.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class PptxSkillConsultingDeckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ShouldCreateDeck_WithConsultingLayouts()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-consulting-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var tool = new PptxSkill();
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"path": "consulting-deck.pptx",
|
||||
"theme": "professional",
|
||||
"slides": [
|
||||
{
|
||||
"layout": "executive_summary",
|
||||
"title": "Executive Summary",
|
||||
"headline": "핵심 투자 우선순위를 재배치하면 두 개 분기 내 수익성을 개선할 수 있습니다.",
|
||||
"summary_points": [
|
||||
"유지율 개선이 신규 확보보다 높은 수익 효과를 보입니다.",
|
||||
"상위 3개 비용 항목에 구조적 비효율이 확인되었습니다.",
|
||||
"즉시 실행 가능한 과제와 중기 투자 과제를 분리해야 합니다."
|
||||
],
|
||||
"recommendation": "CRM 고도화와 운영 자동화를 병행하고 저효율 캠페인은 축소합니다.",
|
||||
"kpis": [
|
||||
{ "label": "매출총이익", "value": "+4.2%p", "trend": "2Q forecast", "note": "product mix 개선" },
|
||||
{ "label": "CAC", "value": "-11%", "trend": "target", "note": "퍼널 최적화" },
|
||||
{ "label": "Retention", "value": "+6pt", "trend": "12M", "note": "핵심 세그먼트" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"layout": "comparison",
|
||||
"title": "Options",
|
||||
"headline": "균형형 대안이 실행 속도와 확장성의 균형이 가장 좋습니다.",
|
||||
"options": [
|
||||
{ "name": "Option A", "pros": "빠른 적용", "cons": "확장성 제한", "verdict": "Fastest" },
|
||||
{ "name": "Option B", "pros": "균형 잡힌 투자", "cons": "의사결정 필요", "verdict": "Recommended" },
|
||||
{ "name": "Option C", "pros": "장기 최적화", "cons": "리드타임 길음", "verdict": "Strategic" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"layout": "roadmap",
|
||||
"title": "Roadmap",
|
||||
"phases": [
|
||||
{ "title": "Phase 1", "detail": "진단 및 설계", "timeline": "0-30일", "owner": "PM" },
|
||||
{ "title": "Phase 2", "detail": "구현 및 검증", "timeline": "30-60일", "owner": "Delivery" },
|
||||
{ "title": "Phase 3", "detail": "확산 및 운영정착", "timeline": "60-90일", "owner": "Business" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"layout": "kpi_dashboard",
|
||||
"title": "KPI Dashboard",
|
||||
"summary_points": [
|
||||
"비용 구조와 유지율 개선이 동시에 진행될 때 목표 이익률 달성이 가능합니다."
|
||||
],
|
||||
"kpis": [
|
||||
{ "label": "Revenue", "value": "12%", "trend": "YoY", "note": "Strong" },
|
||||
{ "label": "Cost", "value": "-8%", "trend": "vs plan", "note": "Improving" },
|
||||
{ "label": "NPS", "value": "61", "trend": "survey", "note": "Stable" },
|
||||
{ "label": "Delivery", "value": "94%", "trend": "SLA", "note": "On track" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"layout": "recommendation",
|
||||
"title": "Recommendation",
|
||||
"recommendation": "고객가치가 높은 세그먼트에 집중하고 운영 자동화로 비용을 즉시 절감합니다.",
|
||||
"summary_points": [
|
||||
"저효율 캠페인 예산을 재배치할 수 있습니다.",
|
||||
"고객 이탈 구간을 CRM 자동화로 줄일 수 있습니다.",
|
||||
"성과 확인까지의 시간이 상대적으로 짧습니다."
|
||||
],
|
||||
"next_steps": [
|
||||
"2주 내 우선순위 확정",
|
||||
"6주 내 자동화 MVP 구축",
|
||||
"분기 말 KPI 리뷰"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
File.Exists(Path.Combine(workDir, "consulting-deck.pptx")).Should().BeTrue();
|
||||
result.Output.Should().Contain("PPTX");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public class WorkspaceContextGeneratorTests
|
||||
var result = WorkspaceContextGenerator.LoadContext(tempDir);
|
||||
result.Should().NotBeNull();
|
||||
result!.Should().Contain("(truncated)");
|
||||
result.Length.Should().BeLessOrEqualTo(4100); // 4000 + truncation message
|
||||
result!.Length.Should().BeLessOrEqualTo(4100); // 4000 + truncation message
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -286,6 +286,13 @@ public class ChatMessage
|
||||
/// <summary>첨부된 이미지 목록. base64 인코딩된 이미지 데이터.</summary>
|
||||
[JsonPropertyName("images")]
|
||||
public List<ImageAttachment>? Images { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 긴 tool_result를 query view에서 안정적으로 재사용하기 위한 축약본.
|
||||
/// 원본 content는 유지하고, 모델에 보낼 때만 이 preview를 우선 사용합니다.
|
||||
/// </summary>
|
||||
[JsonPropertyName("queryPreviewContent")]
|
||||
public string? QueryPreviewContent { get; set; }
|
||||
}
|
||||
|
||||
public class ChatExecutionEvent
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -35,14 +35,7 @@ public class CodeIndexService : IDisposable
|
||||
"import", "export", "function", "const", "let", "def", "self",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".hpp",
|
||||
".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala",
|
||||
".html", ".css", ".scss", ".json", ".xml", ".yaml", ".yml",
|
||||
".md", ".txt", ".sql", ".sh", ".bat", ".ps1",
|
||||
".csproj", ".sln", ".gradle", ".pom",
|
||||
};
|
||||
private static readonly IReadOnlyCollection<string> CodeExtensions = CodeLanguageCatalog.CodeExtensions;
|
||||
|
||||
// ── DB 초기화 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
304
src/AxCopilot/Services/CodeLanguageCatalog.cs
Normal file
304
src/AxCopilot/Services/CodeLanguageCatalog.cs
Normal file
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
public sealed record CodeLanguageCapability(
|
||||
string Key,
|
||||
string DisplayName,
|
||||
IReadOnlyList<string> Extensions,
|
||||
IReadOnlyList<string> Guidance,
|
||||
string? LspLanguageId = null,
|
||||
bool ShowInQuickSelect = false,
|
||||
string? QuickSelectKey = null,
|
||||
string? QuickSelectLabel = null,
|
||||
string? QuickSelectIcon = null);
|
||||
|
||||
/// <summary>
|
||||
/// 코드 탭과 에이전트가 공통으로 참조하는 언어 지원 카탈로그.
|
||||
/// - 파일 분류
|
||||
/// - 인덱싱 대상 확장자
|
||||
/// - 시스템 프롬프트 언어 가이드
|
||||
/// - LSP 연동 가능 언어
|
||||
/// - 설정 UI 설명 문자열
|
||||
/// 를 한 곳에서 관리합니다.
|
||||
/// </summary>
|
||||
public static class CodeLanguageCatalog
|
||||
{
|
||||
private static readonly ReadOnlyCollection<CodeLanguageCapability> s_all =
|
||||
new(new List<CodeLanguageCapability>
|
||||
{
|
||||
new(
|
||||
"csharp",
|
||||
"C# (.NET)",
|
||||
[".cs", ".csx", ".csproj", ".sln"],
|
||||
[
|
||||
"Use dotnet CLI, solution/project files, and NuGet package conventions.",
|
||||
"Follow Microsoft naming conventions and prefer targeted edits over broad rewrites.",
|
||||
"Verify impact on callers, DI registration, nullable flow, and build configuration."
|
||||
],
|
||||
LspLanguageId: "csharp",
|
||||
ShowInQuickSelect: true,
|
||||
QuickSelectKey: "csharp",
|
||||
QuickSelectLabel: "C# (.NET)",
|
||||
QuickSelectIcon: "\uD83D\uDD39"),
|
||||
new(
|
||||
"python",
|
||||
"Python",
|
||||
[".py", ".pyi", ".ipynb"],
|
||||
[
|
||||
"Use pip/venv or conda only if already available in the environment.",
|
||||
"Follow PEP 8, type hints, and module/package boundaries.",
|
||||
"Prefer small focused functions and verify import/runtime errors after edits."
|
||||
],
|
||||
LspLanguageId: "python",
|
||||
ShowInQuickSelect: true,
|
||||
QuickSelectKey: "python",
|
||||
QuickSelectLabel: "Python",
|
||||
QuickSelectIcon: "\uD83D\uDC0D"),
|
||||
new(
|
||||
"java",
|
||||
"Java",
|
||||
[".java", ".gradle", ".pom"],
|
||||
[
|
||||
"Use Maven or Gradle conventions already present in the repository.",
|
||||
"Follow package structure, visibility rules, and style consistent with the existing codebase.",
|
||||
"Check interfaces, implementations, and test fixtures together when modifying shared behavior."
|
||||
],
|
||||
LspLanguageId: "java",
|
||||
ShowInQuickSelect: true,
|
||||
QuickSelectKey: "java",
|
||||
QuickSelectLabel: "Java",
|
||||
QuickSelectIcon: "\u2615"),
|
||||
new(
|
||||
"cpp",
|
||||
"C / C++",
|
||||
[".c", ".cc", ".cxx", ".cpp", ".h", ".hh", ".hpp", ".inl"],
|
||||
[
|
||||
"Respect the repository's existing build system, usually CMake, MSBuild, or compiler-specific scripts.",
|
||||
"Be careful with headers, include order, ownership, ABI-sensitive changes, and platform guards.",
|
||||
"Validate both declaration and implementation impact when editing shared types."
|
||||
],
|
||||
LspLanguageId: "cpp",
|
||||
ShowInQuickSelect: true,
|
||||
QuickSelectKey: "cpp",
|
||||
QuickSelectLabel: "C/C++",
|
||||
QuickSelectIcon: "\u2699"),
|
||||
new(
|
||||
"typescript",
|
||||
"TypeScript",
|
||||
[".ts", ".tsx", ".mts", ".cts"],
|
||||
[
|
||||
"Use the existing package manager and tsconfig structure.",
|
||||
"Prefer explicit types on public boundaries and check build/lint config before changing module format.",
|
||||
"Preserve framework conventions already used by the project."
|
||||
],
|
||||
LspLanguageId: "typescript"),
|
||||
new(
|
||||
"javascript",
|
||||
"JavaScript / Vue",
|
||||
[".js", ".jsx", ".mjs", ".cjs", ".vue"],
|
||||
[
|
||||
"Use the existing Node package manager and lint/format rules.",
|
||||
"For Vue, preserve the current component style and API pattern used by the project.",
|
||||
"Check module boundaries, imports, and runtime side effects after edits."
|
||||
],
|
||||
LspLanguageId: "javascript",
|
||||
ShowInQuickSelect: true,
|
||||
QuickSelectKey: "javascript",
|
||||
QuickSelectLabel: "JavaScript / Vue",
|
||||
QuickSelectIcon: "\uD83C\uDF10"),
|
||||
new(
|
||||
"go",
|
||||
"Go",
|
||||
[".go", ".mod", ".sum"],
|
||||
[
|
||||
"Preserve package boundaries, error-first flow, and gofmt-style formatting.",
|
||||
"Check interfaces, exported identifiers, and concurrency-sensitive changes together."
|
||||
]),
|
||||
new(
|
||||
"rust",
|
||||
"Rust",
|
||||
[".rs", ".toml"],
|
||||
[
|
||||
"Respect Cargo workspace structure, ownership/borrowing rules, and crate boundaries.",
|
||||
"Prefer explicit enums/results and verify compiler diagnostics after edits."
|
||||
]),
|
||||
new(
|
||||
"php",
|
||||
"PHP",
|
||||
[".php", ".phtml"],
|
||||
[
|
||||
"Follow the framework and autoloading structure already present in the project.",
|
||||
"Be careful with runtime includes, container wiring, and mixed template/application files."
|
||||
]),
|
||||
new(
|
||||
"ruby",
|
||||
"Ruby",
|
||||
[".rb", ".rake", ".gemspec"],
|
||||
[
|
||||
"Preserve gem structure, Rails or plain Ruby conventions already used in the repository.",
|
||||
"Check dynamic dispatch, concerns/modules, and tests together after edits."
|
||||
]),
|
||||
new(
|
||||
"kotlin",
|
||||
"Kotlin",
|
||||
[".kt", ".kts"],
|
||||
[
|
||||
"Preserve Gradle structure, package layout, and nullability intent.",
|
||||
"Be careful with JVM interop boundaries and Android-specific module structure when present."
|
||||
]),
|
||||
new(
|
||||
"swift",
|
||||
"Swift",
|
||||
[".swift"],
|
||||
[
|
||||
"Preserve target structure, Apple framework imports, and protocol-oriented design already in use.",
|
||||
"Check app lifecycle and platform-specific behavior after edits."
|
||||
]),
|
||||
new(
|
||||
"scala",
|
||||
"Scala",
|
||||
[".scala", ".sc"],
|
||||
[
|
||||
"Respect sbt/module structure, functional style, and existing typeclass or Akka patterns if present.",
|
||||
"Keep public APIs simple and avoid unnecessary type-level churn."
|
||||
]),
|
||||
new(
|
||||
"shell",
|
||||
"Shell",
|
||||
[".sh", ".bash", ".zsh"],
|
||||
[
|
||||
"Prefer safe quoting, explicit exit handling, and repository-local scripts over one-off inline shell.",
|
||||
"Check portability assumptions and environment-specific commands."
|
||||
]),
|
||||
new(
|
||||
"powershell",
|
||||
"PowerShell",
|
||||
[".ps1", ".psm1", ".psd1", ".bat", ".cmd"],
|
||||
[
|
||||
"Prefer native PowerShell cmdlets and safe path handling.",
|
||||
"Be careful with Windows-specific side effects, quoting, and admin-sensitive operations."
|
||||
]),
|
||||
new(
|
||||
"sql",
|
||||
"SQL",
|
||||
[".sql"],
|
||||
[
|
||||
"Preserve migration ordering, transactional safety, and index/constraint compatibility.",
|
||||
"Call out destructive or data-migrating changes explicitly."
|
||||
]),
|
||||
new(
|
||||
"web",
|
||||
"HTML / CSS / SCSS",
|
||||
[".html", ".htm", ".css", ".scss", ".sass", ".less", ".xaml"],
|
||||
[
|
||||
"Preserve the existing design system, layout structure, and accessibility semantics.",
|
||||
"Prefer incremental visual changes and keep selectors/components scoped."
|
||||
]),
|
||||
new(
|
||||
"markup",
|
||||
"JSON / YAML / XML / Markdown",
|
||||
[".json", ".jsonc", ".xml", ".yaml", ".yml", ".md", ".txt"],
|
||||
[
|
||||
"Preserve schema shape, indentation style, and comment/document conventions already used in the repository.",
|
||||
"Validate references, keys, and generated consumer impact after edits."
|
||||
]),
|
||||
});
|
||||
|
||||
private static readonly ReadOnlyDictionary<string, CodeLanguageCapability> s_byKey =
|
||||
new(s_all.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private static readonly ReadOnlyDictionary<string, CodeLanguageCapability> s_byExtension =
|
||||
new(s_all
|
||||
.SelectMany(cap => cap.Extensions.Select(ext => new KeyValuePair<string, CodeLanguageCapability>(ext, cap)))
|
||||
.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private static readonly HashSet<string> s_codeExtensions = new(
|
||||
s_all.SelectMany(cap => cap.Extensions),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static IReadOnlyList<CodeLanguageCapability> All => s_all;
|
||||
|
||||
public static IReadOnlyCollection<string> CodeExtensions => s_codeExtensions;
|
||||
|
||||
public static IReadOnlyList<CodeLanguageCapability> QuickSelectLanguages =>
|
||||
s_all.Where(x => x.ShowInQuickSelect).ToList();
|
||||
|
||||
public static IReadOnlyList<CodeLanguageCapability> LspBackedLanguages =>
|
||||
s_all.Where(x => !string.IsNullOrWhiteSpace(x.LspLanguageId)).ToList();
|
||||
|
||||
public static bool IsCodeLikeFile(string? extension)
|
||||
=> !string.IsNullOrWhiteSpace(extension) && s_codeExtensions.Contains(extension);
|
||||
|
||||
public static CodeLanguageCapability? FindByKey(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return null;
|
||||
return s_byKey.TryGetValue(key.Trim(), out var found) ? found : null;
|
||||
}
|
||||
|
||||
public static CodeLanguageCapability? FindByExtension(string? extension)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
return null;
|
||||
return s_byExtension.TryGetValue(extension.Trim(), out var found) ? found : null;
|
||||
}
|
||||
|
||||
public static string? DetectLspLanguageId(string? filePath)
|
||||
=> FindByExtension(Path.GetExtension(filePath ?? string.Empty))?.LspLanguageId;
|
||||
|
||||
public static string GetQuickSelectLabel(string? key)
|
||||
=> FindByKey(key)?.DisplayName ?? key ?? "Auto";
|
||||
|
||||
public static string BuildSelectedLanguagePrompt(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.Equals(key, "auto", StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
|
||||
var capability = FindByKey(key);
|
||||
if (capability == null)
|
||||
return string.Empty;
|
||||
|
||||
return $"IMPORTANT: User selected language: {capability.DisplayName}. Prioritize this language for code analysis and generation.";
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetGuidanceLines(string? selectedKey)
|
||||
{
|
||||
var selected = FindByKey(selectedKey);
|
||||
if (selected != null)
|
||||
{
|
||||
foreach (var line in selected.Guidance)
|
||||
yield return $"- {selected.DisplayName}: {line}";
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var capability in s_all.Where(x =>
|
||||
x.Key is "csharp" or "python" or "java" or "cpp" or "typescript" or "javascript" or "go" or "rust" or "kotlin" or "swift"))
|
||||
{
|
||||
var summary = capability.Guidance.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
yield return $"- {capability.DisplayName}: {summary}";
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildLspSupportDescription()
|
||||
=> string.Join(", ", LspBackedLanguages.Select(x => x.DisplayName));
|
||||
|
||||
public static string BuildStaticSupportDescription()
|
||||
=> string.Join(", ", s_all.Select(x => x.DisplayName));
|
||||
|
||||
public static string BuildCodeTabSupportDescription()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("정적 분류/검색/프롬프트 지원: ");
|
||||
sb.Append(BuildStaticSupportDescription());
|
||||
sb.Append(" | LSP 심화 분석: ");
|
||||
sb.Append(BuildLspSupportDescription());
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,9 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
|
||||
/// <summary>CodeSettings 바인딩용 프로퍼티. XAML에서 {Binding Code.EnableLsp} 등으로 접근.</summary>
|
||||
public Models.CodeSettings Code => _service.Settings.Llm.Code;
|
||||
public string CodeLspSupportedLanguagesText => CodeLanguageCatalog.BuildLspSupportDescription();
|
||||
public string CodeStaticSupportedLanguagesText => CodeLanguageCatalog.BuildStaticSupportDescription();
|
||||
public string CodeTabSupportSummaryText => CodeLanguageCatalog.BuildCodeTabSupportDescription();
|
||||
|
||||
// ─── 작업 복사본 ───────────────────────────────────────────────────────
|
||||
private string _hotkey;
|
||||
|
||||
@@ -281,19 +281,14 @@ public partial class ChatWindow
|
||||
sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}");
|
||||
|
||||
// 사용자 선택 개발 언어
|
||||
if (_selectedLanguage != "auto")
|
||||
{
|
||||
var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage };
|
||||
sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation.");
|
||||
}
|
||||
var selectedLanguagePrompt = CodeLanguageCatalog.BuildSelectedLanguagePrompt(_selectedLanguage);
|
||||
if (!string.IsNullOrWhiteSpace(selectedLanguagePrompt))
|
||||
sb.AppendLine($"\n{selectedLanguagePrompt}");
|
||||
|
||||
// 언어별 가이드라인
|
||||
sb.AppendLine("\n## Language Guidelines");
|
||||
sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions.");
|
||||
sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred.");
|
||||
sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide.");
|
||||
sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines.");
|
||||
sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API.");
|
||||
foreach (var guidance in CodeLanguageCatalog.GetGuidanceLines(_selectedLanguage == "auto" ? null : _selectedLanguage))
|
||||
sb.AppendLine(guidance);
|
||||
|
||||
// 코드 품질 + 안전 수칙
|
||||
sb.AppendLine("\n## Code Quality & Safety");
|
||||
|
||||
@@ -3655,8 +3655,7 @@ public partial class ChatWindow : Window
|
||||
],
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" };
|
||||
private static readonly HashSet<string> CodeExtensions = new(Services.CodeLanguageCatalog.CodeExtensions, StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly HashSet<string> DataExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" };
|
||||
// ImageExtensions는 이미지 첨부 영역(line ~1323)에서 정의됨 — 재사용
|
||||
|
||||
@@ -5458,7 +5458,7 @@
|
||||
<LineBreak/>• 심볼 정의 위치 찾기 (Goto Definition)
|
||||
<LineBreak/>• 심볼이 사용된 모든 위치 검색 (Find References)
|
||||
<LineBreak/>• 파일 내 클래스/메서드/필드 목록 조회
|
||||
<LineBreak/><LineBreak/>지원 언어: C#, TypeScript, Python, C++, Java
|
||||
<LineBreak/><LineBreak/>지원 언어는 아래 설정 설명에 표시됩니다.
|
||||
<LineBreak/>해당 언어 서버가 PC에 설치되어 있어야 동작합니다.
|
||||
</TextBlock>
|
||||
</ToolTip>
|
||||
@@ -5466,6 +5466,9 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="언어 서버로 정의 이동, 참조 검색 등 코드 분석을 지원합니다"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Margin="0,4,0,0" Text="{Binding CodeLspSupportedLanguagesText, StringFormat=지원 언어(LSP): {0}}"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Margin="0,2,0,0" Text="{Binding CodeStaticSupportedLanguagesText, StringFormat=코드 탭 기본 지원: {0}}"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Margin="0,2,0,0" Text="격리 환경에서는 내장 분석과 로컬에 이미 설치된 언어 서버만 활용합니다"/>
|
||||
</StackPanel>
|
||||
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="{Binding Code.EnableLsp, Mode=TwoWay}"/>
|
||||
|
||||
@@ -1,125 +1,109 @@
|
||||
---
|
||||
---
|
||||
name: pptx-creator
|
||||
label: PPT 프레젠테이션 생성
|
||||
description: Python을 사용하여 전문적인 PowerPoint 프레젠테이션을 생성합니다. 작업 폴더의 양식 파일을 자동 활용합니다.
|
||||
description: AX 기본 PPT 엔진으로 고급 제안서, 보고서, 발표 자료를 생성합니다. 작업 폴더의 양식 파일과 프로젝트 자료를 함께 활용합니다.
|
||||
icon: \uE7BE
|
||||
when_to_use: 발표 자료, 제안서 deck, 보고용 슬라이드, 교육용 프레젠테이션을 새로 만들어야 하거나 기존 양식 PPT를 활용해야 할 때
|
||||
when_to_use: 제안서 deck, 경영보고, 임원 보고자료, 프로젝트 현황 발표, 교육용 슬라이드, 회의 발표 자료를 새로 만들어야 하거나 기존 양식 PPT를 활용해야 할 때
|
||||
argument-hint: <주제 또는 문서 목적>
|
||||
allowed-tools:
|
||||
- folder_map
|
||||
- document_plan
|
||||
- document_read
|
||||
- file_read
|
||||
- file_write
|
||||
- process
|
||||
- pptx_create
|
||||
- template_render
|
||||
tabs: cowork
|
||||
---
|
||||
|
||||
사용자의 요구에 맞는 PowerPoint 프레젠테이션을 Python으로 생성하세요.
|
||||
## 실행 경로 선택 (Python 가능/불가)
|
||||
- 먼저 `process`로 `python --version`을 확인하세요.
|
||||
- Python 가능: 기존 python-pptx 경로를 사용하세요.
|
||||
- Python 불가: `pptx_create`로 슬라이드 초안을 생성하고 `template_render` + `file_write`로 발표자료 구조를 보강하세요.
|
||||
사용자의 요구에 맞는 PowerPoint 프레젠테이션을 AX 기본 `pptx_create` 도구로 생성하세요.
|
||||
|
||||
## 기본 원칙
|
||||
- 기본 경로는 항상 `pptx_create`입니다. Python 스크립트 생성은 예외 상황에서만 고려하세요.
|
||||
- 슬라이드마다 메시지는 하나만 두고, 제목은 설명형 문장이 아니라 결론형 headline으로 작성하세요.
|
||||
- bullet은 슬라이드당 3~5개 이내의 짧고 강한 문장으로 유지하세요. 공간을 채우기 위한 장문 bullet은 금지합니다.
|
||||
- 숫자, 비교, 일정, 권고안은 각각 맞는 레이아웃으로 분리하세요.
|
||||
|
||||
## 사전 준비
|
||||
필요한 패키지를 확인하고 설치하세요:
|
||||
```
|
||||
process: pip install python-pptx
|
||||
## 권장 작업 순서
|
||||
1. `folder_map`으로 작업 폴더를 확인하고 기존 `.pptx`, 보고서, 분석 문서, 회의록이 있는지 찾으세요.
|
||||
2. 참고할 양식 PPT가 있으면 파일명을 기억하고, 필요 시 `document_read`로 슬라이드 구조를 파악하세요.
|
||||
3. 먼저 `document_plan`을 `document_type: presentation`으로 호출해서 스토리라인을 정리하세요.
|
||||
4. 그 결과를 바탕으로 `pptx_create`용 슬라이드 배열을 설계하세요.
|
||||
5. 생성 후 결과 파일 경로와 핵심 구성(슬라이드 수, 사용 템플릿/테마, 주요 레이아웃)을 간단히 안내하세요.
|
||||
|
||||
## 컨설팅형 스토리라인 규칙
|
||||
- 기본 구조는 가능하면 아래 순서를 따르세요.
|
||||
- `Executive Summary`
|
||||
- `Situation & Imperative`
|
||||
- `Key Findings`
|
||||
- `Options & Recommendation`
|
||||
- `Implementation Roadmap`
|
||||
- `Impact & Ask`
|
||||
|
||||
## 레이아웃 선택 기준
|
||||
- `executive_summary`: 경영진 요약, 한 줄 권고안, 핵심 takeaways, KPI 요약
|
||||
- `recommendation`: 단일 권고안과 근거, 즉시 실행 항목
|
||||
- `comparison`: 대안 비교, 옵션별 pros/risks, 추천안 강조
|
||||
- `roadmap`: 단계별 일정, 소유자, 주요 산출물
|
||||
- `kpi_dashboard`: 핵심 수치, 추세, 시사점
|
||||
- `chart`: 정량 데이터 시각화
|
||||
- `table`: 비교표, 상세 데이터표
|
||||
- `section`: 장 구분
|
||||
- `content`: 일반 설명 슬라이드
|
||||
|
||||
## 양식 활용
|
||||
- 작업 폴더에 기존 양식 PPT가 있으면 `template` 또는 `theme_file`로 우선 활용하세요.
|
||||
- 양식 후보 예시:
|
||||
- 파일명에 `양식`, `template`, `표준`, `기본`, `보고`, `proposal`, `deck` 포함
|
||||
- 사용자가 특정 `.pptx` 파일명을 직접 언급
|
||||
- 양식이 명확하지 않으면 기본 `template: basic100` 또는 문서 성격에 맞는 `corporate`, `professional`, `modern` 중 하나를 사용하세요.
|
||||
|
||||
## 슬라이드 작성 규칙
|
||||
- 제목은 `무엇을 다룰지`가 아니라 `무슨 결론인지`를 말해야 합니다.
|
||||
- 각 슬라이드는 headline, supporting evidence, takeaway의 3요소를 갖추세요.
|
||||
- 데이터가 없으면 억지 차트를 만들지 말고, 비교 카드나 권고안 슬라이드로 전환하세요.
|
||||
- 표는 설명보다 비교에 유리할 때만 사용하세요.
|
||||
- speaker notes가 유용하면 `notes`를 넣어 발표 포인트를 남기세요.
|
||||
|
||||
## 예시 흐름
|
||||
- `document_plan`으로 발표 구조 생성
|
||||
- `pptx_create`에서 아래와 같은 레이아웃 조합 사용
|
||||
- `title`
|
||||
- `executive_summary`
|
||||
- `comparison`
|
||||
- `recommendation`
|
||||
- `roadmap`
|
||||
- `kpi_dashboard`
|
||||
- `section`
|
||||
- `content`
|
||||
|
||||
## 예시 파라미터 스케치
|
||||
```json
|
||||
{
|
||||
"path": "strategy-deck.pptx",
|
||||
"template": "basic100",
|
||||
"slides": [
|
||||
{
|
||||
"layout": "title",
|
||||
"title": "2026 사업 전략 제안",
|
||||
"subtitle": "성장 가속과 운영 효율 동시 달성"
|
||||
},
|
||||
{
|
||||
"layout": "executive_summary",
|
||||
"title": "Executive Summary",
|
||||
"headline": "핵심 투자 우선순위를 재배치하면 2개 분기 내 수익성을 개선할 수 있습니다.",
|
||||
"summary_points": [
|
||||
"핵심 비용 3개 영역에서 구조적 비효율이 확인되었습니다.",
|
||||
"고객 유지율 개선이 신규 확보보다 더 큰 수익 기회를 만듭니다.",
|
||||
"선행 투자 없이 가능한 빠른 실행과제도 존재합니다."
|
||||
],
|
||||
"recommendation": "우선 CRM 고도화와 운영 자동화를 병행하고, 저효율 캠페인은 즉시 축소합니다.",
|
||||
"kpis": [
|
||||
{ "label": "매출총이익", "value": "+4.2%p", "trend": "2Q forecast", "note": "product mix 개선" },
|
||||
{ "label": "CAC", "value": "-11%", "trend": "target", "note": "퍼널 최적화" },
|
||||
{ "label": "Retention", "value": "+6pt", "trend": "12M", "note": "핵심 세그먼트" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 양식 활용 (마스터 슬라이드 템플릿)
|
||||
작업 폴더에 PPT 양식이 있으면 **반드시** 활용하세요:
|
||||
|
||||
1. **양식 탐색**: `folder_map`으로 작업 폴더를 스캔하여 `.pptx` 파일 확인
|
||||
2. **양식 후보 판별**:
|
||||
- 파일명에 "양식", "template", "서식", "표준", "기본" 포함
|
||||
- 또는 사용자가 명시적으로 "XX 양식으로 작성해줘" 요청
|
||||
- 또는 사용자가 특정 .pptx 파일명을 언급
|
||||
3. **양식 구조 파악**: `document_read`로 양식의 슬라이드 레이아웃 목록 확인
|
||||
4. **양식 기반 생성**:
|
||||
```python
|
||||
prs = Presentation('양식_발표.pptx')
|
||||
# 마스터 슬라이드의 배경, 로고, 색 테마, 폰트가 자동 상속
|
||||
# 기존 슬라이드 제거 후 새 내용 추가
|
||||
while len(prs.slides) > 0:
|
||||
rId = prs.slides._sldIdLst[0].rId
|
||||
prs.part.drop_rel(rId)
|
||||
del prs.slides._sldIdLst[0]
|
||||
```
|
||||
5. **레이아웃 확인** (양식마다 다를 수 있음):
|
||||
```python
|
||||
for i, layout in enumerate(prs.slide_layouts):
|
||||
print(f'{i}: {layout.name}')
|
||||
```
|
||||
6. **양식이 없으면**: 아래 기본 템플릿으로 새 프레젠테이션 생성
|
||||
|
||||
## 작업 절차
|
||||
1. **요구사항 파악**: 발표 주제, 슬라이드 수, 스타일 확인
|
||||
2. **양식 확인**: folder_map으로 작업 폴더에 양식 .pptx 파일이 있는지 확인
|
||||
3. **스크립트 작성**: file_write로 Python 스크립트 생성
|
||||
4. **실행**: `process`로 스크립트 실행
|
||||
5. **결과 안내**: 생성된 .pptx 파일 경로를 사용자에게 전달
|
||||
|
||||
## 스크립트 템플릿
|
||||
```python
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
import os
|
||||
|
||||
# 양식 파일 자동 감지
|
||||
template_keywords = ['양식', 'template', '서식', '표준', '기본']
|
||||
template_file = None
|
||||
for f in os.listdir('.'):
|
||||
if f.endswith('.pptx') and any(kw in f.lower() for kw in template_keywords):
|
||||
template_file = f
|
||||
break
|
||||
|
||||
# 양식이 있으면 활용, 없으면 새 프레젠테이션
|
||||
if template_file:
|
||||
prs = Presentation(template_file)
|
||||
# 기존 슬라이드 제거 (마스터/레이아웃은 유지)
|
||||
while len(prs.slides) > 0:
|
||||
rId = prs.slides._sldIdLst[0].rId
|
||||
prs.part.drop_rel(rId)
|
||||
del prs.slides._sldIdLst[0]
|
||||
print(f'양식 활용: {template_file}')
|
||||
print(f'사용 가능한 레이아웃: {[l.name for l in prs.slide_layouts]}')
|
||||
else:
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
|
||||
# 제목 슬라이드
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[0])
|
||||
slide.shapes.title.text = '프레젠테이션 제목'
|
||||
if len(slide.placeholders) > 1:
|
||||
slide.placeholders[1].text = '부제목'
|
||||
|
||||
# 내용 슬라이드
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
slide.shapes.title.text = '섹션 제목'
|
||||
body = slide.placeholders[1]
|
||||
body.text = '첫 번째 포인트'
|
||||
p = body.text_frame.add_paragraph()
|
||||
p.text = '두 번째 포인트'
|
||||
|
||||
prs.save('presentation.pptx')
|
||||
print('프레젠테이션 생성 완료: presentation.pptx')
|
||||
```
|
||||
|
||||
## 지원 기능
|
||||
- 제목/내용/빈 슬라이드 레이아웃
|
||||
- 텍스트 서식 (글꼴, 크기, 색상, 정렬)
|
||||
- 표 삽입
|
||||
- 이미지 삽입
|
||||
- 도형 (사각형, 원, 화살표)
|
||||
- 차트 (막대, 선, 원형)
|
||||
- 슬라이드 번호
|
||||
- 마스터 슬라이드 커스터마이징
|
||||
- **양식 파일 기반 마스터/레이아웃 상속** (배경, 로고, 색 테마, 폰트 자동 유지)
|
||||
|
||||
한국어로 안내하세요. 작업 폴더에 결과 파일을 저장하세요.
|
||||
한국어로 안내하고, 결과 파일은 작업 폴더에 저장하세요.
|
||||
|
||||
Reference in New Issue
Block a user