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:
@@ -16,7 +16,9 @@ public class ArtifactQualityReviewServiceTests
|
||||
<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="metric-strip"></div>
|
||||
<div class="comparison-grid"></div>
|
||||
<div class="decision-matrix"></div>
|
||||
<div class="roadmap-block"></div>
|
||||
<div class="decision-summary"></div>
|
||||
<section class="board-report-panel"></section>
|
||||
@@ -32,6 +34,7 @@ public class ArtifactQualityReviewServiceTests
|
||||
strength.Contains("cover", StringComparison.OrdinalIgnoreCase) ||
|
||||
strength.Contains("contents", StringComparison.OrdinalIgnoreCase));
|
||||
review.Strengths.Should().Contain(strength => strength.Contains("board-ready", StringComparison.OrdinalIgnoreCase));
|
||||
review.Strengths.Should().Contain(strength => strength.Contains("decision matrix", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().NotContain(issue => issue.Severity == ArtifactReviewSeverity.Critical);
|
||||
}
|
||||
|
||||
@@ -183,4 +186,24 @@ public class ArtifactQualityReviewServiceTests
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("evidence table", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("callout or highlight blocks", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReviewHtml_ShouldRecommendDecisionMatrix_WhenTradeoffViewIsMissing()
|
||||
{
|
||||
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="metric-strip"></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("Tradeoff Gap", html, hasCover: true, hasTableOfContents: true, printReady: true);
|
||||
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("comparison or decision matrix", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("decision matrix", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ public class ArtifactRepairGuideServiceTests
|
||||
[
|
||||
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)
|
||||
new ArtifactReviewIssue("Strategy brief should include explicit decisions or a decision summary block.", ArtifactReviewSeverity.Warning),
|
||||
new ArtifactReviewIssue("Board-ready report would benefit from a comparison or decision matrix block.", ArtifactReviewSeverity.Info)
|
||||
]);
|
||||
|
||||
var guide = ArtifactRepairGuideService.BuildGuide(review);
|
||||
@@ -45,6 +46,7 @@ public class ArtifactRepairGuideServiceTests
|
||||
guide.Should().Contain("decision summary");
|
||||
guide.Should().Contain("KPI");
|
||||
guide.Should().Contain("strategy brief");
|
||||
guide.Should().Contain("decision matrix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -103,5 +103,7 @@ public class CodeLanguageCatalogTests
|
||||
summary.Should().Contain("schema.sql");
|
||||
summary.Should().Contain("disposable database");
|
||||
summary.Should().Contain("dialect");
|
||||
summary.Should().Contain("migration order");
|
||||
summary.Should().Contain("dependencies");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +125,46 @@ public class DeckQualityReviewServiceTests
|
||||
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("supporting rationale or next steps", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReviewDeck_ShouldFlagComparisonTradeoffsAndKpiContext()
|
||||
{
|
||||
using var slides = JsonDocument.Parse(
|
||||
"""
|
||||
[
|
||||
{ "layout": "title", "title": "Board Review" },
|
||||
{
|
||||
"layout": "executive_summary",
|
||||
"title": "Executive Summary",
|
||||
"summary_points": ["A", "B"],
|
||||
"recommendation": "Proceed"
|
||||
},
|
||||
{
|
||||
"layout": "comparison",
|
||||
"title": "Options",
|
||||
"headline": "Choose a path",
|
||||
"options": [
|
||||
{ "name": "A", "verdict": "Recommended" },
|
||||
{ "name": "B", "verdict": "Fallback" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"layout": "kpi_dashboard",
|
||||
"title": "KPIs",
|
||||
"headline": "Performance snapshot",
|
||||
"kpis": [
|
||||
{ "label": "Margin", "value": "+2.4pt" },
|
||||
{ "label": "Lead Time", "value": "-12%" },
|
||||
{ "label": "Quality", "value": "96%" }
|
||||
]
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
var review = DeckQualityReviewService.ReviewDeck("Thin Signals", slides.RootElement, hasTemplate: false, autoRepairCount: 0);
|
||||
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("sharper single-message headline", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("trade-offs behind the verdict", StringComparison.OrdinalIgnoreCase));
|
||||
review.Issues.Should().Contain(issue => issue.Message.Contains("trend or note context", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,23 @@ public class HtmlSkillConsultingSectionsTests
|
||||
{ "name": "Option A", "summary": "Fast rollout", "pros": "Low setup effort", "cons": "Limited scale", "verdict": "Fast" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "decision_matrix",
|
||||
"title": "Decision Matrix",
|
||||
"criteria": ["Speed", "Risk", "Margin"],
|
||||
"options": [
|
||||
{ "name": "Option A", "verdict": "Fast", "notes": "Low setup effort", "scores": { "Speed": 5, "Risk": 2, "Margin": 3 } },
|
||||
{ "name": "Option B", "verdict": "Recommended", "notes": "Balanced path", "scores": { "Speed": 4, "Risk": 4, "Margin": 4 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metric_strip",
|
||||
"title": "Top-Line Metrics",
|
||||
"items": [
|
||||
{ "label": "Margin", "value": "+4.2pt", "trend": "up", "note": "pilot mix" },
|
||||
{ "label": "Lead Time", "value": "-18%", "trend": "down", "note": "handoff reduction" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "roadmap",
|
||||
"title": "Delivery Roadmap",
|
||||
@@ -108,6 +125,8 @@ public class HtmlSkillConsultingSectionsTests
|
||||
result.Output.Should().Contain("Repair guide:");
|
||||
var html = File.ReadAllText(Path.Combine(workDir, "consulting.html"));
|
||||
html.Should().Contain("comparison-grid");
|
||||
html.Should().Contain("decision-matrix");
|
||||
html.Should().Contain("metric-strip");
|
||||
html.Should().Contain("roadmap-block");
|
||||
html.Should().Contain("matrix-grid");
|
||||
html.Should().Contain("decision-summary");
|
||||
|
||||
@@ -14,6 +14,7 @@ public class SqlAnalysisServiceTests
|
||||
"""
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT);
|
||||
CREATE VIEW order_summary AS SELECT customer_id FROM customer_dim;
|
||||
UPDATE orders SET customer_id = 0;
|
||||
DROP TABLE legacy_orders;
|
||||
COMMIT;
|
||||
@@ -22,14 +23,17 @@ public class SqlAnalysisServiceTests
|
||||
var report = SqlAnalysisService.Analyze(sql, "20260415_orders_migration.sql");
|
||||
|
||||
report.Dialect.Should().Be("PostgreSQL");
|
||||
report.ScriptIntent.Should().Be("schema migration");
|
||||
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.Dependencies.Should().Contain("customer_dim");
|
||||
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));
|
||||
report.ReviewNotes.Should().Contain(note => note.Contains("rollback", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,6 +52,8 @@ public class SqlAnalysisServiceTests
|
||||
summary.Should().Contain("CREATE TABLE");
|
||||
summary.Should().Contain("DELETE");
|
||||
summary.Should().Contain("users");
|
||||
summary.Should().Contain("script:");
|
||||
summary.Should().Contain("review focus:");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -55,4 +61,24 @@ public class SqlAnalysisServiceTests
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_ShouldDetectSeedIntentDependenciesAndTransactionRisk()
|
||||
{
|
||||
const string sql =
|
||||
"""
|
||||
INSERT INTO seed_status (code, label) VALUES ('ACTIVE', 'Active');
|
||||
UPDATE users
|
||||
SET status_code = map.code
|
||||
FROM account_status_map map
|
||||
WHERE users.status_code IS NULL;
|
||||
""";
|
||||
|
||||
var report = SqlAnalysisService.Analyze(sql, "20260415_seed_status.sql");
|
||||
|
||||
report.ScriptIntent.Should().Be("seed / reference data");
|
||||
report.Dependencies.Should().Contain("account_status_map");
|
||||
report.ReviewNotes.Should().Contain(note => note.Contains("idempotent", StringComparison.OrdinalIgnoreCase));
|
||||
report.Risks.Should().Contain(risk => risk.Contains("transaction", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +339,29 @@ public class WorkspaceContextGeneratorTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_IncludesSqlReviewFocus_WhenWorkspaceContainsSqlScripts()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(tempDir, "001_init.sql"), "CREATE TABLE users (id INT);");
|
||||
File.WriteAllText(Path.Combine(tempDir, "002_seed.sql"), "INSERT INTO users (id) VALUES (1);");
|
||||
File.WriteAllText(Path.Combine(tempDir, "003_view.sql"), "CREATE VIEW active_users AS SELECT * FROM users;");
|
||||
|
||||
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
|
||||
|
||||
result.Should().Contain("## SQL Review Focus");
|
||||
result.Should().Contain("migration, seed, reporting query");
|
||||
result.Should().Contain("rollback");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectLanguageWorkflowHints_ShouldPreferSelectedLanguage()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user