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"));
+ }
+}