SQL 정적 분석과 PPT·HTML 품질 기준을 강화하고 언어 fallback을 고도화한다

- SQL 전용 정적 분석 계층을 추가해 PostgreSQL/MySQL/SQL Server/SQLite/Oracle 방언 추정, statement kind 분류, object 추출, destructive DDL·broad DML·transaction boundary 위험 감지를 지원한다

- CodeLanguageCatalog의 SQL manifest/build/test/lint 힌트와 fallback summary를 SQL 분석 결과 중심으로 보강해 no-LSP 환경에서도 dialect·risk·next checks를 직접 안내한다

- DeckPlanningService가 구조화된 content 슬라이드를 kpi_dashboard/comparison/roadmap/chart로 자동 승격하고 DeckQualityReviewService·DeckRepairGuideService가 KPI 근거, verdict, owner/timeline, takeaway 부족을 별도 진단·보정한다

- HtmlSkill에 kpi_panel 섹션을 추가하고 ArtifactQualityReviewService·ArtifactRepairGuideService가 board/strategy 문서의 KPI·evidence·decision 연결 부족을 더 정확히 감지하도록 확장한다

- README.md, docs/DEVELOPMENT.md, docs/NEXT_ROADMAP.md에 2026-04-15 11:17 (KST) 기준 작업 이력과 검증 결과를 반영했다

검증 결과

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_sql_doc_batch\ -p:IntermediateOutputPath=obj\verify_sql_doc_batch\ : 경고 0 / 오류 0

- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter SqlDialectDetectorTests|SqlAnalysisServiceTests|CodeLanguageCatalogTests|DeckPlanningServiceTests|DeckQualityReviewServiceTests|ArtifactQualityReviewServiceTests|ArtifactRepairGuideServiceTests|HtmlSkillConsultingSectionsTests|HtmlSkillGoldenReportTests|PptxSkillGoldenDeckTests -p:OutputPath=bin\verify_sql_doc_batch_tests\ -p:IntermediateOutputPath=obj\verify_sql_doc_batch_tests\ : 통과 47
This commit is contained in:
2026-04-15 11:19:55 +09:00
parent f283662d30
commit 93c3c647d9
19 changed files with 605 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ public class ArtifactQualityReviewServiceTests
<h2>Current State</h2><p>Current state detail.</p><p>Evidence paragraph.</p>
<div class="callout-info">Important message</div>
<table><tr><th>Metric</th><th>Value</th></tr><tr><td>NPS</td><td>61</td></tr></table>
<div class="kpi-panel"></div>
<div class="comparison-grid"></div>
<div class="roadmap-block"></div>
<div class="decision-summary"></div>
@@ -138,6 +139,27 @@ public class ArtifactQualityReviewServiceTests
review.Issues.Should().Contain(issue => issue.Message.Contains("Strategy brief should include explicit decisions", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ReviewHtml_ShouldRecommendKpiSupport_ForBoardAndStrategyPanels()
{
var html =
"""
<h2>Executive Summary</h2><p>Summary text.</p><p>Supporting paragraph.</p>
<section class="board-report-panel"></section>
<section class="strategy-brief-panel"></section>
<div class="decision-summary"></div>
<div class="comparison-grid"></div>
<div class="roadmap-block"></div>
<h2>Recommendation</h2><p>Recommendation text.</p><p>More detail.</p>
<h2>Appendix</h2><p>Reference detail.</p><p>More detail.</p>
""";
var review = ArtifactQualityReviewService.ReviewHtml("KPI Gap", html, hasCover: true, hasTableOfContents: true, printReady: true);
review.Issues.Should().Contain(issue => issue.Message.Contains("KPI panel or metric strip", StringComparison.OrdinalIgnoreCase));
review.Issues.Should().Contain(issue => issue.Message.Contains("KPI or evidence support blocks", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ReviewStructuredDocument_ShouldRecommendEvidenceTableAndCallouts_ForLongBusinessDoc()
{

View File

@@ -36,12 +36,14 @@ public class ArtifactRepairGuideServiceTests
["Includes print-ready CSS"],
[
new ArtifactReviewIssue("Board-ready report should include a decision summary block.", ArtifactReviewSeverity.Warning),
new ArtifactReviewIssue("Board-ready report would be stronger with a KPI panel or metric strip.", ArtifactReviewSeverity.Info),
new ArtifactReviewIssue("Strategy brief should include explicit decisions or a decision summary block.", ArtifactReviewSeverity.Warning)
]);
var guide = ArtifactRepairGuideService.BuildGuide(review);
guide.Should().Contain("decision summary");
guide.Should().Contain("KPI");
guide.Should().Contain("strategy brief");
}

View File

@@ -77,6 +77,7 @@ public class CodeLanguageCatalogTests
CodeLanguageCatalog.GetBuildHints("go").Should().Contain("go build ./...");
CodeLanguageCatalog.GetTestHints("kotlin").Should().Contain("./gradlew test");
CodeLanguageCatalog.GetLintHints("javascript").Should().Contain("eslint .");
CodeLanguageCatalog.GetManifestHints("sql").Should().Contain("schema.sql");
}
[Fact]
@@ -92,4 +93,15 @@ public class CodeLanguageCatalogTests
summaries.Should().Contain(summary => summary.StartsWith("Go:"));
summaries.Should().Contain(summary => summary.StartsWith("Rust:"));
}
[Fact]
public void BuildWorkflowSummary_ShouldExposeSqlSpecificWorkflowHints()
{
var summary = CodeLanguageCatalog.BuildWorkflowSummary("sql");
summary.Should().Contain("SQL");
summary.Should().Contain("schema.sql");
summary.Should().Contain("disposable database");
summary.Should().Contain("dialect");
}
}

View File

@@ -51,4 +51,51 @@ public class DeckPlanningServiceTests
result.AutoRepairCount.Should().BeGreaterThan(0);
result.Storyline.Should().NotBeEmpty();
}
[Fact]
public void Prepare_ShouldPromoteStructuredContentSlides_ToBetterLayouts()
{
using var args = JsonDocument.Parse(
"""
{
"title": "Operating Review",
"slides": [
{
"layout": "content",
"title": "KPI Snapshot",
"kpis": [
{ "label": "Margin", "value": "+4.2pt" },
{ "label": "Lead Time", "value": "-18%" },
{ "label": "Retention", "value": "92%" }
]
},
{
"layout": "content",
"title": "Options",
"options": [
{ "name": "Pilot", "pros": "Low risk", "cons": "Slow", "verdict": "Too cautious" },
{ "name": "Phased", "pros": "Balanced", "cons": "Needs PMO", "verdict": "Recommended" }
]
},
{
"layout": "content",
"title": "Execution",
"phases": [
{ "title": "Design", "detail": "Scope and governance" },
{ "title": "Launch", "detail": "Rollout wave" }
]
}
]
}
""");
using var result = DeckPlanningService.Prepare(args.RootElement, args.RootElement.GetProperty("slides"), "Operating Review");
var layouts = result.Slides.EnumerateArray()
.Select(slide => slide.GetProperty("layout").GetString())
.ToList();
layouts.Should().Contain("kpi_dashboard");
layouts.Should().Contain("comparison");
layouts.Should().Contain("roadmap");
}
}

View File

@@ -60,6 +60,16 @@ public class HtmlSkillConsultingSectionsTests
"rationale": "Retention and margin improved in the pilot region.",
"actions": ["Approve budget", "Launch phase 2"]
},
{
"type": "kpi_panel",
"title": "KPI Panel",
"headline": "Pilot economics remain above threshold",
"items": [
{ "label": "Margin", "value": "+4.2pt", "trend": "up", "note": "pilot" },
{ "label": "Lead Time", "value": "-18%", "trend": "down", "note": "handoff" }
],
"takeaway": "The pilot shows both service and margin improvement."
},
{
"type": "evidence_cards",
"title": "Evidence",
@@ -101,6 +111,7 @@ public class HtmlSkillConsultingSectionsTests
html.Should().Contain("roadmap-block");
html.Should().Contain("matrix-grid");
html.Should().Contain("decision-summary");
html.Should().Contain("kpi-panel");
html.Should().Contain("evidence-cards");
html.Should().Contain("board-report-panel");
html.Should().Contain("strategy-brief-panel");

View File

@@ -0,0 +1,58 @@
using System.IO;
using AxCopilot.Services;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class SqlAnalysisServiceTests
{
[Fact]
public void Analyze_ShouldSurfaceStatementKindsObjectsAndRisks()
{
const string sql =
"""
BEGIN TRANSACTION;
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT);
UPDATE orders SET customer_id = 0;
DROP TABLE legacy_orders;
COMMIT;
""";
var report = SqlAnalysisService.Analyze(sql, "20260415_orders_migration.sql");
report.Dialect.Should().Be("PostgreSQL");
report.StatementKinds.Should().Contain("CREATE TABLE");
report.StatementKinds.Should().Contain("UPDATE");
report.StatementKinds.Should().Contain("DROP TABLE");
report.Objects.Should().Contain("orders");
report.Objects.Should().Contain("legacy_orders");
report.Risks.Should().Contain(risk => risk.Contains("UPDATE statement without WHERE", StringComparison.OrdinalIgnoreCase));
report.Risks.Should().Contain(risk => risk.Contains("Destructive DROP", StringComparison.OrdinalIgnoreCase));
report.SuggestedChecks.Should().Contain(check => check.Contains("rollback", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void BuildFallbackSummary_ShouldReadSqlFileAndReturnSqlSpecificSummary()
{
var path = Path.GetTempFileName() + ".sql";
try
{
File.WriteAllText(path, "CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT); DELETE FROM users;");
var summary = SqlAnalysisService.BuildFallbackSummary(path);
summary.Should().Contain("SQL static analysis");
summary.Should().Contain("PostgreSQL");
summary.Should().Contain("CREATE TABLE");
summary.Should().Contain("DELETE");
summary.Should().Contain("users");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
}

View File

@@ -0,0 +1,18 @@
using AxCopilot.Services;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class SqlDialectDetectorTests
{
[Theory]
[InlineData("CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);", "PostgreSQL")]
[InlineData("CREATE TABLE [dbo].[Users] (Id INT IDENTITY(1,1), Name NVARCHAR(100)); GO", "SQL Server")]
[InlineData("CREATE TABLE `users` (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100)) ENGINE=InnoDB;", "MySQL")]
[InlineData("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); PRAGMA foreign_keys=ON;", "SQLite")]
public void DetectDialect_ShouldRecognizeCommonDialects(string sql, string expected)
{
SqlDialectDetector.DetectDialect(sql).Should().Be(expected);
}
}

View File

@@ -130,6 +130,7 @@ public static class ArtifactQualityReviewService
var roadmapCount = Regex.Matches(html, @"roadmap(-block|-phase)?", RegexOptions.IgnoreCase).Count;
var matrixCount = Regex.Matches(html, @"matrix-grid|risk-matrix", RegexOptions.IgnoreCase).Count;
var decisionCount = Regex.Matches(html, @"decision-summary", RegexOptions.IgnoreCase).Count;
var kpiPanelCount = Regex.Matches(html, @"kpi-panel", RegexOptions.IgnoreCase).Count;
var evidenceCardCount = Regex.Matches(html, @"evidence-cards|evidence-card", RegexOptions.IgnoreCase).Count;
var boardPanelCount = Regex.Matches(html, @"board-report-panel", RegexOptions.IgnoreCase).Count;
var strategyBriefCount = Regex.Matches(html, @"strategy-brief-panel", RegexOptions.IgnoreCase).Count;
@@ -141,6 +142,7 @@ public static class ArtifactQualityReviewService
+ (roadmapCount > 0 ? 1 : 0)
+ (matrixCount > 0 ? 1 : 0)
+ (decisionCount > 0 ? 1 : 0)
+ (kpiPanelCount > 0 ? 1 : 0)
+ (evidenceCardCount > 0 ? 1 : 0)
+ (kpiCount > 0 ? 1 : 0);
var supportingBlockEstimate = paragraphCount
@@ -150,6 +152,7 @@ public static class ArtifactQualityReviewService
+ roadmapCount
+ matrixCount
+ decisionCount
+ kpiPanelCount
+ evidenceCardCount
+ boardPanelCount
+ strategyBriefCount
@@ -161,11 +164,12 @@ public static class ArtifactQualityReviewService
if (printReady) strengths.Add("Includes print-ready CSS");
if (hasPrintFrame) strengths.Add("Includes print header/footer frame");
if (majorSectionEstimate >= 5) strengths.Add($"Contains {majorSectionEstimate} major sections");
if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || decisionCount > 0 || evidenceCardCount > 0 || boardPanelCount > 0 || strategyBriefCount > 0 || kpiCount > 0)
if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || decisionCount > 0 || kpiPanelCount > 0 || evidenceCardCount > 0 || boardPanelCount > 0 || strategyBriefCount > 0 || kpiCount > 0)
strengths.Add("Uses structured business blocks");
if (calloutCount > 0) strengths.Add("Uses callout blocks for emphasis");
if (boardPanelCount > 0) strengths.Add("Includes board-ready summary panel");
if (strategyBriefCount > 0) strengths.Add("Includes strategy brief panel");
if (kpiPanelCount > 0) strengths.Add("Includes KPI panel");
if (html.Length < 1800)
issues.Add(new("Body content may be too short for an executive-quality document.", ArtifactReviewSeverity.Warning));
@@ -173,7 +177,7 @@ public static class ArtifactQualityReviewService
issues.Add(new("Major section count is low for a business report.", ArtifactReviewSeverity.Warning));
if (supportingBlockEstimate < Math.Max(4, majorSectionEstimate * 2))
issues.Add(new("Several sections may need more supporting paragraphs.", ArtifactReviewSeverity.Warning));
if (tableCount + comparisonCount + roadmapCount + matrixCount + decisionCount + evidenceCardCount + boardPanelCount + strategyBriefCount + kpiCount == 0)
if (tableCount + comparisonCount + roadmapCount + matrixCount + decisionCount + kpiPanelCount + evidenceCardCount + boardPanelCount + strategyBriefCount + kpiCount == 0)
issues.Add(new("Structured visual blocks are limited.", ArtifactReviewSeverity.Warning));
if (placeholderCount > 0)
issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", ArtifactReviewSeverity.Critical));
@@ -187,12 +191,18 @@ public static class ArtifactQualityReviewService
issues.Add(new("Print-ready report could benefit from a cover page.", ArtifactReviewSeverity.Info));
if (boardPanelCount > 0 && decisionCount == 0)
issues.Add(new("Board-ready report should include a decision summary block.", ArtifactReviewSeverity.Warning));
if (boardPanelCount > 0 && kpiPanelCount == 0 && kpiCount == 0)
issues.Add(new("Board-ready report would be stronger with a KPI panel or metric strip.", ArtifactReviewSeverity.Info));
if (boardPanelCount > 0 && evidenceCardCount == 0 && tableCount == 0)
issues.Add(new("Board-ready report would benefit from evidence cards or a supporting table.", ArtifactReviewSeverity.Info));
if (strategyBriefCount > 0 && decisionCount == 0)
issues.Add(new("Strategy brief should include explicit decisions or a decision summary block.", ArtifactReviewSeverity.Warning));
if (strategyBriefCount > 0 && comparisonCount + roadmapCount == 0)
issues.Add(new("Strategy brief would be stronger with a comparison or roadmap block.", ArtifactReviewSeverity.Info));
if (strategyBriefCount > 0 && evidenceCardCount + kpiPanelCount == 0)
issues.Add(new("Strategy brief would benefit from KPI or evidence support blocks.", ArtifactReviewSeverity.Info));
if (kpiPanelCount > 0 && decisionCount == 0 && boardPanelCount == 0)
issues.Add(new("KPI panel should roll into an explicit decision or next-step block.", ArtifactReviewSeverity.Info));
return BuildReport("html", strengths, issues);
}

View File

@@ -36,16 +36,22 @@ public static class ArtifactRepairGuideService
{
if (message.Contains("Board-ready report should include a decision summary block", StringComparison.OrdinalIgnoreCase))
return "Add a decision summary block directly after the board ask so the approval point is explicit";
if (message.Contains("KPI panel or metric strip", StringComparison.OrdinalIgnoreCase))
return "Add a KPI panel or metric strip so the board report shows the top-line business signal at a glance";
if (message.Contains("decision summary or evidence cards", StringComparison.OrdinalIgnoreCase))
return "Add a decision summary and evidence cards near the recommendation section";
if (message.Contains("Strategy brief should include explicit decisions", StringComparison.OrdinalIgnoreCase))
return "Add an explicit decisions block or decision summary to the strategy brief";
if (message.Contains("KPI or evidence support blocks", StringComparison.OrdinalIgnoreCase))
return "Add KPI or evidence support blocks so the strategy brief is backed by measurable proof";
if (message.Contains("cover page", StringComparison.OrdinalIgnoreCase))
return "Add a cover page for print-ready or board-facing reports";
if (message.Contains("table of contents", StringComparison.OrdinalIgnoreCase))
return "Add a table of contents for longer print-ready reports";
if (message.Contains("comparison or roadmap", StringComparison.OrdinalIgnoreCase))
return "Add comparison or roadmap blocks to make options and sequencing explicit";
if (message.Contains("explicit decision or next-step block", StringComparison.OrdinalIgnoreCase))
return "Connect the KPI panel to an explicit decision or next-step block";
if (message.Contains("supporting paragraphs", StringComparison.OrdinalIgnoreCase))
return "Expand the core sections with supporting paragraphs and business evidence";
if (message.Contains("Structured visual blocks are limited", StringComparison.OrdinalIgnoreCase))

View File

@@ -163,6 +163,35 @@ public static class DeckPlanningService
repairs++;
}
var requestedLayout = layout.Trim().ToLowerInvariant();
if (requestedLayout == "content")
{
if (HasArrayItems(slide, "kpis"))
{
slide["layout"] = "kpi_dashboard";
layout = "kpi_dashboard";
repairs++;
}
else if (HasChartData(slide))
{
slide["layout"] = "chart";
layout = "chart";
repairs++;
}
else if (HasArrayItems(slide, "options"))
{
slide["layout"] = "comparison";
layout = "comparison";
repairs++;
}
else if (HasArrayItems(slide, "phases"))
{
slide["layout"] = "roadmap";
layout = "roadmap";
repairs++;
}
}
switch (layout.Trim().ToLowerInvariant())
{
case "issue_tree":
@@ -599,6 +628,13 @@ public static class DeckPlanningService
values.Count > 0;
}
private static bool HasArrayItems(JsonObject slide, string propertyName)
{
return slide.TryGetPropertyValue(propertyName, out var node)
&& node is JsonArray array
&& array.Count > 0;
}
private static int ClampArray(JsonObject slide, string propertyName, int maxCount)
{
if (!slide.TryGetPropertyValue(propertyName, out var node) || node is not JsonArray array || array.Count <= maxCount)

View File

@@ -212,6 +212,11 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: Executive Summary should include a recommendation or decision ask.", DeckReviewSeverity.Info));
added++;
}
if (ReadJsonArrayCount(slide, "kpis") == 0)
{
issues.Add(new($"Slide {slideNumber}: Executive Summary should include KPI cards or quantified evidence.", DeckReviewSeverity.Info));
added++;
}
break;
case "recommendation":
if (string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
@@ -232,6 +237,11 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: comparison slide needs at least two options.", DeckReviewSeverity.Warning));
added++;
}
else if (!slide.GetRawText().Contains("\"verdict\"", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new($"Slide {slideNumber}: comparison slide should highlight a clear verdict.", DeckReviewSeverity.Info));
added++;
}
break;
case "roadmap":
if (ReadJsonArrayCount(slide, "phases") == 0)
@@ -244,6 +254,12 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: roadmap slide should show at least two phases.", DeckReviewSeverity.Info));
added++;
}
if (!slide.GetRawText().Contains("\"owner\"", StringComparison.OrdinalIgnoreCase) ||
!slide.GetRawText().Contains("\"timeline\"", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new($"Slide {slideNumber}: roadmap slide would be stronger with timeline and owner labels.", DeckReviewSeverity.Info));
added++;
}
break;
case "chart":
if (ReadJsonArrayCount(slide, "chart_labels") == 0 || ReadJsonArrayCount(slide, "chart_values") == 0)
@@ -259,6 +275,19 @@ public static class DeckQualityReviewService
added++;
}
break;
case "kpi_dashboard":
if (ReadJsonArrayCount(slide, "kpis") < 3)
{
issues.Add(new($"Slide {slideNumber}: KPI dashboard should show at least three metrics.", DeckReviewSeverity.Warning));
added++;
}
if (ReadStringList(slide, "summary_points").Count == 0 &&
ReadStringList(slide, "body").Count == 0)
{
issues.Add(new($"Slide {slideNumber}: KPI dashboard needs takeaway points, not just metric tiles.", DeckReviewSeverity.Info));
added++;
}
break;
}
return added;

View File

@@ -16,12 +16,18 @@ public static class DeckRepairGuideService
=> "Add a recommendation or decision ask directly to the Executive Summary",
var message when message.Contains("Executive Summary", StringComparison.OrdinalIgnoreCase)
=> "Add or strengthen the Executive Summary with 2-3 evidence-backed takeaways",
var message when message.Contains("KPI cards or quantified evidence", StringComparison.OrdinalIgnoreCase)
=> "Add KPI cards or quantified evidence so the executive message is backed by numbers",
var message when message.Contains("supporting rationale or next steps", StringComparison.OrdinalIgnoreCase)
=> "Add supporting rationale or next steps so the recommendation turns into action",
var message when message.Contains("Recommendation", StringComparison.OrdinalIgnoreCase)
=> "Add a clear recommendation or decision request slide near the end of the deck",
var message when message.Contains("roadmap", StringComparison.OrdinalIgnoreCase)
=> "Add a roadmap slide with phases, owners, and timing",
var message when message.Contains("clear verdict", StringComparison.OrdinalIgnoreCase)
=> "Label the preferred option explicitly so the comparison slide lands a decision",
var message when message.Contains("timeline and owner labels", StringComparison.OrdinalIgnoreCase)
=> "Add timeline and owner labels so the roadmap reads like an execution plan",
var message when message.Contains("Appendix or evidence slide", StringComparison.OrdinalIgnoreCase)
=> "Add appendix or evidence slides with supporting data, assumptions, or source tables",
var message when message.Contains("duplicate headline", StringComparison.OrdinalIgnoreCase)
@@ -42,6 +48,10 @@ public static class DeckRepairGuideService
=> "Provide chart labels and values or replace the slide with a comparison/evidence layout",
var message when message.Contains("table slide", StringComparison.OrdinalIgnoreCase)
=> "Provide complete table headers and rows or simplify the slide to key callouts",
var message when message.Contains("KPI dashboard should show at least three metrics", StringComparison.OrdinalIgnoreCase)
=> "Expand the KPI dashboard to at least three core metrics so the trend is clear",
var message when message.Contains("KPI dashboard needs takeaway points", StringComparison.OrdinalIgnoreCase)
=> "Add takeaway points beneath the KPI tiles so the metrics translate into action",
var message when message.Contains("text-heavy", StringComparison.OrdinalIgnoreCase)
=> "Convert text-heavy slides into message-led visuals with fewer bullets",
var message when message.Contains("Evidence slides", StringComparison.OrdinalIgnoreCase)

View File

@@ -53,6 +53,7 @@ public class HtmlSkill : IAgentTool
"'chart' {kind:'bar|horizontal_bar', title, data:[{label, value, color}]}, " +
"'cards' {items:[{title, body, badge, icon}]}, " +
"'decision_summary' {title, decision, rationale, actions:['...']}, " +
"'kpi_panel' {title, headline, items:[{label,value,trend,note}], takeaway}, " +
"'evidence_cards' {title, items:[{title, detail, source, tag}]}, " +
"'board_report' {title, decision, recommendation, rationale, metrics:[{label,value,note}], risks:['...'], next_steps:['...']}, " +
"'strategy_brief' {title, strategic_question, thesis, implications:['...'], decisions:['...']}, " +
@@ -357,6 +358,9 @@ public class HtmlSkill : IAgentTool
case "decision_summary":
sb.AppendLine(RenderDecisionSummary(section));
break;
case "kpi_panel":
sb.AppendLine(RenderKpiPanel(section));
break;
case "evidence_cards":
sb.AppendLine(RenderEvidenceCards(section));
break;
@@ -605,6 +609,24 @@ public class HtmlSkill : IAgentTool
return sb.ToString();
}
private static string RenderKpiPanel(JsonElement s)
{
var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : "KPI Panel";
var headline = s.SafeTryGetProperty("headline", out var headlineEl) ? headlineEl.SafeGetString() : null;
var takeaway = s.SafeTryGetProperty("takeaway", out var takeawayEl) ? takeawayEl.SafeGetString() : null;
var sb = new StringBuilder();
sb.AppendLine("<section class=\"kpi-panel\" style=\"margin:1.4rem 0;padding:1.15rem 1.2rem;border:1px solid #dbe4f0;border-radius:18px;background:linear-gradient(180deg,#f8fafc,#ffffff);box-shadow:0 10px 26px rgba(15,23,42,.05);\">");
sb.AppendLine($"<div style=\"font-size:1rem;font-weight:800;color:#0f172a;margin-bottom:.55rem\">{Escape(title ?? "KPI Panel")}</div>");
if (!string.IsNullOrWhiteSpace(headline))
sb.AppendLine($"<div style=\"font-size:.95rem;font-weight:700;color:#1d4ed8;margin-bottom:.85rem\">{MarkdownToHtml(headline)}</div>");
sb.AppendLine(RenderKpi(s));
if (!string.IsNullOrWhiteSpace(takeaway))
sb.AppendLine($"<div style=\"margin-top:.85rem;padding:.8rem 1rem;border-radius:14px;background:#eef2ff;color:#312e81;font-weight:700\">Takeaway: {MarkdownToHtml(takeaway)}</div>");
sb.AppendLine("</section>");
return sb.ToString();
}
private static string RenderEvidenceCards(JsonElement s)
{
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)

View File

@@ -39,6 +39,7 @@ public static class CodeLanguageCatalog
["ruby"] = ["Gemfile", "Gemfile.lock", "*.gemspec"],
["kotlin"] = ["build.gradle.kts", "settings.gradle.kts", "gradle.properties"],
["swift"] = ["Package.swift", "*.xcodeproj", "*.xcworkspace"],
["sql"] = ["migrations/*.sql", "schema.sql", "seed.sql", "*.sqlproj"],
};
private static readonly IReadOnlyDictionary<string, string[]> s_buildHints =
@@ -56,6 +57,7 @@ public static class CodeLanguageCatalog
["ruby"] = ["bundle exec rake", "ruby -c"],
["kotlin"] = ["gradle build", "./gradlew build"],
["swift"] = ["swift build", "xcodebuild build"],
["sql"] = ["apply in a disposable database first", "review migration order and dependency impact"],
};
private static readonly IReadOnlyDictionary<string, string[]> s_testHints =
@@ -73,6 +75,7 @@ public static class CodeLanguageCatalog
["ruby"] = ["bundle exec rspec", "bundle exec rake test"],
["kotlin"] = ["gradle test", "./gradlew test"],
["swift"] = ["swift test", "xcodebuild test"],
["sql"] = ["run the script against a disposable database", "verify affected row counts and dependent queries"],
};
private static readonly IReadOnlyDictionary<string, string[]> s_lintHints =
@@ -89,6 +92,7 @@ public static class CodeLanguageCatalog
["ruby"] = ["rubocop", "standardrb"],
["kotlin"] = ["./gradlew ktlintCheck", "./gradlew detekt"],
["swift"] = ["swiftformat --lint", "swiftlint"],
["sql"] = ["check destructive statements and transaction boundaries", "review index, constraint, and view impact"],
};
private static readonly ReadOnlyCollection<CodeLanguageCapability> s_all =
@@ -257,8 +261,8 @@ public static class CodeLanguageCatalog
"SQL",
[".sql"],
[
"Preserve migration ordering, transactional safety, and index/constraint compatibility.",
"Call out destructive or data-migrating changes explicitly."
"Preserve migration ordering, dialect assumptions, and transaction boundaries.",
"Call out destructive DDL, broad DML, and index or constraint impact explicitly."
]),
new(
"web",
@@ -351,7 +355,7 @@ public static class CodeLanguageCatalog
}
foreach (var capability in s_all.Where(x =>
x.Key is "csharp" or "python" or "java" or "cpp" or "typescript" or "javascript" or "go" or "rust" or "kotlin" or "swift"))
x.Key is "csharp" or "python" or "java" or "cpp" or "typescript" or "javascript" or "go" or "rust" or "kotlin" or "swift" or "sql"))
{
var summary = capability.Guidance.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(summary))
@@ -437,6 +441,8 @@ public static class CodeLanguageCatalog
var primaryGuidance = capability.Guidance.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(primaryGuidance))
parts.Add("focus: " + primaryGuidance);
if (string.Equals(capability.Key, "sql", StringComparison.OrdinalIgnoreCase))
parts.Add("analysis: detect dialect, statement kinds, destructive risk, and object dependencies");
if (parts.Count == 0)
return capability.DisplayName;
@@ -481,6 +487,8 @@ public static class CodeLanguageCatalog
public static string BuildFallbackSummary(string? filePathOrExtension)
{
var capability = ResolveCapabilityFromKeyOrExtension(filePathOrExtension);
if (capability != null && string.Equals(capability.Key, "sql", StringComparison.OrdinalIgnoreCase))
return SqlAnalysisService.BuildFallbackSummary(filePathOrExtension);
if (capability == null)
return "정적 fallback: 확장자와 프로젝트 매니페스트를 먼저 확인하고 관련 build/test/lint 힌트를 따라 수동 검증하세요.";

View File

@@ -0,0 +1,215 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services;
public sealed record SqlAnalysisReport(
string Dialect,
IReadOnlyList<string> StatementKinds,
IReadOnlyList<string> Objects,
IReadOnlyList<string> Risks,
IReadOnlyList<string> SuggestedChecks)
{
public string ToFallbackSummary()
{
var parts = new List<string> { $"SQL static analysis: {Dialect}" };
if (StatementKinds.Count > 0)
parts.Add("statements: " + string.Join(", ", StatementKinds.Take(5)));
if (Objects.Count > 0)
parts.Add("objects: " + string.Join(", ", Objects.Take(6)));
if (Risks.Count > 0)
parts.Add("risks: " + string.Join(" | ", Risks.Take(3)));
if (SuggestedChecks.Count > 0)
parts.Add("next checks: " + string.Join(" | ", SuggestedChecks.Take(3)));
return string.Join(Environment.NewLine, parts);
}
}
public static class SqlAnalysisService
{
public static SqlAnalysisReport Analyze(string? sqlText, string? filePath = null)
{
var sql = sqlText ?? string.Empty;
var dialect = SqlDialectDetector.DetectDialect(sql);
var normalized = sql.Replace("\r", " ");
var statements = SplitStatements(normalized);
var statementKinds = DetectStatementKinds(normalized);
var objects = DetectObjects(normalized);
var risks = DetectRisks(statements, normalized);
var checks = BuildSuggestedChecks(statementKinds, risks, dialect, filePath);
return new SqlAnalysisReport(dialect, statementKinds, objects, risks, checks);
}
public static string BuildFallbackSummary(string? filePathOrExtension)
{
if (!string.IsNullOrWhiteSpace(filePathOrExtension) && File.Exists(filePathOrExtension))
{
var sql = File.ReadAllText(filePathOrExtension);
return Analyze(sql, filePathOrExtension).ToFallbackSummary();
}
return new SqlAnalysisReport(
"Generic SQL",
["DDL/DML review"],
[],
["Review destructive statements and transaction boundaries before execution."],
[
"Confirm migration order, object dependencies, and rollback strategy",
"Run the script in a disposable database before applying it to shared environments",
"Review broad UPDATE/DELETE predicates, indexes, and constraint impact"
]).ToFallbackSummary();
}
private static IReadOnlyList<string> DetectStatementKinds(string sql)
{
var kinds = new List<string>();
AddKind(kinds, sql, @"\bcreate\s+table\b", "CREATE TABLE");
AddKind(kinds, sql, @"\balter\s+table\b", "ALTER TABLE");
AddKind(kinds, sql, @"\bdrop\s+table\b", "DROP TABLE");
AddKind(kinds, sql, @"\btruncate\s+table\b", "TRUNCATE TABLE");
AddKind(kinds, sql, @"\bcreate\s+(unique\s+)?index\b", "CREATE INDEX");
AddKind(kinds, sql, @"\bcreate\s+view\b", "CREATE VIEW");
AddKind(kinds, sql, @"\bcreate\s+(or\s+replace\s+)?function\b", "CREATE FUNCTION");
AddKind(kinds, sql, @"\bcreate\s+(or\s+replace\s+)?procedure\b", "CREATE PROCEDURE");
AddKind(kinds, sql, @"\binsert\s+into\b", "INSERT");
AddKind(kinds, sql, @"\bupdate\b", "UPDATE");
AddKind(kinds, sql, @"\bdelete\s+from\b", "DELETE");
AddKind(kinds, sql, @"\bmerge\s+into\b", "MERGE");
AddKind(kinds, sql, @"\bselect\b", "SELECT");
return kinds;
}
private static IReadOnlyList<string> DetectObjects(string sql)
{
var patterns = new[]
{
@"\bcreate\s+table\s+(if\s+not\s+exists\s+)?(?<name>[\[\]`\""\w\.]+)",
@"\balter\s+table\s+(?<name>[\[\]`\""\w\.]+)",
@"\bdrop\s+table\s+(if\s+exists\s+)?(?<name>[\[\]`\""\w\.]+)",
@"\bcreate\s+view\s+(?<name>[\[\]`\""\w\.]+)",
@"\bupdate\s+(?<name>[\[\]`\""\w\.]+)",
@"\binsert\s+into\s+(?<name>[\[\]`\""\w\.]+)",
@"\bdelete\s+from\s+(?<name>[\[\]`\""\w\.]+)",
@"\bfrom\s+(?<name>[\[\]`\""\w\.]+)",
@"\bjoin\s+(?<name>[\[\]`\""\w\.]+)"
};
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pattern in patterns)
{
foreach (Match match in Regex.Matches(sql, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline))
{
var name = NormalizeObjectName(match.Groups["name"].Value);
if (!string.IsNullOrWhiteSpace(name))
names.Add(name);
}
}
return names.Take(10).ToList();
}
private static IReadOnlyList<string> DetectRisks(IReadOnlyList<string> statements, string fullSql)
{
var risks = new List<string>();
if (Regex.IsMatch(fullSql, @"\bdrop\s+table\b|\bdrop\s+view\b", RegexOptions.IgnoreCase))
risks.Add("Destructive DROP statement is present.");
if (Regex.IsMatch(fullSql, @"\btruncate\s+table\b", RegexOptions.IgnoreCase))
risks.Add("TRUNCATE will remove all rows and is hard to recover without backups.");
if (Regex.IsMatch(fullSql, @"\balter\s+table\b[\s\S]{0,120}\bdrop\s+column\b", RegexOptions.IgnoreCase))
risks.Add("ALTER TABLE DROP COLUMN can break downstream queries or code paths.");
foreach (var statement in statements)
{
if (Regex.IsMatch(statement, @"^\s*delete\s+from\b", RegexOptions.IgnoreCase) &&
!Regex.IsMatch(statement, @"\bwhere\b", RegexOptions.IgnoreCase))
{
risks.Add("DELETE statement without WHERE clause was detected.");
break;
}
}
foreach (var statement in statements)
{
if (Regex.IsMatch(statement, @"^\s*update\b", RegexOptions.IgnoreCase) &&
!Regex.IsMatch(statement, @"\bwhere\b", RegexOptions.IgnoreCase))
{
risks.Add("UPDATE statement without WHERE clause was detected.");
break;
}
}
if (Regex.IsMatch(fullSql, @"\b(select\s+\*)", RegexOptions.IgnoreCase))
risks.Add("SELECT * is present and may widen downstream impact.");
var hasTransactionalWork = Regex.IsMatch(fullSql, @"\b(create|alter|drop|truncate|insert|update|delete|merge)\b", RegexOptions.IgnoreCase);
var hasTransaction = Regex.IsMatch(fullSql, @"\bbegin\s+tran(saction)?\b|\bstart\s+transaction\b|\bcommit\b", RegexOptions.IgnoreCase);
if (hasTransactionalWork && !hasTransaction)
risks.Add("Explicit transaction boundaries are not visible in the script.");
return risks.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static IReadOnlyList<string> BuildSuggestedChecks(
IReadOnlyList<string> statementKinds,
IReadOnlyList<string> risks,
string dialect,
string? filePath)
{
var checks = new List<string>();
if (statementKinds.Any(kind => kind is "CREATE TABLE" or "ALTER TABLE" or "DROP TABLE"))
checks.Add("Verify migration order, dependent views, indexes, and foreign keys.");
if (statementKinds.Any(kind => kind is "UPDATE" or "DELETE" or "MERGE"))
checks.Add("Review predicates and expected row counts before running data-changing statements.");
if (risks.Any())
checks.Add("Run the script in a disposable database and confirm rollback strategy first.");
if (string.Equals(dialect, "SQL Server", StringComparison.OrdinalIgnoreCase))
checks.Add("Review GO batch boundaries, transaction scope, and locking behavior.");
else if (string.Equals(dialect, "PostgreSQL", StringComparison.OrdinalIgnoreCase))
checks.Add("Confirm migration order, transactional DDL behavior, and dependent objects.");
else if (string.Equals(dialect, "MySQL", StringComparison.OrdinalIgnoreCase))
checks.Add("Review engine-specific behavior, delimiter usage, and online migration impact.");
var fileName = Path.GetFileName(filePath ?? string.Empty);
if (fileName.Contains("seed", StringComparison.OrdinalIgnoreCase))
checks.Add("Confirm the script is safe to rerun and that seed data remains idempotent.");
if (fileName.Contains("migration", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("schema", StringComparison.OrdinalIgnoreCase))
{
checks.Add("Validate forward-only execution order and any required rollback notes.");
}
return checks.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
}
private static IReadOnlyList<string> SplitStatements(string sql)
{
return sql
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(statement => !string.IsNullOrWhiteSpace(statement))
.ToList();
}
private static void AddKind(List<string> kinds, string sql, string pattern, string label)
{
if (Regex.IsMatch(sql, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline) &&
!kinds.Contains(label, StringComparer.OrdinalIgnoreCase))
{
kinds.Add(label);
}
}
private static string NormalizeObjectName(string name)
{
if (string.IsNullOrWhiteSpace(name))
return string.Empty;
var normalized = name.Trim();
normalized = normalized.Trim('[', ']', '`', '"');
normalized = normalized.TrimEnd(',', ';');
return normalized;
}
}

View File

@@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace AxCopilot.Services;
public static class SqlDialectDetector
{
public static string DetectDialect(string? sqlText)
{
if (string.IsNullOrWhiteSpace(sqlText))
return "Generic SQL";
var sql = sqlText;
var scores = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["PostgreSQL"] = 0,
["MySQL"] = 0,
["SQL Server"] = 0,
["SQLite"] = 0,
["Oracle"] = 0,
};
AddScore(sql, scores, "PostgreSQL", 4, @"\bserial\b|\bbigserial\b|\breturning\b|\bilike\b|::\w+|\bjsonb\b|\blanguage\s+plpgsql\b");
AddScore(sql, scores, "MySQL", 4, @"`[^`]+`|\bauto_increment\b|\bengine\s*=\s*\w+|\bunsigned\b|\bdelimiter\b");
AddScore(sql, scores, "SQL Server", 4, @"\bgo\b|\bidentity\s*\(|\bnvarchar\b|\bgetdate\s*\(|\btop\s+\d+|\[dbo\]");
AddScore(sql, scores, "SQLite", 4, @"\bautoincrement\b|\bwithout\s+rowid\b|\bpragma\b|\binsert\s+or\s+replace\b");
AddScore(sql, scores, "Oracle", 4, @"\bvarchar2\b|\bnumber\s*\(|\bdual\b|\bsysdate\b|\bsequence\b");
var best = scores.OrderByDescending(pair => pair.Value).First();
return best.Value <= 0 ? "Generic SQL" : best.Key;
}
private static void AddScore(string sql, IDictionary<string, int> scores, string dialect, int score, string pattern)
{
if (Regex.IsMatch(sql, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline))
scores[dialect] += score;
}
}