?? ?? ?? ???? PPTX ??? ? ?? ??
?? ?? ??? PPTX/DOCX/XLSX/HTML ???? ? ?????, PPTX? ???? ??? ? ???? ?? ???? ? ??? ?? ?? ???? ????. ?? ????: - ExcelSkill? conditional_formats? ??? ?? ???? ??? ? ?????? OpenXML? ?? ???? workbook quality review? ?? - DocxSkill? style_map? ??? ???? ??/??/?? ???? ?? ?? ParagraphStyleId? ?? - HtmlSkill? print_header/print_footer ?? ?? ???? ???? ArtifactQualityReviewService? ?? ?? ?? ???? ?? - PptxTemplatePackRegistry? PptxSkill template_pack ????? ??? strategy/board/pmo/finance/sales/operating_model ??? ??? ?? ?? ?? ??? ?? - ?????, ????, ?? ???, ??? ?? ?? ?? ???? ???? ?? ?? ?? ???? ???? ???? ?? ?? ??: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\ => ?? 0 / ?? 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\ => ?? 15
This commit is contained in:
10
README.md
10
README.md
@@ -7,6 +7,16 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
|
||||
개발 참고: Claw Code 동등성 작업 추적 문서
|
||||
`docs/claw-code-parity-plan.md`
|
||||
|
||||
- 업데이트: 2026-04-14 21:25 (KST)
|
||||
- 업데이트: 2026-04-14 22:52 (KST)
|
||||
- 문서 포맷 고도화를 한 단계 더 진행했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 `conditional_formats`를 추가해 색상 스케일과 데이터 바 조건부서식을 네이티브 OpenXML로 생성하고, 품질 리뷰에도 반영되도록 했습니다.
|
||||
- [DocxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocxSkill.cs)는 `style_map`을 지원해 템플릿에 정의된 제목/헤딩/본문 스타일을 실제 문단에 매핑할 수 있게 했습니다. 이제 사내 Word 템플릿을 사용할 때 스타일 상속 품질이 더 높아집니다.
|
||||
- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `print_header`, `print_footer`를 받아 인쇄용 헤더/푸터 프레임을 추가하고, [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)는 HTML/DOCX/XLSX 산출물의 품질 요약을 안정적인 공통 형식으로 반환하도록 정리했습니다.
|
||||
- PPTX도 계속 고도화할 수 있게 확장 포인트를 넣었습니다. [PptxTemplatePackRegistry.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs)를 추가해 `strategy`, `board`, `pmo`, `finance`, `sales`, `operating_model` 템플릿 팩을 정의했고, [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs)는 `template_pack` 파라미터와 objective/audience 기반 자동 pack suggestion을 지원합니다.
|
||||
- 테스트: [ExcelSkillConditionalFormattingTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillConditionalFormattingTests.cs), [DocxSkillStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs), [HtmlSkillPrintFrameTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs), [PptxSkillTemplatePackTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs) 추가
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\` 경고 0 / 오류 0
|
||||
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\` 통과 15
|
||||
|
||||
- 업데이트: 2026-04-14 21:25 (KST)
|
||||
- 문서 생성 고도화 2차를 반영했습니다. [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)를 추가해 HTML/DOCX/XLSX 결과물에 공통 품질 점수와 보완 포인트를 부여하고, [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs), [DocxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocxSkill.cs), [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 연결했습니다.
|
||||
- Word 쪽은 템플릿·커버·목차를 강화했습니다. `docx_create`는 `template_path`, `cover_subtitle`, `cover_meta`, `toc`를 받아 템플릿 기반 문서와 커버 페이지, TOC 필드를 함께 만들 수 있게 되었고, 결과 요약에도 문서 품질 리뷰가 같이 남습니다.
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase2\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2\\` 경고 0 / 오류 0
|
||||
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocxSkillTemplateFeaturesTests|ExcelSkillExecutiveSummaryLinkTests|DocumentAssemblerSemanticTests|DocumentPlannerBusinessDocumentTests|HtmlSkillConsultingSectionsTests|ExcelSkillSummarySheetTests" -p:OutputPath=bin\\verify_doc_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2_tests\\` 통과 9
|
||||
|
||||
업데이트: 2026-04-14 22:52 (KST)
|
||||
- 문서 포맷 고도화와 PPTX 확장 포인트를 함께 반영했습니다. `src/AxCopilot/Services/Agent/ExcelSkill.cs`는 `conditional_formats`를 지원해 색상 스케일과 데이터 바 조건부서식을 OpenXML로 직접 생성하고, workbook quality review에도 조건부서식 개수를 반영합니다.
|
||||
- `src/AxCopilot/Services/Agent/DocxSkill.cs`는 `style_map`을 지원하도록 확장했습니다. 템플릿에 정의된 제목/헤딩/본문 스타일을 실제 문단의 `ParagraphStyleId`로 연결해 사내 템플릿 기반 문서 품질을 높였습니다.
|
||||
- `src/AxCopilot/Services/Agent/HtmlSkill.cs`는 `print_header`, `print_footer`를 받아 인쇄용 헤더/푸터 프레임을 렌더링하고, `src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs`는 HTML/DOCX/XLSX 품질 리뷰 문자열과 판정 로직을 공통 형식으로 정리했습니다.
|
||||
- PPTX는 `src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs`를 새로 추가해 `strategy`, `board`, `pmo`, `finance`, `sales`, `operating_model` 템플릿 팩을 정의했습니다. `src/AxCopilot/Services/Agent/PptxSkill.cs`는 `template_pack` 파라미터와 objective/audience 기반 pack suggestion을 받아 이후 목적형 deck 고도화를 이어가기 쉬운 구조가 됐습니다.
|
||||
- 테스트: `ArtifactQualityReviewServiceTests`, `ExcelSkillDataValidationTests`, `ExcelSkillConditionalFormattingTests`, `DocxSkillStyleMapTests`, `HtmlSkillPrintFrameTests`, `PptxSkillTemplatePackTests`
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\` 경고 0 / 오류 0
|
||||
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\` 통과 15
|
||||
|
||||
업데이트: 2026-04-14 19:50 (KST)
|
||||
- Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다.
|
||||
- `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다.
|
||||
|
||||
@@ -24,8 +24,10 @@ public class ArtifactQualityReviewServiceTests
|
||||
var review = ArtifactQualityReviewService.ReviewHtml("Board Report", html, hasCover: true, hasTableOfContents: true, printReady: true);
|
||||
|
||||
review.Score.Should().BeGreaterThan(75);
|
||||
review.Strengths.Should().Contain(s => s.Contains("커버") || s.Contains("목차"));
|
||||
review.Issues.Should().NotContain(i => i.Severity == ArtifactReviewSeverity.Critical);
|
||||
review.Strengths.Should().Contain(strength =>
|
||||
strength.Contains("cover", StringComparison.OrdinalIgnoreCase) ||
|
||||
strength.Contains("contents", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().NotContain(issue => issue.Severity == ArtifactReviewSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,8 +50,8 @@ public class ArtifactQualityReviewServiceTests
|
||||
false,
|
||||
false));
|
||||
|
||||
review.Issues.Should().Contain(i => i.Message.Contains("Executive Summary"));
|
||||
review.Issues.Should().Contain(i => i.Message.Contains("권고안"));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("Executive Summary", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("Recommendation", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,11 +65,12 @@ public class ArtifactQualityReviewServiceTests
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false));
|
||||
|
||||
review.Issues.Should().Contain(i => i.Message.Contains("요약 시트"));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("summary sheet", StringComparison.OrdinalIgnoreCase));
|
||||
review.Score.Should().BeLessThan(80);
|
||||
}
|
||||
}
|
||||
|
||||
106
src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs
Normal file
106
src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class DocxSkillStyleMapTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithStyleMap_ShouldApplyTemplateParagraphStyles()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-docx-stylemap-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
var templatePath = Path.Combine(workDir, "style-template.docx");
|
||||
using (var template = WordprocessingDocument.Create(templatePath, DocumentFormat.OpenXml.WordprocessingDocumentType.Document))
|
||||
{
|
||||
var mainPart = template.AddMainDocumentPart();
|
||||
mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Template Root")))));
|
||||
|
||||
var stylePart = mainPart.AddNewPart<StyleDefinitionsPart>();
|
||||
stylePart.Styles = new Styles(
|
||||
CreateParagraphStyle("CustomTitle"),
|
||||
CreateParagraphStyle("CustomHeading1"),
|
||||
CreateParagraphStyle("CustomHeading2"),
|
||||
CreateParagraphStyle("CustomBody"));
|
||||
stylePart.Styles.Save();
|
||||
|
||||
mainPart.Document.Save();
|
||||
}
|
||||
|
||||
var tool = new DocxSkill();
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"path": "styled-brief.docx",
|
||||
"title": "Styled Brief",
|
||||
"template_path": "style-template.docx",
|
||||
"style_map": {
|
||||
"title": "CustomTitle",
|
||||
"heading1": "CustomHeading1",
|
||||
"heading2": "CustomHeading2",
|
||||
"body": "CustomBody"
|
||||
},
|
||||
"sections": [
|
||||
{ "heading": "Executive Summary", "body": "Body line one.\nBody line two.", "level": 1 },
|
||||
{ "heading": "Detail", "body": "Detail text.", "level": 2 }
|
||||
]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var outputPath = Path.Combine(workDir, "styled-brief.docx");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
|
||||
using var doc = WordprocessingDocument.Open(outputPath, false);
|
||||
var paragraphs = doc.MainDocumentPart!.Document.Body!.Descendants<Paragraph>().ToList();
|
||||
|
||||
paragraphs.First(paragraph => paragraph.InnerText == "Styled Brief")
|
||||
.ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomTitle");
|
||||
paragraphs.First(paragraph => paragraph.InnerText == "Executive Summary")
|
||||
.ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomHeading1");
|
||||
paragraphs.First(paragraph => paragraph.InnerText == "Detail")
|
||||
.ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomHeading2");
|
||||
paragraphs.First(paragraph => paragraph.InnerText == "Body line one.")
|
||||
.ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomBody");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Style CreateParagraphStyle(string styleId)
|
||||
{
|
||||
return new Style
|
||||
{
|
||||
Type = StyleValues.Paragraph,
|
||||
StyleId = styleId,
|
||||
CustomStyle = true,
|
||||
StyleName = new StyleName { Val = styleId },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class ExcelSkillConditionalFormattingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithConditionalFormats_ShouldPersistFormattingRules()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-conditional-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new ExcelSkill();
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"path": "conditional.xlsx",
|
||||
"sheet_name": "Tracker",
|
||||
"headers": ["Metric", "Score", "Trend"],
|
||||
"rows": [
|
||||
["Margin", 42, 12],
|
||||
["Retention", 61, 8],
|
||||
["Cycle Time", 19, -3]
|
||||
],
|
||||
"conditional_formats": [
|
||||
{ "range": "B2:B10", "type": "color_scale", "low": "FEE2E2", "mid": "FEF3C7", "high": "DCFCE7" },
|
||||
{ "range": "C2:C10", "type": "data_bar", "color": "2563EB" }
|
||||
]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("Quality score");
|
||||
|
||||
var outputPath = Path.Combine(workDir, "conditional.xlsx");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
|
||||
using var doc = SpreadsheetDocument.Open(outputPath, false);
|
||||
var firstSheet = doc.WorkbookPart!.Workbook.Sheets!.Elements<Sheet>().First();
|
||||
var worksheetPart = (WorksheetPart)doc.WorkbookPart.GetPartById(firstSheet.Id!);
|
||||
|
||||
worksheetPart.Worksheet.Elements<ConditionalFormatting>().Count().Should().Be(2);
|
||||
var ruleTypes = worksheetPart.Worksheet.Descendants<ConditionalFormattingRule>()
|
||||
.Select(rule => rule.Type?.Value)
|
||||
.ToList();
|
||||
ruleTypes.Should().Contain(ConditionalFormatValues.ColorScale);
|
||||
ruleTypes.Should().Contain(ConditionalFormatValues.DataBar);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ public class ExcelSkillDataValidationTests
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("점수");
|
||||
result.Output.Should().Contain("Quality score");
|
||||
|
||||
var outputPath = Path.Combine(workDir, "validated.xlsx");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
|
||||
62
src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs
Normal file
62
src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class HtmlSkillPrintFrameTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithPrintHeaderAndFooter_ShouldRenderPrintFrame()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-html-printframe-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new HtmlSkill();
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"path": "board-report.html",
|
||||
"title": "Board Report",
|
||||
"print": true,
|
||||
"print_header": "Board Review",
|
||||
"print_footer": "Confidential",
|
||||
"body": "<h2>Executive Summary</h2><p>Summary text with enough detail to qualify as a printable executive report.</p><h2>Recommendation</h2><p>Approve phase 1.</p><h2>Appendix</h2><p>Evidence notes.</p>"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("Quality score");
|
||||
|
||||
var html = File.ReadAllText(Path.Combine(workDir, "board-report.html"));
|
||||
html.Should().Contain("print-header");
|
||||
html.Should().Contain("print-footer");
|
||||
html.Should().Contain("Board Review");
|
||||
html.Should().Contain("Confidential");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs
Normal file
66
src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class PptxSkillTemplatePackTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithTemplatePack_ShouldReportSelectedPack()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-pack-" + 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": "board-pack-deck.pptx",
|
||||
"title": "Board Update",
|
||||
"template_pack": "board",
|
||||
"slides": [
|
||||
{
|
||||
"layout": "content",
|
||||
"title": "Current State",
|
||||
"body": "Margin improved\nDelivery risk is stable\nWorking capital is within plan"
|
||||
},
|
||||
{
|
||||
"layout": "recommendation",
|
||||
"title": "Recommendation",
|
||||
"recommendation": "Approve the next operating phase"
|
||||
}
|
||||
]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("Template pack: board");
|
||||
File.Exists(Path.Combine(workDir, "board-pack-deck.pptx")).Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,19 @@ public sealed record ArtifactQualityReport(
|
||||
public string ToToolSummary()
|
||||
{
|
||||
var strengths = Strengths.Take(3).ToList();
|
||||
var issues = Issues.OrderByDescending(i => i.Severity).Take(3).Select(i => i.Message).ToList();
|
||||
var issues = Issues
|
||||
.OrderByDescending(issue => issue.Severity)
|
||||
.Take(3)
|
||||
.Select(issue => issue.Message)
|
||||
.ToList();
|
||||
|
||||
var parts = new List<string> { $"품질 점수 {Score}/100" };
|
||||
var parts = new List<string> { $"Quality score {Score}/100" };
|
||||
if (strengths.Count > 0)
|
||||
parts.Add("강점: " + string.Join(", ", strengths));
|
||||
parts.Add("Strengths: " + string.Join(", ", strengths));
|
||||
if (issues.Count > 0)
|
||||
parts.Add("보완: " + string.Join(", ", issues));
|
||||
parts.Add("Needs work: " + string.Join(", ", issues));
|
||||
else
|
||||
parts.Add("보완 필요 사항 없음");
|
||||
parts.Add("Needs work: none");
|
||||
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
@@ -59,17 +63,48 @@ public sealed record WorkbookReviewInput(
|
||||
int FormulaCount,
|
||||
int HyperlinkCount,
|
||||
int DataValidationCount,
|
||||
int ConditionalFormattingCount,
|
||||
bool HasSummarySheet,
|
||||
bool HasHighlightSection,
|
||||
bool HasActionSection);
|
||||
|
||||
public static class ArtifactQualityReviewService
|
||||
{
|
||||
private static readonly string[] ExecutiveKeywords = ["executive summary", "요약", "핵심 요약", "summary"];
|
||||
private static readonly string[] RecommendationKeywords = ["recommendation", "권고", "제안", "next steps", "실행 과제"];
|
||||
private static readonly string[] AppendixKeywords = ["appendix", "부록", "참고", "reference"];
|
||||
private static readonly string[] ExecutiveKeywords =
|
||||
[
|
||||
"executive summary",
|
||||
"summary",
|
||||
"\uC694\uC57D",
|
||||
"\uD575\uC2EC \uC694\uC57D",
|
||||
];
|
||||
|
||||
public static ArtifactQualityReport ReviewHtml(string title, string html, bool hasCover, bool hasTableOfContents, bool printReady)
|
||||
private static readonly string[] RecommendationKeywords =
|
||||
[
|
||||
"recommendation",
|
||||
"proposal",
|
||||
"next steps",
|
||||
"action plan",
|
||||
"\uAD8C\uACE0",
|
||||
"\uC81C\uC548",
|
||||
"\uC2E4\uD589",
|
||||
];
|
||||
|
||||
private static readonly string[] AppendixKeywords =
|
||||
[
|
||||
"appendix",
|
||||
"reference",
|
||||
"supplement",
|
||||
"\uBD80\uB85D",
|
||||
"\uCC38\uACE0",
|
||||
];
|
||||
|
||||
public static ArtifactQualityReport ReviewHtml(
|
||||
string title,
|
||||
string html,
|
||||
bool hasCover,
|
||||
bool hasTableOfContents,
|
||||
bool printReady,
|
||||
bool hasPrintFrame = false)
|
||||
{
|
||||
title ??= string.Empty;
|
||||
html ??= string.Empty;
|
||||
@@ -78,7 +113,6 @@ public static class ArtifactQualityReviewService
|
||||
var issues = new List<ArtifactReviewIssue>();
|
||||
|
||||
var sectionCount = Regex.Matches(html, @"<h2\b", RegexOptions.IgnoreCase).Count;
|
||||
var subSectionCount = Regex.Matches(html, @"<h3\b", RegexOptions.IgnoreCase).Count;
|
||||
var paragraphCount = Regex.Matches(html, @"<p\b", RegexOptions.IgnoreCase).Count;
|
||||
var tableCount = Regex.Matches(html, @"<table\b", RegexOptions.IgnoreCase).Count;
|
||||
var calloutCount = Regex.Matches(html, @"callout-(info|warning|tip|danger)", RegexOptions.IgnoreCase).Count;
|
||||
@@ -90,26 +124,27 @@ public static class ArtifactQualityReviewService
|
||||
var kpiCount = Regex.Matches(html, @"kpi-card|kpi-grid|metric-card", RegexOptions.IgnoreCase).Count;
|
||||
var placeholderCount = CountPlaceholders(html);
|
||||
|
||||
if (hasCover) strengths.Add("커버 페이지 포함");
|
||||
if (hasTableOfContents) strengths.Add("목차 자동 생성");
|
||||
if (printReady) strengths.Add("인쇄 최적화 CSS 포함");
|
||||
if (sectionCount >= 5) strengths.Add($"핵심 섹션 {sectionCount}개 구성");
|
||||
if (hasCover) strengths.Add("Includes cover page");
|
||||
if (hasTableOfContents) strengths.Add("Includes table of contents");
|
||||
if (printReady) strengths.Add("Includes print-ready CSS");
|
||||
if (hasPrintFrame) strengths.Add("Includes print header/footer frame");
|
||||
if (sectionCount >= 5) strengths.Add($"Contains {sectionCount} major sections");
|
||||
if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || decisionCount > 0 || evidenceCardCount > 0 || kpiCount > 0)
|
||||
strengths.Add("표/비교/로드맵/KPI 등 구조화 블록 활용");
|
||||
if (calloutCount > 0) strengths.Add("핵심 메시지 강조 블록 포함");
|
||||
strengths.Add("Uses structured business blocks");
|
||||
if (calloutCount > 0) strengths.Add("Uses callout blocks for emphasis");
|
||||
|
||||
if (html.Length < 1800)
|
||||
issues.Add(new("문서 본문이 짧아 메시지와 근거 밀도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Body content may be too short for an executive-quality document.", ArtifactReviewSeverity.Warning));
|
||||
if (sectionCount < 4)
|
||||
issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Major section count is low for a business report.", ArtifactReviewSeverity.Warning));
|
||||
if (paragraphCount < sectionCount * 2)
|
||||
issues.Add(new("일부 섹션의 서술이 충분하지 않습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Several sections may need more supporting paragraphs.", ArtifactReviewSeverity.Warning));
|
||||
if (tableCount + comparisonCount + roadmapCount + matrixCount + decisionCount + evidenceCardCount + kpiCount == 0)
|
||||
issues.Add(new("구조화 시각 요소가 없어 보고서 완성도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Structured visual blocks are limited.", ArtifactReviewSeverity.Warning));
|
||||
if (placeholderCount > 0)
|
||||
issues.Add(new($"플레이스홀더/미완성 표현 {placeholderCount}건이 남아 있습니다", ArtifactReviewSeverity.Critical));
|
||||
issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", ArtifactReviewSeverity.Critical));
|
||||
if (!ContainsAny(html, ExecutiveKeywords))
|
||||
issues.Add(new("요약 또는 핵심 메시지 섹션이 명확하지 않습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Executive summary or summary section is missing.", ArtifactReviewSeverity.Warning));
|
||||
|
||||
return BuildReport("html", strengths, issues);
|
||||
}
|
||||
@@ -119,27 +154,27 @@ public static class ArtifactQualityReviewService
|
||||
var strengths = new List<string>();
|
||||
var issues = new List<ArtifactReviewIssue>();
|
||||
|
||||
if (input.HasTemplate) strengths.Add("템플릿 기반 스타일 상속");
|
||||
if (input.HasCoverPage) strengths.Add("커버 페이지 포함");
|
||||
if (input.HasTableOfContents) strengths.Add("목차 포함");
|
||||
if (input.HasHeaderFooter) strengths.Add("머리글/바닥글 적용");
|
||||
if (input.SectionCount >= 5) strengths.Add($"핵심 섹션 {input.SectionCount}개 구성");
|
||||
if (input.TableCount > 0) strengths.Add($"테이블 {input.TableCount}개 포함");
|
||||
if (input.CalloutCount + input.HighlightCount > 0) strengths.Add("강조 블록 활용");
|
||||
if (input.ListCount > 0) strengths.Add("실행 항목/목록 구조 포함");
|
||||
if (input.HasTemplate) strengths.Add("Uses template-based styling");
|
||||
if (input.HasCoverPage) strengths.Add("Includes cover page");
|
||||
if (input.HasTableOfContents) strengths.Add("Includes table of contents");
|
||||
if (input.HasHeaderFooter) strengths.Add("Includes header/footer");
|
||||
if (input.SectionCount >= 5) strengths.Add($"Contains {input.SectionCount} major sections");
|
||||
if (input.TableCount > 0) strengths.Add($"Includes {input.TableCount} table(s)");
|
||||
if (input.CalloutCount + input.HighlightCount > 0) strengths.Add("Uses emphasis blocks");
|
||||
if (input.ListCount > 0) strengths.Add("Uses structured list sections");
|
||||
|
||||
if (input.BodyCharacterCount < 1400)
|
||||
issues.Add(new("문서 분량이 짧아 경영 보고용 밀도가 부족할 수 있습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Body content may be too short for an executive document.", ArtifactReviewSeverity.Warning));
|
||||
if (input.SectionCount < 4)
|
||||
issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Major section count is low for a business document.", ArtifactReviewSeverity.Warning));
|
||||
if (!input.HasExecutiveSummarySection)
|
||||
issues.Add(new("Executive Summary 또는 요약 섹션이 없습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Executive Summary section is missing.", ArtifactReviewSeverity.Warning));
|
||||
if (!input.HasRecommendationSection)
|
||||
issues.Add(new("권고안 또는 다음 단계 섹션이 없습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Recommendation or next-step section is missing.", ArtifactReviewSeverity.Warning));
|
||||
if (input.SectionCount >= 6 && !input.HasAppendixSection)
|
||||
issues.Add(new("긴 문서인데 부록/참고 섹션이 없습니다", ArtifactReviewSeverity.Info));
|
||||
issues.Add(new("Appendix or reference section is limited for a long document.", ArtifactReviewSeverity.Info));
|
||||
if (input.TableCount + input.ListCount + input.CalloutCount + input.HighlightCount == 0)
|
||||
issues.Add(new("표, 목록, 강조 블록이 없어 문서가 단조로울 수 있습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Document structure is mostly plain paragraphs.", ArtifactReviewSeverity.Warning));
|
||||
|
||||
return BuildReport("docx", strengths, issues);
|
||||
}
|
||||
@@ -149,22 +184,25 @@ public static class ArtifactQualityReviewService
|
||||
var strengths = new List<string>();
|
||||
var issues = new List<ArtifactReviewIssue>();
|
||||
|
||||
if (input.HasSummarySheet) strengths.Add("요약 시트 포함");
|
||||
if (input.DetailSheetCount > 1) strengths.Add($"상세 시트 {input.DetailSheetCount}개 구성");
|
||||
if (input.FormulaCount > 0) strengths.Add($"수식 {input.FormulaCount}개 포함");
|
||||
if (input.HyperlinkCount > 0) strengths.Add("시트 간 빠른 이동 링크 포함");
|
||||
if (input.DataValidationCount > 0) strengths.Add("데이터 검증 규칙 포함");
|
||||
if (input.HasHighlightSection) strengths.Add("핵심 인사이트 영역 포함");
|
||||
if (input.HasActionSection) strengths.Add("후속 액션 영역 포함");
|
||||
if (input.HasSummarySheet) strengths.Add("Includes summary sheet");
|
||||
if (input.DetailSheetCount > 1) strengths.Add($"Contains {input.DetailSheetCount} detail sheets");
|
||||
if (input.FormulaCount > 0) strengths.Add($"Includes {input.FormulaCount} formula cell(s)");
|
||||
if (input.HyperlinkCount > 0) strengths.Add("Includes navigation links");
|
||||
if (input.DataValidationCount > 0) strengths.Add("Includes data validation rules");
|
||||
if (input.ConditionalFormattingCount > 0) strengths.Add("Includes conditional formatting");
|
||||
if (input.HasHighlightSection) strengths.Add("Includes highlight section");
|
||||
if (input.HasActionSection) strengths.Add("Includes action section");
|
||||
|
||||
if (input.SheetCount > 1 && !input.HasSummarySheet)
|
||||
issues.Add(new("멀티시트 워크북인데 요약 시트가 없습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Workbook has multiple sheets but no summary sheet.", ArtifactReviewSeverity.Warning));
|
||||
if (input.DataRowCount >= 10 && input.FormulaCount == 0)
|
||||
issues.Add(new("데이터 행 수 대비 수식/집계가 부족합니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Workbook has enough data to benefit from more formulas or rollups.", ArtifactReviewSeverity.Warning));
|
||||
if (input.HasSummarySheet && input.HyperlinkCount == 0)
|
||||
issues.Add(new("요약 시트에서 상세 시트로 이동하는 링크가 없습니다", ArtifactReviewSeverity.Warning));
|
||||
issues.Add(new("Summary sheet does not link to detail sheets.", ArtifactReviewSeverity.Warning));
|
||||
if (input.DataValidationCount == 0 && input.DataRowCount >= 5)
|
||||
issues.Add(new("입력 통제를 위한 데이터 검증 규칙이 없습니다", ArtifactReviewSeverity.Info));
|
||||
issues.Add(new("Input controls are limited for a workbook with editable data.", ArtifactReviewSeverity.Info));
|
||||
if (input.ConditionalFormattingCount == 0 && input.DataRowCount >= 8)
|
||||
issues.Add(new("Conditional formatting is limited for a workbook with enough data to prioritize or flag.", ArtifactReviewSeverity.Info));
|
||||
|
||||
return BuildReport("xlsx", strengths, issues);
|
||||
}
|
||||
@@ -194,21 +232,17 @@ public static class ArtifactQualityReviewService
|
||||
|
||||
private static int CountPlaceholders(string text)
|
||||
{
|
||||
var count = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return count;
|
||||
return 0;
|
||||
|
||||
var patterns = new[]
|
||||
{
|
||||
@"\[(todo|placeholder|내용.*작성|fill me)\]",
|
||||
@"\[(todo|placeholder|fill me)\]",
|
||||
@"lorem ipsum",
|
||||
@"tbd",
|
||||
@"\bTBD\b",
|
||||
};
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
count += Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count;
|
||||
|
||||
return count;
|
||||
return patterns.Sum(pattern => Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count);
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string text, IEnumerable<string> keywords)
|
||||
|
||||
@@ -55,6 +55,7 @@ public class DocxSkill : IAgentTool
|
||||
["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." },
|
||||
["footer"] = new() { Type = "string", Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set." },
|
||||
["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in footer. Default: true if header or footer is set." },
|
||||
["style_map"] = new() { Type = "object", Description = "Optional paragraph style map for template-based documents. Example: {\"title\":\"Title\",\"heading1\":\"Heading1\",\"heading2\":\"Heading2\",\"body\":\"Normal\"}." },
|
||||
},
|
||||
Required = ["sections"]
|
||||
};
|
||||
@@ -70,6 +71,12 @@ public class DocxSkill : IAgentTool
|
||||
string TableHeader,
|
||||
string BorderColor);
|
||||
|
||||
private sealed record DocxStyleMap(
|
||||
string? TitleStyle,
|
||||
string? Heading1Style,
|
||||
string? Heading2Style,
|
||||
string? BodyStyle);
|
||||
|
||||
private static readonly Dictionary<string, ThemeColors> Themes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["professional"] = new("1F3864", "2E74B5", "404040", "2E74B5", "B4C6E7"),
|
||||
@@ -176,6 +183,7 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
// 기본 스타일 파트 추가 (styles.xml)
|
||||
EnsureStyles(mainPart, templateApplied);
|
||||
var styleMap = ResolveStyleMap(mainPart, args);
|
||||
|
||||
var body = InitializeDocumentBody(mainPart, templateApplied);
|
||||
|
||||
@@ -184,7 +192,7 @@ public class DocxSkill : IAgentTool
|
||||
AddHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(coverSubtitle) || coverMeta.Count > 0)
|
||||
AppendCoverPage(body, title, coverSubtitle, coverMeta, theme);
|
||||
AppendCoverPage(body, title, coverSubtitle, coverMeta, theme, styleMap);
|
||||
|
||||
if (useToc)
|
||||
AppendTableOfContents(body);
|
||||
@@ -192,7 +200,7 @@ public class DocxSkill : IAgentTool
|
||||
// 제목
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
body.Append(CreateTitleParagraph(title, theme));
|
||||
body.Append(CreateTitleParagraph(title, theme, styleMap.TitleStyle));
|
||||
// 제목 아래 구분선
|
||||
body.Append(new Paragraph(
|
||||
new ParagraphProperties
|
||||
@@ -262,7 +270,7 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
if (!string.IsNullOrEmpty(heading))
|
||||
{
|
||||
body.Append(CreateHeadingParagraph(heading, level, theme));
|
||||
body.Append(CreateHeadingParagraph(heading, level, theme, level <= 1 ? styleMap.Heading1Style : styleMap.Heading2Style));
|
||||
headings.Add(heading);
|
||||
}
|
||||
|
||||
@@ -270,7 +278,7 @@ public class DocxSkill : IAgentTool
|
||||
{
|
||||
foreach (var line in bodyText.Split('\n'))
|
||||
{
|
||||
body.Append(CreateBodyParagraph(line));
|
||||
body.Append(CreateBodyParagraph(line, styleMap.BodyStyle));
|
||||
}
|
||||
}
|
||||
sectionCount++;
|
||||
@@ -396,10 +404,36 @@ public class DocxSkill : IAgentTool
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendCoverPage(Body body, string title, string? subtitle, IReadOnlyList<string> metaLines, ThemeColors theme)
|
||||
private static DocxStyleMap ResolveStyleMap(MainDocumentPart mainPart, JsonElement args)
|
||||
{
|
||||
if (!args.SafeTryGetProperty("style_map", out var styleMapEl) || styleMapEl.ValueKind != JsonValueKind.Object)
|
||||
return new DocxStyleMap(null, null, null, null);
|
||||
|
||||
return new DocxStyleMap(
|
||||
FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("title", out var titleStyleEl) ? titleStyleEl.SafeGetString() : null),
|
||||
FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("heading1", out var heading1StyleEl) ? heading1StyleEl.SafeGetString() : null),
|
||||
FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("heading2", out var heading2StyleEl) ? heading2StyleEl.SafeGetString() : null),
|
||||
FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("body", out var bodyStyleEl) ? bodyStyleEl.SafeGetString() : null));
|
||||
}
|
||||
|
||||
private static string? FindAvailableStyle(MainDocumentPart mainPart, string? styleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(styleId))
|
||||
return null;
|
||||
|
||||
var styles = mainPart.StyleDefinitionsPart?.Styles;
|
||||
if (styles == null)
|
||||
return null;
|
||||
|
||||
return styles.Elements<Style>().Any(style => string.Equals(style.StyleId?.Value, styleId, StringComparison.OrdinalIgnoreCase))
|
||||
? styleId
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void AppendCoverPage(Body body, string title, string? subtitle, IReadOnlyList<string> metaLines, ThemeColors theme, DocxStyleMap styleMap)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
body.Append(CreateTitleParagraph(title, theme));
|
||||
body.Append(CreateTitleParagraph(title, theme, styleMap.TitleStyle));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
@@ -457,7 +491,7 @@ public class DocxSkill : IAgentTool
|
||||
body.Append(CreatePageBreak());
|
||||
}
|
||||
|
||||
private static Paragraph CreateTitleParagraph(string text, ThemeColors theme)
|
||||
private static Paragraph CreateTitleParagraph(string text, ThemeColors theme, string? styleId = null)
|
||||
{
|
||||
var para = new Paragraph();
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
@@ -465,7 +499,11 @@ public class DocxSkill : IAgentTool
|
||||
Justification = new Justification { Val = JustificationValues.Center },
|
||||
SpacingBetweenLines = new SpacingBetweenLines { After = "100" },
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(styleId))
|
||||
para.ParagraphProperties.ParagraphStyleId = new ParagraphStyleId { Val = styleId };
|
||||
var run = new Run(new Text(text));
|
||||
if (string.IsNullOrWhiteSpace(styleId))
|
||||
{
|
||||
run.RunProperties = new RunProperties
|
||||
{
|
||||
Bold = new Bold(),
|
||||
@@ -473,11 +511,12 @@ public class DocxSkill : IAgentTool
|
||||
Color = new Color { Val = theme.Title },
|
||||
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
|
||||
};
|
||||
}
|
||||
para.Append(run);
|
||||
return para;
|
||||
}
|
||||
|
||||
private static Paragraph CreateHeadingParagraph(string text, int level, ThemeColors theme)
|
||||
private static Paragraph CreateHeadingParagraph(string text, int level, ThemeColors theme, string? styleId = null)
|
||||
{
|
||||
var para = new Paragraph();
|
||||
var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt
|
||||
@@ -487,15 +526,19 @@ public class DocxSkill : IAgentTool
|
||||
{
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Before = level <= 1 ? "360" : "240", After = "120" },
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(styleId))
|
||||
para.ParagraphProperties.ParagraphStyleId = new ParagraphStyleId { Val = styleId };
|
||||
|
||||
// 레벨1 소제목에 하단 테두리 추가
|
||||
if (level <= 1)
|
||||
if (level <= 1 && string.IsNullOrWhiteSpace(styleId))
|
||||
{
|
||||
para.ParagraphProperties.ParagraphBorders = new ParagraphBorders(
|
||||
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = theme.BorderColor, Space = 1 });
|
||||
}
|
||||
|
||||
var run = new Run(new Text(text));
|
||||
if (string.IsNullOrWhiteSpace(styleId))
|
||||
{
|
||||
run.RunProperties = new RunProperties
|
||||
{
|
||||
Bold = new Bold(),
|
||||
@@ -503,17 +546,20 @@ public class DocxSkill : IAgentTool
|
||||
Color = new Color { Val = color },
|
||||
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
|
||||
};
|
||||
}
|
||||
para.Append(run);
|
||||
return para;
|
||||
}
|
||||
|
||||
private static Paragraph CreateBodyParagraph(string text)
|
||||
private static Paragraph CreateBodyParagraph(string text, string? styleId = null)
|
||||
{
|
||||
var para = new Paragraph();
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Line = "360" }, // 1.5배 줄간격
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(styleId))
|
||||
para.ParagraphProperties.ParagraphStyleId = new ParagraphStyleId { Val = styleId };
|
||||
|
||||
// 인라인 서식 파싱: **bold**, *italic*, `code`
|
||||
AppendFormattedRuns(para, text);
|
||||
|
||||
@@ -43,8 +43,9 @@ public class ExcelSkill : IAgentTool
|
||||
},
|
||||
["number_formats"] = new() { Type = "array", Description = "Number format per column index. Supported: 'currency' (#,##0\"원\"), 'percent' (0.00%), 'decimal' (#,##0.00), 'integer' (#,##0), 'date' (yyyy-mm-dd), or any custom Excel format string. e.g. [\"text\",\"integer\",\"currency\",\"percent\"]", Items = new() { Type = "string" } },
|
||||
["data_validations"] = new() { Type = "array", Description = "Validation rules such as [{\"range\":\"E2:E100\",\"type\":\"list\",\"formula1\":\"\\\"Open,In Progress,Done\\\"\",\"allow_blank\":true,\"prompt\":\"Select status\"}].", Items = new() { Type = "object" } },
|
||||
["conditional_formats"] = new() { Type = "array", Description = "Conditional formatting rules such as [{\"range\":\"C2:C20\",\"type\":\"color_scale\",\"low\":\"FEE2E2\",\"mid\":\"FEF3C7\",\"high\":\"DCFCE7\"},{\"range\":\"D2:D20\",\"type\":\"data_bar\",\"color\":\"2563EB\"}].", Items = new() { Type = "object" } },
|
||||
["col_alignments"] = new() { Type = "array", Description = "Horizontal alignment per column: 'left', 'center', 'right'. Headers always center-aligned.", Items = new() { Type = "string" } },
|
||||
["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?, data_validations?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } },
|
||||
["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?, data_validations?, conditional_formats?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } },
|
||||
},
|
||||
Required = []
|
||||
};
|
||||
@@ -185,10 +186,11 @@ public class ExcelSkill : IAgentTool
|
||||
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
var validationsArg = args.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default;
|
||||
var conditionalFormatsArg = args.SafeTryGetProperty("conditional_formats", out var conditionalFormatsEl) ? conditionalFormatsEl : default;
|
||||
|
||||
var (rowCount, validationCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
var (rowCount, validationCount, conditionalFormattingCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
||||
summaryArg, mergesArg, validationsArg, colCount);
|
||||
summaryArg, mergesArg, validationsArg, conditionalFormatsArg, colCount);
|
||||
|
||||
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
|
||||
wbSheets.Append(new Sheet
|
||||
@@ -212,6 +214,7 @@ public class ExcelSkill : IAgentTool
|
||||
CountFormulaCells(rows),
|
||||
0,
|
||||
validationCount,
|
||||
conditionalFormattingCount,
|
||||
false,
|
||||
false,
|
||||
summaryArg.ValueKind == JsonValueKind.Object));
|
||||
@@ -273,9 +276,10 @@ public class ExcelSkill : IAgentTool
|
||||
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
var validationsArg = args.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default;
|
||||
var (rowCount, validationCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
var conditionalFormatsArg = args.SafeTryGetProperty("conditional_formats", out var conditionalFormatsEl) ? conditionalFormatsEl : default;
|
||||
var (rowCount, validationCount, conditionalFormattingCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
||||
summaryArg, mergesArg, validationsArg, colCount);
|
||||
summaryArg, mergesArg, validationsArg, conditionalFormatsArg, colCount);
|
||||
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
@@ -298,6 +302,7 @@ public class ExcelSkill : IAgentTool
|
||||
CountFormulaCells(rows),
|
||||
1,
|
||||
validationCount,
|
||||
conditionalFormattingCount,
|
||||
true,
|
||||
HasSummaryItems(summarySheet, "highlights"),
|
||||
HasSummaryItems(summarySheet, "actions")));
|
||||
@@ -343,6 +348,7 @@ public class ExcelSkill : IAgentTool
|
||||
var totalRows = 0;
|
||||
var totalFormulaCount = 0;
|
||||
var totalValidationCount = 0;
|
||||
var totalConditionalFormattingCount = 0;
|
||||
var detailSheetNames = new List<string>();
|
||||
|
||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||
@@ -386,13 +392,14 @@ public class ExcelSkill : IAgentTool
|
||||
var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
var validationsArg = sheetDef.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default;
|
||||
var conditionalFormatsArg = sheetDef.SafeTryGetProperty("conditional_formats", out var conditionalFormatsEl) ? conditionalFormatsEl : default;
|
||||
|
||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
|
||||
var (rowCount, validationCount) = WriteSheetContent(worksheetPart, sheetDef, sheetName, headers, rows,
|
||||
var (rowCount, validationCount, conditionalFormattingCount) = WriteSheetContent(worksheetPart, sheetDef, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, combinedCustomFmts,
|
||||
summaryArg, mergesArg, validationsArg, colCount);
|
||||
summaryArg, mergesArg, validationsArg, conditionalFormatsArg, colCount);
|
||||
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
@@ -406,6 +413,7 @@ public class ExcelSkill : IAgentTool
|
||||
totalRows += rowCount;
|
||||
totalFormulaCount += CountFormulaCells(rows);
|
||||
totalValidationCount += validationCount;
|
||||
totalConditionalFormattingCount += conditionalFormattingCount;
|
||||
}
|
||||
|
||||
workbookPart.Workbook.Save();
|
||||
@@ -417,6 +425,7 @@ public class ExcelSkill : IAgentTool
|
||||
totalFormulaCount,
|
||||
summarySheet.ValueKind == JsonValueKind.Object ? detailSheetNames.Count : 0,
|
||||
totalValidationCount,
|
||||
totalConditionalFormattingCount,
|
||||
summarySheet.ValueKind == JsonValueKind.Object,
|
||||
HasSummaryItems(summarySheet, "highlights"),
|
||||
HasSummaryItems(summarySheet, "actions")));
|
||||
@@ -551,7 +560,7 @@ public class ExcelSkill : IAgentTool
|
||||
};
|
||||
}
|
||||
|
||||
private static (int RowCount, int ValidationCount) WriteSheetContent(
|
||||
private static (int RowCount, int ValidationCount, int ConditionalFormattingCount) WriteSheetContent(
|
||||
WorksheetPart worksheetPart,
|
||||
JsonElement args,
|
||||
string sheetName,
|
||||
@@ -566,6 +575,7 @@ public class ExcelSkill : IAgentTool
|
||||
JsonElement summaryArg,
|
||||
JsonElement mergesArg,
|
||||
JsonElement validationsArg,
|
||||
JsonElement conditionalFormatsArg,
|
||||
int colCount)
|
||||
{
|
||||
// Column widths
|
||||
@@ -659,6 +669,7 @@ public class ExcelSkill : IAgentTool
|
||||
}
|
||||
|
||||
var validationCount = AddDataValidations(worksheetPart, sheetData, mergeCellsSection, validationsArg);
|
||||
var conditionalFormattingCount = AddConditionalFormats(worksheetPart, mergeCellsSection, conditionalFormatsArg);
|
||||
|
||||
// Freeze header
|
||||
if (freezeHeader)
|
||||
@@ -684,7 +695,7 @@ public class ExcelSkill : IAgentTool
|
||||
worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore);
|
||||
}
|
||||
|
||||
return (rowCount, validationCount);
|
||||
return (rowCount, validationCount, conditionalFormattingCount);
|
||||
}
|
||||
|
||||
private static int AddDataValidations(WorksheetPart worksheetPart, SheetData sheetData, MergeCells? mergeCells, JsonElement validationsArg)
|
||||
@@ -731,6 +742,97 @@ public class ExcelSkill : IAgentTool
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int AddConditionalFormats(WorksheetPart worksheetPart, MergeCells? mergeCells, JsonElement conditionalFormatsArg)
|
||||
{
|
||||
if (conditionalFormatsArg.ValueKind != JsonValueKind.Array || conditionalFormatsArg.GetArrayLength() == 0)
|
||||
return 0;
|
||||
|
||||
var insertedCount = 0;
|
||||
OpenXmlElement anchor = mergeCells is not null
|
||||
? mergeCells
|
||||
: worksheetPart.Worksheet.Elements<SheetData>().First();
|
||||
|
||||
foreach (var rule in conditionalFormatsArg.EnumerateArray())
|
||||
{
|
||||
var range = rule.SafeTryGetProperty("range", out var rangeEl) ? rangeEl.SafeGetString() : null;
|
||||
var type = rule.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString()?.Trim().ToLowerInvariant() : null;
|
||||
if (string.IsNullOrWhiteSpace(range) || string.IsNullOrWhiteSpace(type))
|
||||
continue;
|
||||
|
||||
ConditionalFormatting? conditionalFormatting = type switch
|
||||
{
|
||||
"color_scale" => BuildColorScaleConditionalFormatting(range, rule, (uint)(insertedCount + 1)),
|
||||
"data_bar" => BuildDataBarConditionalFormatting(range, rule, (uint)(insertedCount + 1)),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (conditionalFormatting == null)
|
||||
continue;
|
||||
|
||||
worksheetPart.Worksheet.InsertAfter(conditionalFormatting, anchor);
|
||||
anchor = conditionalFormatting;
|
||||
insertedCount++;
|
||||
}
|
||||
|
||||
return insertedCount;
|
||||
}
|
||||
|
||||
private static ConditionalFormatting? BuildColorScaleConditionalFormatting(string range, JsonElement rule, uint priority)
|
||||
{
|
||||
var low = rule.SafeTryGetProperty("low", out var lowEl) ? NormalizeHex(lowEl.SafeGetString(), "FEE2E2") : "FEE2E2";
|
||||
var mid = rule.SafeTryGetProperty("mid", out var midEl) ? NormalizeHex(midEl.SafeGetString(), "FEF3C7") : "FEF3C7";
|
||||
var high = rule.SafeTryGetProperty("high", out var highEl) ? NormalizeHex(highEl.SafeGetString(), "DCFCE7") : "DCFCE7";
|
||||
|
||||
var ruleElement = new ConditionalFormattingRule
|
||||
{
|
||||
Type = ConditionalFormatValues.ColorScale,
|
||||
Priority = (int)priority,
|
||||
};
|
||||
ruleElement.Append(new ColorScale(
|
||||
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Min },
|
||||
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Percentile, Val = "50" },
|
||||
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Max },
|
||||
new Color { Rgb = low },
|
||||
new Color { Rgb = mid },
|
||||
new Color { Rgb = high }));
|
||||
|
||||
return new ConditionalFormatting(ruleElement)
|
||||
{
|
||||
SequenceOfReferences = new ListValue<StringValue> { InnerText = range }
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionalFormatting? BuildDataBarConditionalFormatting(string range, JsonElement rule, uint priority)
|
||||
{
|
||||
var color = rule.SafeTryGetProperty("color", out var colorEl) ? NormalizeHex(colorEl.SafeGetString(), "2563EB") : "2563EB";
|
||||
|
||||
var ruleElement = new ConditionalFormattingRule
|
||||
{
|
||||
Type = ConditionalFormatValues.DataBar,
|
||||
Priority = (int)priority,
|
||||
};
|
||||
ruleElement.Append(new DataBar(
|
||||
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Min },
|
||||
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Max },
|
||||
new Color { Rgb = color }));
|
||||
|
||||
return new ConditionalFormatting(ruleElement)
|
||||
{
|
||||
SequenceOfReferences = new ListValue<StringValue> { InnerText = range }
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeHex(string? color, string fallback)
|
||||
{
|
||||
var value = string.IsNullOrWhiteSpace(color) ? fallback : color.Trim().TrimStart('#');
|
||||
return value.Length switch
|
||||
{
|
||||
6 => "FF" + value.ToUpperInvariant(),
|
||||
8 => value.ToUpperInvariant(),
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private static DataValidationValues ResolveValidationType(string? type)
|
||||
{
|
||||
return (type ?? "list").Trim().ToLowerInvariant() switch
|
||||
|
||||
@@ -72,6 +72,8 @@ public class HtmlSkill : IAgentTool
|
||||
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" },
|
||||
["numbered"] = new() { Type = "boolean", Description = "Auto-number h2/h3 sections (1., 1-1., etc). Default: false" },
|
||||
["print"] = new() { Type = "boolean", Description = "If true, adds @media print CSS: removes shadows, adds borders, forces white background, page-breaks before h2, adjusts font sizes. Default: false" },
|
||||
["print_header"] = new() { Type = "string", Description = "Optional print-only header text shown on every printed page." },
|
||||
["print_footer"] = new() { Type = "string", Description = "Optional print-only footer text shown on every printed page." },
|
||||
["cover"] = new()
|
||||
{
|
||||
Type = "object",
|
||||
@@ -131,6 +133,8 @@ public class HtmlSkill : IAgentTool
|
||||
var useToc = hasTocArg && tocVal.ValueKind == JsonValueKind.True;
|
||||
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
|
||||
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
|
||||
var printHeader = args.SafeTryGetProperty("print_header", out var printHeaderEl) ? printHeaderEl.SafeGetString() : null;
|
||||
var printFooter = args.SafeTryGetProperty("print_footer", out var printFooterEl) ? printFooterEl.SafeGetString() : null;
|
||||
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
|
||||
var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null;
|
||||
|
||||
@@ -271,6 +275,8 @@ public class HtmlSkill : IAgentTool
|
||||
sb.AppendLine("</div>"); // container
|
||||
if (isSidebarToc)
|
||||
sb.AppendLine("</div>"); // page-wrapper
|
||||
if (usePrint && (!string.IsNullOrWhiteSpace(printHeader) || !string.IsNullOrWhiteSpace(printFooter)))
|
||||
sb.AppendLine(BuildPrintFrameHtml(printHeader, printFooter));
|
||||
|
||||
// 테마 전환 + 플로팅 TOC / 스크롤 스파이 스크립트
|
||||
sb.AppendLine(TemplateService.ThemeToggleScript);
|
||||
@@ -290,7 +296,13 @@ public class HtmlSkill : IAgentTool
|
||||
if (!string.IsNullOrEmpty(accentColor)) features.Add($"색상:{accentColor}");
|
||||
if (usePrint) features.Add("인쇄최적화");
|
||||
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
|
||||
var review = ArtifactQualityReviewService.ReviewHtml(title, sb.ToString(), hasCover, useToc, usePrint);
|
||||
var review = ArtifactQualityReviewService.ReviewHtml(
|
||||
title,
|
||||
sb.ToString(),
|
||||
hasCover,
|
||||
useToc,
|
||||
usePrint,
|
||||
!string.IsNullOrWhiteSpace(printHeader) || !string.IsNullOrWhiteSpace(printFooter));
|
||||
featureStr += $"\n{review.ToToolSummary()}";
|
||||
|
||||
return ToolResult.Ok(
|
||||
@@ -940,8 +952,30 @@ public class HtmlSkill : IAgentTool
|
||||
.kpi-grid > div, .cards-grid > div { border: 1px solid #ccc !important; }
|
||||
nav.toc { page-break-after: always; }
|
||||
.cover-page { page-break-after: always; background: none !important; color: #000 !important; }
|
||||
.print-header, .print-footer {
|
||||
display: block !important;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 24px;
|
||||
font-size: 9pt;
|
||||
color: #475569;
|
||||
}
|
||||
.print-header { top: 0; border-bottom: 1px solid #cbd5e1; padding-bottom: 6px; }
|
||||
.print-footer { bottom: 0; border-top: 1px solid #cbd5e1; padding-top: 6px; }
|
||||
.body-content { padding-top: 36px; padding-bottom: 28px; }
|
||||
}";
|
||||
|
||||
private static string BuildPrintFrameHtml(string? printHeader, string? printFooter)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(printHeader))
|
||||
sb.AppendLine($"<div class=\"print-header\" aria-hidden=\"true\">{Escape(printHeader)}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(printFooter))
|
||||
sb.AppendLine($"<div class=\"print-footer\" aria-hidden=\"true\">{Escape(printFooter)}</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 기존 헬퍼 메서드 (변경 없음)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,6 +120,12 @@ public class PptxSkill : IAgentTool
|
||||
"Available: basic100, core100, frame_blue, mr_ppt_01, mr_ppt_02, mr_ppt_03, mr_ppt_04, mr_ppt_05. " +
|
||||
"Overrides 'theme' and 'theme_file'. Falls back to color extraction if master clone fails.",
|
||||
},
|
||||
["template_pack"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Optional purpose-based template pack. Available: strategy, board, pmo, finance, sales, operating_model. " +
|
||||
"Used as an extensibility layer for deck-specific defaults when no explicit template or theme file is set.",
|
||||
},
|
||||
["theme_file"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
@@ -536,6 +542,11 @@ public class PptxSkill : IAgentTool
|
||||
!string.IsNullOrWhiteSpace(themeFileEl.SafeGetString());
|
||||
var hasExplicitTemplate = args.SafeTryGetProperty("template", out var templateArgEl) &&
|
||||
!string.IsNullOrWhiteSpace(templateArgEl.SafeGetString());
|
||||
var audience = args.SafeTryGetProperty("audience", out var audienceEl) ? audienceEl.SafeGetString() : null;
|
||||
var objective = args.SafeTryGetProperty("objective", out var objectiveEl) ? objectiveEl.SafeGetString() : null;
|
||||
var requestedTemplatePack = args.SafeTryGetProperty("template_pack", out var templatePackEl)
|
||||
? templatePackEl.SafeGetString()
|
||||
: null;
|
||||
|
||||
// 전체 복제용 텍스트 교체 맵
|
||||
Dictionary<string, string>? globalReplacements = null;
|
||||
@@ -567,6 +578,14 @@ public class PptxSkill : IAgentTool
|
||||
}
|
||||
}
|
||||
|
||||
var templatePack = !string.IsNullOrWhiteSpace(requestedTemplatePack)
|
||||
? PptxTemplatePackRegistry.Resolve(requestedTemplatePack)
|
||||
: (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile
|
||||
? PptxTemplatePackRegistry.Suggest(preparedDeck?.Objective ?? objective, preparedDeck?.Audience ?? audience)
|
||||
: null);
|
||||
var templatePackName = templatePack?.Name;
|
||||
var packTemplateName = templatePack?.PreferredTemplate;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -620,6 +639,28 @@ public class PptxSkill : IAgentTool
|
||||
? new FullTheme(extracted, baseLayout)
|
||||
: FullThemes["professional"];
|
||||
}
|
||||
else if (templatePack != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(packTemplateName))
|
||||
templatePptxPath = ResolveTemplatePath(packTemplateName!);
|
||||
|
||||
if (!FullThemes.TryGetValue(templatePack.FallbackTheme, out var packTheme))
|
||||
packTheme = FullThemes["professional"];
|
||||
|
||||
if (templatePptxPath != null)
|
||||
{
|
||||
var extracted = ExtractThemeFromPptx(templatePptxPath);
|
||||
fullTheme = extracted != null
|
||||
? new FullTheme(extracted, packTheme.Layout)
|
||||
: packTheme;
|
||||
}
|
||||
else
|
||||
{
|
||||
fullTheme = packTheme;
|
||||
}
|
||||
|
||||
theme = templatePack.FallbackTheme;
|
||||
}
|
||||
else if (string.Equals(theme, "custom", StringComparison.OrdinalIgnoreCase)
|
||||
&& args.SafeTryGetProperty("custom_colors", out var ccEl)
|
||||
&& ccEl.ValueKind == JsonValueKind.Object)
|
||||
@@ -957,10 +998,17 @@ public class PptxSkill : IAgentTool
|
||||
// Default를 application/xml로 수정하고 presentation.xml에 Override 추가.
|
||||
RepairContentTypes(fullPath);
|
||||
|
||||
var templateSourceName = templateName ?? packTemplateName;
|
||||
var themeLabel = cloneInfo_cloned
|
||||
? $"template:{templateName ?? cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
||||
? templateSourceName != null
|
||||
? $"template:{templateSourceName} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
||||
: templatePptxPath != null
|
||||
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
||||
: theme
|
||||
: !string.IsNullOrWhiteSpace(templateName)
|
||||
? $"template:{templateName} (color fallback)"
|
||||
: templatePptxPath != null && !string.IsNullOrWhiteSpace(packTemplateName)
|
||||
? $"template:{packTemplateName} (color fallback)"
|
||||
: templatePptxPath != null
|
||||
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
||||
: theme;
|
||||
@@ -978,6 +1026,8 @@ public class PptxSkill : IAgentTool
|
||||
};
|
||||
if (preparedDeck != null)
|
||||
outputParts.Add(preparedDeck.ToToolSummary());
|
||||
if (!string.IsNullOrWhiteSpace(templatePackName))
|
||||
outputParts.Add($"Template pack: {templatePackName}");
|
||||
if (deckReview != null)
|
||||
outputParts.Add(deckReview.ToToolSummary());
|
||||
return ToolResult.Ok(string.Join("\n", outputParts), fullPath);
|
||||
|
||||
93
src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs
Normal file
93
src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed record PptxTemplatePack(
|
||||
string Name,
|
||||
string FallbackTheme,
|
||||
string? PreferredTemplate,
|
||||
IReadOnlyList<string> ObjectiveKeywords,
|
||||
IReadOnlyList<string> AudienceKeywords);
|
||||
|
||||
public static class PptxTemplatePackRegistry
|
||||
{
|
||||
private static readonly IReadOnlyList<PptxTemplatePack> Packs =
|
||||
[
|
||||
new(
|
||||
"strategy",
|
||||
"professional",
|
||||
"basic100",
|
||||
["strategy", "growth", "transformation", "portfolio", "\uC804\uB7B5", "\uC131\uC7A5", "\uC804\uD658"],
|
||||
["executive", "leadership", "steering", "\uACBD\uC601\uC9C4", "\uC784\uC6D0"]),
|
||||
new(
|
||||
"board",
|
||||
"corporate",
|
||||
"core100",
|
||||
["board", "governance", "committee", "decision", "\uC774\uC0AC\uD68C", "\uC6B4\uC601\uC704\uC6D0\uD68C", "\uC758\uC0AC\uACB0\uC815"],
|
||||
["board", "executive", "committee", "\uC774\uC0AC\uD68C", "\uACBD\uC601\uC9C4"]),
|
||||
new(
|
||||
"pmo",
|
||||
"navy",
|
||||
"basic100",
|
||||
["pmo", "program", "delivery", "roadmap", "tracker", "\uD504\uB85C\uADF8\uB7A8", "pmo", "\uB85C\uB4DC\uB9F5"],
|
||||
["pmo", "steering", "delivery", "\uC2A4\uD2F0\uC5B4\uB9C1", "\uCD94\uC9C4\uB2E8"]),
|
||||
new(
|
||||
"finance",
|
||||
"slate",
|
||||
"core100",
|
||||
["finance", "budget", "forecast", "cost", "margin", "\uC608\uC0B0", "\uC804\uB9DD", "\uC190\uC775"],
|
||||
["finance", "cfo", "leadership", "\uC7AC\uBB34", "cfo"]),
|
||||
new(
|
||||
"sales",
|
||||
"modern",
|
||||
"basic100",
|
||||
["sales", "pipeline", "revenue", "commercial", "\uC601\uC5C5", "\uB9E4\uCD9C", "\uD30C\uC774\uD504\uB77C\uC778"],
|
||||
["sales", "commercial", "leadership", "\uC601\uC5C5\uB9AC\uB354\uC2ED"]),
|
||||
new(
|
||||
"operating_model",
|
||||
"professional",
|
||||
"basic100",
|
||||
["operating model", "workflow", "org", "process", "\uC6B4\uC601 \uBAA8\uB378", "\uD504\uB85C\uC138\uC2A4", "\uC870\uC9C1"],
|
||||
["executive", "transformation", "operations", "\uACBD\uC601\uC9C4", "\uC6B4\uC601"]),
|
||||
];
|
||||
|
||||
public static IReadOnlyList<PptxTemplatePack> GetAll()
|
||||
{
|
||||
return Packs;
|
||||
}
|
||||
|
||||
public static PptxTemplatePack? Resolve(string? packName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packName))
|
||||
return null;
|
||||
|
||||
return Packs.FirstOrDefault(pack => string.Equals(pack.Name, packName.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static PptxTemplatePack? Suggest(string? objective, string? audience)
|
||||
{
|
||||
var text = $"{objective} {audience}".Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
var normalized = text.ToLowerInvariant();
|
||||
var bestScore = 0;
|
||||
PptxTemplatePack? bestPack = null;
|
||||
|
||||
foreach (var pack in Packs)
|
||||
{
|
||||
var score = ScoreMatches(normalized, pack.ObjectiveKeywords) * 3 +
|
||||
ScoreMatches(normalized, pack.AudienceKeywords) * 2;
|
||||
if (score <= bestScore)
|
||||
continue;
|
||||
|
||||
bestScore = score;
|
||||
bestPack = pack;
|
||||
}
|
||||
|
||||
return bestPack;
|
||||
}
|
||||
|
||||
private static int ScoreMatches(string normalized, IEnumerable<string> keywords)
|
||||
{
|
||||
return keywords.Count(keyword => normalized.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user