310 lines
9.9 KiB
C#
310 lines
9.9 KiB
C#
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// 에이전트 메모리 서비스.
|
|
/// 작업 폴더별 + 전역 메모리를 관리하며, 대화 간 지속적 컨텍스트를 유지합니다.
|
|
/// 저장소: %APPDATA%\AxCopilot\memory\{hash}.dat (암호화)
|
|
/// </summary>
|
|
public class AgentMemoryService
|
|
{
|
|
private static readonly string MemoryDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "memory");
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
private readonly List<MemoryEntry> _entries = new();
|
|
private readonly object _lock = new();
|
|
private string? _currentWorkFolder;
|
|
|
|
/// <summary>현재 로드된 메모리 항목 수.</summary>
|
|
public int Count { get { lock (_lock) return _entries.Count; } }
|
|
|
|
/// <summary>모든 메모리 항목 (읽기 전용).</summary>
|
|
public IReadOnlyList<MemoryEntry> All { get { lock (_lock) return _entries.ToList(); } }
|
|
|
|
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
|
|
public void Load(string? workFolder)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_entries.Clear();
|
|
_currentWorkFolder = workFolder;
|
|
|
|
// 전역 메모리
|
|
var globalPath = GetFilePath(null);
|
|
LoadFromFile(globalPath);
|
|
|
|
// 폴더별 메모리
|
|
if (!string.IsNullOrEmpty(workFolder))
|
|
{
|
|
var folderPath = GetFilePath(workFolder);
|
|
LoadFromFile(folderPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>새 메모리를 추가합니다. 유사한 내용이 있으면 병합합니다.</summary>
|
|
public MemoryEntry Add(string type, string content, string? source = null, string? workFolder = null)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
// 중복 검사: 동일 타입 + 유사 내용 (80% 이상 키워드 겹침)
|
|
var existing = _entries.FirstOrDefault(e =>
|
|
e.Type == type && CalculateSimilarity(e.Content, content) > 0.8);
|
|
if (existing != null)
|
|
{
|
|
existing.LastUsedAt = DateTime.Now;
|
|
existing.UseCount++;
|
|
Save();
|
|
return existing;
|
|
}
|
|
|
|
var entry = new MemoryEntry
|
|
{
|
|
Id = Guid.NewGuid().ToString("N")[..12],
|
|
Type = type,
|
|
Content = content,
|
|
Source = source ?? "",
|
|
CreatedAt = DateTime.Now,
|
|
LastUsedAt = DateTime.Now,
|
|
UseCount = 1,
|
|
Relevance = 1.0,
|
|
WorkFolder = workFolder,
|
|
};
|
|
_entries.Add(entry);
|
|
Prune();
|
|
Save();
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
/// <summary>쿼리와 관련된 메모리를 검색합니다.</summary>
|
|
public List<MemoryEntry> GetRelevant(string query, int maxCount = 10)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query)) return new();
|
|
|
|
lock (_lock)
|
|
{
|
|
var queryTokens = Tokenize(query);
|
|
var scored = _entries
|
|
.Select(e =>
|
|
{
|
|
var entryTokens = Tokenize(e.Content);
|
|
var overlap = queryTokens.Intersect(entryTokens, StringComparer.OrdinalIgnoreCase).Count();
|
|
var score = queryTokens.Count > 0 ? (double)overlap / queryTokens.Count : 0;
|
|
// 사용 빈도와 최근 사용 가중치 적용
|
|
score += Math.Min(e.UseCount * 0.05, 0.3);
|
|
if ((DateTime.Now - e.LastUsedAt).TotalDays < 7) score += 0.1;
|
|
return (Entry: e, Score: score);
|
|
})
|
|
.Where(x => x.Score > 0.1)
|
|
.OrderByDescending(x => x.Score)
|
|
.Take(maxCount)
|
|
.Select(x =>
|
|
{
|
|
x.Entry.Relevance = x.Score;
|
|
return x.Entry;
|
|
})
|
|
.ToList();
|
|
|
|
return scored;
|
|
}
|
|
}
|
|
|
|
/// <summary>메모리 사용 기록을 갱신합니다.</summary>
|
|
public void Touch(string id)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var entry = _entries.FirstOrDefault(e => e.Id == id);
|
|
if (entry != null)
|
|
{
|
|
entry.UseCount++;
|
|
entry.LastUsedAt = DateTime.Now;
|
|
Save();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>메모리를 삭제합니다.</summary>
|
|
public bool Remove(string id)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var removed = _entries.RemoveAll(e => e.Id == id) > 0;
|
|
if (removed) Save();
|
|
return removed;
|
|
}
|
|
}
|
|
|
|
/// <summary>모든 메모리를 삭제합니다.</summary>
|
|
public void Clear()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_entries.Clear();
|
|
Save();
|
|
}
|
|
}
|
|
|
|
/// <summary>오래되고 사용되지 않는 메모리를 정리합니다.</summary>
|
|
private void Prune()
|
|
{
|
|
var maxEntries = 100;
|
|
try
|
|
{
|
|
var app = System.Windows.Application.Current as App;
|
|
maxEntries = app?.SettingsService?.Settings.Llm.MaxMemoryEntries ?? 100;
|
|
}
|
|
catch { }
|
|
|
|
if (_entries.Count <= maxEntries) return;
|
|
|
|
// 점수 기반 정리: 오래되고 사용 안 되는 것부터
|
|
var toRemove = _entries
|
|
.OrderBy(e => e.UseCount)
|
|
.ThenBy(e => e.LastUsedAt)
|
|
.Take(_entries.Count - maxEntries)
|
|
.ToList();
|
|
|
|
foreach (var entry in toRemove)
|
|
_entries.Remove(entry);
|
|
}
|
|
|
|
/// <summary>메모리를 암호화하여 파일에 저장합니다.</summary>
|
|
private void Save()
|
|
{
|
|
Directory.CreateDirectory(MemoryDir);
|
|
|
|
// 전역 메모리
|
|
var globalEntries = _entries.Where(e => e.WorkFolder == null).ToList();
|
|
SaveToFile(GetFilePath(null), globalEntries);
|
|
|
|
// 폴더별 메모리
|
|
if (!string.IsNullOrEmpty(_currentWorkFolder))
|
|
{
|
|
var folderEntries = _entries.Where(e => e.WorkFolder != null).ToList();
|
|
SaveToFile(GetFilePath(_currentWorkFolder), folderEntries);
|
|
}
|
|
}
|
|
|
|
private void SaveToFile(string path, List<MemoryEntry> entries)
|
|
{
|
|
try
|
|
{
|
|
var json = JsonSerializer.Serialize(entries, JsonOptions);
|
|
var encrypted = CryptoService.PortableEncrypt(json);
|
|
var tempPath = path + ".tmp";
|
|
File.WriteAllText(tempPath, encrypted);
|
|
File.Move(tempPath, path, overwrite: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"메모리 저장 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void LoadFromFile(string path)
|
|
{
|
|
if (!File.Exists(path)) return;
|
|
try
|
|
{
|
|
var encrypted = File.ReadAllText(path);
|
|
var json = CryptoService.PortableDecrypt(encrypted);
|
|
if (string.IsNullOrEmpty(json)) return;
|
|
var entries = JsonSerializer.Deserialize<List<MemoryEntry>>(json, JsonOptions);
|
|
if (entries != null)
|
|
_entries.AddRange(entries);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"메모리 로드 실패 ({path}): {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static string GetFilePath(string? workFolder)
|
|
{
|
|
if (string.IsNullOrEmpty(workFolder))
|
|
return Path.Combine(MemoryDir, "_global.dat");
|
|
|
|
// 폴더 경로를 해시하여 파일명 생성
|
|
var hash = Convert.ToHexString(
|
|
SHA256.HashData(Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant())))[..16];
|
|
return Path.Combine(MemoryDir, $"{hash}.dat");
|
|
}
|
|
|
|
/// <summary>두 문자열의 키워드 유사도를 계산합니다 (0~1).</summary>
|
|
private static double CalculateSimilarity(string a, string b)
|
|
{
|
|
var tokensA = Tokenize(a);
|
|
var tokensB = Tokenize(b);
|
|
if (tokensA.Count == 0 || tokensB.Count == 0) return 0;
|
|
var intersection = tokensA.Intersect(tokensB, StringComparer.OrdinalIgnoreCase).Count();
|
|
var union = tokensA.Union(tokensB, StringComparer.OrdinalIgnoreCase).Count();
|
|
return union > 0 ? (double)intersection / union : 0;
|
|
}
|
|
|
|
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
|
|
private static HashSet<string> Tokenize(string text)
|
|
{
|
|
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var sb = new StringBuilder();
|
|
foreach (var c in text)
|
|
{
|
|
if (char.IsLetterOrDigit(c) || c == '_')
|
|
sb.Append(c);
|
|
else if (sb.Length > 1) // 1글자 토큰 제외
|
|
{
|
|
tokens.Add(sb.ToString());
|
|
sb.Clear();
|
|
}
|
|
else
|
|
sb.Clear();
|
|
}
|
|
if (sb.Length > 1) tokens.Add(sb.ToString());
|
|
return tokens;
|
|
}
|
|
}
|
|
|
|
/// <summary>에이전트 메모리 항목.</summary>
|
|
public class MemoryEntry
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public string Id { get; set; } = "";
|
|
|
|
[JsonPropertyName("type")]
|
|
public string Type { get; set; } = "fact"; // rule | preference | fact | correction
|
|
|
|
[JsonPropertyName("content")]
|
|
public string Content { get; set; } = "";
|
|
|
|
[JsonPropertyName("source")]
|
|
public string Source { get; set; } = "";
|
|
|
|
[JsonPropertyName("createdAt")]
|
|
public DateTime CreatedAt { get; set; }
|
|
|
|
[JsonPropertyName("lastUsedAt")]
|
|
public DateTime LastUsedAt { get; set; }
|
|
|
|
[JsonPropertyName("useCount")]
|
|
public int UseCount { get; set; }
|
|
|
|
[JsonPropertyName("relevance")]
|
|
public double Relevance { get; set; }
|
|
|
|
[JsonPropertyName("workFolder")]
|
|
public string? WorkFolder { get; set; }
|
|
}
|