SQL 정적 분석과 PPT·HTML critic을 고도화하고 코드 탭 fallback 문맥을 보강

- SqlAnalysisService에 script intent, dependency, review focus 계산을 추가해 migration/seed/reporting SQL의 위험도와 검토 포인트를 더 정확히 안내하도록 개선했습니다.
- HtmlSkill에 decision_matrix, metric_strip 섹션을 추가하고 ArtifactQualityReviewService/ArtifactRepairGuideService에서 board·strategy 문서의 의사결정 구조와 KPI 연결 부족을 더 정밀하게 진단하도록 강화했습니다.
- DeckQualityReviewService와 DeckRepairGuideService를 확장해 executive summary headline, comparison trade-off, roadmap milestone, chart takeaway, KPI context 부족을 추가로 감지하고 보정 가이드를 반환하도록 정리했습니다.
- WorkspaceContextGenerator와 CodeLanguageCatalog를 업데이트해 SQL 저장소에서 SQL Review Focus와 확장된 workflow summary를 제공하도록 맞췄고, README/DEVELOPMENT/NEXT_ROADMAP에 2026-04-15 11:36 (KST) 기준 이력을 반영했습니다.

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_sql_doc_final\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc_final\\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SqlDialectDetectorTests|SqlAnalysisServiceTests|CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|ArtifactQualityReviewServiceTests|ArtifactRepairGuideServiceTests|DeckQualityReviewServiceTests|HtmlSkillConsultingSectionsTests" -p:OutputPath=bin\\verify_code_sql_doc_final_tests\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc_final_tests\\ : 통과 62
This commit is contained in:
2026-04-15 11:38:50 +09:00
parent 93c3c647d9
commit 2a49b1da24
18 changed files with 538 additions and 8 deletions

View File

@@ -127,6 +127,8 @@ public static class ArtifactQualityReviewService
var tableCount = Regex.Matches(html, @"<table\b", RegexOptions.IgnoreCase).Count;
var calloutCount = Regex.Matches(html, @"callout-(info|warning|tip|danger)", RegexOptions.IgnoreCase).Count;
var comparisonCount = Regex.Matches(html, @"comparison-grid|comparison-card", RegexOptions.IgnoreCase).Count;
var decisionMatrixCount = Regex.Matches(html, @"decision-matrix", RegexOptions.IgnoreCase).Count;
var metricStripCount = Regex.Matches(html, @"metric-strip", RegexOptions.IgnoreCase).Count;
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;
@@ -139,6 +141,8 @@ public static class ArtifactQualityReviewService
+ (boardPanelCount > 0 ? 1 : 0)
+ (strategyBriefCount > 0 ? 1 : 0)
+ (comparisonCount > 0 ? 1 : 0)
+ (decisionMatrixCount > 0 ? 1 : 0)
+ (metricStripCount > 0 ? 1 : 0)
+ (roadmapCount > 0 ? 1 : 0)
+ (matrixCount > 0 ? 1 : 0)
+ (decisionCount > 0 ? 1 : 0)
@@ -149,6 +153,8 @@ public static class ArtifactQualityReviewService
+ tableCount
+ calloutCount
+ comparisonCount
+ decisionMatrixCount
+ metricStripCount
+ roadmapCount
+ matrixCount
+ decisionCount
@@ -164,12 +170,14 @@ 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 || kpiPanelCount > 0 || evidenceCardCount > 0 || boardPanelCount > 0 || strategyBriefCount > 0 || kpiCount > 0)
if (tableCount > 0 || comparisonCount > 0 || decisionMatrixCount > 0 || metricStripCount > 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 (decisionMatrixCount > 0) strengths.Add("Includes decision matrix");
if (metricStripCount > 0) strengths.Add("Includes metric strip");
if (html.Length < 1800)
issues.Add(new("Body content may be too short for an executive-quality document.", ArtifactReviewSeverity.Warning));
@@ -177,7 +185,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 + kpiPanelCount + evidenceCardCount + boardPanelCount + strategyBriefCount + kpiCount == 0)
if (tableCount + comparisonCount + decisionMatrixCount + metricStripCount + 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));
@@ -195,14 +203,20 @@ public static class ArtifactQualityReviewService
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 (boardPanelCount > 0 && decisionMatrixCount == 0 && comparisonCount == 0)
issues.Add(new("Board-ready report would benefit from a comparison or decision matrix block.", 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 (strategyBriefCount > 0 && decisionMatrixCount == 0)
issues.Add(new("Strategy brief would be stronger with a decision matrix for option trade-offs.", 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));
if (metricStripCount > 0 && decisionCount + boardPanelCount == 0)
issues.Add(new("Metric strip should connect to an explicit decision, recommendation, or board summary block.", ArtifactReviewSeverity.Info));
return BuildReport("html", strengths, issues);
}

View File

@@ -22,7 +22,7 @@ public static class ArtifactRepairGuideService
if (!string.IsNullOrWhiteSpace(action) && !actions.Contains(action, StringComparer.OrdinalIgnoreCase))
actions.Add(action);
if (actions.Count >= 3)
if (actions.Count >= 4)
break;
}
@@ -40,10 +40,14 @@ public static class ArtifactRepairGuideService
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("comparison or decision matrix", StringComparison.OrdinalIgnoreCase))
return "Add a comparison or decision matrix block so the board pack shows trade-offs before the ask";
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("decision matrix", StringComparison.OrdinalIgnoreCase))
return "Add a decision matrix so the strategy brief makes option trade-offs explicit";
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))
@@ -58,6 +62,8 @@ public static class ArtifactRepairGuideService
return "Use comparison, roadmap, matrix, KPI, or evidence blocks instead of plain text only";
if (message.Contains("Executive summary", StringComparison.OrdinalIgnoreCase))
return "Add an Executive Summary section near the top of the report";
if (message.Contains("Metric strip should connect", StringComparison.OrdinalIgnoreCase))
return "Connect the metric strip to an explicit recommendation, next step, or board summary block";
return null;
}

View File

@@ -135,6 +135,8 @@ public static class DeckQualityReviewService
strengths.Add("Contains evidence-oriented comparison, table, or chart slides");
if (layoutCounts.Count >= 4)
strengths.Add($"Uses {layoutCounts.Count} distinct layouts");
if (executiveSlides > 0 && recommendationSlides > 0 && (comparisonSlides > 0 || chartSlides > 0) && roadmapSlides > 0)
strengths.Add("Reads like a decision-ready consulting storyline");
if (autoRepairCount > 0)
strengths.Add($"Auto-repair applied {autoRepairCount} time(s)");
if (slideAlertCount == 0)
@@ -217,6 +219,12 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: Executive Summary should include KPI cards or quantified evidence.", DeckReviewSeverity.Info));
added++;
}
if (string.IsNullOrWhiteSpace(ReadString(slide, "headline")) &&
ReadStringList(slide, "summary_points").Count < 3)
{
issues.Add(new($"Slide {slideNumber}: Executive Summary should land a sharper single-message headline.", DeckReviewSeverity.Info));
added++;
}
break;
case "recommendation":
if (string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
@@ -242,6 +250,12 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: comparison slide should highlight a clear verdict.", DeckReviewSeverity.Info));
added++;
}
if (!slide.GetRawText().Contains("\"summary\"", StringComparison.OrdinalIgnoreCase) &&
!slide.GetRawText().Contains("\"pros\"", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new($"Slide {slideNumber}: comparison slide should explain the trade-offs behind the verdict.", DeckReviewSeverity.Info));
added++;
}
break;
case "roadmap":
if (ReadJsonArrayCount(slide, "phases") == 0)
@@ -260,6 +274,11 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: roadmap slide would be stronger with timeline and owner labels.", DeckReviewSeverity.Info));
added++;
}
if (!slide.GetRawText().Contains("\"detail\"", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new($"Slide {slideNumber}: roadmap slide should describe the outcome or milestone for each phase.", DeckReviewSeverity.Info));
added++;
}
break;
case "chart":
if (ReadJsonArrayCount(slide, "chart_labels") == 0 || ReadJsonArrayCount(slide, "chart_values") == 0)
@@ -267,6 +286,13 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: chart slide is missing chart data.", DeckReviewSeverity.Critical));
added++;
}
else if (string.IsNullOrWhiteSpace(ReadString(slide, "headline")) &&
ReadStringList(slide, "summary_points").Count == 0 &&
string.IsNullOrWhiteSpace(ReadString(slide, "body")))
{
issues.Add(new($"Slide {slideNumber}: chart slide should include a takeaway, not just visual data.", DeckReviewSeverity.Info));
added++;
}
break;
case "table":
if (ReadJsonArrayCount(slide, "headers") == 0 || ReadJsonArrayCount(slide, "rows") == 0)
@@ -287,6 +313,12 @@ public static class DeckQualityReviewService
issues.Add(new($"Slide {slideNumber}: KPI dashboard needs takeaway points, not just metric tiles.", DeckReviewSeverity.Info));
added++;
}
if (!slide.GetRawText().Contains("\"trend\"", StringComparison.OrdinalIgnoreCase) &&
!slide.GetRawText().Contains("\"note\"", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new($"Slide {slideNumber}: KPI dashboard should include trend or note context for each metric.", DeckReviewSeverity.Info));
added++;
}
break;
}

View File

@@ -18,6 +18,8 @@ public static class DeckRepairGuideService
=> "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("sharper single-message headline", StringComparison.OrdinalIgnoreCase)
=> "Rewrite the Executive Summary headline so the slide lands one decision-ready message",
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)
@@ -26,8 +28,12 @@ public static class DeckRepairGuideService
=> "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("trade-offs behind the verdict", StringComparison.OrdinalIgnoreCase)
=> "Add short trade-off summaries so the comparison slide explains why the preferred option wins",
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("milestone for each phase", StringComparison.OrdinalIgnoreCase)
=> "Add milestone detail for each roadmap phase so execution outcomes are explicit",
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)
@@ -46,12 +52,16 @@ public static class DeckRepairGuideService
=> "Expand the comparison slide to show at least two real options and a verdict",
var message when message.Contains("chart slide", StringComparison.OrdinalIgnoreCase)
=> "Provide chart labels and values or replace the slide with a comparison/evidence layout",
var message when message.Contains("takeaway, not just visual data", StringComparison.OrdinalIgnoreCase)
=> "Add a takeaway statement so each chart translates data into a decision message",
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("trend or note context for each metric", StringComparison.OrdinalIgnoreCase)
=> "Add trend or note context for each KPI so the dashboard explains why the number matters",
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

@@ -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/comparison/roadmap/matrix)을 지원합니다.
/// sections 파라미터로 구조화된 콘텐츠 블록(heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi/comparison/roadmap/matrix/decision_matrix/metric_strip)을 지원합니다.
/// </summary>
public class HtmlSkill : IAgentTool
{
@@ -58,6 +58,8 @@ public class HtmlSkill : IAgentTool
"'board_report' {title, decision, recommendation, rationale, metrics:[{label,value,note}], risks:['...'], next_steps:['...']}, " +
"'strategy_brief' {title, strategic_question, thesis, implications:['...'], decisions:['...']}, " +
"'comparison' {title, items:[{name, summary, pros, cons, verdict}]}, " +
"'decision_matrix' {title, criteria:['...'], options:[{name, verdict, notes, scores:{criterion:number}}]}, " +
"'metric_strip' {title, items:[{label,value,trend,note}]}, " +
"'roadmap' {title, phases:[{title, detail, timeline, owner}]}, " +
"'matrix' {title, quadrants:[{title, items:['...']}]}, " +
"'list' {style:'bullet|number', items:['item', ' - sub-item']}, " +
@@ -373,6 +375,12 @@ public class HtmlSkill : IAgentTool
case "comparison":
sb.AppendLine(RenderComparison(section));
break;
case "decision_matrix":
sb.AppendLine(RenderDecisionMatrix(section));
break;
case "metric_strip":
sb.AppendLine(RenderMetricStrip(section));
break;
case "roadmap":
sb.AppendLine(RenderRoadmap(section));
break;
@@ -804,6 +812,87 @@ public class HtmlSkill : IAgentTool
return sb.ToString();
}
private static string RenderDecisionMatrix(JsonElement s)
{
if (!s.SafeTryGetProperty("options", out var options) || options.ValueKind != JsonValueKind.Array)
return "";
var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
var criteria = s.SafeTryGetProperty("criteria", out var criteriaEl) && criteriaEl.ValueKind == JsonValueKind.Array
? criteriaEl.EnumerateArray().Select(item => item.SafeGetString() ?? string.Empty).Where(item => !string.IsNullOrWhiteSpace(item)).ToList()
: [];
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
sb.AppendLine($"<h3>{Escape(title)}</h3>");
sb.AppendLine("<div class=\"decision-matrix\" style=\"overflow-x:auto;margin:1.25rem 0;\">");
sb.AppendLine("<table>");
sb.Append("<thead><tr><th>Option</th>");
foreach (var criterion in criteria)
sb.Append($"<th>{Escape(criterion)}</th>");
sb.AppendLine("<th>Verdict</th><th>Notes</th></tr></thead>");
sb.AppendLine("<tbody>");
foreach (var option in options.EnumerateArray())
{
var name = option.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "Option" : "Option";
var verdict = option.SafeTryGetProperty("verdict", out var verdictEl) ? verdictEl.SafeGetString() : null;
var notes = option.SafeTryGetProperty("notes", out var notesEl) ? notesEl.SafeGetString() : null;
option.SafeTryGetProperty("scores", out var scoresEl);
sb.Append("<tr>");
sb.Append($"<td><strong>{Escape(name)}</strong></td>");
foreach (var criterion in criteria)
{
var score = scoresEl.ValueKind == JsonValueKind.Object &&
scoresEl.SafeTryGetProperty(criterion, out var scoreEl)
? scoreEl.ToString()
: "-";
sb.Append($"<td>{Escape(score)}</td>");
}
sb.Append($"<td>{MarkdownToHtml(verdict ?? string.Empty)}</td>");
sb.Append($"<td>{MarkdownToHtml(notes ?? string.Empty)}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
return sb.ToString();
}
private static string RenderMetricStrip(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=\"metric-strip\" style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:.85rem;margin:1rem 0 1.35rem;\">");
foreach (var item in items.EnumerateArray())
{
var label = item.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? string.Empty : string.Empty;
var value = item.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? string.Empty : string.Empty;
var trend = item.SafeTryGetProperty("trend", out var trendEl) ? trendEl.SafeGetString() : null;
var note = item.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() : null;
sb.AppendLine("<div style=\"border:1px solid #dbe4f0;border-radius:14px;padding:.9rem 1rem;background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.05);\">");
sb.AppendLine($"<div style=\"font-size:.78rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em\">{Escape(label)}</div>");
sb.AppendLine($"<div style=\"font-size:1.35rem;font-weight:800;color:#0f172a;margin-top:.2rem\">{Escape(value)}</div>");
if (!string.IsNullOrWhiteSpace(trend))
sb.AppendLine($"<div style=\"font-size:.8rem;font-weight:700;color:#1d4ed8;margin-top:.28rem\">{Escape(trend)}</div>");
if (!string.IsNullOrWhiteSpace(note))
sb.AppendLine($"<div style=\"font-size:.82rem;color:#475569;margin-top:.28rem;line-height:1.55\">{MarkdownToHtml(note)}</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)

View File

@@ -104,6 +104,16 @@ internal static class WorkspaceContextGenerator
sb.AppendLine();
}
if (string.Equals(primaryLang.Key, ".sql", StringComparison.OrdinalIgnoreCase) ||
extDist.Any(entry => string.Equals(entry.Key, ".sql", StringComparison.OrdinalIgnoreCase) && entry.Value >= 3))
{
sb.AppendLine("## SQL Review Focus");
sb.AppendLine("- Classify each script as migration, seed, reporting query, or operational patch before execution.");
sb.AppendLine("- Review destructive DDL, broad UPDATE/DELETE statements, transaction scope, and dependency order.");
sb.AppendLine("- Validate scripts in a disposable database and capture rollback notes for production changes.");
sb.AppendLine();
}
var contextFiles = DetectContextFiles(workFolder);
if (contextFiles.Count > 0)
{

View File

@@ -442,7 +442,7 @@ public static class CodeLanguageCatalog
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");
parts.Add("analysis: detect dialect, script intent, destructive risk, migration order, and object dependencies");
if (parts.Count == 0)
return capability.DisplayName;

View File

@@ -6,22 +6,31 @@ namespace AxCopilot.Services;
public sealed record SqlAnalysisReport(
string Dialect,
string ScriptIntent,
IReadOnlyList<string> StatementKinds,
IReadOnlyList<string> Objects,
IReadOnlyList<string> Dependencies,
IReadOnlyList<string> Risks,
IReadOnlyList<string> SuggestedChecks)
IReadOnlyList<string> SuggestedChecks,
IReadOnlyList<string> ReviewNotes)
{
public string ToFallbackSummary()
{
var parts = new List<string> { $"SQL static analysis: {Dialect}" };
if (!string.IsNullOrWhiteSpace(ScriptIntent))
parts.Add("script: " + ScriptIntent);
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 (Dependencies.Count > 0)
parts.Add("dependencies: " + string.Join(", ", Dependencies.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)));
if (ReviewNotes.Count > 0)
parts.Add("review focus: " + string.Join(" | ", ReviewNotes.Take(3)));
return string.Join(Environment.NewLine, parts);
}
}
@@ -36,11 +45,14 @@ public static class SqlAnalysisService
var statements = SplitStatements(normalized);
var statementKinds = DetectStatementKinds(normalized);
var scriptIntent = ClassifyScriptIntent(statementKinds, filePath, normalized);
var objects = DetectObjects(normalized);
var dependencies = DetectDependencies(normalized, objects);
var risks = DetectRisks(statements, normalized);
var checks = BuildSuggestedChecks(statementKinds, risks, dialect, filePath);
var reviewNotes = BuildReviewNotes(statementKinds, risks, dependencies, dialect, filePath, scriptIntent);
return new SqlAnalysisReport(dialect, statementKinds, objects, risks, checks);
return new SqlAnalysisReport(dialect, scriptIntent, statementKinds, objects, dependencies, risks, checks, reviewNotes);
}
public static string BuildFallbackSummary(string? filePathOrExtension)
@@ -53,13 +65,20 @@ public static class SqlAnalysisService
return new SqlAnalysisReport(
"Generic SQL",
"generic script",
["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"
],
[
"Identify whether the script is a migration, seed, or ad-hoc query before execution",
"Check dependency order for tables, views, indexes, and procedures",
"Prepare a rollback or recovery note for destructive or high-impact changes"
]).ToFallbackSummary();
}
@@ -72,6 +91,7 @@ public static class SqlAnalysisService
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+)?trigger\b", "CREATE TRIGGER");
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");
@@ -111,6 +131,67 @@ public static class SqlAnalysisService
return names.Take(10).ToList();
}
private static IReadOnlyList<string> DetectDependencies(string sql, IReadOnlyList<string> objects)
{
var dependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ownedObjects = DetectOwnedObjects(sql);
var patterns = new[]
{
@"\breferences\s+(?<name>[\[\]`\""\w\.]+)",
@"\bjoin\s+(?<name>[\[\]`\""\w\.]+)",
@"\bfrom\s+(?<name>[\[\]`\""\w\.]+)",
@"\binto\s+(?<name>[\[\]`\""\w\.]+)",
@"\bupdate\s+(?<name>[\[\]`\""\w\.]+)",
@"\bexec(?:ute)?\s+(?<name>[\[\]`\""\w\.]+)",
@"\bon\s+(?<name>[\[\]`\""\w\.]+)"
};
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))
dependencies.Add(name);
}
}
foreach (var ownedObject in ownedObjects)
dependencies.Remove(ownedObject);
return dependencies.Take(10).ToList();
}
private static IReadOnlyList<string> DetectOwnedObjects(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\.]+)",
@"\bcreate\s+(or\s+replace\s+)?function\s+(?<name>[\[\]`\""\w\.]+)",
@"\bcreate\s+(or\s+replace\s+)?procedure\s+(?<name>[\[\]`\""\w\.]+)",
@"\bcreate\s+(unique\s+)?index\s+(?<name>[\[\]`\""\w\.]+)",
@"\binsert\s+into\s+(?<name>[\[\]`\""\w\.]+)",
@"\bupdate\s+(?<name>[\[\]`\""\w\.]+)",
@"\bdelete\s+from\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.ToList();
}
private static IReadOnlyList<string> DetectRisks(IReadOnlyList<string> statements, string fullSql)
{
var risks = new List<string>();
@@ -152,6 +233,26 @@ public static class SqlAnalysisService
return risks.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static string ClassifyScriptIntent(IReadOnlyList<string> statementKinds, string? filePath, string sql)
{
var fileName = Path.GetFileName(filePath ?? string.Empty);
if (fileName.Contains("seed", StringComparison.OrdinalIgnoreCase))
return "seed / reference data";
if (fileName.Contains("migration", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("schema", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("ddl", StringComparison.OrdinalIgnoreCase))
return "schema migration";
if (statementKinds.Any(kind => kind.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) ||
kind.StartsWith("ALTER", StringComparison.OrdinalIgnoreCase) ||
kind.StartsWith("DROP", StringComparison.OrdinalIgnoreCase)))
return "schema change";
if (statementKinds.Any(kind => kind is "INSERT" or "UPDATE" or "DELETE" or "MERGE"))
return "data change";
if (Regex.IsMatch(sql, @"\bselect\b", RegexOptions.IgnoreCase))
return "query / reporting";
return "generic script";
}
private static IReadOnlyList<string> BuildSuggestedChecks(
IReadOnlyList<string> statementKinds,
IReadOnlyList<string> risks,
@@ -185,6 +286,57 @@ public static class SqlAnalysisService
return checks.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
}
private static IReadOnlyList<string> BuildReviewNotes(
IReadOnlyList<string> statementKinds,
IReadOnlyList<string> risks,
IReadOnlyList<string> dependencies,
string dialect,
string? filePath,
string scriptIntent)
{
var notes = new List<string>();
if (string.Equals(scriptIntent, "schema migration", StringComparison.OrdinalIgnoreCase) ||
string.Equals(scriptIntent, "schema change", StringComparison.OrdinalIgnoreCase))
{
notes.Add("Review forward migration order and any missing rollback note.");
}
if (dependencies.Count > 0)
notes.Add("Confirm downstream impact on referenced tables, views, procedures, and joins.");
if (statementKinds.Any(kind => kind is "CREATE VIEW" or "CREATE FUNCTION" or "CREATE PROCEDURE" or "CREATE TRIGGER"))
notes.Add("Validate dependent objects and deployment order for derived database objects.");
if (statementKinds.Any(kind => kind is "CREATE INDEX" or "ALTER TABLE"))
notes.Add("Review index, constraint, and lock impact before applying changes.");
if (risks.Any(risk => risk.Contains("DELETE statement without WHERE", StringComparison.OrdinalIgnoreCase) ||
risk.Contains("UPDATE statement without WHERE", StringComparison.OrdinalIgnoreCase)))
{
notes.Add("Require expected row-count validation before running broad data changes.");
}
if (!risks.Any(risk => risk.Contains("transaction", StringComparison.OrdinalIgnoreCase)) &&
statementKinds.Any(kind => kind is "INSERT" or "UPDATE" or "DELETE" or "MERGE" or "ALTER TABLE" or "DROP TABLE"))
{
notes.Add("Document transaction scope and failure recovery path explicitly.");
}
if (string.Equals(dialect, "SQL Server", StringComparison.OrdinalIgnoreCase))
notes.Add("Check GO batch boundaries and locking behavior.");
else if (string.Equals(dialect, "PostgreSQL", StringComparison.OrdinalIgnoreCase))
notes.Add("Check transactional DDL behavior and dependent object invalidation.");
else if (string.Equals(dialect, "MySQL", StringComparison.OrdinalIgnoreCase))
notes.Add("Check engine-specific DDL behavior and online migration limits.");
var fileName = Path.GetFileName(filePath ?? string.Empty);
if (fileName.Contains("seed", StringComparison.OrdinalIgnoreCase))
notes.Add("Verify the script is idempotent before rerunning seed data.");
return notes.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
}
private static IReadOnlyList<string> SplitStatements(string sql)
{
return sql