코워크 문서 생성 기본 포맷 편향 완화
Some checks failed
Release Gate / gate (push) Has been cancelled

문서 생성 도구가 인자 없이 호출될 때 html/professional로 고정되던 기본값을 제거했습니다.

DocumentPlannerTool과 DocumentAssemblerTool이 설정값(DefaultOutputFormat, DefaultMood), 문서 유형, 요청 키워드를 함께 보고 docx/html/markdown 및 corporate/dashboard/minimal/creative/professional 무드를 자동 선택하도록 조정했습니다.

README와 DEVELOPMENT 문서에 변경 배경과 검증 결과를 반영했고, dotnet build 기준 경고 0 오류 0을 확인했습니다.
This commit is contained in:
2026-04-06 15:55:12 +09:00
parent 2ae56b2510
commit 8da0a069b7
4 changed files with 159 additions and 21 deletions

View File

@@ -1262,3 +1262,7 @@ MIT License
- 일정 시간마다 표시되는 격려문구 알림 팝업의 자동 닫힘 경로를 점검하고 [ReminderPopupWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ReminderPopupWindow.xaml.cs)를 보강했다.
- 기존에는 `DispatcherTimer` 틱만으로 카운트다운과 닫힘을 함께 처리했는데, UI 틱이 밀리면 팝업 종료가 늦어질 여지가 있었다. 이제 카운트다운 표시와 실제 자동 종료를 분리해 `Task.Delay + CancellationToken` 기반 종료를 추가하고, 남은 시간은 절대 시각 기준으로 계산하도록 바꿨다.
- 이 변경으로 지정 시간이 지난 뒤에도 격려 팝업이 남아 있는 증상을 줄이고, 자동 닫힘이 더 안정적으로 동작하도록 맞췄다.
- 업데이트: 2026-04-06 16:02 (KST)
- 코워크 문서 생성이 항상 비슷한 HTML로 수렴하던 원인을 줄이기 위해 [DocumentPlannerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs), [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)의 기본 포맷/무드 선택 로직을 재정리했다.
- 이제 인자 없이 문서 생성 도구를 호출해도 무조건 `html + professional`로 고정되지 않고, `DefaultOutputFormat`, `DefaultMood`, 문서 유형(`proposal`, `analysis`, `manual`, `minutes` 등), 요청 주제 키워드를 함께 보고 `docx/html/markdown``corporate/dashboard/minimal/creative/professional`을 자동 선택한다.
- 이 변경으로 AX의 문서 생성 체인이 `claw-code`처럼 요청 기반 자유 작성 흐름에 더 가까워졌고, 코워크 결과물이 항상 비슷한 보고서형 HTML로 반복되던 현상을 줄일 기반을 마련했다.

View File

@@ -4967,3 +4967,5 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Document update: 2026-04-06 15:41 (KST) - Reworked the empty-state layout in `ChatWindow.xaml` so the icon/title/description block and the preset grid live inside one vertically centered stack. This fixes the previous behavior where only the preset cards looked centered while the descriptive header stayed visually high on tall windows.
- Document update: 2026-04-06 15:48 (KST) - Hardened the unlock-reminder popup auto-close path in `ReminderPopupWindow.xaml.cs`. The window now tracks an absolute close deadline and uses a separate `Task.Delay + CancellationToken` fail-safe to close the popup even if the UI timer tick is delayed.
- Document update: 2026-04-06 15:48 (KST) - The countdown bar still updates through `DispatcherTimer`, but the actual close action is now guaranteed by the background delay path. This reduces cases where encouragement popups remain visible after the configured display duration.
- Document update: 2026-04-06 16:02 (KST) - Relaxed AX Cowork document-generation defaults so the planner/assembler path no longer collapses to `html + professional` whenever explicit arguments are omitted. `DocumentPlannerTool.cs` now resolves output format and mood from `Llm.DefaultOutputFormat`, `Llm.DefaultMood`, document type, and request keywords instead of hardcoding HTML/professional defaults.
- Document update: 2026-04-06 16:02 (KST) - `DocumentAssemblerTool.cs` now mirrors that resolution logic at assembly time, supporting `auto` as a first-class format input and inferring `docx/html/markdown` plus `corporate/dashboard/minimal/creative/professional` mood variants from title/section intent. This reduces repetitive HTML-style outputs and brings AX closer to the more request-driven document flow seen in `claw-code`.

View File

@@ -10,6 +10,17 @@ namespace AxCopilot.Services.Agent;
/// </summary>
public class DocumentAssemblerTool : IAgentTool
{
private static string GetDefaultOutputFormat()
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.DefaultOutputFormat ?? "auto";
}
private static string GetDefaultMood()
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.DefaultMood ?? "modern";
}
public string Name => "document_assemble";
public string Description =>
@@ -35,13 +46,13 @@ public class DocumentAssemblerTool : IAgentTool
["format"] = new()
{
Type = "string",
Description = "Output format: html, docx, markdown. Default: html",
Enum = ["html", "docx", "markdown"]
Description = "Output format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)",
Enum = ["auto", "html", "docx", "markdown"]
},
["mood"] = new()
{
Type = "string",
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional"
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: inferred from settings and document intent"
},
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" },
["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." },
@@ -55,8 +66,8 @@ public class DocumentAssemblerTool : IAgentTool
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.GetProperty("title").GetString() ?? "Document";
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "professional" : "professional";
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
var requestedMood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? GetDefaultMood() : GetDefaultMood();
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
@@ -78,6 +89,9 @@ public class DocumentAssemblerTool : IAgentTool
if (sections.Count == 0)
return ToolResult.Fail("조립할 섹션이 없습니다.");
var format = ResolveDocumentFormat(requestedFormat, title, sections);
var mood = ResolveDocumentMood(requestedMood, title, sections);
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -318,6 +332,57 @@ public class DocumentAssemblerTool : IAgentTool
return " ✓ Markdown 조립 완료";
}
private static string ResolveDocumentFormat(string? preferred, string title, List<(string Heading, string Content, int Level)> sections)
{
var normalized = (preferred ?? "auto").Trim().ToLowerInvariant();
if (normalized is "html" or "docx" or "markdown")
return normalized;
var intent = BuildIntentText(title, sections);
if (ContainsAny(intent, "docx", "word", "워드"))
return "docx";
if (ContainsAny(intent, "markdown", ".md", "마크다운", "readme", "가이드", "매뉴얼", "회의록"))
return "markdown";
if (ContainsAny(intent, "html", "웹 문서", "웹페이지", "대시보드"))
return "html";
if (ContainsAny(intent, "proposal", "제안", "제안서", "보고"))
return "docx";
if (ContainsAny(intent, "analysis", "분석", "지표", "통계"))
return "html";
return "docx";
}
private static string ResolveDocumentMood(string? preferred, string title, List<(string Heading, string Content, int Level)> sections)
{
var normalized = (preferred ?? "modern").Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalized) && normalized != "modern")
return normalized;
var intent = BuildIntentText(title, sections);
if (ContainsAny(intent, "dashboard", "분석", "지표", "통계", "kpi"))
return "dashboard";
if (ContainsAny(intent, "기획", "아이디어", "브레인스토밍", "creative"))
return "creative";
if (ContainsAny(intent, "기업", "공식", "proposal", "제안서", "사내"))
return "corporate";
if (ContainsAny(intent, "가이드", "매뉴얼", "manual", "guide"))
return "minimal";
if (ContainsAny(intent, "회의록", "minutes"))
return "professional";
return "professional";
}
private static string BuildIntentText(string title, List<(string Heading, string Content, int Level)> sections)
{
var headingText = string.Join(" ", sections.Select(s => s.Heading));
return $"{title} {headingText}".ToLowerInvariant();
}
private static bool ContainsAny(string text, params string[] keywords)
=> keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
private static int EstimateWordCount(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0;

View File

@@ -23,6 +23,18 @@ public class DocumentPlannerTool : IAgentTool
return app?.SettingsService?.Settings.Llm.FolderDataUsage ?? "none";
}
private static string GetDefaultOutputFormat()
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.DefaultOutputFormat ?? "auto";
}
private static string GetDefaultMood()
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.DefaultMood ?? "modern";
}
public string Name => "document_plan";
public string Description =>
"Create a structured document outline/plan and optionally generate the document file immediately. " +
@@ -53,8 +65,8 @@ public class DocumentPlannerTool : IAgentTool
["format"] = new()
{
Type = "string",
Description = "Output document format: html, docx, markdown. Default: html",
Enum = ["html", "docx", "markdown"]
Description = "Output document format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)",
Enum = ["auto", "html", "docx", "markdown"]
},
["sections_hint"] = new()
{
@@ -75,7 +87,7 @@ public class DocumentPlannerTool : IAgentTool
var topic = args.GetProperty("topic").GetString() ?? "";
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
@@ -87,6 +99,8 @@ public class DocumentPlannerTool : IAgentTool
var highQuality = IsMultiPassEnabled();
var folderDataUsage = GetFolderDataUsage();
var format = ResolveDocumentFormat(requestedFormat, docType, topic);
var mood = ResolveDocumentMood(GetDefaultMood(), docType, topic);
// 고품질 모드: 목표 페이지와 단어 수를 1.5배 확장
var effectivePages = highQuality ? (int)Math.Ceiling(targetPages * 1.5) : targetPages;
@@ -97,16 +111,16 @@ public class DocumentPlannerTool : IAgentTool
// 폴더 데이터 활용 모드: 먼저 파일을 읽어야 하므로 별도 처리
if (folderDataUsage is "active" or "passive")
return ExecuteSinglePassWithData(topic, docType, format, effectivePages, totalWords, sections, folderDataUsage, refSummary);
return ExecuteSinglePassWithData(topic, docType, format, mood, effectivePages, totalWords, sections, folderDataUsage, refSummary);
// 일반/고품질 모드 모두 동일 구조:
// 개요 반환 + document_assemble 즉시 호출 지시
return ExecuteWithAssembleInstructions(topic, docType, format, effectivePages, totalWords, sections, highQuality);
return ExecuteWithAssembleInstructions(topic, docType, format, mood, effectivePages, totalWords, sections, highQuality);
}
// ─── 통합: 포맷별 body 골격 생성 + 즉시 호출 가능한 도구 파라미터 제시 ──────
private Task<ToolResult> ExecuteWithAssembleInstructions(string topic, string docType, string format,
private Task<ToolResult> ExecuteWithAssembleInstructions(string topic, string docType, string format, string mood,
int targetPages, int totalWords, List<SectionPlan> sections, bool highQuality)
{
var safeTitle = SanitizeFileName(topic);
@@ -121,13 +135,13 @@ public class DocumentPlannerTool : IAgentTool
case "docx":
return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
default: // html
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label, mood);
}
}
/// <summary>html_create 즉시 호출 가능한 body 골격 반환. 섹션 구조만 고정하고 내부 시각화는 LLM이 자유롭게 선택.</summary>
private Task<ToolResult> ExecuteWithHtmlScaffold(string topic, string fileName,
List<SectionPlan> sections, int targetPages, int totalWords, string label)
List<SectionPlan> sections, int targetPages, int totalWords, string label, string mood)
{
var bodySb = new StringBuilder();
foreach (var s in sections)
@@ -148,10 +162,10 @@ public class DocumentPlannerTool : IAgentTool
output.AppendLine("## 즉시 실행: html_create 호출");
output.AppendLine($"path: \"{fileName}\"");
output.AppendLine($"title: \"{topic}\"");
output.AppendLine("toc: true, numbered: true, mood: \"professional\"");
output.AppendLine($"toc: true, numbered: true, mood: \"{mood}\"");
output.AppendLine($"cover: {{\"title\": \"{topic}\", \"author\": \"AX Copilot Agent\"}}");
output.AppendLine();
output.AppendLine("body에 아래 섹션 구조를 기반으로 각 섹션의 내용과 시각화를 자유롭게 작성하세요:");
output.AppendLine($"body에 아래 섹션 구조를 기반으로 각 섹션의 내용과 시각화를 자유롭게 작성하세요. (추천 디자인: {mood})");
output.AppendLine("(주석의 '활용 가능 요소'는 참고용이며, 내용에 맞게 다른 요소를 써도 됩니다)");
output.AppendLine();
output.AppendLine("--- body 시작 ---");
@@ -275,7 +289,7 @@ public class DocumentPlannerTool : IAgentTool
// ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ──
private Task<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format,
private Task<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format, string mood,
int targetPages, int totalWords, List<SectionPlan> sections, string folderDataUsage, string refSummary)
{
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
@@ -307,11 +321,12 @@ public class DocumentPlannerTool : IAgentTool
? "(skipped)"
: "Summarize the key findings from the folder documents relevant to the topic.",
step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.",
step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
}
};
step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
preferred_mood = format == "html" ? mood : null as string,
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
}
};
var json = JsonSerializer.Serialize(plan, _jsonOptions);
@@ -322,6 +337,7 @@ public class DocumentPlannerTool : IAgentTool
return Task.FromResult(ToolResult.Ok(
$"📋 문서 개요가 생성되었습니다 [싱글패스+데이터활용] ({sections.Count}개 섹션, 목표 {targetPages}페이지/{totalWords}단어).\n" +
$"{dataNote}\n" +
$"{(format == "html" ? $" : {mood}\n" : string.Empty)}" +
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
}
@@ -584,6 +600,57 @@ public class DocumentPlannerTool : IAgentTool
private static string Escape(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static string ResolveDocumentFormat(string? preferred, string docType, string topic)
{
var normalized = (preferred ?? "auto").Trim().ToLowerInvariant();
if (normalized is "html" or "docx" or "markdown")
return normalized;
var intent = $"{docType} {topic}".ToLowerInvariant();
if (ContainsAny(intent, "docx", "word", "워드"))
return "docx";
if (ContainsAny(intent, "markdown", ".md", "md ", "마크다운", "readme", "가이드", "manual", "guide", "회의록", "minutes"))
return "markdown";
if (ContainsAny(intent, "html", "웹 문서", "웹페이지"))
return "html";
return docType switch
{
"manual" or "guide" or "minutes" => "markdown",
"proposal" or "presentation" => "docx",
"analysis" => "html",
_ => "docx",
};
}
private static string ResolveDocumentMood(string? preferred, string docType, string topic)
{
var normalized = (preferred ?? "modern").Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalized) && normalized != "modern")
return normalized;
var intent = $"{docType} {topic}".ToLowerInvariant();
if (ContainsAny(intent, "대시보드", "dashboard", "분석", "analysis", "지표", "통계"))
return "dashboard";
if (ContainsAny(intent, "기획", "아이디어", "creative", "브레인스토밍"))
return "creative";
if (ContainsAny(intent, "기업", "공식", "proposal", "제안서", "사내 보고"))
return "corporate";
if (ContainsAny(intent, "가이드", "manual", "guide", "매뉴얼"))
return "minimal";
return docType switch
{
"proposal" => "corporate",
"analysis" => "dashboard",
"manual" or "guide" => "minimal",
"presentation" => "creative",
"minutes" => "professional",
_ => "professional",
};
}
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,