1
0
Fork 0

feat: AI 텍스트 Provider (LlmProvider) — OpenAI/Anthropic 요약·번역·교정

AI를 '키 없으면 비활성되는 부가 엣지'로 통합. 기존 변환에 무영향, 일반 변환과 충돌 없는 txt↔txt/md↔md self-edge만 노출.

- IChatClient + OpenAiChatClient/AnthropicChatClient (HttpClient 직접, SDK 의존 0)
- LlmProvider: summarize/translate/proofread/custom, ISettingsStore 키 + 환경변수(OPENAI_API_KEY/ANTHROPIC_API_KEY) 폴백
- CheckAvailabilityAsync 게이트: 키 없으면 NotReady → 그래프에서 실행 비활성
- ConvertOptions.Ai (백엔드/모델/작업/대상언어)
- 테스트 5개 (키 게이트·프롬프트 구성·그래프 엣지) — 총 34개 통과
This commit is contained in:
Yun Chan 2026-06-01 13:21:14 +09:00
parent 937d9e7878
commit b76d51f3c1
6 changed files with 313 additions and 0 deletions

View file

@ -66,6 +66,8 @@ public sealed class ConvertOptions
public PdfCompressOptions PdfCompress { get; set; } = new(); public PdfCompressOptions PdfCompress { get; set; } = new();
public AiOptions Ai { get; set; } = new();
public static ConvertOptions Quick() => new(); public static ConvertOptions Quick() => new();
} }
@ -140,3 +142,23 @@ public sealed class PdfCompressOptions
/// <summary>Light(구조 최적화·무손실) | Strong(렌더 재인코딩) | Max(Ghostscript). P1은 Light만 구현.</summary> /// <summary>Light(구조 최적화·무손실) | Strong(렌더 재인코딩) | Max(Ghostscript). P1은 Light만 구현.</summary>
public string Level { get; set; } = "Light"; public string Level { get; set; } = "Light";
} }
public sealed class AiOptions
{
/// <summary>auto | openai | anthropic. auto는 설정된 키 중 가용한 것을 선택.</summary>
public string Backend { get; set; } = "auto";
/// <summary>모델 ID. null이면 백엔드별 기본값.</summary>
public string? Model { get; set; }
/// <summary>summarize | translate | proofread | custom.</summary>
public string Task { get; set; } = "summarize";
/// <summary>translate 작업의 대상 언어 (예: "영어", "일본어").</summary>
public string? TargetLanguage { get; set; }
/// <summary>custom 작업의 사용자 지정 지시문.</summary>
public string? Instruction { get; set; }
public int MaxOutputTokens { get; set; } = 2000;
}

View file

@ -0,0 +1,90 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace Everything2Everything.Core.Converters;
/// <summary>LLM 채팅 완성 클라이언트 추상화. OpenAI/Anthropic을 동일 인터페이스로 다룬다.</summary>
public interface IChatClient
{
string Name { get; }
Task<string> 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<string> 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<string> 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;
}

View file

@ -0,0 +1,127 @@
using Everything2Everything.Core.Providers;
namespace Everything2Everything.Core.Converters;
/// <summary>
/// AI 텍스트 변환 Provider. 요약·번역·교정을 LLM(OpenAI/Anthropic)으로 수행한다.
/// 설계 불변식: 키가 없으면 CheckAvailabilityAsync가 NotReady를 반환해 비활성되고, 기존 변환은 영향 없다.
/// 일반 변환과 충돌하지 않도록 txt→txt / md→md self-edge(동일 포맷, 내용만 가공)만 노출한다.
/// </summary>
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<ProviderAvailability> 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<ConvertResult> ConvertAsync(
string sourcePath, string outputDirectory, string outputExtension,
ConvertOptions options, IProgress<double>? 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);
}
}

View file

@ -31,4 +31,8 @@
<Using Include="System.IO" /> <Using Include="System.IO" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Everything2Everything.Tests" />
</ItemGroup>
</Project> </Project>

View file

@ -6,6 +6,7 @@ public static class Everything2EverythingBootstrap
{ {
public static ConversionEngine CreateDefault() public static ConversionEngine CreateDefault()
{ {
var settings = new DpapiSettingsStore();
var magick = new Converters.MagickProvider(); var magick = new Converters.MagickProvider();
var pdf = new Converters.PdfProvider(); var pdf = new Converters.PdfProvider();
var providers = new IConverterProvider[] var providers = new IConverterProvider[]
@ -22,6 +23,7 @@ public static class Everything2EverythingBootstrap
new Converters.DataProvider(), new Converters.DataProvider(),
new Converters.VectorProvider(), new Converters.VectorProvider(),
new Converters.ImageOptimProvider(), new Converters.ImageOptimProvider(),
new Converters.LlmProvider(settings),
}; };
return new ConversionEngine(new ProviderRegistry(providers)); return new ConversionEngine(new ProviderRegistry(providers));
} }

View file

@ -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<string, string> _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"));
}
}