모델 프로파일 기반 Cowork/Code 루프와 진행 UX 고도화 반영
- 등록 모델 실행 프로파일을 검증 게이트, 문서 fallback, post-tool verification까지 확장 적용 - Cowork/Code 진행 카드에 계획/도구/검증/압축/폴백/재시도 단계 메타를 추가해 대기 상태 가시성 강화 - OpenAI/vLLM tool 요청에 병렬 도구 호출 힌트를 추가하고 회귀 프롬프트 문서를 프로파일 기준으로 전면 정리 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
@@ -1,35 +1,88 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Markdown (.md) 문서를 생성하는 내장 스킬.
|
||||
/// LLM이 마크다운 내용을 전달하면 파일로 저장합니다.
|
||||
/// sections 배열로 구조화된 콘텐츠 블록(heading/paragraph/table/list/callout/code/quote/divider/toc)을 지원합니다.
|
||||
/// frontmatter, toc 옵션으로 메타데이터와 목차를 자동 생성할 수 있습니다.
|
||||
/// content 파라미터로 기존 방식의 원시 마크다운도 계속 지원합니다.
|
||||
/// </summary>
|
||||
public class MarkdownSkill : IAgentTool
|
||||
{
|
||||
public string Name => "markdown_create";
|
||||
public string Description => "Create a Markdown (.md) document file. Provide the content in Markdown format.";
|
||||
public string Description =>
|
||||
"Create a Markdown (.md) document. " +
|
||||
"Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " +
|
||||
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents. " +
|
||||
"Use 'content' for raw markdown (backward compatible).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.md). Relative to work folder." },
|
||||
["content"] = new() { Type = "string", Description = "Markdown content to write" },
|
||||
["title"] = new() { Type = "string", Description = "Optional document title. If provided, prepends '# title' at the top." },
|
||||
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." },
|
||||
["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." },
|
||||
["content"] = new() { Type = "string", Description = "원시 마크다운 내용 (하위 호환). sections가 없을 때 사용합니다." },
|
||||
["sections"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description =
|
||||
"구조화된 콘텐츠 블록 배열. 각 객체는 'type' 필드를 가집니다. " +
|
||||
"타입: " +
|
||||
"'heading' {level:1-6, text} — 제목, " +
|
||||
"'paragraph' {text} — 단락, " +
|
||||
"'table' {headers:[], rows:[[]]} — 표, " +
|
||||
"'list' {items:[], ordered:false} — 목록, " +
|
||||
"'callout' {style:'info|warning|tip|danger', text} — 콜아웃 블록, " +
|
||||
"'code' {language:'python', code:'...'} — 코드 블록, " +
|
||||
"'quote' {text, author} — 인용구, " +
|
||||
"'divider' — 구분선, " +
|
||||
"'toc' — 이 위치에 목차 삽입."
|
||||
},
|
||||
["frontmatter"] = new()
|
||||
{
|
||||
Type = "object",
|
||||
Description = "YAML 프론트매터 메타데이터 키-값 쌍. 예: {\"author\": \"홍길동\", \"date\": \"2026-04-07\"}."
|
||||
},
|
||||
["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." },
|
||||
["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." },
|
||||
},
|
||||
Required = ["path", "content"]
|
||||
Required = []
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var content = args.GetProperty("content").GetString() ?? "";
|
||||
var title = args.TryGetProperty("title", out var t) ? t.GetString() : null;
|
||||
// ── 필수 파라미터 ──────────────────────────────────────────────────
|
||||
var hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||
var hasContent = args.TryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
|
||||
var hasFrontmatter= args.TryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
|
||||
|
||||
if (!hasSections && !hasContent)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
|
||||
|
||||
// path 미제공 시 title에서 자동 생성
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
var baseTitle = (args.TryGetProperty("title", out var autoT) ? autoT.GetString() : null) ?? "document";
|
||||
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
|
||||
}
|
||||
var title = args.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null;
|
||||
var useToc = args.TryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
|
||||
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
||||
|
||||
// ── 경로 처리 ──────────────────────────────────────────────────────
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -43,27 +96,285 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
Encoding fileEncoding;
|
||||
try { fileEncoding = Encoding.GetEncoding(encodingName); }
|
||||
catch { fileEncoding = new UTF8Encoding(false); }
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// ── YAML 프론트매터 ────────────────────────────────────────────
|
||||
if (hasFrontmatter)
|
||||
{
|
||||
sb.AppendLine("---");
|
||||
foreach (var prop in frontEl.EnumerateObject())
|
||||
{
|
||||
var val = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? ""
|
||||
: prop.Value.ToString();
|
||||
// 값에 특수 문자가 있으면 인용
|
||||
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
|
||||
val = $"\"{val.Replace("\"", "\\\"")}\"";
|
||||
sb.AppendLine($"{prop.Name}: {val}");
|
||||
}
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// ── 제목 ───────────────────────────────────────────────────────
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
sb.AppendLine($"# {title}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.Append(content);
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
|
||||
if (hasSections)
|
||||
{
|
||||
// ── sections 모드 ──────────────────────────────────────────
|
||||
// 먼저 heading 목록 수집 (TOC 생성용)
|
||||
var headings = CollectHeadings(sectionsEl);
|
||||
|
||||
// 문서 상단 TOC (toc:true 옵션)
|
||||
if (useToc && headings.Count > 0)
|
||||
{
|
||||
RenderToc(sb, headings);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// 섹션 렌더링
|
||||
foreach (var section in sectionsEl.EnumerateArray())
|
||||
{
|
||||
if (!section.TryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.GetString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "heading":
|
||||
RenderHeading(sb, section);
|
||||
break;
|
||||
case "paragraph":
|
||||
RenderParagraph(sb, section);
|
||||
break;
|
||||
case "table":
|
||||
RenderTable(sb, section);
|
||||
break;
|
||||
case "list":
|
||||
RenderList(sb, section);
|
||||
break;
|
||||
case "callout":
|
||||
RenderCallout(sb, section);
|
||||
break;
|
||||
case "code":
|
||||
RenderCode(sb, section);
|
||||
break;
|
||||
case "quote":
|
||||
RenderQuote(sb, section);
|
||||
break;
|
||||
case "divider":
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
break;
|
||||
case "toc":
|
||||
// 인라인 TOC 삽입
|
||||
if (headings.Count > 0) RenderToc(sb, headings);
|
||||
sb.AppendLine();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── 하위 호환: 원시 content 모드 ──────────────────────────
|
||||
if (useToc)
|
||||
{
|
||||
// content에서 헤딩 파싱하여 TOC 생성
|
||||
var raw = contentEl.GetString() ?? "";
|
||||
var headings = ParseHeadingsFromContent(raw);
|
||||
if (headings.Count > 0)
|
||||
{
|
||||
RenderToc(sb, headings);
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
sb.Append(contentEl.GetString() ?? "");
|
||||
}
|
||||
|
||||
// ── 파일 쓰기 ──────────────────────────────────────────────────
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct);
|
||||
|
||||
var lines = sb.ToString().Split('\n').Length;
|
||||
return ToolResult.Ok(
|
||||
$"Markdown 문서 저장 완료: {fullPath} ({lines} lines)",
|
||||
$"Markdown 문서 저장 완료: {fullPath} ({lines}줄, 인코딩: {encodingName})",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"Markdown 저장 실패: {ex.Message}");
|
||||
return ToolResult.Fail($"Markdown 문서 저장 중 오류가 발생했습니다: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헤딩 수집 (sections 배열에서) ────────────────────────────────────────
|
||||
private static List<(int Level, string Text)> CollectHeadings(JsonElement sections)
|
||||
{
|
||||
var list = new List<(int, string)>();
|
||||
foreach (var s in sections.EnumerateArray())
|
||||
{
|
||||
if (!s.TryGetProperty("type", out var t) || t.GetString() != "heading") continue;
|
||||
var level = s.TryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
|
||||
var text = s.TryGetProperty("text", out var xEl) ? xEl.GetString() ?? "" : "";
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
list.Add((Math.Clamp(level, 1, 6), text));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── 헤딩 파싱 (원시 마크다운에서) ────────────────────────────────────────
|
||||
private static List<(int Level, string Text)> ParseHeadingsFromContent(string content)
|
||||
{
|
||||
var list = new List<(int, string)>();
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var m = Regex.Match(line, @"^(#{1,6})\s+(.+)");
|
||||
if (m.Success)
|
||||
list.Add((m.Groups[1].Length, m.Groups[2].Value.Trim()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── TOC 렌더링 ────────────────────────────────────────────────────────────
|
||||
private static void RenderToc(StringBuilder sb, List<(int Level, string Text)> headings)
|
||||
{
|
||||
sb.AppendLine("## 목차");
|
||||
sb.AppendLine();
|
||||
foreach (var (level, text) in headings)
|
||||
{
|
||||
// level 1은 들여쓰기 없음, level 2 → 2스페이스, 이후 2씩 추가
|
||||
var indent = level <= 1 ? "" : new string(' ', (level - 1) * 2);
|
||||
var anchor = HeadingToAnchor(text);
|
||||
sb.AppendLine($"{indent}- [{text}](#{anchor})");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// ── GitHub-style 앵커 생성 ────────────────────────────────────────────────
|
||||
private static string HeadingToAnchor(string text)
|
||||
{
|
||||
// 소문자 변환, 영문/숫자/한글 이외 공백 → '-', 연속 '-' 정리
|
||||
var anchor = text.ToLowerInvariant();
|
||||
anchor = Regex.Replace(anchor, @"[^\w\uAC00-\uD7A3\s-]", "");
|
||||
anchor = Regex.Replace(anchor, @"\s+", "-");
|
||||
anchor = Regex.Replace(anchor, @"-+", "-").Trim('-');
|
||||
return anchor;
|
||||
}
|
||||
|
||||
// ── 개별 섹션 렌더러 ──────────────────────────────────────────────────────
|
||||
|
||||
private static void RenderHeading(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var level = s.TryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
sb.AppendLine($"{new string('#', level)} {text}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void RenderParagraph(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
sb.AppendLine(text);
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderTable(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
if (!s.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
|
||||
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.GetString() ?? "")).ToList();
|
||||
if (headers.Count == 0) return;
|
||||
|
||||
// 헤더 행
|
||||
sb.AppendLine("| " + string.Join(" | ", headers) + " |");
|
||||
// 구분 행
|
||||
sb.AppendLine("| " + string.Join(" | ", headers.Select(_ => "---")) + " |");
|
||||
// 데이터 행
|
||||
foreach (var row in rowsEl.EnumerateArray())
|
||||
{
|
||||
if (row.ValueKind != JsonValueKind.Array) continue;
|
||||
var cells = row.EnumerateArray()
|
||||
.Select(c => EscapeTableCell(c.ValueKind == JsonValueKind.Null ? "" : c.ToString()))
|
||||
.ToList();
|
||||
// 열 수 맞추기
|
||||
while (cells.Count < headers.Count) cells.Add("");
|
||||
sb.AppendLine("| " + string.Join(" | ", cells.Take(headers.Count)) + " |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static string EscapeTableCell(string value)
|
||||
=> value.Replace("|", "\\|").Replace("\n", " ").Replace("\r", "");
|
||||
|
||||
private static void RenderList(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
|
||||
var ordered = s.TryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
|
||||
int idx = 1;
|
||||
foreach (var item in itemsEl.EnumerateArray())
|
||||
{
|
||||
var text = item.ValueKind == JsonValueKind.String ? item.GetString() ?? "" : item.ToString();
|
||||
// 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로)
|
||||
var prefix = ordered ? $"{idx++}. " : "- ";
|
||||
if (text.StartsWith(" ") || text.StartsWith("\t"))
|
||||
sb.AppendLine(text); // 하위 항목: 이미 들여쓰기 포함
|
||||
else
|
||||
sb.AppendLine($"{prefix}{text}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void RenderCallout(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var style = s.TryGetProperty("style", out var stEl) ? stEl.GetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
|
||||
// 표준 GitHub/Obsidian 콜아웃 형식
|
||||
sb.AppendLine($"> [!{style}]");
|
||||
foreach (var line in text.Split('\n'))
|
||||
sb.AppendLine($"> {line}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void RenderCode(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var lang = s.TryGetProperty("language", out var lEl) ? lEl.GetString() ?? "" : "";
|
||||
var code = s.TryGetProperty("code", out var cEl) ? cEl.GetString() ?? "" : "";
|
||||
|
||||
sb.AppendLine($"```{lang}");
|
||||
sb.AppendLine(code);
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void RenderQuote(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
var author = s.TryGetProperty("author", out var aEl) ? aEl.GetString() : null;
|
||||
|
||||
foreach (var line in text.Split('\n'))
|
||||
sb.AppendLine($"> {line}");
|
||||
|
||||
if (!string.IsNullOrEmpty(author))
|
||||
sb.AppendLine($">");
|
||||
sb.AppendLine($"> — {author}");
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user