Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/IntentGateService.cs
lacvet 8cb08576d5 AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
2026-04-14 17:52:46 +09:00

327 lines
14 KiB
C#

using static AxCopilot.Services.Agent.AgentLoopService;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 사용자 입력을 분석하여 최적 실행 프로파일을 결정하는 2단계 의도 분류기.
/// Stage 1: 키워드 기반 빠른 분류 (ClassifyTaskType + IntentDetector 통합)
/// Stage 2: (taskType, intentCategory) 조합별 ExecutionPolicyOverlay 매핑
/// Stage 3: (선택적) LLM 1-shot 분류 — confidence가 낮을 때만 발동
/// </summary>
internal sealed class IntentGateService
{
private readonly ILlmService? _llm;
/// <summary>DetectComplexTask에서 매번 재생성 방지용 정적 배열.</summary>
private static readonly string[] Conjunctions =
{
"그리고", "하고", "다음에", "이후에", "그런 다음",
" and then ", " after that ", " also ", " additionally "
};
private static readonly string[] ActionVerbs =
{
"해줘", "해 줘", "만들어", "수정해", "분석해", "작성해",
"검토해", "확인해", "추가해", "삭제해", "변경해"
};
/// <summary>입력 길이 제한 — 50KB 이상은 잘라서 처리.</summary>
private const int MaxInputLength = 50_000;
public IntentGateService(ILlmService? llm = null) => _llm = llm;
/// <summary>
/// 사용자 쿼리를 분석하여 IntentResult를 생성합니다.
/// </summary>
public Task<IntentResult> 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: 키워드 기반 작업 유형 분류
// ════════════════════════════════════════════════════════════
/// <summary>
/// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직.
/// </summary>
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";
}
/// <summary>
/// taskType 키워드 매칭 강도로 confidence를 산출합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
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: 프로파일 매핑
// ════════════════════════════════════════════════════════════
/// <summary>
/// (taskType, intentCategory) 조합에 따라 ExecutionPolicy overlay를 생성합니다.
/// </summary>
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,
};
}
// ════════════════════════════════════════════════════════════
// 탐색 범위 결정
// ════════════════════════════════════════════════════════════
/// <summary>
/// IntentGate 결과를 기반으로 ExplorationScope를 결정합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
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 연동)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 복합 요청을 감지합니다. <paramref name="lowerQuery"/>는 이미 lowercase 변환된 문자열입니다.
/// </summary>
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;
}
}