Files
AX-Copilot-Codex/src/AxCopilot/Services/ModelRouterService.cs

252 lines
9.0 KiB
C#

using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 자동 모델 라우팅 결과.
/// </summary>
public record ModelRouteResult(
string Service,
string Model,
string DisplayName,
string DetectedIntent,
double Confidence);
/// <summary>
/// 사용자 메시지의 인텐트를 분석하여 최적 모델을 선택하는 라우터.
/// 현재는 잠금 상태(EnableAutoRouter=false)이며, 사내 서버 확정 후 활성화 예정.
///
/// 개발자 가이드:
/// - 하드코딩 기본값 수정: GetDefaultCapabilities() 메서드
/// - 사내 Ollama/vLLM 모델 추가: GetDefaultCapabilities()에 항목 추가
/// - 확신도 조정: Route() 메서드의 confidence 체크 로직
/// </summary>
public class ModelRouterService
{
private readonly SettingsService _settings;
public ModelRouterService(SettingsService settings)
{
_settings = settings;
}
/// <summary>
/// 사용자 메시지를 분석하여 최적 모델을 선택합니다.
/// </summary>
/// <returns>라우팅 결과. null이면 기본 모델 유지.</returns>
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);
}
/// <summary>
/// 사용 가능한 모델 후보를 수집합니다.
/// 사용자 설정(ModelCapabilities) 우선, 없으면 하드코딩 기본값 사용.
/// </summary>
private List<ModelCapability> GatherCandidates(LlmSettings llm)
{
var candidates = new List<ModelCapability>();
// 사용자 설정 후보
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;
}
/// <summary>
/// 해당 서비스가 사용 가능한지 확인 (API 키 또는 엔드포인트 설정 여부).
/// </summary>
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,
};
}
/// <summary>현재 활성 모델명을 반환 (암호화 고려).</summary>
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,
};
}
/// <summary>
/// 하드코딩된 기본 모델 능력 점수.
///
/// ★ 개발자 수정 포인트 ★
/// 사내 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 (최적)
/// </summary>
public static List<ModelCapability> GetDefaultCapabilities()
{
return new List<ModelCapability>
{
// ── 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,
// }
// },
};
}
}