diff --git a/src/Everything2Everything.Core/ConvertOptions.cs b/src/Everything2Everything.Core/ConvertOptions.cs index 03fcf82..bc8ed6b 100644 --- a/src/Everything2Everything.Core/ConvertOptions.cs +++ b/src/Everything2Everything.Core/ConvertOptions.cs @@ -66,6 +66,8 @@ public sealed class ConvertOptions public PdfCompressOptions PdfCompress { get; set; } = new(); + public AiOptions Ai { get; set; } = new(); + public static ConvertOptions Quick() => new(); } @@ -140,3 +142,23 @@ public sealed class PdfCompressOptions /// Light(구조 최적화·무손실) | Strong(렌더 재인코딩) | Max(Ghostscript). P1은 Light만 구현. public string Level { get; set; } = "Light"; } + +public sealed class AiOptions +{ + /// auto | openai | anthropic. auto는 설정된 키 중 가용한 것을 선택. + public string Backend { get; set; } = "auto"; + + /// 모델 ID. null이면 백엔드별 기본값. + public string? Model { get; set; } + + /// summarize | translate | proofread | custom. + public string Task { get; set; } = "summarize"; + + /// translate 작업의 대상 언어 (예: "영어", "일본어"). + public string? TargetLanguage { get; set; } + + /// custom 작업의 사용자 지정 지시문. + public string? Instruction { get; set; } + + public int MaxOutputTokens { get; set; } = 2000; +} diff --git a/src/Everything2Everything.Core/Converters/ChatClients.cs b/src/Everything2Everything.Core/Converters/ChatClients.cs new file mode 100644 index 0000000..df4fee1 --- /dev/null +++ b/src/Everything2Everything.Core/Converters/ChatClients.cs @@ -0,0 +1,90 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace Everything2Everything.Core.Converters; + +/// LLM 채팅 완성 클라이언트 추상화. OpenAI/Anthropic을 동일 인터페이스로 다룬다. +public interface IChatClient +{ + string Name { get; } + Task CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct); +} + +public sealed class OpenAiChatClient : IChatClient +{ + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(3) }; + private readonly string _apiKey; + + public OpenAiChatClient(string apiKey) => _apiKey = apiKey; + public string Name => "OpenAI"; + + public async Task CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct) + { + var body = new + { + model, + messages = new object[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = userPrompt }, + }, + max_tokens = maxTokens, + }; + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions"); + req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _apiKey); + req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + + using var resp = await Http.SendAsync(req, ct).ConfigureAwait(false); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException($"OpenAI API 오류 ({(int)resp.StatusCode}): {Truncate(json)}"); + + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? ""; + } + + private static string Truncate(string s) => s.Length > 400 ? s[..400] : s; +} + +public sealed class AnthropicChatClient : IChatClient +{ + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(3) }; + private readonly string _apiKey; + + public AnthropicChatClient(string apiKey) => _apiKey = apiKey; + public string Name => "Anthropic"; + + public async Task CompleteAsync(string systemPrompt, string userPrompt, string model, int maxTokens, CancellationToken ct) + { + var body = new + { + model, + max_tokens = maxTokens, + system = systemPrompt, + messages = new object[] + { + new { role = "user", content = userPrompt }, + }, + }; + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Headers.TryAddWithoutValidation("x-api-key", _apiKey); + req.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); + req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + + using var resp = await Http.SendAsync(req, ct).ConfigureAwait(false); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException($"Anthropic API 오류 ({(int)resp.StatusCode}): {Truncate(json)}"); + + using var doc = JsonDocument.Parse(json); + var content = doc.RootElement.GetProperty("content"); + var sb = new StringBuilder(); + foreach (var block in content.EnumerateArray()) + if (block.TryGetProperty("text", out var text)) + sb.Append(text.GetString()); + return sb.ToString(); + } + + private static string Truncate(string s) => s.Length > 400 ? s[..400] : s; +} diff --git a/src/Everything2Everything.Core/Converters/LlmProvider.cs b/src/Everything2Everything.Core/Converters/LlmProvider.cs new file mode 100644 index 0000000..e8eba99 --- /dev/null +++ b/src/Everything2Everything.Core/Converters/LlmProvider.cs @@ -0,0 +1,127 @@ +using Everything2Everything.Core.Providers; + +namespace Everything2Everything.Core.Converters; + +/// +/// AI 텍스트 변환 Provider. 요약·번역·교정을 LLM(OpenAI/Anthropic)으로 수행한다. +/// 설계 불변식: 키가 없으면 CheckAvailabilityAsync가 NotReady를 반환해 비활성되고, 기존 변환은 영향 없다. +/// 일반 변환과 충돌하지 않도록 txt→txt / md→md self-edge(동일 포맷, 내용만 가공)만 노출한다. +/// +public sealed class LlmProvider : IConverterProvider +{ + private readonly ISettingsStore _settings; + + public LlmProvider(ISettingsStore settings) => _settings = settings; + + public ProviderCapability Capability { get; } = new( + Id: "ai", + DisplayName: "AI 텍스트 (요약·번역·교정)", + SupportedConversions: new[] + { + new ConversionPair(".txt", ".txt", LossClass.Recode), + new ConversionPair(".md", ".md", LossClass.Recode), + }, + Status: ProviderStatus.RequiresExternal, + Summary: "OpenAI 또는 Anthropic API로 텍스트를 요약·번역·교정합니다 (✨AI · 종량 과금 · 네트워크 필요). API 키가 없으면 비활성됩니다.", + ExternalDependencies: new[] + { + new ExternalDependency( + Name: "OpenAI 또는 Anthropic API 키", + Description: "설정에서 API 키를 입력하면 활성화됩니다. 환경변수 OPENAI_API_KEY / ANTHROPIC_API_KEY 도 인식합니다.", + DownloadUrl: "https://platform.openai.com/api-keys", + IsRequired: true), + }, + RoadmapNote: "이미지 캡션(비전)·메타데이터 추출(JSON)·Codex CLI OAuth는 후속 확장."); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + { + var (client, _) = ResolveClient(new AiOptions()); + if (client is null) + return Task.FromResult(ProviderAvailability.NotReady( + "AI API 키가 설정되지 않았습니다. 설정에서 OpenAI 또는 Anthropic 키를 입력하세요 (또는 환경변수 OPENAI_API_KEY/ANTHROPIC_API_KEY).", + Capability.ExternalDependencies)); + return Task.FromResult(ProviderAvailability.Ready); + } + + public async Task ConvertAsync( + string sourcePath, string outputDirectory, string outputExtension, + ConvertOptions options, IProgress? progress, CancellationToken cancellationToken) + { + var (client, model) = ResolveClient(options.Ai); + if (client is null) + return ConvertResult.Fail(sourcePath, "AI API 키가 없습니다. 설정에서 키를 입력하세요."); + + var outExt = ConversionPair.Normalize(outputExtension); + var baseName = Path.GetFileNameWithoutExtension(sourcePath); + var suffix = "_" + (options.Ai.Task ?? "ai"); + var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, outExt, options.OnCollision); + if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision)) + return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); + + try + { + var input = await File.ReadAllTextAsync(sourcePath, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(input)) + return ConvertResult.Fail(sourcePath, "입력 텍스트가 비어 있습니다."); + + var (system, user) = BuildPrompt(options.Ai, input); + progress?.Report(0.2); + + var result = await client.CompleteAsync(system, user, model, options.Ai.MaxOutputTokens, cancellationToken) + .ConfigureAwait(false); + progress?.Report(0.9); + + var tmp = outPath + ".tmp"; + await File.WriteAllTextAsync(tmp, result, new System.Text.UTF8Encoding(false), cancellationToken).ConfigureAwait(false); + if (File.Exists(outPath)) File.Delete(outPath); + File.Move(tmp, outPath); + + progress?.Report(1.0); + return ConvertResult.Ok(sourcePath, new[] { outPath }); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return ConvertResult.Fail(sourcePath, $"AI 변환 실패: {ex.Message}", ex); + } + } + + private (IChatClient? client, string model) ResolveClient(AiOptions ai) + { + var backend = (ai.Backend ?? "auto").ToLowerInvariant(); + var openaiKey = GetKey("openai"); + var anthropicKey = GetKey("anthropic"); + + if (backend == "openai") + return openaiKey is null ? (null, "") : (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini"); + if (backend == "anthropic") + return anthropicKey is null ? (null, "") : (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest"); + + // auto: OpenAI 우선, 없으면 Anthropic + if (openaiKey is not null) return (new OpenAiChatClient(openaiKey), ai.Model ?? "gpt-4o-mini"); + if (anthropicKey is not null) return (new AnthropicChatClient(anthropicKey), ai.Model ?? "claude-3-5-sonnet-latest"); + return (null, ""); + } + + private string? GetKey(string provider) + { + var stored = _settings.Get($"{provider}.apikey"); + if (!string.IsNullOrWhiteSpace(stored)) return stored; + var env = Environment.GetEnvironmentVariable(provider == "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"); + return string.IsNullOrWhiteSpace(env) ? null : env; + } + + internal static (string system, string user) BuildPrompt(AiOptions ai, string input) + { + var task = (ai.Task ?? "summarize").ToLowerInvariant(); + var system = task switch + { + "summarize" => "너는 문서 요약 도우미다. 입력의 핵심을 간결하고 명확하게 요약하라. 요약문만 출력하라.", + "translate" => $"너는 전문 번역가다. 입력을 {ai.TargetLanguage ?? "영어"}(으)로 자연스럽게 번역하라. 번역문만 출력하라.", + "proofread" => "너는 교정 도우미다. 입력의 오탈자·문법·어색한 표현·줄바꿈을 정리하되 원래 의미는 보존하라. 교정된 본문만 출력하라.", + "custom" => string.IsNullOrWhiteSpace(ai.Instruction) ? "입력 텍스트를 처리하라." : ai.Instruction!, + _ => "입력 텍스트를 처리하라.", + }; + return (system, input); + } +} diff --git a/src/Everything2Everything.Core/Everything2Everything.Core.csproj b/src/Everything2Everything.Core/Everything2Everything.Core.csproj index 12a9a38..5a1c2a5 100644 --- a/src/Everything2Everything.Core/Everything2Everything.Core.csproj +++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj @@ -31,4 +31,8 @@ + + + + diff --git a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs index d873341..8532b04 100644 --- a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs +++ b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs @@ -6,6 +6,7 @@ public static class Everything2EverythingBootstrap { public static ConversionEngine CreateDefault() { + var settings = new DpapiSettingsStore(); var magick = new Converters.MagickProvider(); var pdf = new Converters.PdfProvider(); var providers = new IConverterProvider[] @@ -22,6 +23,7 @@ public static class Everything2EverythingBootstrap new Converters.DataProvider(), new Converters.VectorProvider(), new Converters.ImageOptimProvider(), + new Converters.LlmProvider(settings), }; return new ConversionEngine(new ProviderRegistry(providers)); } diff --git a/src/Everything2Everything.Tests/LlmProviderTests.cs b/src/Everything2Everything.Tests/LlmProviderTests.cs new file mode 100644 index 0000000..eecff8e --- /dev/null +++ b/src/Everything2Everything.Tests/LlmProviderTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Xunit; + +namespace Everything2Everything.Tests; + +public class LlmProviderTests +{ + private sealed class FakeStore : ISettingsStore + { + private readonly Dictionary _d = new(); + public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null; + public void Set(string key, string value) => _d[key] = value; + public void Remove(string key) => _d.Remove(key); + public bool Contains(string key) => _d.ContainsKey(key); + } + + private static bool EnvHasKey() + => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")); + + [Fact] + public async Task NoKey_IsNotReady() + { + if (EnvHasKey()) return; // 환경변수 키가 있으면 이 단언은 건너뜀 + var p = new LlmProvider(new FakeStore()); + var a = await p.CheckAvailabilityAsync(); + Assert.False(a.IsReady); + } + + [Fact] + public async Task WithKey_IsReady() + { + var store = new FakeStore(); + store.Set("openai.apikey", "sk-test-key"); + var p = new LlmProvider(store); + var a = await p.CheckAvailabilityAsync(); + Assert.True(a.IsReady); + } + + [Fact] + public void BuildPrompt_Translate_IncludesTargetLanguage() + { + var (system, user) = LlmProvider.BuildPrompt( + new AiOptions { Task = "translate", TargetLanguage = "일본어" }, "hello"); + Assert.Contains("일본어", system); + Assert.Equal("hello", user); + } + + [Fact] + public void BuildPrompt_Summarize_MentionsSummary() + { + var (system, _) = LlmProvider.BuildPrompt(new AiOptions { Task = "summarize" }, "text"); + Assert.Contains("요약", system); + } + + [Fact] + public void DefaultGraph_HasAiTextSelfEdge() + { + var graph = Everything2EverythingBootstrap.CreateDefault().Providers.Graph; + // AI self-edge(txt→txt)가 그래프에 존재 — 키 없으면 실행 시 NotReady로 비활성되지만 엣지는 등록됨 + Assert.NotNull(graph.FindBestPath(".txt", ".txt")); + Assert.NotNull(graph.FindBestPath(".md", ".md")); + } +}