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:
parent
937d9e7878
commit
b76d51f3c1
6 changed files with 313 additions and 0 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
90
src/Everything2Everything.Core/Converters/ChatClients.cs
Normal file
90
src/Everything2Everything.Core/Converters/ChatClients.cs
Normal 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;
|
||||||
|
}
|
||||||
127
src/Everything2Everything.Core/Converters/LlmProvider.cs
Normal file
127
src/Everything2Everything.Core/Converters/LlmProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,4 +31,8 @@
|
||||||
<Using Include="System.IO" />
|
<Using Include="System.IO" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Everything2Everything.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
src/Everything2Everything.Tests/LlmProviderTests.cs
Normal file
68
src/Everything2Everything.Tests/LlmProviderTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue