문서 생성 고도화 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:
2026-04-14 21:02:08 +09:00
parent 0b6d60e959
commit d9cb02f3c4
14 changed files with 1002 additions and 466 deletions

View File

@@ -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("&nbsp;", " ")
.Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace("&quot;", "\"");
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,

View File

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

View File

@@ -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,

View File

@@ -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";