문서 생성 고도화 1차: 네이티브 워드·엑셀·HTML 경로 정렬 및 품질 보강
- Word/Excel/HTML 스킬을 Python 우회 중심에서 AX 네이티브 문서 도구 우선 경로로 재작성했습니다. - DocumentPlannerTool의 보고서·제안서·분석 문서 아웃라인을 Executive Summary, Business Case, Decision Ask, Appendix 중심의 업무형 구조로 확장했습니다. - DocumentAssemblerTool의 DOCX 조립 경로에서 표·목록·콜아웃·소제목 같은 HTML/Markdown 구조를 더 보존하도록 개선했습니다. - ExcelSkill에 summary_sheet를 추가해 KPI·핵심 인사이트·후속 과제를 담은 요약 시트를 상세 데이터 시트 앞에 생성할 수 있게 했습니다. - HtmlSkill에 comparison, roadmap, matrix 구조화 섹션을 추가하고 sections 중심 호출 스키마를 정리했습니다. - DocumentAssemblerSemanticTests, ExcelSkillSummarySheetTests, HtmlSkillConsultingSectionsTests, DocumentPlannerBusinessDocumentTests를 추가했습니다. - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase1\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1\ - 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter 문서_고도화_테스트_5건 -p:OutputPath=bin\\verify_doc_phase1_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1_tests\
This commit is contained in:
@@ -1764,3 +1764,12 @@ MIT License
|
||||
- 업데이트: 2026-04-12 23:59 (KST)
|
||||
- AX Agent 이력 영역에서 모지바케처럼 보이던 깨진 한국어 문자열을 복구했습니다.
|
||||
- [ChatWindow.V2AgentEventPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.V2AgentEventPresentation.cs)의 완료/오류/토큰 메타 문구를 정상 한국어로 정리해, 작업 이력 카드와 완료 배너가 더 이상 `ㅁㅁㅁㅁ`처럼 보이지 않도록 수정했습니다.
|
||||
- 업데이트: 2026-04-14 21:00 (KST)
|
||||
- Word/Excel/HTML 문서 생성 고도화 1차를 반영했습니다. [DocumentPlannerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs)는 보고서/제안서/분석 문서의 기본 아웃라인을 `Executive Summary`, `Business Case`, `Decision Ask`, `Appendix`까지 포함한 업무형 구조로 생성하도록 보강했습니다.
|
||||
- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 DOCX 조립 시 HTML/Markdown을 평문으로 밀어버리던 경로를 줄이고, 표/목록/콜아웃/소제목 같은 구조를 Word 문서에 더 잘 보존하도록 개선했습니다.
|
||||
- [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 `summary_sheet`를 추가해, 상세 데이터 시트 앞에 KPI/핵심 인사이트/후속 과제를 담은 요약 시트를 함께 생성할 수 있게 했습니다.
|
||||
- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `comparison`, `roadmap`, `matrix` 구조화 섹션을 지원해 전략 보고서형 HTML을 더 쉽게 만들 수 있게 했고, `sections`만으로도 호출 가능하도록 함수 스키마도 정리했습니다.
|
||||
- [docx-creator.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/docx-creator.skill.md), [csv-to-xlsx.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/csv-to-xlsx.skill.md), [markdown-to-doc.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/markdown-to-doc.skill.md), [report-writer.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/report-writer.skill.md)는 Python 우회 중심에서 AX 네이티브 문서 도구 우선 경로로 재작성했습니다.
|
||||
- 테스트로 [DocumentAssemblerSemanticTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerSemanticTests.cs), [ExcelSkillSummarySheetTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillSummarySheetTests.cs), [HtmlSkillConsultingSectionsTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs), [DocumentPlannerBusinessDocumentTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentPlannerBusinessDocumentTests.cs)를 추가했습니다.
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase1\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1\\` 경고 0 / 오류 0
|
||||
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerSemanticTests|ExcelSkillSummarySheetTests|HtmlSkillConsultingSectionsTests|DocumentPlannerBusinessDocumentTests|DocumentPlannerPresentationTests" -p:OutputPath=bin\\verify_doc_phase1_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1_tests\\` 통과 5
|
||||
|
||||
@@ -833,3 +833,12 @@ UI ?붿옄???洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾
|
||||
- 寃利? dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_logroll\\ -p:IntermediateOutputPath=obj\\verify_logroll\\ 寃쎄퀬 0 / ?ㅻ쪟 0
|
||||
- 寃利? dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter RollingTextLogStoreTests -p:OutputPath=bin\\verify_logroll_tests\\ -p:IntermediateOutputPath=obj\\verify_logroll_tests\\ ?듦낵 3
|
||||
- 李멸퀬: ?뚯뒪???꾨줈?앺듃??湲곗〈 nullable 寃쎄퀬 src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76) 1嫄댁? ?좎??⑸땲??
|
||||
업데이트: 2026-04-14 21:00 (KST)
|
||||
- Word/Excel/HTML 문서 생성 고도화 1차를 반영했습니다. `DocumentPlannerTool`은 제안서/보고서/분석 문서를 `Executive Summary`, `Business Case`, `Decision Ask`, `Appendix`까지 포함한 업무형 아웃라인으로 확장합니다.
|
||||
- `DocumentAssemblerTool`은 DOCX 조립 시 HTML/Markdown 구조를 더 보존하도록 손봤습니다. 표, 목록, 콜아웃, 소제목을 평문으로만 밀어버리지 않고 Word 블록으로 다시 조립합니다.
|
||||
- `ExcelSkill`에 `summary_sheet`를 추가해 KPI/핵심 인사이트/후속 과제를 담은 요약 시트를 상세 데이터 시트 앞에 함께 생성할 수 있게 했습니다.
|
||||
- `HtmlSkill`은 `comparison`, `roadmap`, `matrix` 구조화 섹션을 지원하고, 함수 스키마의 `body` 필수 조건을 완화해 `sections` 중심 호출도 자연스럽게 받도록 정리했습니다.
|
||||
- `docx-creator.skill.md`, `csv-to-xlsx.skill.md`, `markdown-to-doc.skill.md`, `report-writer.skill.md`는 Python 우회 경로보다 AX 네이티브 문서 도구를 우선 사용하도록 재작성했습니다.
|
||||
- 테스트: `DocumentAssemblerSemanticTests`, `ExcelSkillSummarySheetTests`, `HtmlSkillConsultingSectionsTests`, `DocumentPlannerBusinessDocumentTests` 추가
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase1\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1\\` 경고 0 / 오류 0
|
||||
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerSemanticTests|ExcelSkillSummarySheetTests|HtmlSkillConsultingSectionsTests|DocumentPlannerBusinessDocumentTests|DocumentPlannerPresentationTests" -p:OutputPath=bin\\verify_doc_phase1_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1_tests\\` 통과 5
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class DocumentAssemblerSemanticTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ForDocx_ShouldPreserveTablesListsAndCallouts()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-doc-assemble-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new DocumentAssemblerTool();
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = workDir,
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"path": "assembled.docx",
|
||||
"title": "운영 개선 보고서",
|
||||
"format": "docx",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "1. Executive Summary",
|
||||
"level": 1,
|
||||
"content": "<p>핵심 메시지 요약입니다.</p><ul><li>첫 항목</li><li>둘째 항목</li></ul><table><tr><th>구분</th><th>값</th></tr><tr><td>매출</td><td>120</td></tr></table><div class=\"callout-info\"><strong>핵심 메모</strong><p>즉시 조치가 필요합니다.</p></div>"
|
||||
}
|
||||
]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var outputPath = Path.Combine(workDir, "assembled.docx");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
|
||||
using var doc = WordprocessingDocument.Open(outputPath, false);
|
||||
var body = doc.MainDocumentPart?.Document.Body;
|
||||
body.Should().NotBeNull();
|
||||
body!.Descendants<DocumentFormat.OpenXml.Wordprocessing.Table>().Should().NotBeEmpty();
|
||||
body.InnerText.Should().Contain("첫 항목");
|
||||
body.InnerText.Should().Contain("즉시 조치가 필요합니다.");
|
||||
body.InnerText.Should().Contain("매출");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 DocumentPlannerBusinessDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ForProposal_ShouldReturnConsultingProposalStructure()
|
||||
{
|
||||
var tool = new DocumentPlannerTool();
|
||||
var context = new AgentContext
|
||||
{
|
||||
WorkFolder = Path.GetTempPath(),
|
||||
Permission = "Auto",
|
||||
OperationMode = "external",
|
||||
};
|
||||
|
||||
var args = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"topic": "2026 운영 혁신 제안서",
|
||||
"document_type": "proposal",
|
||||
"target_pages": 8
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("Executive Summary");
|
||||
result.Output.Should().Contain("Business Case");
|
||||
result.Output.Should().Contain("Risks & Mitigations");
|
||||
result.Output.Should().Contain("Decision Ask");
|
||||
result.Output.Should().Contain("Appendix");
|
||||
}
|
||||
}
|
||||
83
src/AxCopilot.Tests/Services/ExcelSkillSummarySheetTests.cs
Normal file
83
src/AxCopilot.Tests/Services/ExcelSkillSummarySheetTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
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 ExcelSkillSummarySheetTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithSummarySheet_ShouldCreateExecutiveSheet()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-summary-" + 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": "review.xlsx",
|
||||
"sheet_name": "Detail",
|
||||
"headers": ["항목", "값", "증감"],
|
||||
"rows": [
|
||||
["Revenue", "120", "12%"],
|
||||
["Cost", "80", "-4%"]
|
||||
],
|
||||
"summary_sheet": {
|
||||
"name": "Summary",
|
||||
"title": "2026 운영 리뷰",
|
||||
"subtitle": "핵심 KPI와 후속 과제",
|
||||
"kpis": [
|
||||
{ "label": "Revenue", "value": "120", "trend": "YoY +12%", "note": "Strong" }
|
||||
],
|
||||
"highlights": ["수익성 개선", "비용 효율화"],
|
||||
"actions": ["우선 과제 확정", "월간 리뷰 체계화"]
|
||||
}
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var outputPath = Path.Combine(workDir, "review.xlsx");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
|
||||
using var doc = SpreadsheetDocument.Open(outputPath, false);
|
||||
var sheets = doc.WorkbookPart?.Workbook.Sheets?.Elements<Sheet>().ToList();
|
||||
sheets.Should().NotBeNull();
|
||||
var actualSheets = sheets ?? throw new InvalidOperationException("Workbook sheets were not created.");
|
||||
actualSheets.Should().HaveCount(2);
|
||||
actualSheets[0].Name?.Value.Should().Be("Summary");
|
||||
actualSheets[1].Name?.Value.Should().Be("Detail");
|
||||
|
||||
var summaryWorksheet = (WorksheetPart)doc.WorkbookPart!.GetPartById(actualSheets[0].Id!);
|
||||
var firstCell = summaryWorksheet.Worksheet.Descendants<Cell>().FirstOrDefault(c => c.CellReference == "A1");
|
||||
firstCell.Should().NotBeNull();
|
||||
firstCell!.CellValue!.Text.Should().Be("2026 운영 리뷰");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class HtmlSkillConsultingSectionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithConsultingSections_ShouldRenderAdvancedBlocks()
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "ax-html-consulting-" + 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": "consulting.html",
|
||||
"title": "전략 리뷰",
|
||||
"body": "",
|
||||
"sections": [
|
||||
{
|
||||
"type": "comparison",
|
||||
"title": "옵션 비교",
|
||||
"items": [
|
||||
{ "name": "Option A", "summary": "빠른 실행", "pros": "도입이 쉽다", "cons": "확장성 제약", "verdict": "Fast" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "roadmap",
|
||||
"title": "실행 로드맵",
|
||||
"phases": [
|
||||
{ "title": "Phase 1", "detail": "진단 및 설계", "timeline": "0-30일", "owner": "PMO" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "matrix",
|
||||
"title": "우선순위 매트릭스",
|
||||
"quadrants": [
|
||||
{ "title": "Quick Wins", "items": ["자동화 과제", "리포트 표준화"] },
|
||||
{ "title": "Strategic Bets", "items": ["플랫폼 재구성"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var html = File.ReadAllText(Path.Combine(workDir, "consulting.html"));
|
||||
html.Should().Contain("comparison-grid");
|
||||
html.Should().Contain("roadmap-block");
|
||||
html.Should().Contain("matrix-grid");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
@@ -238,13 +239,11 @@ public class DocumentAssemblerTool : IAgentTool
|
||||
private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections,
|
||||
string? headerText, string? footerText)
|
||||
{
|
||||
// DOCX 조립: DocxSkill의 sections 형식으로 변환하여 OpenXML 사용
|
||||
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
|
||||
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
|
||||
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
|
||||
// 기본 스타일 파트 추가 (styles.xml — 없으면 Word에서 글꼴/서식 깨짐)
|
||||
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
|
||||
stylesPart.Styles = CreateDefaultDocxStyles();
|
||||
stylesPart.Styles.Save();
|
||||
@@ -252,8 +251,7 @@ public class DocumentAssemblerTool : IAgentTool
|
||||
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
||||
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
|
||||
|
||||
// 한글 호환 글꼴 설정 헬퍼
|
||||
static DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new()
|
||||
DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new()
|
||||
{
|
||||
Ascii = "맑은 고딕",
|
||||
HighAnsi = "맑은 고딕",
|
||||
@@ -261,7 +259,235 @@ public class DocumentAssemblerTool : IAgentTool
|
||||
ComplexScript = "맑은 고딕"
|
||||
};
|
||||
|
||||
// 제목
|
||||
string DecodeHtml(string text)
|
||||
=> text.Replace(" ", " ")
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace(""", "\"");
|
||||
|
||||
string ExtractStructuredText(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "";
|
||||
|
||||
var text = Regex.Replace(html, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, @"</(p|div|li|tr|h[1-6]|blockquote|table|thead|tbody|ul|ol)>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, @"<[^>]+>", " ");
|
||||
text = DecodeHtml(text);
|
||||
text = Regex.Replace(text, @"[ \t]+\n", "\n");
|
||||
text = Regex.Replace(text, @"\n{3,}", "\n\n");
|
||||
return text.Trim();
|
||||
}
|
||||
|
||||
DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateParagraph(
|
||||
string text,
|
||||
string fontSize = "22",
|
||||
bool bold = false,
|
||||
string? color = null,
|
||||
string? fill = null)
|
||||
{
|
||||
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var props = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties();
|
||||
props.SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines
|
||||
{
|
||||
After = "160"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fill))
|
||||
{
|
||||
props.ParagraphBorders = new DocumentFormat.OpenXml.Wordprocessing.ParagraphBorders(
|
||||
new DocumentFormat.OpenXml.Wordprocessing.LeftBorder
|
||||
{
|
||||
Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single,
|
||||
Size = 10,
|
||||
Color = color ?? "2B579A"
|
||||
});
|
||||
props.Shading = new DocumentFormat.OpenXml.Wordprocessing.Shading
|
||||
{
|
||||
Val = DocumentFormat.OpenXml.Wordprocessing.ShadingPatternValues.Clear,
|
||||
Fill = fill
|
||||
};
|
||||
}
|
||||
|
||||
para.AppendChild(props);
|
||||
|
||||
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
var runProps = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
RunFonts = KoreanFonts(),
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
|
||||
};
|
||||
|
||||
if (bold)
|
||||
runProps.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
|
||||
if (!string.IsNullOrWhiteSpace(color))
|
||||
runProps.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
|
||||
|
||||
run.AppendChild(runProps);
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
|
||||
{
|
||||
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
|
||||
});
|
||||
para.AppendChild(run);
|
||||
return para;
|
||||
}
|
||||
|
||||
DocumentFormat.OpenXml.Wordprocessing.Table CreateTableFromHtml(string tableHtml)
|
||||
{
|
||||
var table = new DocumentFormat.OpenXml.Wordprocessing.Table();
|
||||
table.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.TableProperties(
|
||||
new DocumentFormat.OpenXml.Wordprocessing.TableBorders(
|
||||
new DocumentFormat.OpenXml.Wordprocessing.TopBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" },
|
||||
new DocumentFormat.OpenXml.Wordprocessing.BottomBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" },
|
||||
new DocumentFormat.OpenXml.Wordprocessing.LeftBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" },
|
||||
new DocumentFormat.OpenXml.Wordprocessing.RightBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" },
|
||||
new DocumentFormat.OpenXml.Wordprocessing.InsideHorizontalBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "E2E8F0" },
|
||||
new DocumentFormat.OpenXml.Wordprocessing.InsideVerticalBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "E2E8F0" })));
|
||||
|
||||
var rowMatches = Regex.Matches(tableHtml, @"<tr\b[^>]*>(.*?)</tr>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
var rowIndex = 0;
|
||||
foreach (Match rowMatch in rowMatches)
|
||||
{
|
||||
var row = new DocumentFormat.OpenXml.Wordprocessing.TableRow();
|
||||
var rowHtml = rowMatch.Groups[1].Value;
|
||||
var isHeader = rowIndex == 0 || Regex.IsMatch(rowHtml, "<th", RegexOptions.IgnoreCase);
|
||||
var cellMatches = Regex.Matches(rowHtml, @"<t[hd]\b[^>]*>(.*?)</t[hd]>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
foreach (Match cellMatch in cellMatches)
|
||||
{
|
||||
var cellText = ExtractStructuredText(cellMatch.Groups[1].Value);
|
||||
var cell = new DocumentFormat.OpenXml.Wordprocessing.TableCell();
|
||||
if (isHeader)
|
||||
{
|
||||
cell.AppendChild(CreateParagraph(cellText, fontSize: "21", bold: true, color: "1F3A5F", fill: "E8EEF8"));
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.AppendChild(CreateParagraph(cellText));
|
||||
}
|
||||
cell.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.TableCellProperties(
|
||||
new DocumentFormat.OpenXml.Wordprocessing.TableCellWidth
|
||||
{
|
||||
Type = DocumentFormat.OpenXml.Wordprocessing.TableWidthUnitValues.Auto
|
||||
}));
|
||||
row.AppendChild(cell);
|
||||
}
|
||||
|
||||
if (cellMatches.Count > 0)
|
||||
table.AppendChild(row);
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
void AppendPlainText(string plain)
|
||||
{
|
||||
foreach (var rawLine in plain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
if (Regex.IsMatch(line, @"^#{2,6}\s+"))
|
||||
{
|
||||
body.AppendChild(CreateParagraph(Regex.Replace(line, @"^#{2,6}\s+", ""), fontSize: "26", bold: true, color: "2B579A"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(line, @"^[-*]\s+"))
|
||||
{
|
||||
body.AppendChild(CreateParagraph($"• {Regex.Replace(line, @"^[-*]\s+", "")}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(line, @"^\d+\.\s+"))
|
||||
{
|
||||
body.AppendChild(CreateParagraph(line));
|
||||
continue;
|
||||
}
|
||||
|
||||
body.AppendChild(CreateParagraph(line));
|
||||
}
|
||||
}
|
||||
|
||||
void AppendListBlock(string listHtml, bool ordered)
|
||||
{
|
||||
var matches = Regex.Matches(listHtml, @"<li\b[^>]*>(.*?)</li>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
var number = 1;
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var text = ExtractStructuredText(match.Groups[1].Value);
|
||||
var prefix = ordered ? $"{number}. " : "• ";
|
||||
body.AppendChild(CreateParagraph(prefix + text));
|
||||
number++;
|
||||
}
|
||||
}
|
||||
|
||||
void AppendStructuredContent(string rawContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawContent))
|
||||
return;
|
||||
|
||||
var normalized = rawContent.Replace("\r\n", "\n");
|
||||
if (!Regex.IsMatch(normalized, @"<\s*(p|ul|ol|table|blockquote|div|h[1-6]|li)\b", RegexOptions.IgnoreCase))
|
||||
{
|
||||
AppendPlainText(normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
normalized = Regex.Replace(normalized, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
var blockPattern = @"<table\b[^>]*>.*?</table>|<ul\b[^>]*>.*?</ul>|<ol\b[^>]*>.*?</ol>|<blockquote\b[^>]*>.*?</blockquote>|<div\b[^>]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid)[^""]*""[^>]*>.*?</div>|<h[2-6]\b[^>]*>.*?</h[2-6]>|<p\b[^>]*>.*?</p>";
|
||||
var matches = Regex.Matches(normalized, blockPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
AppendPlainText(ExtractStructuredText(normalized));
|
||||
return;
|
||||
}
|
||||
|
||||
var cursor = 0;
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (match.Index > cursor)
|
||||
{
|
||||
var leadingText = ExtractStructuredText(normalized.Substring(cursor, match.Index - cursor));
|
||||
AppendPlainText(leadingText);
|
||||
}
|
||||
|
||||
var block = match.Value;
|
||||
if (Regex.IsMatch(block, @"^<table", RegexOptions.IgnoreCase))
|
||||
{
|
||||
body.AppendChild(CreateTableFromHtml(block));
|
||||
}
|
||||
else if (Regex.IsMatch(block, @"^<ul", RegexOptions.IgnoreCase))
|
||||
{
|
||||
AppendListBlock(block, ordered: false);
|
||||
}
|
||||
else if (Regex.IsMatch(block, @"^<ol", RegexOptions.IgnoreCase))
|
||||
{
|
||||
AppendListBlock(block, ordered: true);
|
||||
}
|
||||
else if (Regex.IsMatch(block, @"^<h", RegexOptions.IgnoreCase))
|
||||
{
|
||||
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "26", bold: true, color: "2B579A"));
|
||||
}
|
||||
else if (Regex.IsMatch(block, @"^<(blockquote|div)", RegexOptions.IgnoreCase))
|
||||
{
|
||||
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "21", bold: true, color: "1F3A5F", fill: "EDF4FF"));
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendPlainText(ExtractStructuredText(block));
|
||||
}
|
||||
|
||||
cursor = match.Index + match.Length;
|
||||
}
|
||||
|
||||
if (cursor < normalized.Length)
|
||||
AppendPlainText(ExtractStructuredText(normalized[cursor..]));
|
||||
}
|
||||
|
||||
var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
@@ -277,10 +503,8 @@ public class DocumentAssemblerTool : IAgentTool
|
||||
// 빈 줄
|
||||
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
|
||||
|
||||
// 각 섹션
|
||||
foreach (var (heading, content, level) in sections)
|
||||
{
|
||||
// 섹션 제목
|
||||
var headPara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
@@ -294,31 +518,10 @@ public class DocumentAssemblerTool : IAgentTool
|
||||
headPara.AppendChild(headRun);
|
||||
body.AppendChild(headPara);
|
||||
|
||||
// 섹션 본문 (줄 단위 분할)
|
||||
var lines = StripHtmlTags(content).Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
RunFonts = KoreanFonts(),
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" }
|
||||
});
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim())
|
||||
{
|
||||
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
|
||||
});
|
||||
para.AppendChild(run);
|
||||
body.AppendChild(para);
|
||||
}
|
||||
|
||||
// 섹션 간 빈 줄
|
||||
AppendStructuredContent(content);
|
||||
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
|
||||
}
|
||||
|
||||
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
|
||||
// 첫 번째에 넣으면 Word가 무시하거나 문서가 깨짐
|
||||
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
|
||||
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
|
||||
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
|
||||
|
||||
@@ -203,9 +203,16 @@ public class DocumentPlannerTool : IAgentTool
|
||||
hints.Add("callout-info(핵심 전략)");
|
||||
hints.Add("span.badge-blue/green(구분 배지)");
|
||||
}
|
||||
if (ContainsAny(joined, "대안", "옵션", "비교", "우선순위", "선택"))
|
||||
hints.Add("comparison(대안 비교)");
|
||||
// 일정/단계/프로세스 → 타임라인
|
||||
if (ContainsAny(joined, "일정", "단계", "절차", "프로세스", "과정", "순서", "연혁", "역사"))
|
||||
{
|
||||
hints.Add("div.timeline(단계/일정)");
|
||||
hints.Add("roadmap(실행 로드맵)");
|
||||
}
|
||||
if (ContainsAny(joined, "리스크", "위험", "영향도", "가능성", "우선순위"))
|
||||
hints.Add("matrix(우선순위/리스크 매트릭스)");
|
||||
// 진행률/달성도 → 진행 바
|
||||
if (ContainsAny(joined, "달성", "진행", "완료", "목표 대비", "진척"))
|
||||
hints.Add("div.progress(달성률)");
|
||||
@@ -580,25 +587,28 @@ public class DocumentPlannerTool : IAgentTool
|
||||
return result;
|
||||
}
|
||||
|
||||
return docType switch
|
||||
var sections = docType switch
|
||||
{
|
||||
"proposal" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
|
||||
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
|
||||
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
|
||||
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
|
||||
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
|
||||
new() { Id = "sec-1", Heading = "1. Executive Summary", Level = 1, KeyPoints = ["핵심 메시지", "권고안", "의사결정 포인트"] },
|
||||
new() { Id = "sec-2", Heading = "2. Background & Imperative", Level = 1, KeyPoints = ["배경", "왜 지금 중요한가", "프로젝트 범위"] },
|
||||
new() { Id = "sec-3", Heading = "3. Current State Diagnosis", Level = 1, KeyPoints = ["현황 진단", "Pain Point", "주요 제약사항"] },
|
||||
new() { Id = "sec-4", Heading = "4. Proposed Approach", Level = 1, KeyPoints = ["핵심 제안", "실행 방식", "차별 포인트"] },
|
||||
new() { Id = "sec-5", Heading = "5. Delivery Plan & Governance", Level = 1, KeyPoints = ["단계별 일정", "거버넌스", "역할 분담"] },
|
||||
new() { Id = "sec-6", Heading = "6. Business Case", Level = 1, KeyPoints = ["예상 효과", "비용/투입", "ROI 또는 기대 가치"] },
|
||||
new() { Id = "sec-7", Heading = "7. Risks & Mitigations", Level = 1, KeyPoints = ["주요 리스크", "대응 방안", "의사결정 필요 항목"] },
|
||||
new() { Id = "sec-8", Heading = "8. Decision Ask", Level = 1, KeyPoints = ["승인 요청", "즉시 결정사항", "다음 단계"] },
|
||||
},
|
||||
"analysis" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
|
||||
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
|
||||
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
|
||||
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
|
||||
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
|
||||
new() { Id = "sec-1", Heading = "1. Executive Summary", Level = 1, KeyPoints = ["핵심 발견", "시사점", "권고사항"] },
|
||||
new() { Id = "sec-2", Heading = "2. Analysis Objective & Key Questions", Level = 1, KeyPoints = ["분석 목적", "핵심 질문", "분석 범위"] },
|
||||
new() { Id = "sec-3", Heading = "3. Data & Method", Level = 1, KeyPoints = ["데이터 출처", "품질 점검", "분석 방법론"] },
|
||||
new() { Id = "sec-4", Heading = "4. Key Findings", Level = 1, KeyPoints = ["정량 결과", "패턴", "이상 징후"] },
|
||||
new() { Id = "sec-5", Heading = "5. Implications", Level = 1, KeyPoints = ["사업 영향", "원인 해석", "우선순위"] },
|
||||
new() { Id = "sec-6", Heading = "6. Recommendation", Level = 1, KeyPoints = ["개선안", "실행 우선순위", "추적 KPI"] },
|
||||
new() { Id = "sec-7", Heading = "7. Next Steps", Level = 1, KeyPoints = ["추가 확인 항목", "실행 계획", "후속 분석"] },
|
||||
},
|
||||
"manual" or "guide" => new List<SectionPlan>
|
||||
{
|
||||
@@ -610,11 +620,11 @@ public class DocumentPlannerTool : IAgentTool
|
||||
},
|
||||
"minutes" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
|
||||
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
|
||||
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
|
||||
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
|
||||
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
|
||||
new() { Id = "sec-1", Heading = "1. Meeting Snapshot", Level = 1, KeyPoints = ["일시", "참석자", "목적"] },
|
||||
new() { Id = "sec-2", Heading = "2. Agenda & Discussion", Level = 1, KeyPoints = ["안건별 논의", "핵심 쟁점", "합의 전제"] },
|
||||
new() { Id = "sec-3", Heading = "3. Decisions Made", Level = 1, KeyPoints = ["결정 사항", "변경 내용", "보류 항목"] },
|
||||
new() { Id = "sec-4", Heading = "4. Action Items", Level = 1, KeyPoints = ["담당자", "기한", "산출물"] },
|
||||
new() { Id = "sec-5", Heading = "5. Risks & Follow-ups", Level = 1, KeyPoints = ["리스크", "추가 확인 사항", "다음 회의 준비"] },
|
||||
},
|
||||
"presentation" => new List<SectionPlan>
|
||||
{
|
||||
@@ -627,14 +637,38 @@ public class DocumentPlannerTool : IAgentTool
|
||||
},
|
||||
_ => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
|
||||
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
|
||||
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
|
||||
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
|
||||
new() { Id = "sec-1", Heading = "1. Executive Summary", Level = 1, KeyPoints = ["핵심 메시지", "요약 결론", "요청사항"] },
|
||||
new() { Id = "sec-2", Heading = "2. Scope & Approach", Level = 1, KeyPoints = ["배경", "목적", "접근 방법"] },
|
||||
new() { Id = "sec-3", Heading = "3. Current State", Level = 1, KeyPoints = ["현재 상태", "주요 지표", "핵심 이슈"] },
|
||||
new() { Id = "sec-4", Heading = "4. Key Findings", Level = 1, KeyPoints = ["중요 인사이트", "문제점", "기회 요인"] },
|
||||
new() { Id = "sec-5", Heading = "5. Recommendation", Level = 1, KeyPoints = ["권고안", "우선순위", "실행 전제"] },
|
||||
new() { Id = "sec-6", Heading = "6. Roadmap & Ask", Level = 1, KeyPoints = ["실행 계획", "책임 주체", "승인 요청"] },
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(refSummary))
|
||||
{
|
||||
sections.Add(new SectionPlan
|
||||
{
|
||||
Id = $"sec-{sections.Count + 1}",
|
||||
Heading = $"{sections.Count + 1}. Evidence & Reference Notes",
|
||||
Level = 1,
|
||||
KeyPoints = ["참조 데이터 요약", "근거 출처", "검증 메모"],
|
||||
});
|
||||
}
|
||||
|
||||
if (pages >= 8)
|
||||
{
|
||||
sections.Add(new SectionPlan
|
||||
{
|
||||
Id = $"sec-{sections.Count + 1}",
|
||||
Heading = $"{sections.Count + 1}. Appendix",
|
||||
Level = 1,
|
||||
KeyPoints = ["상세 데이터", "보조 분석", "용어 정의 또는 참고 자료"],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
|
||||
|
||||
@@ -17,7 +17,7 @@ public class ExcelSkill : IAgentTool
|
||||
"Supports: header styling (bold white text on colored background), " +
|
||||
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
|
||||
"cell merge, freeze panes (freeze header row), number formatting, " +
|
||||
"themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks. " +
|
||||
"themes (professional/modern/dark/minimal), column alignments, executive summary sheets, and multi-sheet workbooks. " +
|
||||
"Inline icons: use {icon:name} in cell text (e.g. '{icon:checkmark} 완료', '{icon:warning} 주의'). " +
|
||||
"170+ built-in icons: checkmark, warning, star, rocket, chart_up, chart_down, etc.";
|
||||
|
||||
@@ -35,6 +35,12 @@ public class ExcelSkill : IAgentTool
|
||||
["freeze_header"] = new() { Type = "boolean", Description = "Freeze the header row. Default: true for styled." },
|
||||
["merges"] = new() { Type = "array", Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]", Items = new() { Type = "string" } },
|
||||
["summary_row"] = new() { Type = "object", Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom." },
|
||||
["summary_sheet"] = new()
|
||||
{
|
||||
Type = "object",
|
||||
Description = "Optional executive summary sheet inserted before detail sheets. " +
|
||||
"Example: {\"name\":\"Summary\",\"title\":\"2026 운영 리뷰\",\"subtitle\":\"핵심 KPI와 후속 과제\",\"kpis\":[{\"label\":\"Revenue\",\"value\":\"12%\",\"trend\":\"YoY\",\"note\":\"Strong\"}],\"highlights\":[\"핵심 인사이트 1\",\"핵심 인사이트 2\"],\"actions\":[\"즉시 과제 1\",\"즉시 과제 2\"]}"
|
||||
},
|
||||
["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" } },
|
||||
["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?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } },
|
||||
@@ -117,13 +123,18 @@ public class ExcelSkill : IAgentTool
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
var hasSummarySheet = args.SafeTryGetProperty("summary_sheet", out var summarySheet)
|
||||
&& summarySheet.ValueKind == JsonValueKind.Object;
|
||||
|
||||
// Determine if we are in multi-sheet mode
|
||||
var multiSheetMode = args.SafeTryGetProperty("sheets", out var sheetsArr)
|
||||
&& sheetsArr.ValueKind == JsonValueKind.Array
|
||||
&& sheetsArr.GetArrayLength() > 0;
|
||||
|
||||
if (multiSheetMode)
|
||||
return GenerateMultiSheetWorkbook(args, sheetsArr, fullPath);
|
||||
return GenerateMultiSheetWorkbook(args, sheetsArr, fullPath, hasSummarySheet ? summarySheet : default);
|
||||
else if (hasSummarySheet)
|
||||
return GenerateSingleSheetWorkbookWithSummary(args, summarySheet, fullPath);
|
||||
else
|
||||
return GenerateSingleSheetWorkbook(args, fullPath);
|
||||
}
|
||||
@@ -186,7 +197,6 @@ public class ExcelSkill : IAgentTool
|
||||
});
|
||||
|
||||
workbookPart.Workbook.Save();
|
||||
|
||||
var features = BuildFeatureList(isStyled, freezeHeader,
|
||||
mergesArg.ValueKind == JsonValueKind.Array,
|
||||
summaryArg.ValueKind == JsonValueKind.Object,
|
||||
@@ -201,7 +211,76 @@ public class ExcelSkill : IAgentTool
|
||||
// Multi-sheet workbook
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static ToolResult GenerateMultiSheetWorkbook(JsonElement args, JsonElement sheetsArr, string fullPath)
|
||||
private static ToolResult GenerateSingleSheetWorkbookWithSummary(JsonElement args, JsonElement summarySheet, string fullPath)
|
||||
{
|
||||
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("?꾩닔 ?뚮씪誘명꽣 ?꾨씫: 'headers'");
|
||||
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("?꾩닔 ?뚮씪誘명꽣 ?꾨씫: 'rows'");
|
||||
|
||||
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
|
||||
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
|
||||
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
|
||||
var isStyled = tableStyle != "plain";
|
||||
var freezeHeader = args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
|
||||
|
||||
var theme = GetTheme(themeName);
|
||||
var numFmts = ParseNumberFormats(args, "number_formats");
|
||||
var alignments = ParseAlignments(args, "col_alignments");
|
||||
var customFmts = CollectCustomFormats(numFmts);
|
||||
|
||||
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
|
||||
var workbookPart = spreadsheet.AddWorkbookPart();
|
||||
workbookPart.Workbook = new Workbook();
|
||||
|
||||
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
|
||||
stylesPart.Stylesheet = CreateStylesheet(true, theme, customFmts, numFmts, alignments);
|
||||
stylesPart.Stylesheet.Save();
|
||||
|
||||
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
|
||||
|
||||
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
|
||||
? summaryNameEl.SafeGetString() ?? "Summary"
|
||||
: "Summary";
|
||||
var summaryPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
WriteExecutiveSummarySheet(summaryPart, summarySheet, [sheetName]);
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
Id = workbookPart.GetIdOfPart(summaryPart),
|
||||
SheetId = 1,
|
||||
Name = summaryName,
|
||||
});
|
||||
|
||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
|
||||
var colCount = headers.GetArrayLength();
|
||||
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
||||
summaryArg, mergesArg, colCount);
|
||||
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
Id = workbookPart.GetIdOfPart(worksheetPart),
|
||||
SheetId = 2,
|
||||
Name = sheetName,
|
||||
});
|
||||
|
||||
workbookPart.Workbook.Save();
|
||||
|
||||
var features = BuildFeatureList(isStyled, freezeHeader,
|
||||
mergesArg.ValueKind == JsonValueKind.Array,
|
||||
summaryArg.ValueKind == JsonValueKind.Object,
|
||||
numFmts.Count > 0, alignments.Count > 0, themeName);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Excel ?뚯씪 ?앹꽦 ?꾨즺: {fullPath}\n?쒗듃: {summaryName}, {sheetName}, ?? {colCount}, ?? {rowCount}{features} [?붿빟 ?쒗듃]",
|
||||
fullPath);
|
||||
}
|
||||
|
||||
private static ToolResult GenerateMultiSheetWorkbook(JsonElement args, JsonElement sheetsArr, string fullPath, JsonElement summarySheet)
|
||||
{
|
||||
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
|
||||
var workbookPart = spreadsheet.AddWorkbookPart();
|
||||
@@ -234,6 +313,30 @@ public class ExcelSkill : IAgentTool
|
||||
uint sheetId = 1;
|
||||
var totalSheets = 0;
|
||||
var totalRows = 0;
|
||||
var detailSheetNames = new List<string>();
|
||||
|
||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||
{
|
||||
var detailSheetName = sheetDef.SafeTryGetProperty("name", out var nameEl)
|
||||
? nameEl.SafeGetString() ?? $"Sheet{detailSheetNames.Count + 1}"
|
||||
: $"Sheet{detailSheetNames.Count + 1}";
|
||||
detailSheetNames.Add(detailSheetName);
|
||||
}
|
||||
|
||||
if (summarySheet.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
|
||||
? summaryNameEl.SafeGetString() ?? "Summary"
|
||||
: "Summary";
|
||||
var summaryPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
WriteExecutiveSummarySheet(summaryPart, summarySheet, detailSheetNames);
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
Id = workbookPart.GetIdOfPart(summaryPart),
|
||||
SheetId = sheetId++,
|
||||
Name = summaryName,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||
{
|
||||
@@ -283,6 +386,108 @@ public class ExcelSkill : IAgentTool
|
||||
// Core sheet writer (shared by single + multi)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static void WriteExecutiveSummarySheet(WorksheetPart worksheetPart, JsonElement summarySheet, IReadOnlyList<string> detailSheetNames)
|
||||
{
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
|
||||
var columns = new Columns(
|
||||
new Column { Min = 1, Max = 1, Width = 22, CustomWidth = true },
|
||||
new Column { Min = 2, Max = 2, Width = 18, CustomWidth = true },
|
||||
new Column { Min = 3, Max = 3, Width = 18, CustomWidth = true },
|
||||
new Column { Min = 4, Max = 4, Width = 28, CustomWidth = true },
|
||||
new Column { Min = 5, Max = 5, Width = 18, CustomWidth = true },
|
||||
new Column { Min = 6, Max = 6, Width = 30, CustomWidth = true });
|
||||
worksheetPart.Worksheet.Append(columns);
|
||||
|
||||
var sheetData = new SheetData();
|
||||
worksheetPart.Worksheet.Append(sheetData);
|
||||
|
||||
var merges = new MergeCells();
|
||||
uint rowIndex = 1;
|
||||
|
||||
var title = summarySheet.SafeTryGetProperty("title", out var titleEl)
|
||||
? titleEl.SafeGetString() ?? "Executive Summary"
|
||||
: "Executive Summary";
|
||||
var subtitle = summarySheet.SafeTryGetProperty("subtitle", out var subtitleEl)
|
||||
? subtitleEl.SafeGetString() ?? ""
|
||||
: "";
|
||||
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", title, 1);
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", subtitle, 3);
|
||||
|
||||
rowIndex++;
|
||||
|
||||
if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0)
|
||||
{
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Key KPIs", 1);
|
||||
var headerRow = new Row { RowIndex = rowIndex++ };
|
||||
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Metric", 1));
|
||||
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Value", 1));
|
||||
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Trend", 1));
|
||||
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Note", 1));
|
||||
sheetData.Append(headerRow);
|
||||
|
||||
var kpiIndex = 0;
|
||||
foreach (var kpi in kpis.EnumerateArray())
|
||||
{
|
||||
var styleIndex = kpiIndex % 2 == 0 ? (uint)0 : (uint)2;
|
||||
var row = new Row { RowIndex = rowIndex++ };
|
||||
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, kpi.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", styleIndex));
|
||||
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, kpi.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "", styleIndex));
|
||||
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, kpi.SafeTryGetProperty("trend", out var trendEl) ? trendEl.SafeGetString() ?? "" : "", styleIndex));
|
||||
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, kpi.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? "" : "", styleIndex));
|
||||
sheetData.Append(row);
|
||||
kpiIndex++;
|
||||
}
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
AppendSummaryTextSection(summarySheet, "highlights", "Key Highlights", sheetData, merges, ref rowIndex);
|
||||
AppendSummaryTextSection(summarySheet, "actions", "Next Actions", sheetData, merges, ref rowIndex);
|
||||
|
||||
if (detailSheetNames.Count > 0)
|
||||
{
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Detail Sheets", 1);
|
||||
foreach (var detailSheetName in detailSheetNames)
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {detailSheetName}", 0);
|
||||
}
|
||||
|
||||
if (merges.HasChildren)
|
||||
worksheetPart.Worksheet.InsertAfter(merges, sheetData);
|
||||
}
|
||||
|
||||
private static void AppendSummaryTextSection(JsonElement summarySheet, string key, string heading, SheetData sheetData, MergeCells merges, ref uint rowIndex)
|
||||
{
|
||||
if (!summarySheet.SafeTryGetProperty(key, out var items) || items.ValueKind != JsonValueKind.Array || items.GetArrayLength() == 0)
|
||||
return;
|
||||
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", heading, 1);
|
||||
foreach (var item in items.EnumerateArray())
|
||||
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {item.SafeGetString() ?? ""}", 0);
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
private static void AppendMergedTextRow(SheetData sheetData, MergeCells merges, uint rowIndex, string startColumn, string endColumn, string text, uint styleIndex)
|
||||
{
|
||||
var row = new Row { RowIndex = rowIndex };
|
||||
row.Append(CreateSummaryCell(startColumn, rowIndex, text, styleIndex));
|
||||
sheetData.Append(row);
|
||||
merges.Append(new MergeCell { Reference = $"{startColumn}{rowIndex}:{endColumn}{rowIndex}" });
|
||||
}
|
||||
|
||||
private static Cell CreateSummaryCell(string column, uint rowIndex, string text, uint styleIndex)
|
||||
{
|
||||
return new Cell
|
||||
{
|
||||
CellReference = $"{column}{rowIndex}",
|
||||
DataType = CellValues.String,
|
||||
CellValue = new CellValue(text),
|
||||
StyleIndex = styleIndex,
|
||||
};
|
||||
}
|
||||
|
||||
private static int WriteSheetContent(
|
||||
WorksheetPart worksheetPart,
|
||||
JsonElement args,
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace AxCopilot.Services.Agent;
|
||||
/// HTML (.html) 보고서를 생성하는 내장 스킬.
|
||||
/// 테마 무드(mood)를 선택하면 TemplateService에서 해당 CSS를 가져와 적용합니다.
|
||||
/// TOC, 커버 페이지, 섹션 번호 등 고급 문서 기능을 지원합니다.
|
||||
/// sections 파라미터로 구조화된 콘텐츠 블록(heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi)을 지원합니다.
|
||||
/// sections 파라미터로 구조화된 콘텐츠 블록(heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi/comparison/roadmap/matrix)을 지원합니다.
|
||||
/// </summary>
|
||||
public class HtmlSkill : IAgentTool
|
||||
{
|
||||
@@ -52,6 +52,9 @@ public class HtmlSkill : IAgentTool
|
||||
"'table' {headers:[], rows:[[]]}, " +
|
||||
"'chart' {kind:'bar|horizontal_bar', title, data:[{label, value, color}]}, " +
|
||||
"'cards' {items:[{title, body, badge, icon}]}, " +
|
||||
"'comparison' {title, items:[{name, summary, pros, cons, verdict}]}, " +
|
||||
"'roadmap' {title, phases:[{title, detail, timeline, owner}]}, " +
|
||||
"'matrix' {title, quadrants:[{title, items:['...']}]}, " +
|
||||
"'list' {style:'bullet|number', items:['item', ' - sub-item']}, " +
|
||||
"'quote' {text, author}, " +
|
||||
"'divider', " +
|
||||
@@ -73,7 +76,7 @@ public class HtmlSkill : IAgentTool
|
||||
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
|
||||
},
|
||||
},
|
||||
Required = ["title", "body"]
|
||||
Required = ["title"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
@@ -329,6 +332,15 @@ public class HtmlSkill : IAgentTool
|
||||
case "cards":
|
||||
sb.AppendLine(RenderCards(section));
|
||||
break;
|
||||
case "comparison":
|
||||
sb.AppendLine(RenderComparison(section));
|
||||
break;
|
||||
case "roadmap":
|
||||
sb.AppendLine(RenderRoadmap(section));
|
||||
break;
|
||||
case "matrix":
|
||||
sb.AppendLine(RenderMatrix(section));
|
||||
break;
|
||||
case "list":
|
||||
sb.AppendLine(RenderList(section));
|
||||
break;
|
||||
@@ -511,6 +523,110 @@ public class HtmlSkill : IAgentTool
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderComparison(JsonElement s)
|
||||
{
|
||||
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
sb.AppendLine($"<h3>{Escape(title)}</h3>");
|
||||
sb.AppendLine("<div class=\"comparison-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin:1.25rem 0;\">");
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var name = item.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(name) && item.SafeTryGetProperty("title", out var titleItemEl))
|
||||
name = titleItemEl.SafeGetString();
|
||||
var summary = item.SafeTryGetProperty("summary", out var summaryEl) ? summaryEl.SafeGetString() : null;
|
||||
var pros = item.SafeTryGetProperty("pros", out var prosEl) ? prosEl.SafeGetString() : null;
|
||||
var cons = item.SafeTryGetProperty("cons", out var consEl) ? consEl.SafeGetString() : null;
|
||||
var verdict = item.SafeTryGetProperty("verdict", out var verdictEl) ? verdictEl.SafeGetString() : null;
|
||||
|
||||
sb.AppendLine("<div style=\"border:1px solid #e5e7eb;border-radius:14px;padding:1rem 1.1rem;background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.06);\">");
|
||||
sb.AppendLine($"<div style=\"font-size:1rem;font-weight:700;color:#111827\">{Escape(name ?? "Option")}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(verdict))
|
||||
sb.AppendLine($"<div style=\"display:inline-block;margin:.35rem 0 .65rem;padding:.2rem .55rem;border-radius:999px;background:#EEF2FF;color:#4338CA;font-size:.78rem;font-weight:700;\">{Escape(verdict)}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
sb.AppendLine($"<p style=\"margin:.15rem 0 .85rem;color:#374151;line-height:1.65\">{MarkdownToHtml(summary)}</p>");
|
||||
if (!string.IsNullOrWhiteSpace(pros))
|
||||
sb.AppendLine($"<div style=\"margin:.5rem 0\"><strong style=\"color:#166534\">장점</strong><div style=\"margin-top:.25rem;color:#374151\">{MarkdownToHtml(pros)}</div></div>");
|
||||
if (!string.IsNullOrWhiteSpace(cons))
|
||||
sb.AppendLine($"<div style=\"margin:.5rem 0\"><strong style=\"color:#B45309\">유의사항</strong><div style=\"margin-top:.25rem;color:#374151\">{MarkdownToHtml(cons)}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderRoadmap(JsonElement s)
|
||||
{
|
||||
if (!s.SafeTryGetProperty("phases", out var phases) || phases.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
sb.AppendLine($"<h3>{Escape(title)}</h3>");
|
||||
|
||||
sb.AppendLine("<div class=\"roadmap-block\" style=\"display:grid;gap:.9rem;margin:1.25rem 0;\">");
|
||||
foreach (var phase in phases.EnumerateArray())
|
||||
{
|
||||
var phaseTitle = phase.SafeTryGetProperty("title", out var phaseTitleEl) ? phaseTitleEl.SafeGetString() ?? "" : "";
|
||||
var detail = phase.SafeTryGetProperty("detail", out var detailEl) ? detailEl.SafeGetString() ?? "" : "";
|
||||
var timeline = phase.SafeTryGetProperty("timeline", out var timelineEl) ? timelineEl.SafeGetString() : null;
|
||||
var owner = phase.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() : null;
|
||||
|
||||
sb.AppendLine("<div style=\"display:grid;grid-template-columns:160px 1fr;gap:1rem;align-items:start;border:1px solid #e5e7eb;border-radius:14px;padding:1rem 1.1rem;background:linear-gradient(180deg,#fff,#f8fafc);\">");
|
||||
sb.AppendLine("<div>");
|
||||
sb.AppendLine($"<div style=\"font-size:.8rem;color:#6b7280;text-transform:uppercase;letter-spacing:.06em\">Timeline</div>");
|
||||
sb.AppendLine($"<div style=\"font-size:1.05rem;font-weight:700;color:#111827\">{Escape(timeline ?? "TBD")}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(owner))
|
||||
sb.AppendLine($"<div style=\"margin-top:.35rem;font-size:.85rem;color:#475569\">Owner: {Escape(owner)}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("<div>");
|
||||
sb.AppendLine($"<div style=\"font-size:1rem;font-weight:700;color:#111827\">{Escape(phaseTitle)}</div>");
|
||||
sb.AppendLine($"<div style=\"margin-top:.35rem;color:#374151;line-height:1.65\">{MarkdownToHtml(detail)}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderMatrix(JsonElement s)
|
||||
{
|
||||
if (!s.SafeTryGetProperty("quadrants", out var quadrants) || quadrants.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
sb.AppendLine($"<h3>{Escape(title)}</h3>");
|
||||
|
||||
sb.AppendLine("<div class=\"matrix-grid\" style=\"display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin:1.25rem 0;\">");
|
||||
foreach (var quadrant in quadrants.EnumerateArray())
|
||||
{
|
||||
var quadrantTitle = quadrant.SafeTryGetProperty("title", out var quadrantTitleEl) ? quadrantTitleEl.SafeGetString() ?? "" : "";
|
||||
sb.AppendLine("<div style=\"border:1px solid #dbe4f0;border-radius:14px;padding:1rem 1.1rem;background:#fff;min-height:180px;\">");
|
||||
sb.AppendLine($"<div style=\"font-size:.92rem;font-weight:700;color:#0f172a;margin-bottom:.65rem\">{Escape(quadrantTitle)}</div>");
|
||||
|
||||
if (quadrant.SafeTryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine("<ul style=\"margin:0;padding-left:1.1rem;color:#334155;line-height:1.65;\">");
|
||||
foreach (var item in items.EnumerateArray())
|
||||
sb.AppendLine($"<li>{MarkdownToHtml(item.SafeGetString() ?? "")}</li>");
|
||||
sb.AppendLine("</ul>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderList(JsonElement s)
|
||||
{
|
||||
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "bullet" : "bullet";
|
||||
|
||||
@@ -1,137 +1,38 @@
|
||||
---
|
||||
---
|
||||
name: csv-to-xlsx
|
||||
label: CSV → Excel 변환
|
||||
description: CSV 파일을 서식이 완성된 Excel(.xlsx)로 변환합니다. 헤더 고정, 필터, 조건부 서식, 자동 열 너비를 적용합니다.
|
||||
description: AX 네이티브 XLSX 엔진으로 CSV를 보고용 Excel 워크북으로 변환합니다. 요약 시트, 상세 시트, 서식, 수식까지 포함할 수 있습니다.
|
||||
icon: \uE9F9
|
||||
allowed-tools:
|
||||
- folder_map
|
||||
- file_read
|
||||
- file_write
|
||||
- process
|
||||
- format_convert
|
||||
- data_pivot
|
||||
- template_render
|
||||
- excel_create
|
||||
tabs: cowork
|
||||
---
|
||||
|
||||
CSV 파일을 전문적인 서식이 적용된 Excel 파일로 변환하세요.
|
||||
## 실행 경로 선택 (Python 가능/불가)
|
||||
- 먼저 `process`로 `python --version`을 확인하세요.
|
||||
- Python 가능: 기존 Python 스크립트 경로로 변환/서식 자동화를 수행하세요.
|
||||
- Python 불가: `format_convert`로 CSV를 XLSX로 변환하고, `data_pivot`으로 핵심 요약 시트를 구성한 뒤 `file_write`로 사용 안내를 남기세요.
|
||||
CSV를 단순 변환이 아니라 업무용 Excel 워크북으로 완성하세요.
|
||||
|
||||
핵심 원칙:
|
||||
- Python 스크립트보다 `excel_create`를 우선 사용하세요.
|
||||
- 결과물은 가능하면 `요약 시트 + 상세 데이터 시트` 구조로 만드세요.
|
||||
- 숫자 중심 데이터면 `summary_sheet`, `summary_row`, `number_formats`, `freeze_header`를 적극 사용하세요.
|
||||
|
||||
## 사전 준비
|
||||
먼저 필요한 패키지가 설치되어 있는지 확인하고, 없으면 설치하세요:
|
||||
```
|
||||
process: pip install openpyxl pandas
|
||||
```
|
||||
작업 절차:
|
||||
1. `folder_map`으로 대상 CSV 파일을 찾습니다.
|
||||
2. `file_read`로 컬럼 구조, 값 유형, 행 규모를 파악합니다.
|
||||
3. 필요하면 `data_pivot`으로 핵심 집계나 KPI 후보를 먼저 만듭니다.
|
||||
4. `excel_create`로 아래 원칙에 맞게 워크북을 생성합니다.
|
||||
|
||||
## 작업 절차
|
||||
워크북 작성 원칙:
|
||||
- 첫 시트는 `summary_sheet`로 두고 제목, KPI, 핵심 인사이트, 후속 과제를 정리하세요.
|
||||
- 상세 데이터는 별도 시트에 두고 헤더 고정, 줄무늬, 숫자 포맷, 합계 행을 적용하세요.
|
||||
- 금액/비율/정수/날짜는 `number_formats`로 명시하세요.
|
||||
- 의사결정자가 보는 파일이면 KPI, 리스크, 다음 액션을 요약 시트에 먼저 배치하세요.
|
||||
|
||||
1. **파일 확인**: folder_map으로 작업 폴더에서 CSV 파일을 탐색
|
||||
2. **CSV 분석**: file_read로 CSV 파일의 구조(컬럼, 행 수, 인코딩, 구분자) 파악
|
||||
3. **변환 옵션 확인**: 사용자에게 다음 옵션을 확인
|
||||
- 헤더 행 고정 여부 (기본: 활성)
|
||||
- 자동 필터 적용 여부 (기본: 활성)
|
||||
- 조건부 서식 대상 컬럼 (숫자 컬럼 자동 감지)
|
||||
- 시트 이름 (기본: 파일명)
|
||||
4. **Python 스크립트 작성**: file_write로 변환 스크립트 생성
|
||||
5. **스크립트 실행**: `process`로 Python 스크립트 실행
|
||||
6. **결과 확인**: 생성된 .xlsx 파일 경로와 요약 정보를 안내
|
||||
기본 구성 예시:
|
||||
- `summary_sheet`: title, subtitle, kpis, highlights, actions
|
||||
- `sheets`: 상세 데이터 시트 1개 이상
|
||||
- `summary_row`: 합계/평균 자동 계산
|
||||
|
||||
## Python 스크립트 템플릿
|
||||
```python
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.formatting.rule import CellIsRule
|
||||
|
||||
# CSV 읽기 (인코딩 자동 감지)
|
||||
for enc in ['utf-8', 'cp949', 'euc-kr', 'utf-8-sig']:
|
||||
try:
|
||||
df = pd.read_csv('input.csv', encoding=enc)
|
||||
break
|
||||
except (UnicodeDecodeError, Exception):
|
||||
continue
|
||||
|
||||
# Excel 저장
|
||||
output_path = 'output.xlsx'
|
||||
df.to_excel(output_path, index=False, sheet_name='Sheet1')
|
||||
|
||||
# 서식 적용
|
||||
wb = load_workbook(output_path)
|
||||
ws = wb.active
|
||||
|
||||
# 헤더 스타일
|
||||
header_font = Font(bold=True, color='FFFFFF', size=11)
|
||||
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||||
header_align = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
for col_idx, cell in enumerate(ws[1], 1):
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_align
|
||||
cell.border = thin_border
|
||||
|
||||
# 자동 열 너비
|
||||
for col_idx in range(1, ws.max_column + 1):
|
||||
max_length = 0
|
||||
col_letter = get_column_letter(col_idx)
|
||||
for row in ws.iter_rows(min_col=col_idx, max_col=col_idx):
|
||||
for cell in row:
|
||||
if cell.value:
|
||||
max_length = max(max_length, len(str(cell.value)))
|
||||
ws.column_dimensions[col_letter].width = min(max_length + 4, 50)
|
||||
|
||||
# 헤더 행 고정 (Freeze Panes)
|
||||
ws.freeze_panes = 'A2'
|
||||
|
||||
# 자동 필터
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
|
||||
# 숫자 컬럼 조건부 서식 (음수 빨강)
|
||||
for col_idx in range(1, ws.max_column + 1):
|
||||
col_letter = get_column_letter(col_idx)
|
||||
sample_values = [ws.cell(row=r, column=col_idx).value for r in range(2, min(ws.max_row + 1, 12))]
|
||||
if any(isinstance(v, (int, float)) for v in sample_values if v is not None):
|
||||
cell_range = f'{col_letter}2:{col_letter}{ws.max_row}'
|
||||
ws.conditional_formatting.add(cell_range,
|
||||
CellIsRule(operator='lessThan', formula=['0'],
|
||||
font=Font(color='FF0000')))
|
||||
|
||||
# 데이터 행 줄무늬 (가독성)
|
||||
light_fill = PatternFill(start_color='D9E2F3', end_color='D9E2F3', fill_type='solid')
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
for col_idx in range(1, ws.max_column + 1):
|
||||
cell = ws.cell(row=row_idx, column=col_idx)
|
||||
cell.border = thin_border
|
||||
if row_idx % 2 == 0:
|
||||
cell.fill = light_fill
|
||||
|
||||
wb.save(output_path)
|
||||
print(f'변환 완료: {output_path} ({ws.max_row - 1}행 × {ws.max_column}열)')
|
||||
```
|
||||
|
||||
## 서식 옵션
|
||||
- **헤더 스타일**: 파란 배경 + 흰색 굵은 글씨 + 가운데 정렬
|
||||
- **줄무늬**: 짝수 행 연한 파랑 배경 (가독성 향상)
|
||||
- **열 너비**: 내용 기준 자동 조정 (최대 50)
|
||||
- **조건부 서식**: 숫자 컬럼 음수 빨강 표시
|
||||
- **Freeze Panes**: 헤더 행 고정
|
||||
- **Auto Filter**: 전체 컬럼 필터 활성화
|
||||
|
||||
## 규칙
|
||||
- 원본 CSV 파일은 수정하지 않음
|
||||
- 인코딩 자동 감지 (UTF-8 → CP949 → EUC-KR 순)
|
||||
- 대용량 파일 (100,000행 이상) 경고 후 진행
|
||||
- 출력 파일명: 원본 파일명 기준 (.csv → .xlsx)
|
||||
|
||||
한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요.
|
||||
한국어로 안내하고, 결과는 작업 폴더에 저장하세요.
|
||||
|
||||
@@ -1,111 +1,41 @@
|
||||
---
|
||||
---
|
||||
name: docx-creator
|
||||
label: Word 문서 생성
|
||||
description: Python을 사용하여 전문적인 Word 문서(.docx)를 생성합니다. 작업 폴더의 양식 파일을 자동 활용합니다.
|
||||
description: AX 네이티브 DOCX 엔진으로 보고서, 제안서, 공문, 회의록, PRD 같은 정식 Word 문서를 생성합니다.
|
||||
icon: \uE8A5
|
||||
when_to_use: 보고서, 제안서, 공문, 가이드 문서처럼 정식 Word 문서를 새로 만들거나 기존 양식 DOCX를 이어서 작성해야 할 때
|
||||
argument-hint: <문서 종류 또는 목적>
|
||||
when_to_use: 정식 배포용 Word 문서를 새로 만들거나, 기존 DOCX 양식의 구조를 참고해 높은 완성도의 문서를 작성해야 할 때
|
||||
argument-hint: <문서 목적 또는 산출물 유형>
|
||||
allowed-tools:
|
||||
- folder_map
|
||||
- document_read
|
||||
- file_read
|
||||
- file_write
|
||||
- process
|
||||
- document_plan
|
||||
- document_assemble
|
||||
- format_convert
|
||||
- docx_create
|
||||
tabs: cowork
|
||||
---
|
||||
|
||||
사용자의 요구에 맞는 전문적인 Word 문서를 Python으로 생성하세요.
|
||||
## 실행 경로 선택 (Python 가능/불가)
|
||||
- 먼저 `process`로 `python --version`을 확인하세요.
|
||||
- Python 가능: 기존 python-docx 경로를 사용하세요.
|
||||
- Python 불가: `document_assemble`로 문서 본문을 구성하고 `format_convert`로 docx 산출을 시도하세요. 실패 시 Markdown/HTML 결과와 변환 가이드를 함께 제공하세요.
|
||||
사용자의 요청을 Word 문서로 완성하세요.
|
||||
|
||||
핵심 원칙:
|
||||
- Python 스크립트보다 AX의 `docx_create`, `document_plan`, `document_assemble`를 우선 사용하세요.
|
||||
- 문서는 단순 본문 나열이 아니라 `Executive Summary → 배경/현황 → 핵심 분석 → 권고안 → 실행계획` 흐름을 기본으로 생각하세요.
|
||||
- 짧은 문서는 `docx_create`로 직접 생성하고, 3페이지 이상이거나 구조가 중요한 문서는 `document_plan` 후 `document_assemble`로 조립하세요.
|
||||
|
||||
## 사전 준비
|
||||
먼저 python-docx 패키지가 설치되어 있는지 확인하고, 없으면 설치하세요:
|
||||
```
|
||||
process: pip install python-docx
|
||||
```
|
||||
양식 활용:
|
||||
1. `folder_map`으로 작업 폴더의 `.docx` 양식/서식 파일이 있는지 확인합니다.
|
||||
2. 양식 후보가 있으면 `document_read`로 제목 체계, 표 스타일, 헤더/푸터, 기존 섹션 구성을 먼저 파악합니다.
|
||||
3. 양식이 있더라도 내용을 그대로 복제하지 말고, 구조와 톤만 반영해 새 문서를 만듭니다.
|
||||
|
||||
## 양식 활용 (템플릿 모드)
|
||||
작업 폴더에 양식 파일이 있으면 **반드시** 활용하세요:
|
||||
작성 지침:
|
||||
- 각 섹션 제목은 메시지 중심으로 쓰고, 본문은 근거와 결론이 드러나게 작성하세요.
|
||||
- 표는 숫자 비교, 일정, 책임자 정리에만 사용하고, 장문 설명은 표에 넣지 마세요.
|
||||
- 강조가 필요한 내용은 callout 또는 highlight box로 분리하세요.
|
||||
- 문서 말미에는 의사결정 요청, 다음 단계, 참고자료를 정리하세요.
|
||||
|
||||
1. **양식 탐색**: `folder_map`으로 작업 폴더를 스캔하여 `.docx` 파일 확인
|
||||
2. **양식 후보 판별**:
|
||||
- 파일명에 "양식", "template", "서식", "표준", "기본" 포함
|
||||
- 또는 사용자가 명시적으로 "XX 양식으로 작성해줘" 요청
|
||||
- 또는 사용자가 특정 .docx 파일명을 언급
|
||||
3. **양식 구조 파악**: `document_read`로 양식 파일의 구조(스타일, 헤더, 섹션)를 먼저 확인
|
||||
4. **양식 기반 생성**:
|
||||
```python
|
||||
doc = Document('양식_보고서.docx') # ← 빈 Document() 대신 양식 로드
|
||||
# 양식의 스타일, 머리글/바닥글, 로고, 페이지 설정이 자동 상속됨
|
||||
# 기존 본문 내용을 지우고 새 내용만 추가
|
||||
for paragraph in doc.paragraphs:
|
||||
paragraph.clear() # 기존 내용 제거
|
||||
# 또는 필요에 따라 특정 섹션만 교체
|
||||
```
|
||||
5. **양식이 없으면**: 아래 기본 템플릿으로 새 문서 생성
|
||||
도구 선택 규칙:
|
||||
- 구조 설계가 먼저 필요하면 `document_plan`
|
||||
- 섹션별 상세 작성 후 최종 조립이 필요하면 `document_assemble`
|
||||
- 짧고 직접적인 산출이면 `docx_create`
|
||||
|
||||
## 작업 절차
|
||||
1. **요구사항 파악**: 사용자가 원하는 문서의 종류, 구조, 내용을 확인
|
||||
2. **양식 확인**: folder_map으로 작업 폴더에 양식 .docx 파일이 있는지 확인
|
||||
3. **Python 스크립트 작성**: file_write로 .py 파일 생성
|
||||
4. **스크립트 실행**: `process`로 Python 스크립트 실행
|
||||
5. **결과 확인**: 생성된 .docx 파일 경로를 사용자에게 안내
|
||||
|
||||
## Python 스크립트 템플릿
|
||||
```python
|
||||
from docx import Document
|
||||
from docx.shared import Inches, Pt, Cm
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.style import WD_STYLE_TYPE
|
||||
import os
|
||||
|
||||
# 양식 파일 자동 감지
|
||||
template_keywords = ['양식', 'template', '서식', '표준', '기본']
|
||||
template_file = None
|
||||
for f in os.listdir('.'):
|
||||
if f.endswith('.docx') and any(kw in f.lower() for kw in template_keywords):
|
||||
template_file = f
|
||||
break
|
||||
|
||||
# 양식이 있으면 활용, 없으면 새 문서
|
||||
if template_file:
|
||||
doc = Document(template_file)
|
||||
print(f'양식 활용: {template_file}')
|
||||
else:
|
||||
doc = Document()
|
||||
# 스타일 설정 (양식이 없을 때만)
|
||||
style = doc.styles['Normal']
|
||||
font = style.font
|
||||
font.name = '맑은 고딕'
|
||||
font.size = Pt(11)
|
||||
|
||||
# 제목
|
||||
doc.add_heading('문서 제목', level=0)
|
||||
|
||||
# 본문
|
||||
doc.add_paragraph('내용을 여기에 작성합니다.')
|
||||
|
||||
# 표
|
||||
table = doc.add_table(rows=2, cols=3)
|
||||
table.style = 'Light Grid Accent 1'
|
||||
|
||||
# 저장
|
||||
doc.save('output.docx')
|
||||
```
|
||||
|
||||
## 지원 기능
|
||||
- 제목/소제목 계층 구조
|
||||
- 표 (스타일, 병합, 서식)
|
||||
- 이미지 삽입
|
||||
- 머리글/바닥글
|
||||
- 페이지 번호
|
||||
- 목차
|
||||
- 글머리 기호/번호 목록
|
||||
- **양식 파일 기반 스타일 상속** (로고, 헤더, 페이지 설정 자동 유지)
|
||||
|
||||
한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요.
|
||||
한국어로 작성하세요. 결과는 작업 폴더에 저장하세요.
|
||||
|
||||
@@ -1,176 +1,34 @@
|
||||
---
|
||||
---
|
||||
name: markdown-to-doc
|
||||
label: Markdown → 문서 변환
|
||||
description: Markdown 파일을 서식이 적용된 Word(.docx) 또는 PDF 문서로 변환합니다.
|
||||
description: Markdown 초안을 구조가 유지된 Word 문서로 변환합니다. 목록, 표, 인용, 소제목 같은 블록을 최대한 보존합니다.
|
||||
icon: \uE8A5
|
||||
when_to_use: 이미 정리된 Markdown 초안을 외부 공유용 Word/PDF 문서로 바꿔야 할 때
|
||||
argument-hint: <입력 markdown 파일 또는 출력 형식>
|
||||
when_to_use: 정리된 Markdown 초안을 외부 공유용 Word 문서로 바꾸거나, Markdown 기반 보고서를 정식 배포 문서로 다듬어야 할 때
|
||||
argument-hint: <입력 markdown 파일 또는 출력 목적>
|
||||
allowed-tools:
|
||||
- file_read
|
||||
- file_write
|
||||
- process
|
||||
- format_convert
|
||||
- document_plan
|
||||
- document_assemble
|
||||
- docx_create
|
||||
tabs: cowork
|
||||
---
|
||||
|
||||
Markdown 파일을 전문적인 Word 또는 PDF 문서로 변환하세요.
|
||||
## 실행 경로 선택 (Python 가능/불가)
|
||||
- 먼저 `process`로 `python --version`을 확인하세요.
|
||||
- Python 가능: 기존 python-docx 경로를 사용하세요.
|
||||
- Python 불가: `format_convert`를 우선 사용해 Markdown을 docx/pdf로 변환하고, 변환 제한 시 `file_write`로 보정 가이드를 생성하세요.
|
||||
Markdown 초안을 Word 문서로 변환하세요.
|
||||
|
||||
핵심 원칙:
|
||||
- Python 변환 스크립트보다 `document_assemble` 또는 `docx_create`를 우선 사용하세요.
|
||||
- Markdown의 제목, 목록, 표, 인용 구조를 최대한 유지하세요.
|
||||
- 단순 변환에서 끝내지 말고, 문서 목적에 맞게 `요약`, `핵심 메시지`, `다음 단계`를 보강하세요.
|
||||
|
||||
## 사전 준비
|
||||
먼저 필요한 패키지가 설치되어 있는지 확인하고, 없으면 설치하세요:
|
||||
```
|
||||
process: pip install python-docx markdown
|
||||
```
|
||||
권장 경로:
|
||||
- 이미 잘 정리된 Markdown 초안이 있으면 `file_read`로 구조를 파악한 뒤 `document_assemble`로 DOCX 조립
|
||||
- 단순 본문 몇 개를 Word로 옮기는 수준이면 `docx_create`
|
||||
- 문서 흐름을 다시 잡아야 하면 `document_plan` 후 `document_assemble`
|
||||
|
||||
## 작업 절차
|
||||
작성 지침:
|
||||
- 제목 계층은 유지하세요.
|
||||
- 표는 의미 단위가 보존되게 유지하고, 긴 문장은 표 셀에 몰아넣지 마세요.
|
||||
- 인용/주의/결론은 callout 성격으로 분리하세요.
|
||||
- 문서 끝에는 결론 또는 후속 조치 섹션을 추가하세요.
|
||||
|
||||
1. **Markdown 파일 확인**: file_read로 변환할 Markdown 파일의 내용과 구조를 파악
|
||||
2. **변환 옵션 확인**: 사용자에게 다음 옵션을 확인
|
||||
- 출력 형식: Word(.docx) 또는 PDF
|
||||
- 폰트: 맑은 고딕 (기본) / 사용자 지정
|
||||
- 여백, 페이지 크기 설정
|
||||
- 머리글/바닥글 포함 여부
|
||||
3. **Python 스크립트 작성**: file_write로 변환 스크립트 생성
|
||||
4. **스크립트 실행**: `process`로 Python 스크립트 실행
|
||||
5. **결과 확인**: 생성된 문서 파일 경로와 페이지 수를 안내
|
||||
|
||||
## 스타일 매핑
|
||||
|
||||
| Markdown | Word 스타일 | 설명 |
|
||||
|----------|------------|------|
|
||||
| `# 제목` | Heading 1 | 16pt, 굵게 |
|
||||
| `## 소제목` | Heading 2 | 14pt, 굵게 |
|
||||
| `### 항목` | Heading 3 | 12pt, 굵게 |
|
||||
| 본문 텍스트 | Normal | 11pt |
|
||||
| `**굵게**` | Bold run | 굵게 |
|
||||
| `*기울임*` | Italic run | 기울임 |
|
||||
| `` `코드` `` | 코드 스타일 | Consolas, 배경색 |
|
||||
| `> 인용` | Quote | 들여쓰기 + 왼쪽 테두리 |
|
||||
| `- 목록` | List Bullet | 글머리 기호 |
|
||||
| `1. 번호` | List Number | 번호 목록 |
|
||||
| 표 | Table Grid | 테두리 표 |
|
||||
| `---` | 페이지 구분 | 가로선 → 페이지 나누기 |
|
||||
| 코드 블록 | 코드 단락 | Consolas, 회색 배경 |
|
||||
|
||||
## Python 스크립트 템플릿
|
||||
```python
|
||||
import re
|
||||
from docx import Document
|
||||
from docx.shared import Inches, Pt, Cm, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
import markdown
|
||||
|
||||
# Markdown 파일 읽기
|
||||
with open('input.md', 'r', encoding='utf-8') as f:
|
||||
md_content = f.read()
|
||||
|
||||
doc = Document()
|
||||
|
||||
# 기본 스타일 설정
|
||||
style = doc.styles['Normal']
|
||||
font = style.font
|
||||
font.name = '맑은 고딕'
|
||||
font.size = Pt(11)
|
||||
style.element.rPr.rFonts.set(qn('w:eastAsia'), '맑은 고딕')
|
||||
|
||||
# 여백 설정
|
||||
for section in doc.sections:
|
||||
section.top_margin = Cm(2.54)
|
||||
section.bottom_margin = Cm(2.54)
|
||||
section.left_margin = Cm(3.17)
|
||||
section.right_margin = Cm(3.17)
|
||||
|
||||
# Markdown 파싱 및 변환
|
||||
lines = md_content.split('\n')
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# 제목 (Heading)
|
||||
if line.startswith('#'):
|
||||
level = len(line) - len(line.lstrip('#'))
|
||||
text = line.lstrip('#').strip()
|
||||
doc.add_heading(text, level=min(level, 4))
|
||||
|
||||
# 코드 블록
|
||||
elif line.startswith('```'):
|
||||
code_lines = []
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].startswith('```'):
|
||||
code_lines.append(lines[i])
|
||||
i += 1
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run('\n'.join(code_lines))
|
||||
run.font.name = 'Consolas'
|
||||
run.font.size = Pt(9)
|
||||
|
||||
# 인용
|
||||
elif line.startswith('>'):
|
||||
text = line.lstrip('>').strip()
|
||||
p = doc.add_paragraph(text)
|
||||
p.paragraph_format.left_indent = Cm(1.27)
|
||||
|
||||
# 글머리 기호
|
||||
elif line.startswith('- ') or line.startswith('* '):
|
||||
text = line[2:].strip()
|
||||
doc.add_paragraph(text, style='List Bullet')
|
||||
|
||||
# 번호 목록
|
||||
elif re.match(r'^\d+\.\s', line):
|
||||
text = re.sub(r'^\d+\.\s', '', line).strip()
|
||||
doc.add_paragraph(text, style='List Number')
|
||||
|
||||
# 가로선 → 페이지 나누기
|
||||
elif line.strip() in ('---', '***', '___'):
|
||||
doc.add_page_break()
|
||||
|
||||
# 빈 줄
|
||||
elif line.strip() == '':
|
||||
pass
|
||||
|
||||
# 일반 텍스트
|
||||
else:
|
||||
p = doc.add_paragraph()
|
||||
# 굵게, 기울임 처리
|
||||
parts = re.split(r'(\*\*.*?\*\*|\*.*?\*|`.*?`)', line)
|
||||
for part in parts:
|
||||
if part.startswith('**') and part.endswith('**'):
|
||||
run = p.add_run(part[2:-2])
|
||||
run.bold = True
|
||||
elif part.startswith('*') and part.endswith('*'):
|
||||
run = p.add_run(part[1:-1])
|
||||
run.italic = True
|
||||
elif part.startswith('`') and part.endswith('`'):
|
||||
run = p.add_run(part[1:-1])
|
||||
run.font.name = 'Consolas'
|
||||
run.font.size = Pt(10)
|
||||
else:
|
||||
p.add_run(part)
|
||||
|
||||
i += 1
|
||||
|
||||
# 저장
|
||||
doc.save('output.docx')
|
||||
print(f'변환 완료: output.docx ({len(doc.paragraphs)}개 단락)')
|
||||
```
|
||||
|
||||
## 표 변환
|
||||
Markdown 표가 있으면 Word 표로 변환합니다:
|
||||
- 헤더 행: 굵게, 배경색 적용
|
||||
- 셀 정렬: Markdown의 `:---`, `:---:`, `---:` 구문 반영
|
||||
- 테두리: 전체 셀에 얇은 테두리
|
||||
|
||||
## 규칙
|
||||
- 원본 Markdown 파일은 수정하지 않음
|
||||
- 인코딩: UTF-8 기본
|
||||
- 이미지 링크(``)는 로컬 파일이면 삽입, URL이면 경로만 표시
|
||||
- 복잡한 Markdown(수식, 다이어그램)은 지원 범위와 한계를 안내
|
||||
- 출력 파일명: 원본 파일명 기준 (.md → .docx)
|
||||
|
||||
한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요.
|
||||
한국어로 작성하고, 결과는 작업 폴더에 저장하세요.
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
---
|
||||
---
|
||||
name: report-writer
|
||||
label: 보고서 작성
|
||||
description: 작업 폴더의 데이터를 분석하여 체계적인 업무 보고서를 생성합<EFBFBD><EFBFBD>다.
|
||||
description: 작업 폴더의 근거 데이터를 바탕으로 HTML, Word, Excel 보고서를 구조적으로 생성합니다.
|
||||
icon: \uE9F9
|
||||
when_to_use: 파일과 데이터 근거를 바탕으로 주간·월간·현황·분석 보고서를 빠르게 정리해야 할 때
|
||||
argument-hint: <보고 목적 또는 대상>
|
||||
allowed-tools:
|
||||
- folder_map
|
||||
- file_read
|
||||
- file_write
|
||||
- data_pivot
|
||||
- chart_create
|
||||
- template_render
|
||||
- text_summarize
|
||||
- document_plan
|
||||
- document_assemble
|
||||
- html_create
|
||||
- docx_create
|
||||
- excel_create
|
||||
tabs: cowork
|
||||
---
|
||||
|
||||
작업 폴더의 파일과 데이터를 분석하여 업무 보고서를 작성하세요.
|
||||
근거 기반 보고서를 작성하세요.
|
||||
|
||||
다음 도구를 사용하세요:
|
||||
1. folder_map — 작업 폴더의 파일 구조 파악
|
||||
2. file_read — 관련 데이터 파일 읽기 (CSV, Excel, 텍스트)
|
||||
3. file_write — 보고서 파일 생성 (HTML 또는 Markdown)
|
||||
핵심 원칙:
|
||||
- 먼저 `folder_map`과 `file_read`로 근거를 확인한 뒤 작성하세요.
|
||||
- 보고서는 `Executive Summary → 현황/근거 → 핵심 발견 → 권고안 → 다음 단계` 흐름을 기본으로 합니다.
|
||||
- 사용 목적에 따라 산출 포맷을 고르되, 기본은 HTML 또는 DOCX입니다.
|
||||
- 수치가 많으면 `data_pivot` 또는 `excel_create`로 별도 워크북을 함께 만드세요.
|
||||
|
||||
보고서 구성:
|
||||
## 제목
|
||||
- 작성 일시, 작성자 (요청 시)
|
||||
권장 도구 흐름:
|
||||
1. `folder_map`으로 관련 파일 탐색
|
||||
2. `file_read`로 핵심 데이터/메모 확인
|
||||
3. 길거나 구조가 중요한 보고서는 `document_plan`
|
||||
4. 본문형 결과는 `html_create` 또는 `document_assemble`
|
||||
5. 수치형 보조 산출은 `excel_create`
|
||||
|
||||
## 요약 (Executive Summary)
|
||||
- 핵심 내용을 3줄 이내로 요약
|
||||
작성 지침:
|
||||
- 요약은 3~5개 핵심 메시지로 짧고 강하게 씁니다.
|
||||
- 본문은 근거와 해석이 연결되게 작성하세요.
|
||||
- 표/차트/KPI는 의사결정에 필요한 경우에만 사용하세요.
|
||||
- 마지막에는 요청사항, 후속 액션, 담당 주체를 분명히 적으세요.
|
||||
|
||||
## 본문
|
||||
- 데이터 기반 분석 결과
|
||||
- 표/차트를 활용한 시각적 정리
|
||||
- 주요 발견 사항
|
||||
|
||||
## 결론 및 제안
|
||||
- 결론 요약
|
||||
- 향후 조치 사항
|
||||
|
||||
HTML 보고서 생성 시 현재 적용된 디자인 무드를 반영하세요.
|
||||
한국어로 작성하세요.
|
||||
한국어로 작성하고, 결과는 작업 폴더에 저장하세요.
|
||||
|
||||
Reference in New Issue
Block a user