diff --git a/README.md b/README.md index 475d32b..2425008 100644 --- a/README.md +++ b/README.md @@ -2005,3 +2005,20 @@ MIT License - 검증: - `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 + +업데이트: 2026-04-15 11:36 (KST) +- SQL 정적 분석을 한 단계 더 깊게 만들었습니다. [SqlAnalysisService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SqlAnalysisService.cs)는 이제 dialect/statement/risk뿐 아니라 `script intent`, `dependency`, `review focus`를 함께 계산해 migration, seed, reporting query, generic script를 구분하고 rollback·transaction·의존 객체 확인 포인트까지 fallback summary에 포함합니다. +- 코드 탭과 워크스페이스 문맥도 SQL 전용 관점을 더 강하게 반영합니다. [CodeLanguageCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/CodeLanguageCatalog.cs)는 SQL workflow summary에 `script intent`, `migration order`, `dependency` 힌트를 포함하고, [WorkspaceContextGenerator.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs)는 SQL 저장소에서 `## SQL Review Focus` 섹션을 생성해 destructive DDL, broad DML, transaction scope, rollback 점검을 바로 안내합니다. +- HTML 문서는 의사결정형 구조를 더 직접적으로 표현할 수 있게 됐습니다. [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `decision_matrix`, `metric_strip` 섹션을 새로 지원하고, [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 문서에서 trade-off 설명과 KPI 요약 연결이 약할 때 decision matrix나 recommendation 연결을 구체적으로 제안합니다. +- PPT deck critic도 더 날카로워졌습니다. [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 headline, comparison trade-off, roadmap phase milestone, chart takeaway, KPI trend/note context 부족을 추가로 감지하고 바로 보정 문장으로 돌려줍니다. +- 테스트: + - [SqlAnalysisServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/SqlAnalysisServiceTests.cs) + - [CodeLanguageCatalogTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs) + - [WorkspaceContextGeneratorTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs) + - [HtmlSkillConsultingSectionsTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs) + - [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs) + - [ArtifactRepairGuideServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactRepairGuideServiceTests.cs) + - [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs) +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_sql_doc\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc\\` 경고 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_tests\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc_tests\\` 통과 62 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7fb206d..b7049e5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1191,3 +1191,59 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 검증: - `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 + +업데이트: 2026-04-15 11:36 (KST) + +### SQL fallback 분석 심화 +- `SqlAnalysisService.cs` + - `SqlAnalysisReport`에 `ScriptIntent`, `Dependencies`, `ReviewNotes`를 추가했습니다. + - `Analyze()`가 dialect/statement/risk/object 외에 `schema migration`, `schema change`, `seed / reference data`, `query / reporting` intent를 계산합니다. + - dependency 감지는 script가 직접 생성/수정하는 owned object와 참조 dependency를 분리해, view/reporting query가 의존하는 테이블과 lookup source를 별도로 잡습니다. + - review note는 rollback, dependency impact, transaction scope, dialect-specific validation 포인트를 함께 생성합니다. + - fallback summary는 `script`, `dependencies`, `review focus`까지 포함하도록 확장했습니다. +- `CodeLanguageCatalog.cs` + - SQL workflow summary를 `dialect/statement/risk/object dependency` 수준에서 `script intent/migration order/dependency`까지 보이도록 강화했습니다. +- `WorkspaceContextGenerator.cs` + - SQL 저장소에서 `## SQL Review Focus` 섹션을 생성하도록 확장했습니다. + - migration/seed/reporting query 분류, destructive DDL·broad DML·transaction scope·rollback 점검을 문맥에 직접 넣습니다. + +### HTML 의사결정형 문서 강화 +- `HtmlSkill.cs` + - 새 섹션 타입 `decision_matrix`, `metric_strip`를 추가했습니다. + - `decision_matrix`는 option/criteria/verdict/notes를 한 표로 렌더링합니다. + - `metric_strip`은 KPI headline, trend, note를 가로 카드형 요약으로 렌더링합니다. +- `ArtifactQualityReviewService.cs` + - HTML 품질 평가에 `decision_matrix`, `metric_strip` 존재를 반영합니다. + - board report에서 comparison/decision matrix 부재, strategy brief에서 trade-off matrix 부재, metric strip이 recommendation과 연결되지 않는 경우를 별도 이슈로 판정합니다. +- `ArtifactRepairGuideService.cs` + - decision matrix 추가, comparison/decision block 보강, metric strip을 recommendation/board summary와 연결하는 수리 가이드를 추가했습니다. + - HTML repair action 상한을 4개로 넓혀 품질 피드백 손실을 줄였습니다. + +### PPT 슬라이드 품질 critic 세분화 +- `DeckQualityReviewService.cs` + - Executive Summary의 headline 선명도 부족을 더 엄격히 감지합니다. + - Comparison slide의 trade-off 설명 부족, roadmap phase milestone 부족, chart takeaway 부재, KPI dashboard trend/note context 부족을 별도 경고로 판정합니다. + - executive/recommendation/comparison(or chart)/roadmap가 갖춰진 deck에 `decision-ready consulting storyline` 강점 신호를 추가했습니다. +- `DeckRepairGuideService.cs` + - 위 추가 이슈를 각각 headline 압축, trade-off 보강, phase milestone 명시, chart takeaway 추가, KPI trend/note 보강 액션으로 변환합니다. + +### 테스트 +- `SqlAnalysisServiceTests.cs` + - migration intent/dependency/rollback note 검증 추가 + - seed/reference data intent, transaction risk, lookup dependency 검증 추가 +- `CodeLanguageCatalogTests.cs` + - SQL workflow summary에 `migration order`, `dependencies`가 포함되는지 검증 +- `WorkspaceContextGeneratorTests.cs` + - SQL 저장소에서 `## SQL Review Focus` 섹션 생성 검증 +- `HtmlSkillConsultingSectionsTests.cs` + - `decision_matrix`, `metric_strip` 렌더링 회귀 추가 +- `ArtifactQualityReviewServiceTests.cs` + - decision matrix를 강점으로 인식하고, trade-off view 부재 시 별도 경고를 반환하는지 검증 +- `ArtifactRepairGuideServiceTests.cs` + - HTML repair guide에 decision matrix 보강 액션이 포함되는지 검증 +- `DeckQualityReviewServiceTests.cs` + - headline/trade-off/KPI context 추가 경고 회귀 검증 + +### 검증 +- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_sql_doc\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc\\` 경고 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_tests\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc_tests\\` 통과 62 diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index 5187148..3926459 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -203,3 +203,10 @@ 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 쪽이 중심입니다. + +업데이트: 2026-04-15 11:36 (KST) + +### 추가 진행 메모 +1. SQL fallback은 `dialect/statement/risk` 수준을 넘어 `script intent/dependency/review focus`까지 확장됐습니다. 다음 선택지는 dialect별 migration lint, schema dependency graph, rollback 시뮬레이션처럼 더 깊은 전용 리뷰 계층입니다. +2. HTML 문서는 `decision_matrix`와 `metric_strip`이 들어오면서 board/strategy 보고서의 의사결정 구조를 더 직접적으로 표현할 수 있게 됐습니다. 남은 작업은 bundled skill을 목적형으로 더 쪼개고 print/export polish를 마감하는 수준입니다. +3. PPT critic은 headline, trade-off, phase milestone, chart takeaway, KPI context를 세밀하게 보기 시작했습니다. 이후 남는 작업은 finance/sales/board fixture 확대와 slide-level auto-repair 정교화처럼 golden 마감 중심입니다. diff --git a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs index bcb2bc8..b1c2c06 100644 --- a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs @@ -16,7 +16,9 @@ public class ArtifactQualityReviewServiceTests
Important message
MetricValue
NPS61
+
+
@@ -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 = + """ +

Executive Summary

Summary text.

Supporting paragraph.

+
+
+
+
+

Recommendation

Recommendation text.

More detail.

+

Appendix

Reference detail.

More detail.

+ """; + + 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)); + } } diff --git a/src/AxCopilot.Tests/Services/ArtifactRepairGuideServiceTests.cs b/src/AxCopilot.Tests/Services/ArtifactRepairGuideServiceTests.cs index 9c108b6..7e9b39e 100644 --- a/src/AxCopilot.Tests/Services/ArtifactRepairGuideServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ArtifactRepairGuideServiceTests.cs @@ -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] diff --git a/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs b/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs index 62f2df7..496e8a0 100644 --- a/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs +++ b/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs @@ -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"); } } diff --git a/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs index 0b85673..ee328b6 100644 --- a/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs +++ b/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs @@ -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)); + } } diff --git a/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs b/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs index 8937944..535dfa1 100644 --- a/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs +++ b/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs @@ -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"); diff --git a/src/AxCopilot.Tests/Services/SqlAnalysisServiceTests.cs b/src/AxCopilot.Tests/Services/SqlAnalysisServiceTests.cs index 0d1bf91..7b17365 100644 --- a/src/AxCopilot.Tests/Services/SqlAnalysisServiceTests.cs +++ b/src/AxCopilot.Tests/Services/SqlAnalysisServiceTests.cs @@ -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)); + } } diff --git a/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs b/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs index 0a1a465..b5b67d0 100644 --- a/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs +++ b/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs @@ -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() { diff --git a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs index 5030c21..5ab7c3e 100644 --- a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs +++ b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs @@ -127,6 +127,8 @@ public static class ArtifactQualityReviewService var tableCount = Regex.Matches(html, @" 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); } diff --git a/src/AxCopilot/Services/Agent/ArtifactRepairGuideService.cs b/src/AxCopilot/Services/Agent/ArtifactRepairGuideService.cs index 3c42bcc..bebd246 100644 --- a/src/AxCopilot/Services/Agent/ArtifactRepairGuideService.cs +++ b/src/AxCopilot/Services/Agent/ArtifactRepairGuideService.cs @@ -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; } diff --git a/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs b/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs index c85a5f0..4c44f14 100644 --- a/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs +++ b/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs @@ -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; } diff --git a/src/AxCopilot/Services/Agent/DeckRepairGuideService.cs b/src/AxCopilot/Services/Agent/DeckRepairGuideService.cs index a69a7e9..51f6ee9 100644 --- a/src/AxCopilot/Services/Agent/DeckRepairGuideService.cs +++ b/src/AxCopilot/Services/Agent/DeckRepairGuideService.cs @@ -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) diff --git a/src/AxCopilot/Services/Agent/HtmlSkill.cs b/src/AxCopilot/Services/Agent/HtmlSkill.cs index 0aa0d28..dc1ef59 100644 --- a/src/AxCopilot/Services/Agent/HtmlSkill.cs +++ b/src/AxCopilot/Services/Agent/HtmlSkill.cs @@ -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)을 지원합니다. /// 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($"

{Escape(title)}

"); + + sb.AppendLine("
"); + sb.AppendLine(""); + sb.Append(""); + foreach (var criterion in criteria) + sb.Append($""); + sb.AppendLine(""); + sb.AppendLine(""); + + 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(""); + sb.Append($""); + foreach (var criterion in criteria) + { + var score = scoresEl.ValueKind == JsonValueKind.Object && + scoresEl.SafeTryGetProperty(criterion, out var scoreEl) + ? scoreEl.ToString() + : "-"; + sb.Append($""); + } + sb.Append($""); + sb.Append($""); + sb.AppendLine(""); + } + + sb.AppendLine(""); + sb.AppendLine("
Option{Escape(criterion)}VerdictNotes
{Escape(name)}{Escape(score)}{MarkdownToHtml(verdict ?? string.Empty)}{MarkdownToHtml(notes ?? string.Empty)}
"); + sb.AppendLine("
"); + 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($"

{Escape(title)}

"); + + sb.AppendLine("
"); + 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("
"); + sb.AppendLine($"
{Escape(label)}
"); + sb.AppendLine($"
{Escape(value)}
"); + if (!string.IsNullOrWhiteSpace(trend)) + sb.AppendLine($"
{Escape(trend)}
"); + if (!string.IsNullOrWhiteSpace(note)) + sb.AppendLine($"
{MarkdownToHtml(note)}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + return sb.ToString(); + } + private static string RenderRoadmap(JsonElement s) { if (!s.SafeTryGetProperty("phases", out var phases) || phases.ValueKind != JsonValueKind.Array) diff --git a/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs b/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs index 0eb7376..972d742 100644 --- a/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs +++ b/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs @@ -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) { diff --git a/src/AxCopilot/Services/CodeLanguageCatalog.cs b/src/AxCopilot/Services/CodeLanguageCatalog.cs index 35076d4..8b458e6 100644 --- a/src/AxCopilot/Services/CodeLanguageCatalog.cs +++ b/src/AxCopilot/Services/CodeLanguageCatalog.cs @@ -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; diff --git a/src/AxCopilot/Services/SqlAnalysisService.cs b/src/AxCopilot/Services/SqlAnalysisService.cs index 237e8d5..7cc3c59 100644 --- a/src/AxCopilot/Services/SqlAnalysisService.cs +++ b/src/AxCopilot/Services/SqlAnalysisService.cs @@ -6,22 +6,31 @@ namespace AxCopilot.Services; public sealed record SqlAnalysisReport( string Dialect, + string ScriptIntent, IReadOnlyList StatementKinds, IReadOnlyList Objects, + IReadOnlyList Dependencies, IReadOnlyList Risks, - IReadOnlyList SuggestedChecks) + IReadOnlyList SuggestedChecks, + IReadOnlyList ReviewNotes) { public string ToFallbackSummary() { var parts = new List { $"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 DetectDependencies(string sql, IReadOnlyList objects) + { + var dependencies = new HashSet(StringComparer.OrdinalIgnoreCase); + var ownedObjects = DetectOwnedObjects(sql); + var patterns = new[] + { + @"\breferences\s+(?[\[\]`\""\w\.]+)", + @"\bjoin\s+(?[\[\]`\""\w\.]+)", + @"\bfrom\s+(?[\[\]`\""\w\.]+)", + @"\binto\s+(?[\[\]`\""\w\.]+)", + @"\bupdate\s+(?[\[\]`\""\w\.]+)", + @"\bexec(?:ute)?\s+(?[\[\]`\""\w\.]+)", + @"\bon\s+(?[\[\]`\""\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 DetectOwnedObjects(string sql) + { + var patterns = new[] + { + @"\bcreate\s+table\s+(if\s+not\s+exists\s+)?(?[\[\]`\""\w\.]+)", + @"\balter\s+table\s+(?[\[\]`\""\w\.]+)", + @"\bdrop\s+table\s+(if\s+exists\s+)?(?[\[\]`\""\w\.]+)", + @"\bcreate\s+view\s+(?[\[\]`\""\w\.]+)", + @"\bcreate\s+(or\s+replace\s+)?function\s+(?[\[\]`\""\w\.]+)", + @"\bcreate\s+(or\s+replace\s+)?procedure\s+(?[\[\]`\""\w\.]+)", + @"\bcreate\s+(unique\s+)?index\s+(?[\[\]`\""\w\.]+)", + @"\binsert\s+into\s+(?[\[\]`\""\w\.]+)", + @"\bupdate\s+(?[\[\]`\""\w\.]+)", + @"\bdelete\s+from\s+(?[\[\]`\""\w\.]+)" + }; + + var names = new HashSet(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 DetectRisks(IReadOnlyList statements, string fullSql) { var risks = new List(); @@ -152,6 +233,26 @@ public static class SqlAnalysisService return risks.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } + private static string ClassifyScriptIntent(IReadOnlyList 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 BuildSuggestedChecks( IReadOnlyList statementKinds, IReadOnlyList risks, @@ -185,6 +286,57 @@ public static class SqlAnalysisService return checks.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList(); } + private static IReadOnlyList BuildReviewNotes( + IReadOnlyList statementKinds, + IReadOnlyList risks, + IReadOnlyList dependencies, + string dialect, + string? filePath, + string scriptIntent) + { + var notes = new List(); + + 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 SplitStatements(string sql) { return sql