모델 프로파일 기반 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:
2026-04-08 13:41:57 +09:00
parent b391dfdfb3
commit a2c952879d
552 changed files with 8094 additions and 13595 deletions

View File

@@ -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();
}
}