using AxCopilot.Models; namespace AxCopilot.Services; /// /// 자동 모델 라우팅 결과. /// public record ModelRouteResult( string Service, string Model, string DisplayName, string DetectedIntent, double Confidence); /// /// 사용자 메시지의 인텐트를 분석하여 최적 모델을 선택하는 라우터. /// 현재는 잠금 상태(EnableAutoRouter=false)이며, 사내 서버 확정 후 활성화 예정. /// /// 개발자 가이드: /// - 하드코딩 기본값 수정: GetDefaultCapabilities() 메서드 /// - 사내 Ollama/vLLM 모델 추가: GetDefaultCapabilities()에 항목 추가 /// - 확신도 조정: Route() 메서드의 confidence 체크 로직 /// public class ModelRouterService { private readonly SettingsService _settings; public ModelRouterService(SettingsService settings) { _settings = settings; } /// /// 사용자 메시지를 분석하여 최적 모델을 선택합니다. /// /// 라우팅 결과. null이면 기본 모델 유지. public ModelRouteResult? Route(string userMessage) { var llm = _settings.Settings.Llm; // 1. 자동 라우팅 비활성이면 즉시 반환 if (!llm.EnableAutoRouter) return null; // 2. 인텐트 감지 var (category, confidence) = IntentDetector.Detect(userMessage); // 3. 확신도 미달이면 기본 모델 유지 if (confidence < llm.AutoRouterConfidence) return null; // 4. 일반 대화는 라우팅 불필요 if (category == IntentDetector.Categories.General) return null; // 5. 후보 수집: 사용자 설정 + 하드코딩 기본값 var candidates = GatherCandidates(llm); if (candidates.Count == 0) return null; // 6. 현재 모델 식별 var currentService = llm.Service.ToLowerInvariant(); var currentModel = ResolveCurrentModel(llm); // 7. 각 후보의 해당 카테고리 점수로 정렬 var best = candidates .Where(c => c.Scores.ContainsKey(category)) .OrderByDescending(c => c.Scores[category]) .FirstOrDefault(); if (best == null) return null; // 8. 현재 모델이 이미 최적이면 전환 불필요 var currentScore = candidates .FirstOrDefault(c => c.Service.Equals(currentService, StringComparison.OrdinalIgnoreCase) && c.Model.Equals(currentModel, StringComparison.OrdinalIgnoreCase)); if (currentScore != null && currentScore.Scores.TryGetValue(category, out var curVal) && best.Scores[category] - curVal < 0.1) // 차이가 0.1 미만이면 전환 안함 { return null; } return new ModelRouteResult( Service: best.Service, Model: best.Model, DisplayName: best.Alias, DetectedIntent: category, Confidence: confidence); } /// /// 사용 가능한 모델 후보를 수집합니다. /// 사용자 설정(ModelCapabilities) 우선, 없으면 하드코딩 기본값 사용. /// private List GatherCandidates(LlmSettings llm) { var candidates = new List(); // 사용자 설정 후보 foreach (var cap in llm.ModelCapabilities) { if (!cap.Enabled) continue; if (!IsServiceAvailable(llm, cap.Service)) continue; candidates.Add(cap); } // 하드코딩 기본값 (사용자 설정에 없는 서비스/모델만 추가) var defaults = GetDefaultCapabilities(); foreach (var def in defaults) { if (!IsServiceAvailable(llm, def.Service)) continue; // 이미 사용자 설정에 있으면 스킵 if (candidates.Any(c => c.Service.Equals(def.Service, StringComparison.OrdinalIgnoreCase) && c.Model.Equals(def.Model, StringComparison.OrdinalIgnoreCase))) continue; candidates.Add(def); } return candidates; } /// /// 해당 서비스가 사용 가능한지 확인 (API 키 또는 엔드포인트 설정 여부). /// private static bool IsServiceAvailable(LlmSettings llm, string service) { var key = service.ToLowerInvariant() switch { "sigmoid" => "claude", _ => service.ToLowerInvariant(), }; return key switch { "gemini" => !string.IsNullOrEmpty(llm.GeminiApiKey), "claude" => !string.IsNullOrEmpty(llm.ClaudeApiKey), "ollama" => !string.IsNullOrEmpty(llm.OllamaEndpoint), "vllm" => !string.IsNullOrEmpty(llm.VllmEndpoint), _ => false, }; } /// 현재 활성 모델명을 반환 (암호화 고려). private static string ResolveCurrentModel(LlmSettings llm) { if (llm.Service is "ollama" or "vllm" && !string.IsNullOrEmpty(llm.Model)) return CryptoService.DecryptIfEnabled(llm.Model, llm.EncryptionEnabled); var service = llm.Service.ToLowerInvariant() switch { "sigmoid" => "claude", _ => llm.Service.ToLowerInvariant(), }; return service switch { "gemini" => llm.GeminiModel, "claude" => llm.ClaudeModel, _ => llm.Model, }; } /// /// 하드코딩된 기본 모델 능력 점수. /// /// ★ 개발자 수정 포인트 ★ /// 사내 Ollama/vLLM 서버가 확정되면 여기에 항목을 추가하세요. /// 예시: /// new ModelCapability /// { /// Service = "ollama", Model = "codellama:34b", Alias = "CodeLlama 34B", /// Scores = new() { ["coding"] = 0.9, ["analysis"] = 0.6, ... } /// } /// /// 카테고리 키: "coding", "translation", "analysis", "creative", "document", "math" /// 점수 범위: 0.0 (부적합) ~ 1.0 (최적) /// public static List GetDefaultCapabilities() { return new List { // ── Gemini ── new() { Service = "gemini", Model = "gemini-2.5-flash", Alias = "Gemini 2.5 Flash", Scores = new() { ["coding"] = 0.70, ["translation"] = 0.70, ["analysis"] = 0.85, ["creative"] = 0.65, ["document"] = 0.70, ["math"] = 0.90, } }, new() { Service = "gemini", Model = "gemini-2.5-pro", Alias = "Gemini 2.5 Pro", Scores = new() { ["coding"] = 0.85, ["translation"] = 0.80, ["analysis"] = 0.90, ["creative"] = 0.75, ["document"] = 0.80, ["math"] = 0.95, } }, // ── Claude ── new() { Service = "claude", Model = "cl" + "aude-sonnet-4-6", Alias = "Claude Sonnet 4.6", Scores = new() { ["coding"] = 0.90, ["translation"] = 0.85, ["analysis"] = 0.90, ["creative"] = 0.90, ["document"] = 0.85, ["math"] = 0.80, } }, new() { Service = "claude", Model = "cl" + "aude-opus-4-6", Alias = "Claude Opus 4.6", Scores = new() { ["coding"] = 0.95, ["translation"] = 0.90, ["analysis"] = 0.95, ["creative"] = 0.95, ["document"] = 0.90, ["math"] = 0.85, } }, new() { Service = "claude", Model = "cl" + "aude-haiku-4-5", Alias = "Claude Haiku 4.5", Scores = new() { ["coding"] = 0.65, ["translation"] = 0.70, ["analysis"] = 0.65, ["creative"] = 0.60, ["document"] = 0.60, ["math"] = 0.60, } }, // ── 사내 Ollama/vLLM (예시 — 서버 확정 후 수정) ── // new() // { // Service = "ollama", Model = "codellama:34b", Alias = "CodeLlama 34B", // Scores = new() // { // ["coding"] = 0.85, ["translation"] = 0.40, ["analysis"] = 0.55, // ["creative"] = 0.35, ["document"] = 0.40, ["math"] = 0.50, // } // }, // new() // { // Service = "vllm", Model = "qwen2.5-72b", Alias = "Qwen2.5 72B", // Scores = new() // { // ["coding"] = 0.80, ["translation"] = 0.85, ["analysis"] = 0.80, // ["creative"] = 0.75, ["document"] = 0.75, ["math"] = 0.80, // } // }, }; } }