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;
}
}