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 " }; private static readonly string[] ActionVerbs = { "해줘", "해 줘", "만들어", "수정해", "분석해", "작성해", "검토해", "확인해", "추가해", "삭제해", "변경해" }; /// 입력 길이 제한 — 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 ?? ""; 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 overlay = MapToOverlay(taskType, intentCategory, activeTab); var scope = ClassifyScopeFromIntent(lowerQuery, activeTab, taskType, intentCategory); // ── 복합 요청 감지 (P5 연동) ── var (isComplex, hint) = DetectComplexTask(lowerQuery); var result = 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); } // ════════════════════════════════════════════════════════════ // Stage 1: 키워드 기반 작업 유형 분류 // ════════════════════════════════════════════════════════════ /// /// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직. /// internal static string ClassifyTaskTypeKeyword(string? userQuery, string? activeTab) { var q = userQuery ?? ""; if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검")) return "review"; if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패")) return "bugfix"; if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선")) return "refactor"; if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) && ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서")) return "docs"; if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능")) return "feature"; 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; // 직접 매칭 키워드 수 세기 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", "보고서", "문서", "제안서"), "feature" => CountHits(lowerQuery, "feature", "implement", "add", "추가", "구현", "기능"), _ => 0, }; return hitCount switch { >= 3 => 0.95, 2 => 0.85, 1 => 0.7, _ => 0.5, }; } private static int CountHits(string lower, params string[] keywords) { var count = 0; foreach (var kw in keywords) { if (lower.Contains(kw, 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, EnableCodeQualityGates: true), ("bugfix", _) => new( ToolTemperatureCap: 0.25, ForceInitialToolCall: true, EnableCodeQualityGates: true), ("feature", "coding" or "general") => new( ToolTemperatureCap: 0.3, ForceInitialToolCall: true, EnableCodeQualityGates: true), ("refactor", _) => new( ToolTemperatureCap: 0.25, ForceInitialToolCall: true, EnableCodeQualityGates: true), // 문서 생성 ("docs", "document" or "creative" or "general") => new( EnableDocumentVerificationGate: true, ReduceEarlyMemoryPressure: true), ("docs", _) => new( EnableDocumentVerificationGate: true), // 리뷰/분석 ("review", "analysis" or "coding" or "general") => new( ToolTemperatureCap: 0.3, EnableCodeQualityGates: true, ForceInitialToolCall: true), ("review", _) => new( ToolTemperatureCap: 0.3, ForceInitialToolCall: true), // general + 순수 대화 (Chat 탭) ("general", _) when string.Equals(activeTab, "Chat", StringComparison.OrdinalIgnoreCase) => null, // Chat 탭은 도구 없음, overlay 불필요 // 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", "전체 구조", "아키텍처", "전체 점검")) return ExplorationScope.RepoWide; // Localized if (lowerQuery.Contains('.') || lowerQuery.Contains('/') || lowerQuery.Contains('\\') || ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ", "bug", "오류", "버그", "예외")) return ExplorationScope.Localized; // TopicBased 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", "작성하", "작성을", "생성하", "생성을", "작성 부탁", "만들어 부탁"); // ════════════════════════════════════════════════════════════ // 복합 요청 감지 (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) { if (lowerQuery.Contains(conj, 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) { verbCount++; idx += verb.Length; } } if (conjunctionCount >= 2 || verbCount >= 3) { return (true, "이 요청에 여러 독립 작업이 포함되어 있습니다. spawn_agents로 병렬 처리를 고려하세요."); } return (false, null); } // ════════════════════════════════════════════════════════════ // 공통 유틸 // ════════════════════════════════════════════════════════════ private static bool ContainsAny(string text, params string[] keywords) { foreach (var kw in keywords) { if (text.Contains(kw, StringComparison.OrdinalIgnoreCase)) return true; } return false; } }