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

@@ -1996,3 +1996,12 @@ MIT License
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_final_batch\\ -p:IntermediateOutputPath=obj\\verify_final_batch\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopIterationPreparationServiceTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|ChatStorageServiceTests|HtmlSkillGoldenReportTests|PptxSkillGoldenDeckTests|DocxSkillGoldenDocumentTests|ExcelSkillGoldenWorkbookTests" -p:OutputPath=bin\\verify_final_batch_tests\\ -p:IntermediateOutputPath=obj\\verify_final_batch_tests\\` 통과 54
업데이트: 2026-04-15 11:17 (KST)
- SQL 전용 정적 분석 계층을 추가했습니다. 새 [SqlDialectDetector.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SqlDialectDetector.cs)와 [SqlAnalysisService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SqlAnalysisService.cs)는 PostgreSQL/MySQL/SQL Server/SQLite/Oracle 방언 추정, statement 분류, object 추출, destructive DDL·broad DML·transaction boundary 위험 감지, SQL 전용 fallback summary 생성을 담당합니다.
- [CodeLanguageCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/CodeLanguageCatalog.cs)는 SQL용 manifest/build/test/lint 힌트와 workflow summary를 제공하고, no-LSP fallback 경로에서 일반 언어 설명 대신 SQL 분석 요약을 우선 반환하도록 보강했습니다.
- PPT/HTML 문서 품질도 더 올렸습니다. [DeckPlanningService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckPlanningService.cs)는 구조화된 `content` 슬라이드를 `kpi_dashboard`, `comparison`, `roadmap`, `chart`로 자동 승격하고, [DeckQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs>)와 [DeckRepairGuideService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckRepairGuideService.cs>)는 executive summary KPI 근거, comparison verdict, roadmap owner/timeline, KPI dashboard takeaway 부족을 별도 경고/보정 가이드로 다룹니다.
- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 새 `kpi_panel` 섹션을 지원하고, [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs>)와 [ArtifactRepairGuideService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactRepairGuideService.cs>)는 board/strategy 문서의 KPI/evidence/decision 연결 부족을 더 정확히 감지합니다.
- 검증:
- `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

View File

@@ -1150,3 +1150,44 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_final_batch\\ -p:IntermediateOutputPath=obj\\verify_final_batch\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopIterationPreparationServiceTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|ChatStorageServiceTests|HtmlSkillGoldenReportTests|PptxSkillGoldenDeckTests|DocxSkillGoldenDocumentTests|ExcelSkillGoldenWorkbookTests" -p:OutputPath=bin\\verify_final_batch_tests\\ -p:IntermediateOutputPath=obj\\verify_final_batch_tests\\` 통과 54
업데이트: 2026-04-15 11:17 (KST)
- SQL 전용 fallback 분석 추가:
- 새 `SqlDialectDetector.cs`
- PostgreSQL / MySQL / SQL Server / SQLite / Oracle 방언 휴리스틱 감지
- 새 `SqlAnalysisService.cs`
- statement kind 분류: `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, `TRUNCATE TABLE`, `CREATE INDEX`, `CREATE VIEW`, `CREATE FUNCTION`, `CREATE PROCEDURE`, `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `SELECT`
- object 추출: table/view/update/insert/delete/from/join 기반 상위 object 수집
- 위험 감지: `DROP`, `TRUNCATE`, `ALTER TABLE DROP COLUMN`, `DELETE/UPDATE without WHERE`, `SELECT *`, transaction boundary 부재
- SQL 전용 fallback summary 생성
- 개발언어 카탈로그 확장:
- `CodeLanguageCatalog.cs`
- SQL manifest 힌트: `migrations/*.sql`, `schema.sql`, `seed.sql`, `*.sqlproj`
- SQL build/test/lint 힌트 강화
- `BuildWorkflowSummary()`에 SQL 전용 `dialect/statement/risk/object dependency` 분석 요약 추가
- `BuildFallbackSummary()`에서 SQL은 일반 fallback 대신 `SqlAnalysisService.BuildFallbackSummary()`를 사용
- PPT/HTML 고도화:
- `DeckPlanningService.cs`
- 구조화된 `content` 슬라이드를 입력 데이터에 따라 `kpi_dashboard`, `comparison`, `roadmap`, `chart`로 자동 승격
- `DeckQualityReviewService.cs`
- Executive Summary의 정량 근거 부족, comparison verdict 부재, roadmap owner/timeline 부족, KPI dashboard metric/takeaway 부족 진단 추가
- `DeckRepairGuideService.cs`
- 위 PPT 진단 항목을 실제 보정 액션으로 매핑
- `HtmlSkill.cs`
- 새 `kpi_panel` 섹션 타입 추가
- `ArtifactQualityReviewService.cs`
- KPI panel을 major/supporting block 판단에 반영
- board/strategy 문서에서 KPI/evidence/decision 연결 부족 이슈 추가
- `ArtifactRepairGuideService.cs`
- KPI panel/metric strip, evidence support, decision/next-step 연결 보정 가이드 추가
- 테스트:
- 새 `SqlDialectDetectorTests.cs`
- 새 `SqlAnalysisServiceTests.cs`
- `CodeLanguageCatalogTests.cs`
- `DeckPlanningServiceTests.cs`
- `ArtifactQualityReviewServiceTests.cs`
- `ArtifactRepairGuideServiceTests.cs`
- `HtmlSkillConsultingSectionsTests.cs`
- 검증:
- `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

View File

@@ -196,3 +196,10 @@
4. 명령/스킬 합성 및 릴리즈 게이트
업데이트: 2026-04-15 10:57 (KST)
- Agent loop 마감 작업으로 `도구 미호출 복구` 규칙을 서비스화했습니다. 남은 우선순위는 `iteration/tool dispatch 추가 분리`, `장기 세션 replacement state 완전 고정`, `문서 golden fixture 확대`, `릴리즈 체크리스트 닫기` 정도의 마감 품질 중심입니다.
업데이트: 2026-04-15 11:17 (KST)
### 추가 진행 메모
1. SQL은 별도 정적 분석 계층까지 올라왔습니다. 현재도 no-LSP 환경에서 `dialect/statement/risk/next checks`를 직접 설명할 수 있고, 다음 단계로 더 간다면 dialect별 migration lint나 schema dependency graph 정도가 후보입니다.
2. PPT는 구조화된 슬라이드를 더 적극적으로 `comparison/roadmap/kpi_dashboard/chart`로 승격하고, KPI/evidence/verdict/owner 같은 컨설팅형 품질 기준을 slide critic에 반영했습니다. 큰 기능보다 golden fixture 확대 성격의 마감 작업이 남아 있습니다.
3. HTML은 `kpi_panel`이 새 핵심 블록으로 들어오면서 board/strategy 문서의 decision/evidence/KPI 연결성이 더 중요해졌습니다. 이후 작업은 목적형 bundled skill 확장과 print/export polish 쪽이 중심입니다.

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;
}
}