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
This commit is contained in:
326
src/AxCopilot/Services/Agent/IntentGateService.cs
Normal file
326
src/AxCopilot/Services/Agent/IntentGateService.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user