diff --git a/README.md b/README.md index 65e2c2c..65ebd95 100644 --- a/README.md +++ b/README.md @@ -2326,3 +2326,15 @@ MIT License - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_build_failure_recovery\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_build_failure_recovery_tests\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery_tests\\` 통과 134 +업데이트: 2026-04-15 23:44 (KST) +- Code 탭의 구조형 프로젝트 생성 흐름을 프로젝트 스캐폴드 모드로 승격했습니다. `src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs`를 추가해 WPF/MVVM, ASP.NET API, React/Vue/Next, Node, Python API, Spring, Android, Go, Rust CLI, generic solution 요청을 감지하고 최소 폴더 구조/대표 파일 경로를 프로파일 단위로 제안합니다. +- `src/AxCopilot/Services/Agent/IntentGateService.cs`, `src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs`, `src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs`, `src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs`, `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs`를 함께 조정해 빈 작업 폴더에서 `index.html/app.js/style.css` 같은 평면 예시 대신 `file_manage(mkdir)`와 중첩 `file_write`로 최소 프로젝트 트리를 먼저 만들도록 유도합니다. +- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs`에는 프로젝트 레이아웃 게이트를 추가했습니다. 초기 워크스페이스가 비어 있었고 구조형 스캐폴드 요청이 감지된 경우, 루트에 구현 파일이 평면으로 쌓인 채 종료하려 하면 `Views/ViewModels/Models/Services/Themes`, `src/components/pages/services`, `app/api/models/services`, `src/main/java/resources/test` 같은 프레임워크별 기본 분리를 다시 요구합니다. +- `src/AxCopilot/skills/code-scaffold.skill.md`는 `when_to_use`와 `file_manage/file_edit` 메타데이터를 추가해 proactive auto-skill 대상으로 승격했습니다. 이제 새 프로젝트/프레임워크 앱 생성에서는 기존 코드 탐색보다 폴더 트리 설계와 스캐폴딩 규칙이 먼저 주입됩니다. +- 테스트: + - `src/AxCopilot.Tests/Services/IntentGateServiceTests.cs` + - `src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs` + - `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs` +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\` 통과 183 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f878338..0d32311 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1657,3 +1657,41 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_build_failure_recovery\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_build_failure_recovery_tests\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery_tests\\` 통과 134 +업데이트: 2026-04-15 23:44 (KST) +- 구조형 프로젝트 스캐폴드 정책을 추가했다. `src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs` + - WPF/MVVM, ASP.NET API, React/Vue/Next, Node backend, Python API, Spring Boot, Android/Kotlin, Go service, Rust CLI, generic solution 프로파일을 정의한다. + - 프로파일마다 트리거 토큰, 기대 폴더, 대표 시작 파일, 루트 허용 파일, 최소 디렉터리 적중 기준을 관리한다. + - `AssessLayout(...)`로 초기 빈 워크스페이스에서 생성된 결과가 평면 루트 파일 위주인지 검사한다. +- 인텐트/탐색 흐름을 프로젝트 스캐폴드 중심으로 재구성했다. + - `src/AxCopilot/Services/Agent/IntentGateService.cs` + - Code 탭에서 구조형 프로젝트 요청을 `ExplorationScope.ProjectScaffold`로 분류한다. + - WPF, XAML, csproj, MVVM, ViewModel, ResourceDictionary뿐 아니라 React, Python API, Spring, Android, Go, Rust 같은 다른 언어/개발 유형도 같은 기준으로 감지한다. + - `src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs` + - `ProjectScaffold` scope를 추가하고 `file_manage/file_write/file_edit`를 우선 노출한다. + - 빈 워크스페이스의 구조형 요청은 broad 탐색 대신 최소 트리 생성 후 구현 파일 배치를 기본 순서로 안내한다. + - `src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs` + - 탐색 상태 초기화 시 `ProjectScaffoldProfileCatalog.Detect(...)` 결과를 붙여 run 단위 스캐폴드 메타를 유지한다. + - `src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs` + - empty workspace 복구/초기 가이드를 `file_write 즉시 호출`에서 `single file direct create / multi-file scaffold tree-first` 규칙으로 확장했다. + - `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs` + - Code 시스템 프롬프트에 `file_manage`를 실제 available tools로 반영하고, 프레임워크/멀티파일 스캐폴드는 트리 생성부터 시작하라고 명시했다. +- 코드 품질 게이트에 프로젝트 레이아웃 검사를 추가했다. + - `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs` + - `RunState.ProjectLayoutGateRetry`를 추가했다. + - `src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs` + - 구조형 스캐폴드 요청이었고 초기 워크스페이스가 비어 있었던 경우, 루트에 구현 파일이 평면으로 남아 있으면 종료 전 `ProjectLayoutGate`를 발동한다. + - `file_manage(mkdir/move)`와 `file_edit/file_write`로 폴더 재배치를 먼저 수행하도록 요구한다. +- 스킬 메타데이터도 함께 보강했다. + - `src/AxCopilot/skills/code-scaffold.skill.md` + - `when_to_use`를 추가해 auto-skill 후보로 포함되게 했다. + - `file_manage`, `file_edit`를 허용 도구에 추가하고, 빈 작업 폴더에서는 최소 폴더 트리부터 설계하도록 명시했다. +- 테스트: + - `src/AxCopilot.Tests/Services/IntentGateServiceTests.cs` + - WPF/React 구조형 요청이 `ProjectScaffold`로 분류되는지 확인한다. + - `src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs` + - WPF/FastAPI 프로파일 감지와 flat-root vs structured-layout 평가를 검증한다. + - `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs` + - `BuildProjectLayoutGatePrompt(...)`가 폴더 재배치 지시를 포함하는지 검증한다. +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\` 통과 183 diff --git a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs index 371dceb..55621de 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs @@ -258,6 +258,31 @@ public class AgentLoopCodeQualityTests prompt.Should().NotContain("git_tool(diff) -> targeted tool retry"); } + [Fact] + public void BuildProjectLayoutGatePrompt_CallsForFolderReorganization() + { + var profile = ProjectScaffoldProfileCatalog.Detect( + "Create a C# WPF MVVM desktop app with ResourceDictionary themes", + "Code")!; + var assessment = new ProjectScaffoldLayoutAssessment( + IsSatisfied: false, + MatchedDirectoryCount: 0, + ExistingDirectories: [], + MissingDirectories: ["Views", "ViewModels", "Themes"], + SuspiciousRootFiles: ["MainWindow.xaml", "MainWindowViewModel.cs"]); + + var prompt = InvokePrivateStatic( + "BuildProjectLayoutGatePrompt", + profile, + assessment); + + prompt.Should().Contain("[System:ProjectLayoutGate]"); + prompt.Should().Contain("Views"); + prompt.Should().Contain("ViewModels"); + prompt.Should().Contain("MainWindow.xaml"); + prompt.Should().Contain("file_manage(mkdir/move)"); + } + [Fact] public void BuildFailureNextToolPriorityPrompt_IncludesOrderedPriority() { diff --git a/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs b/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs index fabc9a4..7483999 100644 --- a/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs @@ -135,6 +135,22 @@ public class IntentGateServiceTests result.SuggestedScope.Should().Be(ExplorationScope.Localized); } + [Fact] + public async Task ClassifyAsync_WpfProjectQuery_SuggestsProjectScaffold() + { + var result = await _sut.ClassifyAsync("Create a new C# WPF MVVM project with ResourceDictionary themes", "Code"); + + result.SuggestedScope.Should().Be(ExplorationScope.ProjectScaffold); + } + + [Fact] + public async Task ClassifyAsync_ReactProjectQuery_SuggestsProjectScaffold() + { + var result = await _sut.ClassifyAsync("Generate a React TypeScript frontend starter with pages and services", "Code"); + + result.SuggestedScope.Should().Be(ExplorationScope.ProjectScaffold); + } + // ═══════════════════════════════════════════ // 복합 요청 감지 (P5) // ═══════════════════════════════════════════ diff --git a/src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs b/src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs new file mode 100644 index 0000000..696c100 --- /dev/null +++ b/src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs @@ -0,0 +1,85 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using System.IO; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class ProjectScaffoldProfileCatalogTests +{ + [Fact] + public void Detect_ReturnsWpfProfile_ForWpfQuery() + { + var profile = ProjectScaffoldProfileCatalog.Detect( + "Create a C# WPF MVVM desktop app with ResourceDictionary themes", + "Code"); + + profile.Should().NotBeNull(); + profile!.Key.Should().Be("wpf-mvvm"); + } + + [Fact] + public void Detect_ReturnsPythonServiceProfile_ForFastApiQuery() + { + var profile = ProjectScaffoldProfileCatalog.Detect( + "Generate a Python FastAPI backend service scaffold", + "Code"); + + profile.Should().NotBeNull(); + profile!.Key.Should().Be("python-service"); + } + + [Fact] + public void AssessLayout_Fails_WhenStructuredProjectFilesAreFlatInRoot() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-scaffold-flat-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + File.WriteAllText(Path.Combine(tempDir, "MainWindow.xaml"), ""); + File.WriteAllText(Path.Combine(tempDir, "MainWindowViewModel.cs"), "class MainWindowViewModel {}"); + + var profile = ProjectScaffoldProfileCatalog.Detect( + "Create a C# WPF MVVM desktop app with ResourceDictionary themes", + "Code")!; + var assessment = ProjectScaffoldProfileCatalog.AssessLayout(profile, tempDir); + + assessment.IsSatisfied.Should().BeFalse(); + assessment.SuspiciousRootFiles.Should().Contain("MainWindow.xaml"); + assessment.SuspiciousRootFiles.Should().Contain("MainWindowViewModel.cs"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void AssessLayout_Passes_WhenExpectedDirectoriesExist() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-scaffold-structured-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + Directory.CreateDirectory(Path.Combine(tempDir, "Views")); + Directory.CreateDirectory(Path.Combine(tempDir, "ViewModels")); + Directory.CreateDirectory(Path.Combine(tempDir, "Themes")); + File.WriteAllText(Path.Combine(tempDir, "App.xaml"), ""); + + var profile = ProjectScaffoldProfileCatalog.Detect( + "Create a C# WPF MVVM desktop app with ResourceDictionary themes", + "Code")!; + var assessment = ProjectScaffoldProfileCatalog.AssessLayout(profile, tempDir); + + assessment.IsSatisfied.Should().BeTrue(); + assessment.MatchedDirectoryCount.Should().BeGreaterThanOrEqualTo(2); + assessment.SuspiciousRootFiles.Should().BeEmpty(); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs b/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs index b65b5f9..4a3ce68 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs @@ -66,7 +66,7 @@ public partial class AgentLoopService var blockedMessage = isExternalEscalation ? $"Empty workspace guard blocked external path search: {target}" - : $"Empty workspace guard blocked detour tool '{toolName}'. Use file_write directly."; + : $"Empty workspace guard blocked detour tool '{toolName}'. Use file_manage/file_write directly."; EmitEvent(AgentEventType.Error, toolName, blockedMessage); EmitEvent( @@ -81,18 +81,34 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = BuildEmptyWorkspaceCreationRecoveryPrompt(activeToolNames) + Content = BuildEmptyWorkspaceCreationRecoveryPrompt(activeToolNames, explorationState: null, context.InitialUserQuery) }); return true; } - private static string BuildEmptyWorkspaceCreationRecoveryPrompt(IReadOnlyCollection activeToolNames) + private static string BuildEmptyWorkspaceCreationRecoveryPrompt( + IReadOnlyCollection activeToolNames, + ExplorationTrackingState? explorationState, + string? userQuery) { var activeToolPreview = AgentLoopNoToolResponseRecoveryService.BuildActiveToolPreview(activeToolNames); + var scaffoldProfile = explorationState?.ScaffoldProfile ?? ProjectScaffoldProfileCatalog.Detect(userQuery, "Code"); + if (scaffoldProfile != null) + { + var layoutPreview = ProjectScaffoldProfileCatalog.BuildDirectoryPreview(scaffoldProfile); + var starterPreview = ProjectScaffoldProfileCatalog.BuildStarterPathPreview(scaffoldProfile); + return "[System:EmptyWorkspaceCreation] The current work folder is empty and the request is a structured project scaffold. " + + "Do not search C:\\, other drive roots, or repeat folder_map, grep, glob, file_read, env_tool, skill_manager, or mcp tools. " + + $"First establish a minimal folder layout such as {layoutPreview}, then create representative files directly in those paths. " + + $"Use file_manage(mkdir) and file_write with relative paths like {starterPreview}. " + + "Do not flatten implementation files in the workspace root, and do not only describe the plan. Emit the actual tool calls now. " + + $"Available tools: {activeToolPreview}"; + } + return "[System:EmptyWorkspaceCreation] The current work folder is empty. " + "Do not search C:\\, other drive roots, or repeat folder_map, grep, glob, file_read, env_tool, skill_manager, or mcp tools. " + "Call file_write immediately using a relative path in the current work folder and create the needed file directly. " + - "For example: index.html, app.js, or style.css. " + + "If the user asked for a multi-file app or framework scaffold, create a minimal project tree first instead of flattening files in the root. " + "Do not only describe the plan. Emit the actual tool call now. " + $"Available tools: {activeToolPreview}"; } @@ -108,7 +124,7 @@ public partial class AgentLoopService if (!runState.WorkspaceAppearsEmpty) return; - if (explorationState.Scope != ExplorationScope.DirectCreation) + if (explorationState.Scope is not (ExplorationScope.DirectCreation or ExplorationScope.ProjectScaffold)) return; runState.EmptyWorkspaceGuardTriggered = true; @@ -116,10 +132,7 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "system", - Content = - "[System:EmptyWorkspaceStart] The current work folder is empty and the user asked to create a new artifact. " + - "Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " + - "Call file_write immediately with a relative path inside the current work folder and create the requested file directly." + Content = BuildInitialEmptyWorkspaceGuidance(explorationState, contextHint: null) }); EmitEvent( @@ -128,6 +141,28 @@ public partial class AgentLoopService "빈 작업 폴더 감지 · 탐색 없이 새 파일 생성을 시작합니다"); } + private static string BuildInitialEmptyWorkspaceGuidance( + ExplorationTrackingState explorationState, + string? contextHint) + { + var scaffoldProfile = explorationState.ScaffoldProfile + ?? ProjectScaffoldProfileCatalog.Detect(contextHint, "Code"); + if (explorationState.Scope == ExplorationScope.ProjectScaffold && scaffoldProfile != null) + { + var layoutPreview = ProjectScaffoldProfileCatalog.BuildDirectoryPreview(scaffoldProfile); + var starterPreview = ProjectScaffoldProfileCatalog.BuildStarterPathPreview(scaffoldProfile); + return "[System:EmptyWorkspaceStart] The current work folder is empty and the user asked for a structured project scaffold. " + + "Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " + + $"Create a minimal folder layout such as {layoutPreview} first, then place code into relative paths like {starterPreview}. " + + "Use file_manage(mkdir) and nested file_write/file_edit calls, and do not dump implementation files into the workspace root."; + } + + return "[System:EmptyWorkspaceStart] The current work folder is empty and the user asked to create a new artifact. " + + "Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " + + "Call file_write immediately with a relative path inside the current work folder and create the requested file directly. " + + "If the request is a framework or multi-file scaffold, create a minimal project tree first instead of flattening files in the root."; + } + private static bool IsExternalWorkspaceEscalationTarget(string? target, string? workFolder) { if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(workFolder)) diff --git a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs index 8d919ad..12b0bf3 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs @@ -11,7 +11,7 @@ public partial class AgentLoopService TopicBased, RepoWide, OpenEnded, - /// 문서 생성 요청 — 탐색 단계를 건너뛰고 바로 document_plan/생성 도구 사용. + ProjectScaffold, DirectCreation, } @@ -24,8 +24,8 @@ public partial class AgentLoopService public bool BroadScanDetected { get; set; } public bool SelectiveHit { get; set; } public bool CorrectiveHintInjected { get; set; } - /// 스킬 런타임이 allowed-tools를 명시했으면 true — 탐색 필터링을 건너뜀. public bool SkillAllowedToolsActive { get; set; } + public ProjectScaffoldProfile? ScaffoldProfile { get; set; } } private static IReadOnlyCollection FilterExplorationToolsForCurrentIteration( @@ -38,21 +38,37 @@ public partial class AgentLoopService if (tools.Count == 0) return tools; - // 스킬 런타임 정책으로 allowed-tools가 명시된 경우 탐색 필터링을 건너뜀 - // — 스킬이 의도적으로 허용한 도구(folder_map 등)를 정책이 차단하면 안 됨 if (state.SkillAllowedToolsActive) return tools; - // 문서 생성 모드: 생성 도구를 최우선, 탐색 도구를 뒤로 배치 + if (state.Scope == ExplorationScope.ProjectScaffold) + { + var scaffoldFirst = new List(tools.Count); + scaffoldFirst.AddRange(tools.Where(IsProjectScaffoldTool)); + scaffoldFirst.AddRange(tools.Where(t => + !IsProjectScaffoldTool(t) + && !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase) + && !string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase) + && !string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase))); + scaffoldFirst.AddRange(tools.Where(t => + string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase) + || string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase) + || string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase))); + return scaffoldFirst + .DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList() + .AsReadOnly(); + } + if (state.Scope == ExplorationScope.DirectCreation) { var creationFirst = new List(tools.Count); creationFirst.AddRange(tools.Where(IsDocumentCreationTool)); - creationFirst.AddRange(tools.Where(t => !IsDocumentCreationTool(t) + creationFirst.AddRange(tools.Where(t => + !IsDocumentCreationTool(t) && !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase))); - // 탐색 도구는 마지막에 (필요 시에만 사용) creationFirst.AddRange(tools.Where(t => string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase) || string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase))); @@ -66,12 +82,11 @@ public partial class AgentLoopService var ordered = new List(tools.Count); ordered.AddRange(tools.Where(IsSelectiveDiscoveryTool)); - ordered.AddRange(tools.Where(t => !IsSelectiveDiscoveryTool(t) && - (allowFolderMap || !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)))); + ordered.AddRange(tools.Where(t => + !IsSelectiveDiscoveryTool(t) + && (allowFolderMap || !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)))); if (allowFolderMap) - { ordered.AddRange(tools.Where(t => string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase))); - } return ordered .DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase) @@ -86,14 +101,19 @@ public partial class AgentLoopService or "document_assemble" or "file_write"; } + private static bool IsProjectScaffoldTool(IAgentTool tool) + { + return tool.Name is "file_manage" or "file_write" or "file_edit" or "file_read" + or "build_run" or "test_loop" or "dev_env_detect" or "process"; + } + private static bool ShouldAllowFolderMapForCurrentIteration( ExplorationTrackingState state, string userQuery, string? activeTab, int totalToolCalls) { - // 문서 생성 모드에서는 folder_map 차단 — 탐색 없이 바로 생성 - if (state.Scope == ExplorationScope.DirectCreation) + if (state.Scope is ExplorationScope.DirectCreation or ExplorationScope.ProjectScaffold) return false; if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded) @@ -116,23 +136,8 @@ public partial class AgentLoopService return ContainsAny( userQuery, - "folder", - "directory", - "tree", - "structure", - "list files", - "workspace layout", - "work folder", - "project structure", - "폴더", - "디렉터리", - "폴더 구조", - "디렉터리 구조", - "파일 목록", - "작업 폴더", - "폴더 안", - "구조를 보여", - "구조 확인"); + "folder", "directory", "tree", "structure", "list files", "workspace layout", "work folder", "project structure", + "폴더", "디렉터리", "폴더 구조", "디렉터리 구조", "파일 목록", "작업 폴더", "구조를 보여", "구조 확인"); } private static bool IsExistingMaterialReferenceRequest(string userQuery) @@ -142,21 +147,8 @@ public partial class AgentLoopService return ContainsAny( userQuery, - "existing file", - "existing files", - "existing document", - "existing documents", - "reference files", - "reference docs", - "existing materials", - "기존 파일", - "기존 문서", - "기존 자료", - "참고 파일", - "참고 문서", - "폴더 내 자료", - "안의 자료", - "작업 폴더 파일"); + "existing file", "existing files", "existing document", "existing documents", "reference files", "reference docs", "existing materials", + "기존 파일", "기존 문서", "기존 자료", "참고 파일", "참고 문서", "작업 폴더 파일"); } private static bool IsSelectiveDiscoveryTool(IAgentTool tool) @@ -171,27 +163,9 @@ public partial class AgentLoopService return ContainsAny( userQuery, - "definition", - "reference", - "references", - "implementation", - "implementations", - "caller", - "callers", - "callee", - "call hierarchy", - "symbol", - "symbols", - "interface", - "override", - "정의", - "참조", - "구현", - "호출부", - "호출 관계", - "심볼", - "인터페이스", - "오버라이드"); + "definition", "reference", "references", "implementation", "implementations", + "caller", "callers", "callee", "call hierarchy", "symbol", "symbols", "interface", "override", + "정의", "참조", "구현", "호출부", "호출 관계", "심볼", "인터페이스", "오버라이드"); } private static string BuildPreferredInitialToolSequence( @@ -200,7 +174,14 @@ public partial class AgentLoopService string? activeTab, string userQuery) { - // 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용 + if (state.Scope == ExplorationScope.ProjectScaffold) + { + var layoutPreview = state.ScaffoldProfile == null + ? "src, tests, config" + : ProjectScaffoldProfileCatalog.BuildDirectoryPreview(state.ScaffoldProfile); + return $"file_manage(mkdir)/file_write with a minimal project tree ({layoutPreview}) -> file_read/file_edit -> build_run/test_loop as needed"; + } + if (state.Scope == ExplorationScope.DirectCreation) { if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) @@ -233,31 +214,28 @@ public partial class AgentLoopService if (string.IsNullOrWhiteSpace(userQuery)) return ExplorationScope.OpenEnded; - var q = userQuery.Trim(); - var lower = q.ToLowerInvariant(); + var lower = userQuery.Trim().ToLowerInvariant(); - // Cowork 탭에서 문서 생성 의도가 있으면 탐색을 건너뛰고 바로 생성 도구 사용 if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) && HasDocumentCreationIntent(lower)) return ExplorationScope.DirectCreation; + if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + && ProjectScaffoldProfileCatalog.IsStructuredProjectRequest(lower, activeTab)) + return ExplorationScope.ProjectScaffold; + if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) && HasCodeArtifactCreationIntent(lower)) return ExplorationScope.DirectCreation; - if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") || - lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") || - lower.Contains("아키텍처") || lower.Contains("전체 점검")) + if (ContainsAny(lower, "전체", "전반", "코드베이스 전체", "repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 맥락")) return ExplorationScope.RepoWide; - if (q.Contains('.') || q.Contains('/') || q.Contains('\\') || - lower.Contains("file ") || lower.Contains("class ") || lower.Contains("method ") || - lower.Contains("function ") || lower.Contains("line ") || lower.Contains("bug") || - lower.Contains("오류") || lower.Contains("버그") || lower.Contains("예외")) + if (lower.Contains('.') || lower.Contains('/') || lower.Contains('\\') || + ContainsAny(lower, "file ", "class ", "method ", "function ", "line ", "bug", "오류", "버그", "예외")) return ExplorationScope.Localized; - if (lower.Contains("정리") || lower.Contains("요약") || lower.Contains("보고서") || - lower.Contains("주제") || lower.Contains("관련") || lower.Contains("분석")) + if (ContainsAny(lower, "정리", "요약", "보고", "주제", "관련", "분석")) return ExplorationScope.TopicBased; return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) @@ -265,38 +243,30 @@ public partial class AgentLoopService : ExplorationScope.OpenEnded; } - /// - /// 사용자가 문서를 새로 생성/작성하려는 의도인지 판별합니다. - /// "보고서 작성해줘", "문서 만들어줘" 등 생성 동사가 포함된 경우 true. - /// private static bool HasDocumentCreationIntent(string lowerQuery) { - // 생성 동사 키워드 - var hasCreationVerb = ContainsAny(lowerQuery, - "작성해", "써줘", "써 줘", "만들어", "생성해", "작성 해", - "만들어줘", "만들어 줘", "생성해줘", "생성해 줘", - "write", "create", "draft", "generate", "compose", - "작성하", "작성을", "생성하", "생성을", - "리포트 써", "보고서 써", "문서 써", - "작성 부탁", "만들어 부탁"); + var hasCreationVerb = ContainsAny( + lowerQuery, + "작성", "써줘", "만들", "생성", "만들어줘", "생성해줘", + "write", "create", "draft", "generate", "compose"); if (!hasCreationVerb) return false; - // 생성 대상이 문서/보고서/자료 등인지 확인 - return ContainsAny(lowerQuery, - "보고서", "문서", "제안서", "리포트", "분석서", "기획서", - "report", "document", "proposal", "analysis", - "요약서", "발표자료", "ppt", "pptx", "docx", "xlsx", "excel", "word", - "표", "차트", "스프레드시트", "프레젠테이션", - "정리해", "정리 해"); + return ContainsAny( + lowerQuery, + "보고서", "문서", "제안서", "리포트", "분석서", "기획서", "요약", "발표자료", "ppt", "pptx", "docx", "xlsx", "excel", "word", "sheet", "chart", + "report", "document", "proposal", "analysis", "presentation", "template", "spreadsheet", "excel", "memo"); } private static bool HasCodeArtifactCreationIntent(string lowerQuery) { + if (ProjectScaffoldProfileCatalog.IsStructuredProjectRequest(lowerQuery, "Code")) + return true; + var hasCreationVerb = ContainsAny( lowerQuery, - "만들", "생성", "작성", "create", "generate", "build", "write", "scaffold", "draft"); + "만들", "생성", "작성", "구현", "create", "generate", "build", "write", "scaffold", "draft"); if (!hasCreationVerb) return false; @@ -310,35 +280,52 @@ public partial class AgentLoopService "template", "템플릿", "index.html", "app.js", "style.css"); } - private static void InjectExplorationScopeGuidance(List messages, ExplorationScope scope) + private static void InjectExplorationScopeGuidance(List messages, ExplorationTrackingState state) { + var scope = state.Scope; var guidance = scope switch { + ExplorationScope.ProjectScaffold => + "Exploration scope = project-scaffold. The user wants a framework or multi-file project scaffold. " + + "Create a minimal project tree first with file_manage(mkdir) or nested file_write paths, then place source files into the proper folders. " + + "Do not flatten implementation files in the workspace root, and do not waste turns on folder_map/glob/grep unless the user explicitly asked to inspect the workspace.", ExplorationScope.DirectCreation => - "Exploration scope = direct-creation. The user wants to CREATE a new document/report/file. " + - "Do NOT search for existing files with glob/grep/folder_map — skip exploration entirely. " + - "Call document_plan first to outline the document structure, then immediately call the appropriate creation tool " + - "(docx_create, html_create, excel_create, markdown_create, etc.) to produce the actual file. " + - "The output MUST be a real file on disk, not a text response.", + "Exploration scope = direct-creation. The user wants to create a new document or file. " + + "Do not search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " + + "Produce a real file on disk instead of only describing the result.", ExplorationScope.Localized => - "Exploration scope = localized. Start with lsp_code_intel when the request is about definitions/references/implementations/call hierarchy; otherwise use targeted file_read or grep/glob. Avoid folder_map unless the user explicitly asks for folder structure or file listing.", + "Exploration scope = localized. Start with lsp_code_intel for definitions, references, and implementations; otherwise use targeted file_read or grep/glob. Avoid folder_map unless the user explicitly asked for folder structure.", ExplorationScope.TopicBased => "Exploration scope = topic-based. Identify candidate files by topic keywords first with glob/grep, then read only a small targeted set.", ExplorationScope.RepoWide => "Exploration scope = repo-wide. Broad structure inspection is allowed when needed.", _ => - "Exploration scope = open-ended. Expand gradually. Prefer selective discovery before broad scans." + "Exploration scope = open-ended. Expand gradually and prefer selective discovery before broad scans." }; - if (scope == ExplorationScope.DirectCreation) + if (scope == ExplorationScope.ProjectScaffold) + { + var layoutPreview = state.ScaffoldProfile == null + ? "src, tests, config" + : ProjectScaffoldProfileCatalog.BuildDirectoryPreview(state.ScaffoldProfile); + var starterPreview = state.ScaffoldProfile == null + ? "'src/main.ext', 'tests/main.spec.ext'" + : ProjectScaffoldProfileCatalog.BuildStarterPathPreview(state.ScaffoldProfile); + guidance = + "Exploration scope = project-scaffold. The user wants to create a structured project. " + + $"Establish a minimal folder layout such as {layoutPreview} before filling in implementation files. " + + $"Use relative scaffold paths like {starterPreview}. " + + "Prefer file_manage(mkdir) and nested file_write/file_edit calls. Do not dump every generated file into the workspace root."; + } + else if (scope == ExplorationScope.DirectCreation) { guidance = - "Exploration scope = direct-creation. The user wants to CREATE a new file or document. " + - "Do NOT search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " + + "Exploration scope = direct-creation. The user wants to create a new file or document. " + + "Do not search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " + "If you are in the Code tab, call file_write immediately with a relative path inside the current work folder, then use file_edit/build_run/test_loop only if needed. " + "If you are in Cowork and the user asked for a presentation, deck, slide pack, or PPT, call pptx_create directly unless the user explicitly asked for a plan or outline. " + "Use document_plan first only when you are drafting a multi-section written document or when structure planning materially improves the result. " + - "The output MUST be a real file on disk, not a text response."; + "The output must be a real file on disk, not a text response."; } messages.Add(new ChatMessage @@ -353,8 +340,8 @@ public partial class AgentLoopService try { using var doc = JsonDocument.Parse(argsJson); - if (doc.RootElement.TryGetProperty("paths", out var pathsEl) && pathsEl.ValueKind == JsonValueKind.Array) - return pathsEl.GetArrayLength(); + if (doc.RootElement.TryGetProperty("paths", out var pathsElement) && pathsElement.ValueKind == JsonValueKind.Array) + return pathsElement.GetArrayLength(); } catch { @@ -368,13 +355,12 @@ public partial class AgentLoopService string toolName, string argsJson) { - // 문서 생성 모드: 탐색 도구가 1회라도 호출되면 즉시 교정 - if (state.Scope == ExplorationScope.DirectCreation) + if (state.Scope is ExplorationScope.DirectCreation or ExplorationScope.ProjectScaffold) { return string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase) || string.Equals(toolName, "glob", StringComparison.OrdinalIgnoreCase) || string.Equals(toolName, "grep", StringComparison.OrdinalIgnoreCase) - || state.TotalFilesRead >= 1; + || (state.Scope == ExplorationScope.ProjectScaffold && state.TotalFilesRead >= 3); } if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded) @@ -391,11 +377,11 @@ public partial class AgentLoopService try { using var doc = JsonDocument.Parse(argsJson); - var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesEl) && - includeFilesEl.ValueKind is JsonValueKind.True or JsonValueKind.False && - includeFilesEl.GetBoolean(); - var depth = doc.RootElement.TryGetProperty("depth", out var depthEl) && depthEl.ValueKind == JsonValueKind.Number - ? depthEl.GetInt32() + var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesElement) + && includeFilesElement.ValueKind is JsonValueKind.True or JsonValueKind.False + && includeFilesElement.GetBoolean(); + var depth = doc.RootElement.TryGetProperty("depth", out var depthElement) && depthElement.ValueKind == JsonValueKind.Number + ? depthElement.GetInt32() : 2; if (includeFiles || depth >= 3) return true; @@ -429,8 +415,8 @@ public partial class AgentLoopService return; } - if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase) || - string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase)) { state.TotalFilesRead++; } diff --git a/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs b/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs index e1af779..2677463 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs @@ -60,10 +60,12 @@ public partial class AgentLoopService { var intentGate = new IntentGateService(_llm); var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false); + var scaffoldProfile = ProjectScaffoldProfileCatalog.Detect(userQuery, ActiveTab); var explorationState = new ExplorationTrackingState { - Scope = intentResult.SuggestedScope, + Scope = scaffoldProfile == null ? intentResult.SuggestedScope : ExplorationScope.ProjectScaffold, SelectiveHit = true, + ScaffoldProfile = scaffoldProfile, }; var pathAccessState = new PathAccessTrackingState(); var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings) @@ -79,7 +81,7 @@ public partial class AgentLoopService maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType); InjectTaskTypeGuidance(messages, taskPolicy); - InjectExplorationScopeGuidance(messages, explorationState.Scope); + InjectExplorationScopeGuidance(messages, explorationState); if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint)) { diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index ceb3af9..172ed92 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -298,6 +298,7 @@ public partial class AgentLoopService var context = BuildContext(); context.InitialUserQuery = userQuery; runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder); + var workspaceWasInitiallyEmpty = runState.WorkspaceAppearsEmpty; var preferredInitialToolSequence = BuildPreferredInitialToolSequence( explorationState, @@ -309,6 +310,7 @@ public partial class AgentLoopService "", explorationState.Scope switch { + ExplorationScope.ProjectScaffold => "프로젝트 스캐폴드 모드 · 최소 폴더 구조부터 만드는 중", ExplorationScope.DirectCreation => string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) ? "즉시 생성 모드 · 바로 파일을 만드는 중" : "문서 생성 모드 · 바로 문서를 만드는 중", @@ -1042,6 +1044,9 @@ public partial class AgentLoopService taskPolicy, requireHighImpactCodeVerification, totalToolCalls, + context, + explorationState, + workspaceWasInitiallyEmpty, runState, executionPolicy)) continue; diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index e39a6a1..eef660a 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -1238,6 +1238,7 @@ public partial class AgentLoopService public int ExecutionSuccessGateRetry; public int HighImpactBuildTestGateRetry; public int CodeVerificationGateRetry; + public int ProjectLayoutGateRetry; public int FinalReportGateRetry; public int TransientLlmErrorRetries; public int DocumentArtifactGateRetry; diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs index 8057db7..146f78d 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs @@ -1,4 +1,4 @@ -using AxCopilot.Models; +using AxCopilot.Models; namespace AxCopilot.Services.Agent; @@ -28,8 +28,8 @@ public partial class AgentLoopService taskPolicy) }); EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange - ? "고영향 코드 변경으로 분류돼 참조 검증과 build/test 검증을 더 엄격하게 이어갑니다." - : "코드 변경 후 build/test/diff 검증을 이어갑니다."); + ? "怨좎쁺??肄붾뱶 蹂€寃쎌쑝濡?遺꾨쪟??李몄“ 寃€利앷낵 build/test 寃€利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??" + : "肄붾뱶 蹂€寃???build/test/diff 寃€利앹쓣 ?댁뼱媛묐땲??"); } else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification)) { @@ -43,12 +43,24 @@ public partial class AgentLoopService TaskTypePolicy taskPolicy, bool requireHighImpactCodeVerification, int totalToolCalls, + AgentContext context, + ExplorationTrackingState explorationState, + bool workspaceWasInitiallyEmpty, RunState runState, ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) { if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0) return false; + if (TryApplyProjectLayoutGateTransition( + messages, + textResponse, + context, + explorationState, + workspaceWasInitiallyEmpty, + runState)) + return true; + var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification( messages, requireHighImpactCodeVerification); @@ -69,12 +81,12 @@ public partial class AgentLoopService { Role = "user", Content = requireHighImpactCodeVerification - ? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요." - : "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요." + ? "[System:CodeQualityGate] 怨듭슜/?듭떖 肄붾뱶 蹂€寃??댄썑 寃€利?洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 file_read, grep/glob, git diff, build/test源뚯? ?뺤씤???ㅼ뿉留?留덈Т由ы븯?몄슂." + : "[System:CodeQualityGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test/file_read/diff 洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 寃€利?洹쇨굅瑜?蹂닿컯???ㅼ뿉留?留덈Т由ы븯?몄슂." }); EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification - ? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..." - : "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다..."); + ? "?듭떖 肄붾뱶 蹂€寃쎌쓽 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??.." + : "肄붾뱶 寃곌낵 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??.."); return true; } @@ -89,9 +101,9 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요." + Content = "[System:HighImpactBuildTestGate] ?듭떖 肄붾뱶 蹂€寃쎌엯?덈떎. 醫낅즺?섏? 留먭퀬 build_run怨?test_loop瑜?紐⑤몢 ?ㅽ뻾???깃났 洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂." }); - EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다..."); + EmitEvent(AgentEventType.Thinking, "", "?듭떖 蹂€寃쎌씠??build+test ?깃났 洹쇨굅瑜?紐⑤몢 ?뺣낫???뚭퉴吏€ 吏꾪뻾?⑸땲??.."); return true; } @@ -115,13 +127,92 @@ public partial class AgentLoopService Role = "user", Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification) }); - EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다..."); + EmitEvent(AgentEventType.Thinking, "", "理쒖쥌 蹂닿퀬??蹂€寃승룰?利씲룸━?ㅽ겕 ?붿빟??遺€議깊빐 ??踰????뺣━?⑸땲??.."); return true; } return false; } + private bool TryApplyProjectLayoutGateTransition( + List messages, + string? textResponse, + AgentContext context, + ExplorationTrackingState explorationState, + bool workspaceWasInitiallyEmpty, + RunState runState) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return false; + + var scaffoldProfile = explorationState.ScaffoldProfile; + if (!workspaceWasInitiallyEmpty || scaffoldProfile == null) + return false; + + if (runState.ProjectLayoutGateRetry >= 1) + return false; + + if (!HasProjectScaffoldModificationEvidence(messages)) + return false; + + var assessment = ProjectScaffoldProfileCatalog.AssessLayout(scaffoldProfile, context.WorkFolder); + if (assessment.IsSatisfied) + return false; + + runState.ProjectLayoutGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildProjectLayoutGatePrompt(scaffoldProfile, assessment) + }); + EmitEvent( + AgentEventType.Thinking, + "", + $"?꾨줈?앺듃 援ъ“媛€ ?됰㈃?곸쑝濡??앹꽦???대뜑 ?덉씠?꾩썐??癒쇱? ?뺣━?⑸땲??({runState.ProjectLayoutGateRetry}/1)"); + return true; + } + + private static bool HasProjectScaffoldModificationEvidence(List messages) + { + foreach (var message in messages) + { + if (!TryGetToolResultToolName(message, out var toolName)) + continue; + + if (toolName is "file_write" or "file_edit" or "file_manage" or "script_create") + return true; + } + + return false; + } + + private static string BuildProjectLayoutGatePrompt( + ProjectScaffoldProfile profile, + ProjectScaffoldLayoutAssessment assessment) + { + var missingDirectories = assessment.MissingDirectories.Count == 0 + ? "none" + : string.Join(", ", assessment.MissingDirectories.Take(6)); + var suspiciousRootFiles = assessment.SuspiciousRootFiles.Count == 0 + ? "none" + : string.Join(", ", assessment.SuspiciousRootFiles.Take(8)); + var allowedRootFiles = profile.AllowedRootFiles.Count == 0 + ? "only manifest and entry files" + : string.Join(", ", profile.AllowedRootFiles); + + return "[System:ProjectLayoutGate] This looks like a structured scaffold request for " + + $"{profile.Label}, but the current workspace layout is still too flat. " + + $"Create or complete folders such as {ProjectScaffoldProfileCatalog.BuildDirectoryPreview(profile)}, " + + $"especially the missing ones: {missingDirectories}. " + + $"Move implementation files that are still sitting in the workspace root into the proper folders: {suspiciousRootFiles}. " + + $"Keep only appropriate root files such as {allowedRootFiles}. " + + "Use file_manage(mkdir/move) plus file_edit/file_write to reorganize the scaffold before finishing, " + + "then rerun build/test if relevant and only after that summarize the result."; + } + private bool TryApplyCodeDiffEvidenceGateTransition( List messages, string? textResponse, @@ -147,9 +238,9 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. 지금 즉시 git_tool 도구를 호출하세요." + Content = "[System:CodeDiffGate] 肄붾뱶 蹂€寃??댄썑 diff 洹쇨굅媛€ 遺€議깊빀?덈떎. git_tool ?꾧뎄濡?蹂€寃??뚯씪怨??듭떖 diff瑜?癒쇱? ?뺤씤?섍퀬 ?붿빟?섏꽭?? 吏€湲?利됱떆 git_tool ?꾧뎄瑜??몄텧?섏꽭??" }); - EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다..."); + EmitEvent(AgentEventType.Thinking, "", "肄붾뱶 diff 洹쇨굅媛€ 遺€議깊빐 git diff 寃€利앹쓣 異붽??⑸땲??.."); return true; } @@ -186,7 +277,7 @@ public partial class AgentLoopService Role = "user", Content = BuildRecentExecutionEvidencePrompt(taskPolicy) }); - EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다..."); + EmitEvent(AgentEventType.Thinking, "", "理쒓렐 ?섏젙 ?댄썑 ?ㅽ뻾 洹쇨굅媛€ 遺€議깊빐 build/test ?ш?利앹쓣 ?섑뻾?⑸땲??.."); return true; } @@ -223,7 +314,7 @@ public partial class AgentLoopService Role = "user", Content = BuildExecutionSuccessGatePrompt(taskPolicy) }); - EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다..."); + EmitEvent(AgentEventType.Thinking, "", "?ㅽ뙣???ㅽ뻾 洹쇨굅留??덉뼱 build/test ?깃났 寃곌낵瑜??ㅼ떆 寃€利앺빀?덈떎..."); return true; } @@ -263,7 +354,7 @@ public partial class AgentLoopService Role = "user", Content = BuildTerminalEvidenceGatePrompt(taskPolicy, lastArtifactFilePath) }); - EmitEvent(AgentEventType.Thinking, "", $"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})"); + EmitEvent(AgentEventType.Thinking, "", $"醫낅즺 ???ㅽ뻾 利앷굅媛€ 遺€議깊빐 蹂닿컯 ?④퀎瑜?吏꾪뻾?⑸땲??({runState.TerminalEvidenceGateRetry}/{retryMax})"); return true; } } diff --git a/src/AxCopilot/Services/Agent/IntentGateService.cs b/src/AxCopilot/Services/Agent/IntentGateService.cs index 8aba7da..90a333c 100644 --- a/src/AxCopilot/Services/Agent/IntentGateService.cs +++ b/src/AxCopilot/Services/Agent/IntentGateService.cs @@ -1,126 +1,117 @@ +using AxCopilot.Services; using static AxCopilot.Services.Agent.AgentLoopService; namespace AxCopilot.Services.Agent; -/// -/// 사용자 입력을 분석하여 최적 실행 프로파일을 결정하는 2단계 의도 분류기. -/// Stage 1: 키워드 기반 빠른 분류 (ClassifyTaskType + IntentDetector 통합) -/// Stage 2: (taskType, intentCategory) 조합별 ExecutionPolicyOverlay 매핑 -/// Stage 3: (선택적) LLM 1-shot 분류 — confidence가 낮을 때만 발동 -/// internal sealed class IntentGateService { private readonly ILlmService? _llm; - /// DetectComplexTask에서 매번 재생성 방지용 정적 배열. private static readonly string[] Conjunctions = - { - "그리고", "하고", "다음에", "이후에", "그런 다음", - " and then ", " after that ", " also ", " additionally " - }; + [ + "그리고", + "하고", + "다음", + "이후", + "그런 다음", + " and then ", + " after that ", + " also ", + " additionally " + ]; private static readonly string[] ActionVerbs = - { - "해줘", "해 줘", "만들어", "수정해", "분석해", "작성해", - "검토해", "확인해", "추가해", "삭제해", "변경해" - }; + [ + "해줘", + "해 줘", + "만들", + "수정", + "분석", + "작성", + "검토", + "확인", + "추가", + "삭제", + "변경", + "fix", + "review", + "analyze", + "write", + "create", + "implement", + "add", + "remove", + "change" + ]; - /// 입력 길이 제한 — 50KB 이상은 잘라서 처리. private const int MaxInputLength = 50_000; public IntentGateService(ILlmService? llm = null) => _llm = llm; - /// - /// 사용자 쿼리를 분석하여 IntentResult를 생성합니다. - /// public Task ClassifyAsync( string userQuery, string? activeTab, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - // 안전 가드: null/과도한 길이 - var safeQuery = userQuery ?? ""; + var safeQuery = userQuery ?? string.Empty; if (safeQuery.Length > MaxInputLength) safeQuery = safeQuery[..MaxInputLength]; - // 한 번만 lowercase 변환 후 하위 메서드에 전달 var lowerQuery = safeQuery.ToLowerInvariant(); - - // ── Stage 1: 키워드 분류 ── var taskType = ClassifyTaskTypeKeyword(safeQuery, activeTab); var (intentCategory, intentConfidence) = IntentDetector.Detect(safeQuery); - - // 종합 confidence: taskType 확정도 + IntentDetector 확신도 가중 평균 var taskTypeConfidence = ComputeTaskTypeConfidence(taskType, lowerQuery); - var combinedConfidence = Math.Min(1.0, - taskTypeConfidence * 0.6 + intentConfidence * 0.4); - - // ── Stage 2: 프로파일 매핑 ── + var combinedConfidence = Math.Min(1.0, taskTypeConfidence * 0.6 + intentConfidence * 0.4); var overlay = MapToOverlay(taskType, intentCategory, activeTab); var scope = ClassifyScopeFromIntent(lowerQuery, activeTab, taskType, intentCategory); - - // ── 복합 요청 감지 (P5 연동) ── var (isComplex, hint) = DetectComplexTask(lowerQuery); - var result = new IntentResult( + return Task.FromResult(new IntentResult( TaskType: taskType, IntentCategory: intentCategory, Confidence: Math.Round(combinedConfidence, 2, MidpointRounding.AwayFromZero), PolicyOverlay: overlay, SuggestedScope: scope, IsComplexTask: isComplex, - DecompositionHint: hint - ); - - return Task.FromResult(result); + DecompositionHint: hint)); } - // ════════════════════════════════════════════════════════════ - // Stage 1: 키워드 기반 작업 유형 분류 - // ════════════════════════════════════════════════════════════ - - /// - /// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직. - /// internal static string ClassifyTaskTypeKeyword(string? userQuery, string? activeTab) { - var q = userQuery ?? ""; + var query = userQuery ?? string.Empty; - if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검")) + if (ContainsAny(query, "review", "code review", "리뷰", "검토")) return "review"; - if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패")) + if (ContainsAny(query, "bug", "fix", "error", "failure", "broken", "버그", "오류", "수정", "고쳐", "실패")) return "bugfix"; - if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선")) + if (ContainsAny(query, "refactor", "cleanup", "rename", "reorganize", "리팩토링", "리팩터링", "정리", "개편", "구조 개선")) return "refactor"; if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) - && ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서")) + && ContainsAny(query, "report", "document", "proposal", "analysis", "보고서", "문서", "제안서", "기획서")) return "docs"; - if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능")) + if (ContainsAny(query, "feature", "implement", "add", "support", "추가", "구현", "기능")) return "feature"; - return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? "feature" : "general"; + return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + ? "feature" + : "general"; } - /// - /// taskType 키워드 매칭 강도로 confidence를 산출합니다. - /// 는 이미 ToLowerInvariant 처리된 문자열입니다. - /// private static double ComputeTaskTypeConfidence(string taskType, string lowerQuery) { - // "general"은 폴백이므로 confidence 낮음 - if (string.Equals(taskType, "general", StringComparison.Ordinal)) return 0.3; + if (string.Equals(taskType, "general", StringComparison.Ordinal)) + return 0.3; - // 직접 매칭 키워드 수 세기 var hitCount = taskType switch { - "review" => CountHits(lowerQuery, "review", "리뷰", "검토", "code review", "점검"), - "bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "오류", "버그", "수정", "고쳐", "실패"), - "refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩터링", "정리", "개편"), - "docs" => CountHits(lowerQuery, "report", "document", "보고서", "문서", "제안서"), + "review" => CountHits(lowerQuery, "review", "code review", "리뷰", "검토"), + "bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "버그", "오류", "수정", "고쳐"), + "refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩토링", "리팩터링", "정리", "개편"), + "docs" => CountHits(lowerQuery, "report", "document", "proposal", "보고서", "문서", "제안서"), "feature" => CountHits(lowerQuery, "feature", "implement", "add", "추가", "구현", "기능"), _ => 0, }; @@ -137,27 +128,20 @@ internal sealed class IntentGateService private static int CountHits(string lower, params string[] keywords) { var count = 0; - foreach (var kw in keywords) + foreach (var keyword in keywords) { - if (lower.Contains(kw, StringComparison.OrdinalIgnoreCase)) + if (lower.Contains(keyword, StringComparison.OrdinalIgnoreCase)) count++; } + return count; } - // ════════════════════════════════════════════════════════════ - // Stage 2: 프로파일 매핑 - // ════════════════════════════════════════════════════════════ - - /// - /// (taskType, intentCategory) 조합에 따라 ExecutionPolicy overlay를 생성합니다. - /// private static ExecutionPolicyOverlay? MapToOverlay( string taskType, string intentCategory, string? activeTab) { return (taskType, intentCategory) switch { - // 코드 수정 관련 ("bugfix", "coding" or "general") => new( ToolTemperatureCap: 0.2, ForceInitialToolCall: true, @@ -178,7 +162,6 @@ internal sealed class IntentGateService ForceInitialToolCall: true, EnableCodeQualityGates: true), - // 문서 생성 ("docs", "document" or "creative" or "general") => new( EnableDocumentVerificationGate: true, ReduceEarlyMemoryPressure: true), @@ -186,7 +169,6 @@ internal sealed class IntentGateService ("docs", _) => new( EnableDocumentVerificationGate: true), - // 리뷰/분석 ("review", "analysis" or "coding" or "general") => new( ToolTemperatureCap: 0.3, EnableCodeQualityGates: true, @@ -196,131 +178,110 @@ internal sealed class IntentGateService ToolTemperatureCap: 0.3, ForceInitialToolCall: true), - // general + 순수 대화 (Chat 탭) ("general", _) when string.Equals(activeTab, "Chat", StringComparison.OrdinalIgnoreCase) - => null, // Chat 탭은 도구 없음, overlay 불필요 + => null, - // general + 문서 의도 ("general", "document") => new( EnableDocumentVerificationGate: true), - // general + 분석 의도 ("general", "analysis") => new( ToolTemperatureCap: 0.35, MaxParallelReadBatch: 8), - // 기타: base policy 그대로 _ => null, }; } - // ════════════════════════════════════════════════════════════ - // 탐색 범위 결정 - // ════════════════════════════════════════════════════════════ - - /// - /// IntentGate 결과를 기반으로 ExplorationScope를 결정합니다. - /// 는 이미 ToLowerInvariant 처리된 문자열입니다. - /// private static ExplorationScope ClassifyScopeFromIntent( string lowerQuery, string? activeTab, string taskType, string intentCategory) { if (string.IsNullOrWhiteSpace(lowerQuery)) return ExplorationScope.OpenEnded; - // docs 타입이면서 생성 동사가 있으면 DirectCreation if (taskType == "docs" && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) { if (HasCreationVerb(lowerQuery)) return ExplorationScope.DirectCreation; } - // document 인텐트 + 생성 동사 → DirectCreation if (intentCategory == "document" && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) && HasCreationVerb(lowerQuery)) return ExplorationScope.DirectCreation; - // RepoWide - if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체", - "repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 점검")) + if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + && ProjectScaffoldProfileCatalog.IsStructuredProjectRequest(lowerQuery, activeTab)) + return ExplorationScope.ProjectScaffold; + + if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + && HasCreationVerb(lowerQuery) + && ContainsAny( + lowerQuery, + "html", "css", "javascript", "js", "typescript", "ts", + "web page", "webpage", "website", "landing page", + "file", "script", "component", "template", + "index.html", "app.js", "style.css", + "파일", "스크립트", "컴포넌트", "템플릿")) + return ExplorationScope.DirectCreation; + + if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체", "repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 맥락")) return ExplorationScope.RepoWide; - // Localized if (lowerQuery.Contains('.') || lowerQuery.Contains('/') || lowerQuery.Contains('\\') || - ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ", - "bug", "오류", "버그", "예외")) + ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ", "bug", "오류", "버그", "예외")) return ExplorationScope.Localized; - // TopicBased - if (ContainsAny(lowerQuery, "정리", "요약", "보고서", "주제", "관련", "분석")) + if (ContainsAny(lowerQuery, "정리", "요약", "보고", "주제", "관련", "분석")) return ExplorationScope.TopicBased; - // 탭 기반 기본값 return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? ExplorationScope.Localized : ExplorationScope.OpenEnded; } private static bool HasCreationVerb(string lower) - => ContainsAny(lower, - "작성해", "써줘", "써 줘", "만들어", "생성해", - "만들어줘", "만들어 줘", "생성해줘", "생성해 줘", - "write", "create", "draft", "generate", "compose", - "작성하", "작성을", "생성하", "생성을", - "작성 부탁", "만들어 부탁"); + => ContainsAny( + lower, + "작성", "작성해", "써줘", "만들", "생성", "만들어줘", "만들어", "생성해줘", "생성해", + "write", "create", "draft", "generate", "compose", "scaffold", "bootstrap"); - // ════════════════════════════════════════════════════════════ - // 복합 요청 감지 (P5 연동) - // ════════════════════════════════════════════════════════════ - - /// - /// 복합 요청을 감지합니다. 는 이미 lowercase 변환된 문자열입니다. - /// private static (bool IsComplex, string? Hint) DetectComplexTask(string lowerQuery) { if (lowerQuery.Length < 20) return (false, null); - // 접속사/열거 패턴 감지 (클래스 수준 static readonly 배열 사용) var conjunctionCount = 0; - foreach (var conj in Conjunctions) + foreach (var conjunction in Conjunctions) { - if (lowerQuery.Contains(conj, StringComparison.Ordinal)) + if (lowerQuery.Contains(conjunction, StringComparison.Ordinal)) conjunctionCount++; } - // 동사 열거 패턴 (클래스 수준 static readonly 배열 사용) var verbCount = 0; foreach (var verb in ActionVerbs) { - var idx = 0; - while ((idx = lowerQuery.IndexOf(verb, idx, StringComparison.Ordinal)) >= 0) + var index = 0; + while ((index = lowerQuery.IndexOf(verb, index, StringComparison.Ordinal)) >= 0) { verbCount++; - idx += verb.Length; + index += verb.Length; } } if (conjunctionCount >= 2 || verbCount >= 3) - { - return (true, "이 요청에 여러 독립 작업이 포함되어 있습니다. spawn_agents로 병렬 처리를 고려하세요."); - } + return (true, "이 요청은 여러 단계의 작업을 포함합니다. 병렬화 가능한 하위 작업을 분리해 처리하는 것이 좋습니다."); return (false, null); } - // ════════════════════════════════════════════════════════════ - // 공통 유틸 - // ════════════════════════════════════════════════════════════ - private static bool ContainsAny(string text, params string[] keywords) { - foreach (var kw in keywords) + foreach (var keyword in keywords) { - if (text.Contains(kw, StringComparison.OrdinalIgnoreCase)) + if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase)) return true; } + return false; } } diff --git a/src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs b/src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs new file mode 100644 index 0000000..2507a08 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs @@ -0,0 +1,583 @@ +using System.IO; + +namespace AxCopilot.Services.Agent; + +internal sealed record ProjectScaffoldProfile( + string Key, + string Label, + IReadOnlyList TriggerTokens, + IReadOnlyList ExpectedDirectories, + IReadOnlyList StarterPaths, + IReadOnlyList AllowedRootFiles, + int MinimumDirectoryHits = 2); + +internal sealed record ProjectScaffoldLayoutAssessment( + bool IsSatisfied, + int MatchedDirectoryCount, + IReadOnlyList ExistingDirectories, + IReadOnlyList MissingDirectories, + IReadOnlyList SuspiciousRootFiles); + +internal static class ProjectScaffoldProfileCatalog +{ + private static readonly string[] s_creationVerbs = + [ + "create", + "generate", + "build", + "write", + "scaffold", + "bootstrap", + "set up", + "setup", + "make", + "implement", + "start", + "만들", + "생성", + "작성", + "구현", + "구축", + "세팅", + "스캐폴드" + ]; + + private static readonly string[] s_projectNouns = + [ + "project", + "app", + "application", + "service", + "api", + "solution", + "starter", + "template", + "boilerplate", + "workspace", + "repo", + "repository", + "library", + "package", + "module", + "프로젝트", + "앱", + "애플리케이션", + "서비스", + "솔루션", + "템플릿", + "보일러플레이트", + "워크스페이스", + "저장소", + "라이브러리", + "패키지", + "모듈" + ]; + + private static readonly string[] s_languageTokens = + [ + "c#", + "dotnet", + ".net", + "csproj", + "wpf", + "xaml", + "mvvm", + "viewmodel", + "resourcedictionary", + "resource dictionary", + "javascript", + "typescript", + "react", + "next", + "vue", + "nuxt", + "node", + "express", + "python", + "fastapi", + "flask", + "django", + "java", + "spring", + "spring boot", + "kotlin", + "android", + "go", + "golang", + "rust", + "cli", + "web", + "desktop", + "mobile", + "frontend", + "backend", + "api", + "웹", + "데스크톱", + "모바일", + "프론트엔드", + "백엔드", + "파이썬", + "자바", + "코틀린", + "안드로이드", + "리액트", + "뷰", + "고", + "러스트", + "스프링", + "장고", + "플라스크", + "패스트api" + ]; + + private static readonly string[] s_solutionShapeTokens = + [ + "frontend", + "backend", + "desktop", + "mobile", + "cli", + "tool", + "worker", + "daemon", + "game", + "프론트엔드", + "백엔드", + "데스크톱", + "모바일", + "도구", + "워커", + "게임" + ]; + + private static readonly HashSet s_commonAllowedRootFiles = new(StringComparer.OrdinalIgnoreCase) + { + "README.md", + ".gitignore", + ".editorconfig", + ".gitattributes", + ".env", + ".env.example", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "tsconfig.json", + "vite.config.ts", + "vite.config.js", + "next.config.js", + "next.config.mjs", + "nuxt.config.ts", + "requirements.txt", + "pyproject.toml", + "poetry.lock", + "Pipfile", + "Pipfile.lock", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "Cargo.toml", + "go.mod", + "go.sum", + "composer.json", + "Gemfile", + "index.html" + }; + + private static readonly string[] s_relevantRootExtensions = + [ + ".cs", + ".xaml", + ".razor", + ".ts", + ".tsx", + ".js", + ".jsx", + ".css", + ".scss", + ".py", + ".java", + ".kt", + ".go", + ".rs", + ".php", + ".rb", + ".json", + ".xml", + ".yaml", + ".yml" + ]; + + private static readonly IReadOnlyList s_profiles = + [ + new( + Key: "wpf-mvvm", + Label: "WPF / MVVM desktop app", + TriggerTokens: + [ + "wpf", + "xaml", + "mvvm", + "viewmodel", + "resourcedictionary", + "resource dictionary", + "csproj", + "desktop app", + "window", + "usercontrol", + "user control" + ], + ExpectedDirectories: ["Views", "ViewModels", "Models", "Services", "Themes", "Assets"], + StarterPaths: + [ + "App.xaml", + "App.xaml.cs", + "Views/MainWindow.xaml", + "Views/MainWindow.xaml.cs", + "ViewModels/MainWindowViewModel.cs", + "Themes/Colors.xaml" + ], + AllowedRootFiles: ["App.xaml", "App.xaml.cs"], + MinimumDirectoryHits: 2), + new( + Key: "aspnet-api", + Label: "ASP.NET / Web API service", + TriggerTokens: + [ + "asp.net", + "aspnet", + "web api", + "webapi", + "minimal api", + "mvc", + "blazor", + "razor", + "controller", + "swagger" + ], + ExpectedDirectories: ["Controllers", "Services", "Models", "Contracts", "Data", "Properties"], + StarterPaths: + [ + "Program.cs", + "Controllers/HealthController.cs", + "Services/HealthService.cs", + "Models/HealthStatus.cs", + "appsettings.json" + ], + AllowedRootFiles: ["Program.cs", "appsettings.json", "appsettings.Development.json"], + MinimumDirectoryHits: 2), + new( + Key: "react-web", + Label: "React / Next / Vue frontend", + TriggerTokens: + [ + "react", + "next", + "next.js", + "nextjs", + "vue", + "nuxt", + "svelte", + "vite", + "frontend", + "front-end", + "spa", + "component" + ], + ExpectedDirectories: ["src/components", "src/pages", "src/services", "src/styles", "public"], + StarterPaths: + [ + "src/App.tsx", + "src/main.tsx", + "src/components/AppShell.tsx", + "src/pages/HomePage.tsx", + "src/services/api.ts" + ], + AllowedRootFiles: ["index.html"], + MinimumDirectoryHits: 2), + new( + Key: "node-service", + Label: "Node / Express / Nest backend", + TriggerTokens: + [ + "node", + "express", + "nestjs", + "nest", + "koa", + "backend", + "back-end", + "rest api", + "server app", + "middleware" + ], + ExpectedDirectories: ["src/controllers", "src/routes", "src/services", "src/models", "tests"], + StarterPaths: + [ + "src/index.ts", + "src/controllers/HealthController.ts", + "src/routes/index.ts", + "src/services/HealthService.ts", + "tests/health.spec.ts" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2), + new( + Key: "python-service", + Label: "Python API service", + TriggerTokens: + [ + "python", + "fastapi", + "flask", + "django", + "uvicorn", + "backend api", + "python api" + ], + ExpectedDirectories: ["app/api", "app/models", "app/services", "app/core", "tests"], + StarterPaths: + [ + "app/main.py", + "app/api/routes.py", + "app/services/health_service.py", + "app/models/health.py", + "tests/test_health.py" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2), + new( + Key: "spring-service", + Label: "Java / Spring Boot service", + TriggerTokens: + [ + "spring", + "spring boot", + "java api", + "maven", + "gradle", + "jpa", + "controller", + "repository" + ], + ExpectedDirectories: ["src/main/java", "src/main/resources", "src/test/java"], + StarterPaths: + [ + "src/main/java/com/example/Application.java", + "src/main/java/com/example/controller/HealthController.java", + "src/main/java/com/example/service/HealthService.java", + "src/main/resources/application.yml", + "src/test/java/com/example/ApplicationTests.java" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2), + new( + Key: "android-kotlin", + Label: "Android / Kotlin app", + TriggerTokens: + [ + "android", + "kotlin", + "jetpack compose", + "compose", + "xml layout", + "activity", + "fragment" + ], + ExpectedDirectories: ["app/src/main/java", "app/src/main/res/layout", "app/src/main/res/values", "app/src/test"], + StarterPaths: + [ + "app/src/main/java/com/example/MainActivity.kt", + "app/src/main/res/layout/activity_main.xml", + "app/src/main/res/values/colors.xml", + "app/src/test/java/com/example/MainActivityTest.kt" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2), + new( + Key: "go-service", + Label: "Go service", + TriggerTokens: + [ + "golang", + "go api", + "go service", + "gin", + "echo", + "fiber", + "handler" + ], + ExpectedDirectories: ["cmd", "internal", "pkg", "api", "tests"], + StarterPaths: + [ + "cmd/server/main.go", + "internal/handler/health.go", + "internal/service/health.go", + "pkg/config/config.go", + "tests/health_test.go" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2), + new( + Key: "rust-cli", + Label: "Rust CLI / tool", + TriggerTokens: + [ + "rust", + "cargo", + "cli", + "command line", + "terminal tool", + "binary", + "subcommand" + ], + ExpectedDirectories: ["src/bin", "src/commands", "src/domain", "tests"], + StarterPaths: + [ + "src/main.rs", + "src/commands/root.rs", + "src/domain/config.rs", + "tests/cli_tests.rs" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2), + new( + Key: "generic-solution", + Label: "Generic structured solution", + TriggerTokens: + [ + "project", + "solution", + "starter", + "template", + "boilerplate", + "library", + "module", + "workspace" + ], + ExpectedDirectories: ["src", "tests", "docs", "config"], + StarterPaths: + [ + "src/main.ext", + "src/services/service.ext", + "tests/main.spec.ext", + "docs/README.md" + ], + AllowedRootFiles: [], + MinimumDirectoryHits: 2) + ]; + + internal static ProjectScaffoldProfile? Detect(string? userQuery, string? activeTab) + { + if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + return null; + + if (string.IsNullOrWhiteSpace(userQuery)) + return null; + + var query = userQuery.ToLowerInvariant(); + if (!ContainsAny(query, s_creationVerbs)) + return null; + + ProjectScaffoldProfile? bestProfile = null; + var bestScore = 0; + foreach (var profile in s_profiles) + { + var score = profile.TriggerTokens.Count(token => query.Contains(token, StringComparison.OrdinalIgnoreCase)); + if (score > bestScore) + { + bestScore = score; + bestProfile = profile; + } + } + + if (bestProfile != null && bestScore > 0) + return bestProfile; + + if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_languageTokens)) + return GetProfile("generic-solution"); + + if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_solutionShapeTokens)) + return GetProfile("generic-solution"); + + return null; + } + + internal static bool IsStructuredProjectRequest(string? userQuery, string? activeTab) + => Detect(userQuery, activeTab) != null; + + internal static string BuildDirectoryPreview(ProjectScaffoldProfile profile, int maxCount = 5) + => string.Join(", ", profile.ExpectedDirectories.Take(Math.Max(1, maxCount))); + + internal static string BuildStarterPathPreview(ProjectScaffoldProfile profile, int maxCount = 5) + => string.Join(", ", profile.StarterPaths.Take(Math.Max(1, maxCount)).Select(path => $"'{path}'")); + + internal static ProjectScaffoldLayoutAssessment AssessLayout(ProjectScaffoldProfile profile, string? workFolder) + { + if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)) + { + return new ProjectScaffoldLayoutAssessment( + IsSatisfied: false, + MatchedDirectoryCount: 0, + ExistingDirectories: [], + MissingDirectories: profile.ExpectedDirectories.ToList(), + SuspiciousRootFiles: []); + } + + var existingDirectories = profile.ExpectedDirectories + .Where(dir => Directory.Exists(Path.Combine(workFolder, NormalizeDirectory(dir)))) + .ToList(); + var missingDirectories = profile.ExpectedDirectories + .Where(dir => !existingDirectories.Contains(dir, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + var allowedRootFiles = new HashSet(s_commonAllowedRootFiles, StringComparer.OrdinalIgnoreCase); + foreach (var file in profile.AllowedRootFiles) + allowedRootFiles.Add(file); + + var suspiciousRootFiles = Directory.EnumerateFiles(workFolder) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name!) + .Where(name => + { + if (allowedRootFiles.Contains(name)) + return false; + + var extension = Path.GetExtension(name); + return s_relevantRootExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + }) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var isSatisfied = existingDirectories.Count >= profile.MinimumDirectoryHits + && suspiciousRootFiles.Count == 0; + + return new ProjectScaffoldLayoutAssessment( + IsSatisfied: isSatisfied, + MatchedDirectoryCount: existingDirectories.Count, + ExistingDirectories: existingDirectories, + MissingDirectories: missingDirectories, + SuspiciousRootFiles: suspiciousRootFiles); + } + + private static ProjectScaffoldProfile GetProfile(string key) + => s_profiles.First(profile => string.Equals(profile.Key, key, StringComparison.OrdinalIgnoreCase)); + + private static string NormalizeDirectory(string value) + => value.Replace('/', Path.DirectorySeparatorChar); + + private static bool ContainsAny(string text, IEnumerable tokens) + { + foreach (var token in tokens) + { + if (text.Contains(token, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs b/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs index 6797dfc..da48076 100644 --- a/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs +++ b/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs @@ -31,7 +31,9 @@ public partial class ChatWindow sb.AppendLine("A text-only response is fine once the requested artifact already exists, the requested analysis is complete, or enough evidence has been gathered."); sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete."); - sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and call file_write directly with a relative path inside the current work folder."); + sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and move directly to artifact creation."); + sb.AppendLine("For single-file outputs, call file_write directly with a relative path inside the current work folder."); + sb.AppendLine("For framework or multi-file scaffolds such as WPF/MVVM, ASP.NET, React/Vue/Next, FastAPI/Django/Flask, Spring, Android, Go, or Rust, first establish a minimal project tree and place files inside framework-appropriate folders instead of flattening everything in the workspace root."); sb.AppendLine("Do not call skill_manager, mcp_list_resources, or mcp_read_resource for normal Code tasks unless the user explicitly asked about skills or MCP resources."); sb.AppendLine(""); sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); @@ -212,7 +214,9 @@ public partial class ChatWindow sb.AppendLine("A text-only response is fine once the requested code work is complete or enough evidence has been gathered."); sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete."); - sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and call file_write directly with a relative path inside the current work folder."); + sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and move directly to artifact creation."); + sb.AppendLine("For a single-file artifact, call file_write directly with a relative path inside the current work folder."); + sb.AppendLine("For framework or multi-file scaffolds such as WPF/MVVM, ASP.NET, React/Vue/Next, FastAPI/Django/Flask, Spring, Android, Go, or Rust, create a minimal project tree first and place files inside framework-appropriate folders instead of dumping all source files in the workspace root."); sb.AppendLine("Do not call skill_manager, mcp_list_resources, or mcp_read_resource for normal Code tasks unless the user explicitly asked about skills or MCP resources."); sb.AppendLine(""); sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); @@ -251,7 +255,7 @@ public partial class ChatWindow sb.AppendLine("---"); sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development."); sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd})."); - sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), lsp_code_intel, folder_map, process, dev_env_detect, build_run, git_tool."); + sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), file_manage, glob, grep (supports context_lines, case_sensitive), lsp_code_intel, folder_map, process, dev_env_detect, build_run, git_tool."); sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached."); sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above."); sb.AppendLine("Do NOT call open_external, launch browsers, or start preview/server commands unless the user explicitly asks to open, preview, serve, launch, or run the result."); @@ -268,6 +272,7 @@ public partial class ChatWindow sb.AppendLine("4. VERIFY: Run build_run/test_loop when the change affects buildable or testable behavior, or when the user explicitly asks for verification."); sb.AppendLine(" - Use git_tool(diff) when it helps confirm the final change set or explain what changed."); sb.AppendLine(" - After editing code, do not stop until you have enough evidence from file_read, diff, build_run, or test_loop."); + sb.AppendLine(" - If the task is a new framework/project scaffold, verify the layout too: keep views/components/routes/services/models/resources under appropriate folders instead of leaving implementation files flat in the root."); sb.AppendLine("5. REPORT: Summarize what changed, which files/callers were affected, and what verification evidence was collected."); sb.AppendLine(" - For bugfix/feature/refactor tasks, keep the final report structured and concrete rather than minimal."); diff --git a/src/AxCopilot/skills/code-scaffold.skill.md b/src/AxCopilot/skills/code-scaffold.skill.md index d5e12e0..708f2b2 100644 --- a/src/AxCopilot/skills/code-scaffold.skill.md +++ b/src/AxCopilot/skills/code-scaffold.skill.md @@ -2,28 +2,34 @@ name: code-scaffold label: 코드 스캐폴딩 description: 프로젝트 구조를 분석하고 새 기능의 코드 뼈대를 자동 생성합니다. +when_to_use: 빈 작업 폴더에서 새 프로젝트를 만들거나, WPF/MVVM, ASP.NET, React/Vue/Next, FastAPI/Django/Flask, Spring, Android, Go, Rust 같은 구조형 스캐폴드를 폴더 트리부터 잡아야 할 때 icon: \uE943 allowed-tools: + - file_manage - folder_map - file_read - grep - file_write + - file_edit - search_codebase tabs: code --- -작업 폴더의 프로젝트 구조를 분석하고 새 기능의 코드 뼈대를 생성하세요. +작업 폴더의 프로젝트 구조를 분석하고 새 기능이나 새 프로젝트의 코드 뼈대를 생성하세요. 다음 도구를 사용하세요: -1. folder_map — 프로젝트 구조 파악 -2. file_read — 기존 코드 패턴 분석 -3. grep — 코딩 컨벤션 확인 -4. file_write — 새 파일 생성 +1. file_manage — 폴더 생성, 이동, 정리 +2. folder_map — 기존 프로젝트 구조 파악 +3. file_read — 기존 코드 패턴 분석 +4. grep — 코딩 컨벤션 확인 +5. file_write / file_edit — 새 파일 생성 및 보강 작업 순서: 1. 프로젝트 타입 감지 (언어, 프레임워크, 빌드 시스템) -2. 기존 코드 패턴 분석 (네이밍, 폴더 구조, 임포트 스타일) -3. 사용자 요청에 맞는 코드 뼈대 생성 +2. 빈 작업 폴더라면 최소 프로젝트 트리를 먼저 설계하고 file_manage로 폴더를 만든 뒤 파일을 배치 +3. 기존 프로젝트가 있다면 네이밍, 폴더 구조, 임포트 스타일을 먼저 맞춤 +4. 사용자 요청에 맞는 코드 뼈대 생성 +5. 구조형 프로젝트는 뷰/UI, 모델, 서비스, 리소스, 테스트를 가능한 한 폴더로 분리 생성 항목: - 클래스/모듈 파일 (프로젝트 컨벤션에 맞춰) @@ -33,5 +39,8 @@ tabs: code 규칙: - 기존 프로젝트의 코딩 스타일을 따르세요 +- WPF, ASP.NET, React, Python API, Spring, Android, Go, Rust 같은 구조형 프로젝트는 구현 파일을 루트에 평평하게 두지 마세요 +- 루트에는 manifest, entrypoint, README 같은 최소 파일만 두고 나머지는 적절한 폴더로 분리하세요 +- 새 프로젝트를 시작할 때는 단일 파일 생성보다 폴더 트리와 대표 파일 경로를 먼저 확정하세요 - TODO 주석으로 구현이 필요한 부분을 표시하세요 - 한국어 주석을 추가하세요