[Phase 17-A] Reflexion 자기성찰 메모리 시스템 구현

ReflexionEvaluatorService 신규 구현 (ReflexionService.cs):
- LLM 기반 자기평가: 완성도 점수(0~1), 강점/약점/교훈 추출
- $$raw string 평가 프롬프트로 JSON 포맷 안전하게 삽입
- JSON 블록 추출 + 역직렬화, LLM 실패 시 규칙 기반 폴백 엔트리
- ReflexionRepository.BuildContextPromptAsync() maxEntries 파라미터 추가

AgentLoopService.Reflexion.cs (신규, 82줄):
- InjectReflexionContextAsync(): 세션 시작 전 과거 교훈→시스템 메시지 주입
- FireAndForgetReflexionEval(): 세션 완료 후 Task.Run 비동기 자기평가 저장
- 지연 초기화(_reflexionRepo, _reflexionEval): 사용 시점에 생성

AgentLoopService.cs 통합 포인트 2개 추가:
- RunAsync() 루프 시작 전: await InjectReflexionContextAsync()
- finally 블록 통계 섹션: FireAndForgetReflexionEval() 호출

AgentSettingsPanel — 자기성찰 메모리 섹션 추가:
- 활성화 토글(ChkReflexionEnabled)
- 성공 세션만 평가 토글(ChkReflexionSuccessOnly)
- 최대 참고 교훈 수 슬라이더(1~20, 기본값 5)
- LoadFromSettings() 초기화 + 3개 이벤트 핸들러

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-03 23:07:59 +09:00
parent 5fe6d5c6ba
commit 90d5943327
6 changed files with 380 additions and 4 deletions

View File

@@ -4954,5 +4954,29 @@ ThemeResourceHelper에 5개 정적 필드 추가:
---
최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E 구현 완료)
## Phase 17-A — Reflexion 강화 (v1.8.0) ✅ 완료
> **목표**: 성공·실패 모두 구조화된 자기평가 저장 → 동일 작업 유형 재실행 시 자동 참고
### 변경 파일
| 파일 | 변경 내용 |
|------|----------|
| `ReflexionService.cs` | `ReflexionEvaluatorService` 신규 추가: LLM 기반 자기평가(점수·강점·약점·교훈 추출), 규칙 기반 폴백 엔트리. `ReflexionRepository.BuildContextPromptAsync()` maxEntries 파라미터 추가. |
| `AgentLoopService.Reflexion.cs` (신규, 82줄) | `InjectReflexionContextAsync()` — 세션 시작 시 과거 교훈을 시스템 메시지에 주입. `FireAndForgetReflexionEval()` — 세션 완료 후 비동기 자기평가 저장. |
| `AgentLoopService.cs` | `RunAsync()` 루프 시작 전: `InjectReflexionContextAsync()` 호출. `finally` 블록: `FireAndForgetReflexionEval()` 호출. |
| `AgentSettingsPanel.xaml` | "자기성찰 메모리" 섹션 추가: 활성화 토글, 성공 시만 평가 토글, 최대 참고 교훈 수 슬라이더. |
| `AgentSettingsPanel.xaml.cs` | `LoadFromSettings()`: Reflexion 설정 초기화. `ChkReflexion_Changed()`, `ChkReflexionSuccessOnly_Changed()`, `SliderReflexionMaxEntries_ValueChanged()` 핸들러 추가. |
### 구현 세부사항
- **ReflexionEvaluatorService**: `$$"""..."""` raw string으로 평가 프롬프트 생성, JSON 블록 추출 후 역직렬화
- **작업 유형 분류**: `TaskTypeClassifier.Classify()` — 8개 카테고리 (code_generation, file_refactor, document, analysis, search, test, git, debug, general)
- **저장 위치**: `%APPDATA%\AxCopilot\reflexion\<taskType>.jsonl` (JSONL 형식, 최신 N개 역순 조회)
- **비동기 처리**: fire-and-forget — 루프 취소(`CancellationToken`) 영향 없음
- **빌드**: 경고 0, 오류 0
---
최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A 구현 완료)

View File

@@ -0,0 +1,87 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 17-A: AgentLoopService — Reflexion(자기성찰 메모리) 통합.
/// 세션 시작 시 과거 교훈을 시스템 메시지에 주입하고,
/// 세션 완료 후 LLM 자기평가를 비동기로 저장합니다.
/// </summary>
public partial class AgentLoopService
{
// ── 지연 초기화 (첫 사용 시 생성) ────────────────────────────────────
private ReflexionRepository? _reflexionRepo;
private ReflexionEvaluatorService? _reflexionEval;
private ReflexionRepository ReflexionRepo =>
_reflexionRepo ??= new ReflexionRepository();
private ReflexionEvaluatorService ReflexionEval =>
_reflexionEval ??= new ReflexionEvaluatorService(_llm, ReflexionRepo);
/// <summary>
/// 세션 시작 시 과거 유사 작업 교훈을 시스템 메시지 끝에 주입합니다.
/// Reflexion이 비활성화되었거나 교훈이 없으면 아무것도 하지 않습니다.
/// </summary>
internal async Task InjectReflexionContextAsync(List<ChatMessage> messages, string userQuery)
{
var reflexion = _settings.Settings.Llm.Reflexion;
if (reflexion?.Enabled != true) return;
if (string.IsNullOrWhiteSpace(userQuery)) return;
try
{
var taskType = TaskTypeClassifier.Classify(userQuery);
var maxEntries = reflexion.MaxContextEntries > 0 ? reflexion.MaxContextEntries : 5;
var context = await ReflexionRepo.BuildContextPromptAsync(taskType, maxEntries);
if (string.IsNullOrEmpty(context)) return;
// 시스템 메시지 찾기 (첫 번째 system 역할 메시지)
var sysMsg = messages.FirstOrDefault(m => m.Role == "system");
if (sysMsg != null)
sysMsg.Content += "\n" + context;
// 시스템 메시지가 없으면 리스트 앞에 추가
else
messages.Insert(0, new ChatMessage { Role = "system", Content = context });
}
catch (Exception)
{
// 교훈 주입 실패는 루프 진행에 영향 없음 (무시)
}
}
/// <summary>
/// 세션 완료 후 자기평가를 비동기(fire-and-forget)로 실행·저장합니다.
/// 실패 시 루프에 영향 없음.
/// </summary>
internal void FireAndForgetReflexionEval(
string userQuery,
string agentResult,
string sessionId,
bool isSuccess,
int toolCallCount,
int iterationCount)
{
var reflexion = _settings.Settings.Llm.Reflexion;
if (reflexion?.Enabled != true) return;
if (!isSuccess && reflexion.EvaluateOnSuccess) return; // 성공 시만 평가 설정일 때 실패면 스킵
if (string.IsNullOrWhiteSpace(userQuery)) return;
if (toolCallCount == 0) return; // 도구 사용 없는 단순 대화는 평가 불필요
// CancellationToken 없이 fire-and-forget — 루프 취소에 영향받지 않음
_ = Task.Run(async () =>
{
try
{
await ReflexionEval.EvaluateAndSaveAsync(
userQuery, agentResult, sessionId,
isSuccess, toolCallCount, iterationCount,
CancellationToken.None);
}
catch (Exception)
{
// 평가 저장 실패 — UI 방해 없이 조용히 무시
}
});
}
}

View File

@@ -196,6 +196,9 @@ public partial class AgentLoopService
var context = BuildContext(activeTabSnapshot);
// Phase 17-A: Reflexion — 과거 교훈을 시스템 메시지에 주입
await InjectReflexionContextAsync(messages, userQuery);
try
{
// ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ──
@@ -820,6 +823,13 @@ public partial class AgentLoopService
$"소요 {durationSec:F1}초 | 사용 도구: {toolList}";
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
}
// Phase 17-A: Reflexion — 세션 완료 후 자기평가 비동기 저장
var isSessionSuccess = statsSuccessCount > 0 && statsFailCount == 0;
var lastResult = messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? "";
FireAndForgetReflexionEval(
userQuery, lastResult, _sessionId ?? "",
isSessionSuccess, totalToolCalls, iteration);
}
}
}

View File

@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
@@ -81,13 +82,13 @@ public class ReflexionRepository
}
/// <summary>동일 작업 유형의 과거 교훈을 시스템 프롬프트 주입용 텍스트로 빌드.</summary>
public async Task<string> BuildContextPromptAsync(string taskType)
public async Task<string> BuildContextPromptAsync(string taskType, int maxEntries = 5)
{
var entries = await GetByTaskTypeAsync(taskType, 5);
var entries = await GetByTaskTypeAsync(taskType, maxEntries);
if (entries.Count == 0) return string.Empty;
var sb = new StringBuilder();
sb.AppendLine($"## 이전 유사 작업({taskType})에서 배운 점:");
sb.AppendLine($"\n## 이전 유사 작업({taskType})에서 배운 점:");
foreach (var e in entries)
{
if (e.Lessons.Length > 0)
@@ -136,3 +137,165 @@ public static class TaskTypeClassifier
return best;
}
}
/// <summary>
/// Phase 17-A: LLM 기반 자기평가 서비스.
/// 세션 완료 후 LLM을 호출해 완성도·강점·약점·교훈을 추출하고 ReflexionEntry를 생성합니다.
/// </summary>
public class ReflexionEvaluatorService
{
private readonly LlmService _llm;
private readonly ReflexionRepository _repo;
private static readonly JsonSerializerOptions _jsonOpts = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNameCaseInsensitive = true,
};
public ReflexionEvaluatorService(LlmService llm, ReflexionRepository repo)
{
_llm = llm;
_repo = repo;
}
/// <summary>
/// 세션을 평가하고 ReflexionEntry를 저장합니다.
/// 내부 LLM 호출 실패 시 규칙 기반 폴백 엔트리를 저장합니다.
/// </summary>
public async Task EvaluateAndSaveAsync(
string userQuery,
string agentResult,
string sessionId,
bool isSuccess,
int toolCallCount,
int iterationCount,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(userQuery)) return;
var taskType = TaskTypeClassifier.Classify(userQuery);
try
{
var entry = await EvaluateWithLlmAsync(
userQuery, agentResult, taskType, sessionId, isSuccess, toolCallCount, iterationCount, ct);
await _repo.SaveAsync(entry);
}
catch (Exception)
{
// LLM 평가 실패 → 규칙 기반 폴백 엔트리
var fallback = BuildFallbackEntry(userQuery, taskType, sessionId, isSuccess, iterationCount);
try { await _repo.SaveAsync(fallback); } catch (Exception) { /* 저장 실패도 무시 */ }
}
}
private async Task<ReflexionEntry> EvaluateWithLlmAsync(
string userQuery,
string agentResult,
string taskType,
string sessionId,
bool isSuccess,
int toolCallCount,
int iterationCount,
CancellationToken ct)
{
var resultPreview = agentResult.Length > 500 ? agentResult[..500] + "…" : agentResult;
var evalPrompt = $$"""
에이전트가 다음 작업을 수행했습니다. 수행 결과를 평가하고 JSON으로 응답하세요.
## 사용자 요청
{{userQuery}}
## 에이전트 결과 요약
{{resultPreview}}
## 수행 통계
- 성공 여부: {{(isSuccess ? "" : " ")}}
- : {{toolCallCount}}
- : {{iterationCount}}
## (JSON만 , )
{
"score": 0.0~1.0,
"strengths": ["잘된 점1", "잘된 점2"],
"weaknesses": ["부족한 점1", "부족한 점2"],
"lessons": ["다음에 적용할 교훈1", "다음에 적용할 교훈2"],
"summary": "한 줄 요약"
}
""";
var messages = new List<ChatMessage>
{
new() { Role = "user", Content = evalPrompt }
};
var response = await _llm.SendAsync(messages, ct);
// JSON 블록 추출
var json = ExtractJson(response);
if (string.IsNullOrEmpty(json))
return BuildFallbackEntry(userQuery, taskType, sessionId, isSuccess, iterationCount);
var parsed = JsonSerializer.Deserialize<EvalResponse>(json, _jsonOpts);
if (parsed == null)
return BuildFallbackEntry(userQuery, taskType, sessionId, isSuccess, iterationCount);
return new ReflexionEntry
{
TaskType = taskType,
Summary = parsed.Summary ?? userQuery[..Math.Min(80, userQuery.Length)],
IsSuccess = isSuccess,
CompletionScore = Math.Clamp(parsed.Score, 0f, 1f),
Strengths = parsed.Strengths ?? Array.Empty<string>(),
Weaknesses = parsed.Weaknesses ?? Array.Empty<string>(),
Lessons = parsed.Lessons ?? Array.Empty<string>(),
CreatedAt = DateTime.UtcNow,
SessionId = sessionId,
};
}
private static ReflexionEntry BuildFallbackEntry(
string userQuery, string taskType, string sessionId, bool isSuccess, int iterationCount)
{
return new ReflexionEntry
{
TaskType = taskType,
Summary = userQuery.Length > 80 ? userQuery[..80] + "…" : userQuery,
IsSuccess = isSuccess,
CompletionScore = isSuccess ? 0.7f : 0.3f,
Strengths = isSuccess ? new[] { "작업 완료" } : Array.Empty<string>(),
Weaknesses = !isSuccess ? new[] { $"반복 {iterationCount}회 후 완료 못함" } : Array.Empty<string>(),
Lessons = Array.Empty<string>(),
CreatedAt = DateTime.UtcNow,
SessionId = sessionId,
};
}
private static string ExtractJson(string text)
{
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start < 0 || end <= start) return string.Empty;
return text[start..(end + 1)];
}
// JSON 응답 역직렬화용 내부 DTO
private class EvalResponse
{
[JsonPropertyName("score")]
public float Score { get; set; }
[JsonPropertyName("strengths")]
public string[]? Strengths { get; set; }
[JsonPropertyName("weaknesses")]
public string[]? Weaknesses { get; set; }
[JsonPropertyName("lessons")]
public string[]? Lessons { get; set; }
[JsonPropertyName("summary")]
public string? Summary { get; set; }
}
}

View File

@@ -195,6 +195,57 @@
<Border Height="1" Background="{DynamicResource BorderColor}" Margin="0,8,0,16"/>
<!-- ═══ 자기성찰 메모리 (Phase 17-A) ═══ -->
<TextBlock Text="자기성찰 메모리" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="0,0,0,8"/>
<!-- 자기성찰 활성화 -->
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="자기성찰 메모리" Foreground="{DynamicResource SecondaryText}" FontSize="12"/>
<TextBlock Text="동일 유형 작업 시 과거 교훈 자동 참고" FontSize="10"
Foreground="{DynamicResource SecondaryText}" Opacity="0.7"/>
</StackPanel>
<CheckBox x:Name="ChkReflexionEnabled" Grid.Column="1"
Style="{StaticResource ToggleSwitch}"
Checked="ChkReflexion_Changed" Unchecked="ChkReflexion_Changed"/>
</Grid>
<!-- 성공 시만 평가 -->
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="성공 세션만 평가" Foreground="{DynamicResource SecondaryText}" FontSize="12"
VerticalAlignment="Center"/>
<CheckBox x:Name="ChkReflexionSuccessOnly" Grid.Column="1"
Style="{StaticResource ToggleSwitch}"
Checked="ChkReflexionSuccessOnly_Changed" Unchecked="ChkReflexionSuccessOnly_Changed"/>
</Grid>
<!-- 최대 참고 교훈 수 -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="40"/>
</Grid.ColumnDefinitions>
<TextBlock Text="최대 참고 교훈 수" Foreground="{DynamicResource SecondaryText}" FontSize="12"
VerticalAlignment="Center"/>
<TextBlock x:Name="TxtReflexionMaxEntries" Grid.Column="1" Text="5"
Foreground="{DynamicResource PrimaryText}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
<Slider x:Name="SliderReflexionMaxEntries" Minimum="1" Maximum="20" Value="5"
IsSnapToTickEnabled="True" TickFrequency="1"
ValueChanged="SliderReflexionMaxEntries_ValueChanged" Margin="0,0,0,8"/>
<Border Height="1" Background="{DynamicResource BorderColor}" Margin="0,8,0,16"/>
<!-- ═══ 탭 전용 설정 ═══ -->
<TextBlock Text="탭 전용 설정" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="0,0,0,8"/>

View File

@@ -66,6 +66,13 @@ public partial class AgentSettingsPanel : UserControl
SliderAutoCompact.Value = llm.AutoCompactThreshold > 0 ? llm.AutoCompactThreshold : 80;
TxtAutoCompact.Text = ((int)SliderAutoCompact.Value).ToString();
// Phase 17-A: Reflexion 설정 로드
var reflexion = llm.Reflexion;
ChkReflexionEnabled.IsChecked = reflexion?.Enabled ?? true;
ChkReflexionSuccessOnly.IsChecked = reflexion?.EvaluateOnSuccess ?? true;
SliderReflexionMaxEntries.Value = reflexion?.MaxContextEntries > 0 ? reflexion.MaxContextEntries : 5;
TxtReflexionMaxEntries.Text = ((int)SliderReflexionMaxEntries.Value).ToString();
ChkDevMode.IsChecked = llm.DevMode;
// 탭별 설정 표시
@@ -226,6 +233,40 @@ public partial class AgentSettingsPanel : UserControl
SaveSetting(s => s.Llm.DevMode = ChkDevMode.IsChecked == true);
}
// ── Phase 17-A: Reflexion 핸들러 ──────────────────────────────────────
private void ChkReflexion_Changed(object sender, RoutedEventArgs e)
{
if (_isLoading) return;
SaveSetting(s =>
{
s.Llm.Reflexion ??= new Models.ReflexionConfig();
s.Llm.Reflexion.Enabled = ChkReflexionEnabled.IsChecked == true;
});
}
private void ChkReflexionSuccessOnly_Changed(object sender, RoutedEventArgs e)
{
if (_isLoading) return;
SaveSetting(s =>
{
s.Llm.Reflexion ??= new Models.ReflexionConfig();
s.Llm.Reflexion.EvaluateOnSuccess = ChkReflexionSuccessOnly.IsChecked == true;
});
}
private void SliderReflexionMaxEntries_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (_isLoading || TxtReflexionMaxEntries == null) return;
var val = (int)SliderReflexionMaxEntries.Value;
TxtReflexionMaxEntries.Text = val.ToString();
SaveSetting(s =>
{
s.Llm.Reflexion ??= new Models.ReflexionConfig();
s.Llm.Reflexion.MaxContextEntries = val;
});
}
private void ToolToggle_Changed(object sender, RoutedEventArgs e)
{
if (_isLoading || sender is not CheckBox chk || chk.Tag is not string toolName) return;