chore: initial commit
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--TODO: Add test coverage-->
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AltGen.API\AltGen.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Files\**\*">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.Test.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace AltGen.API.Tests.EndToEnd;
|
||||
|
||||
public class EndToEndTest(AppFactory factory, TestConfiguration config) : IClassFixture<AppFactory>, IClassFixture<TestConfiguration>
|
||||
{
|
||||
protected HttpClient Client { get; } = factory.CreateClient();
|
||||
protected TestConfiguration Config { get; } = config;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
|
||||
namespace AltGen.API.Tests.EndToEnd;
|
||||
|
||||
public class GenerateEndpointTests(
|
||||
AppFactory factory,
|
||||
TestConfiguration config
|
||||
) : EndToEndTest(factory, config)
|
||||
{
|
||||
[Fact]
|
||||
public async Task GenerateEndpoint_WhenCalled_ItShouldReturnOk()
|
||||
{
|
||||
var fileName = "library.jpg";
|
||||
var file = await TestFileManager.GetFileAsync(fileName);
|
||||
var byteContent = new ByteArrayContent(file);
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
|
||||
|
||||
var content = new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent(Config.GeminiApiKey), "ProviderKey" },
|
||||
{ byteContent, "File", fileName }
|
||||
};
|
||||
|
||||
var response = await Client.PostAsync("/generate", content);
|
||||
var altText = await response.Content.ReadFromJsonAsync<GenerateResponse>();
|
||||
|
||||
response.Should().HaveStatusCode(HttpStatusCode.OK);
|
||||
altText!.AltText.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace AltGen.API.Tests.EndToEnd;
|
||||
|
||||
public class PromptEvaluations(
|
||||
AppFactory factory,
|
||||
TestConfiguration config
|
||||
) : EndToEndTest(factory, config)
|
||||
{
|
||||
// TODO: Use LLM-assisted prompt evaluation instead of
|
||||
// doing partial string matching. Better suited due to
|
||||
// subjective nature of the task.
|
||||
[Theory]
|
||||
[ClassData(typeof(TestData))]
|
||||
public async Task Generate_WhenCalled_ItShouldRespondWithAltTextContainingKeyWords(string imageName, string[] keyWords)
|
||||
{
|
||||
var file = await TestFileManager.GetFileAsync(imageName);
|
||||
var byteContent = new ByteArrayContent(file);
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
|
||||
|
||||
var content = new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent(Config.GeminiApiKey), "ProviderKey" },
|
||||
{ byteContent, "File", imageName }
|
||||
};
|
||||
|
||||
var response = await Client.PostAsync("/generate", content);
|
||||
var altText = await response.Content.ReadFromJsonAsync<GenerateResponse>();
|
||||
|
||||
response.Should().HaveStatusCode(HttpStatusCode.OK);
|
||||
altText!.AltText.ToLowerInvariant().Should().ContainAll(keyWords);
|
||||
}
|
||||
|
||||
class TestData : IEnumerable<object[]>
|
||||
{
|
||||
public IEnumerator<object[]> GetEnumerator()
|
||||
{
|
||||
yield return new object[] { "library.jpg", new[] { "book", } };
|
||||
yield return new object[] { "ascii_art.jpg", new[] { "test", "100", "success", } };
|
||||
yield return new object[] { "living_room.jpg", new[] { "blue", "dog", } };
|
||||
yield return new object[] { "podcast.jpg", new[] { "podcast", "scott", "mark", } };
|
||||
yield return new object[] { "youtube_comment.jpg", new[] { "feedback", } };
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,11 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AltGen.API.Tests.Fixtures;
|
||||
|
||||
public class AppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureLogging(static l => l.ClearProviders());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace AltGen.API.Tests.Fixtures;
|
||||
|
||||
public class TestConfiguration
|
||||
{
|
||||
static IConfiguration Config { get; } = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.Test.json")
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
#pragma warning disable CA1822
|
||||
public string GeminiApiKey => GetGeminiApiKey();
|
||||
#pragma warning restore CA1822
|
||||
|
||||
static string GetGeminiApiKey()
|
||||
{
|
||||
var apiKey = Config.GetSection("Gemini").GetValue<string>("ApiKey");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
throw new InvalidOperationException("Gemini__ApiKey is required");
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
namespace AltGen.API.Tests.Integration;
|
||||
|
||||
public class GenerateEndpointTests : IntegrationTest
|
||||
{
|
||||
const string TestImageName = "library.jpg";
|
||||
|
||||
public GenerateEndpointTests(AppFactory factory) : base(factory)
|
||||
{
|
||||
MockGeminiServiceHandler.Clear();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[ClassData(typeof(InvalidRequestTestData))]
|
||||
public async Task GenerateEndpoint_WhenCalledWithoutInvalidParameters_ItShouldReturnAProblemDetailWithStatusCode400(MultipartFormDataContent content, Dictionary<string, string[]> errors)
|
||||
{
|
||||
var response = await Client.PostAsync("/generate", content);
|
||||
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
|
||||
response.Should().HaveStatusCode(HttpStatusCode.BadRequest);
|
||||
problem!.Errors.Should().BeEquivalentTo(errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[ClassData(typeof(ValidRequestTestData))]
|
||||
public async Task GenerateEndpoint_WhenCalledWithRequiredParameters_ItShouldReturnOkWithAltText(MultipartFormDataContent content)
|
||||
{
|
||||
MockGeminiServiceHandler
|
||||
.When(HttpMethod.Post, "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent/?key=ProviderKey")
|
||||
.Respond(
|
||||
"application/json",
|
||||
/*lang=json,strict*/
|
||||
@"{
|
||||
""candidates"": [
|
||||
{
|
||||
""content"": {
|
||||
""parts"": [
|
||||
{ ""text"": ""A long, perspective view of a classic library interior showcases richly colored wooden bookshelves filled with antique books, creating an atmosphere of history and scholarship.\n""
|
||||
}
|
||||
],
|
||||
""role"": ""model""
|
||||
},
|
||||
""finishReason"": ""STOP"",
|
||||
""avgLogprobs"": -0.3317602475484212
|
||||
}
|
||||
],
|
||||
""usageMetadata"": {
|
||||
""promptTokenCount"": 405,
|
||||
""candidatesTokenCount"": 30,
|
||||
""totalTokenCount"": 435,
|
||||
""promptTokensDetails"": [
|
||||
{
|
||||
""modality"": ""TEXT"",
|
||||
""tokenCount"": 147
|
||||
},
|
||||
{
|
||||
""modality"": ""IMAGE"",
|
||||
""tokenCount"": 258
|
||||
}
|
||||
],
|
||||
""candidatesTokensDetails"": [
|
||||
{
|
||||
""modality"": ""TEXT"",
|
||||
""tokenCount"": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
""modelVersion"": ""gemini-1.5-flash""
|
||||
}"
|
||||
);
|
||||
|
||||
var response = await Client.PostAsync("/generate", content);
|
||||
var altText = await response.Content.ReadFromJsonAsync<GenerateResponse>();
|
||||
|
||||
response.Should().HaveStatusCode(HttpStatusCode.OK);
|
||||
altText!.AltText.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
class ValidRequestTestData : IEnumerable<object[]>
|
||||
{
|
||||
public IEnumerator<object[]> GetEnumerator()
|
||||
{
|
||||
var testImage = TestFileManager.GetFile(TestImageName);
|
||||
var byteContent = new ByteArrayContent(testImage);
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent("ProviderKey"), "ProviderKey" },
|
||||
{ byteContent, "File", TestImageName }
|
||||
}
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent("ProviderKey"), "ProviderKey" },
|
||||
{ byteContent, "File", TestImageName }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidRequestTestData : IEnumerable<object[]>
|
||||
{
|
||||
public IEnumerator<object[]> GetEnumerator()
|
||||
{
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent(string.Empty), "Provider" },
|
||||
{ new StringContent(string.Empty), "ProviderKey" },
|
||||
{ new ByteArrayContent([]), "File", "file.jpeg" }
|
||||
},
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
{ "Provider", ["The Provider field is required."] },
|
||||
{ "ProviderKey", ["The ProviderKey field is required."] },
|
||||
{ "File", ["The File has no content."] }
|
||||
},
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent(string.Empty), "ProviderKey" },
|
||||
{ new ByteArrayContent([]), "File", "file.jpeg" }
|
||||
},
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
{ "ProviderKey", ["The ProviderKey field is required."] },
|
||||
{ "File", ["The File has no content."] }
|
||||
},
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent(string.Empty), "Provider" },
|
||||
{ new StringContent("ProviderKey"), "ProviderKey" },
|
||||
{ new ByteArrayContent([]), "File", "file.jpeg" }
|
||||
},
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
{ "Provider", ["The Provider field is required."] },
|
||||
{ "File", ["The File has no content."] }
|
||||
},
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent("ProviderKey"), "ProviderKey" },
|
||||
{ new ByteArrayContent([]), "File", "file.jpeg" }
|
||||
},
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
{ "File", ["The File has no content."] }
|
||||
},
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("MadeUp"), "Provider" },
|
||||
{ new StringContent("ProviderKey"), "ProviderKey" },
|
||||
{ new ByteArrayContent(Encoding.UTF8.GetBytes("Hello, World!")), "File", "file.jpeg" }
|
||||
},
|
||||
new Dictionary<string, string[]>()
|
||||
{
|
||||
{ "Provider", ["The Provider field is invalid."] }
|
||||
},
|
||||
};
|
||||
|
||||
yield return new object[]
|
||||
{
|
||||
new MultipartFormDataContent()
|
||||
{
|
||||
{ new StringContent("Gemini"), "Provider" },
|
||||
{ new StringContent("ProviderKey"), "ProviderKey" },
|
||||
{ new ByteArrayContent(Encoding.UTF8.GetBytes("Hello, World!")), "File", "file.txt" }
|
||||
},
|
||||
new Dictionary<string, string[]>()
|
||||
{
|
||||
{ "File", ["The File must be a valid image file."] }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace AltGen.API.Tests.Integration;
|
||||
|
||||
public class IntegrationTest : IClassFixture<AppFactory>
|
||||
{
|
||||
protected MockHttpMessageHandler MockGeminiServiceHandler { get; } = new();
|
||||
protected WebApplicationFactory<Program> Factory { get; }
|
||||
protected HttpClient Client { get; }
|
||||
|
||||
public IntegrationTest(AppFactory factory)
|
||||
{
|
||||
Factory = factory.WithWebHostBuilder(
|
||||
builder => builder.ConfigureTestServices(
|
||||
services => services.AddHttpClient<IGeminiService, GeminiService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => MockGeminiServiceHandler)
|
||||
)
|
||||
);
|
||||
|
||||
Client = Factory.CreateClient();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
global using System.Collections;
|
||||
global using System.Net;
|
||||
global using System.Net.Http.Headers;
|
||||
global using System.Net.Http.Json;
|
||||
global using System.Text;
|
||||
|
||||
global using AltGen.API.Generate;
|
||||
global using AltGen.API.Generate.Providers.Gemini;
|
||||
global using AltGen.API.Tests.Fixtures;
|
||||
global using AltGen.API.Tests.Utils;
|
||||
|
||||
global using Microsoft.AspNetCore.Hosting;
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.AspNetCore.Mvc.Testing;
|
||||
global using Microsoft.AspNetCore.TestHost;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
global using RichardSzalay.MockHttp;
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace AltGen.API.Tests.Utils;
|
||||
|
||||
public static class TestFileManager
|
||||
{
|
||||
public static byte[] GetFile(string testFileName)
|
||||
{
|
||||
var filePath = Path.Combine(AppContext.BaseDirectory, "Files", testFileName);
|
||||
var file = File.ReadAllBytes(filePath);
|
||||
return file;
|
||||
}
|
||||
|
||||
public static Task<byte[]> GetFileAsync(string testFileName)
|
||||
{
|
||||
var filePath = Path.Combine(AppContext.BaseDirectory, "Files", testFileName);
|
||||
var file = File.ReadAllBytesAsync(filePath);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Gemini": {
|
||||
"ApiKey": "ApiKey"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**\*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@AltGen.API_HostAddress = http://localhost:5005
|
||||
|
||||
GET {{AltGen.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace AltGen.API.Common;
|
||||
|
||||
class AltGenException(string message) : Exception(message)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AltGen.API.Common;
|
||||
|
||||
static class ValidationResultsExtensions
|
||||
{
|
||||
public static Dictionary<string, string[]> ToErrors(this IEnumerable<ValidationResult> results)
|
||||
{
|
||||
// group by the first member name and select the error message
|
||||
return results.GroupBy(static r => r.MemberNames.First())
|
||||
.ToDictionary(
|
||||
static r => r.Key,
|
||||
static r => r.Select(static e => e.ErrorMessage!).ToArray()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace AltGen.API.Generate;
|
||||
|
||||
static class GenerateEndpoint
|
||||
{
|
||||
public static async Task<IResult> HandleAsync([AsParameters] GenerateRequest request, [FromServices] IAltTextProviderFactory factory)
|
||||
{
|
||||
var validationResults = request.Validate(new ValidationContext(request));
|
||||
|
||||
if (validationResults.Any())
|
||||
{
|
||||
return Results.ValidationProblem(validationResults.ToErrors());
|
||||
}
|
||||
|
||||
var provider = factory.Create(request.Provider);
|
||||
|
||||
var imageStream = new MemoryStream();
|
||||
await request.File.CopyToAsync(imageStream);
|
||||
|
||||
var altText = await provider.GenerateAltTextAsync(
|
||||
request.ProviderKey,
|
||||
request.File.ContentType,
|
||||
imageStream
|
||||
);
|
||||
|
||||
return Results.Ok(new GenerateResponse(altText));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace AltGen.API.Generate;
|
||||
|
||||
record GenerateRequest(
|
||||
[FromForm]
|
||||
string Provider,
|
||||
[FromForm]
|
||||
string ProviderKey,
|
||||
IFormFile File
|
||||
) : IValidatableObject
|
||||
{
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Provider))
|
||||
{
|
||||
yield return new ValidationResult($"The {nameof(Provider)} field is required.", [nameof(Provider)]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Provider) is false && LLMProvider.IsValid(Provider) is false)
|
||||
{
|
||||
yield return new ValidationResult($"The {nameof(Provider)} field is invalid.", [nameof(Provider)]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ProviderKey))
|
||||
{
|
||||
yield return new ValidationResult($"The {nameof(ProviderKey)} field is required.", [nameof(ProviderKey)]);
|
||||
}
|
||||
|
||||
// TODO: Probably need to consider upper bound for file size
|
||||
// this also needs to take into account provider-specific limits
|
||||
if (File.Length is 0)
|
||||
{
|
||||
yield return new ValidationResult($"The {nameof(File)} has no content.", [nameof(File)]);
|
||||
}
|
||||
|
||||
if (File.Length > 0)
|
||||
{
|
||||
var extension = Path.GetExtension(File.FileName);
|
||||
|
||||
// TODO: prob needs to be specific to the provider
|
||||
// different LLMs support different image formats
|
||||
if (extension is not ".jpeg" and not ".jpg" and not ".png")
|
||||
{
|
||||
yield return new ValidationResult($"The {nameof(File)} must be a valid image file.", [nameof(File)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AltGen.API.Generate;
|
||||
|
||||
record GenerateResponse(string AltText);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace AltGen.API.Generate.Providers;
|
||||
|
||||
class AltTextProviderFactory(IServiceProvider serviceProvider) : IAltTextProviderFactory
|
||||
{
|
||||
readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||
|
||||
public IAltTextProvider Create(string provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
LLMProvider.Gemini => _serviceProvider.GetRequiredKeyedService<IAltTextProvider>(LLMProvider.Gemini),
|
||||
_ => throw new NotSupportedException($"The provider '{provider}' is not supported.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
record Candidate(Content Content);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
record Content(Part[] Parts, string Role);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
class GeminiAltTextProvider(IGeminiService geminiService) : IAltTextProvider
|
||||
{
|
||||
readonly IGeminiService _geminiService = geminiService;
|
||||
|
||||
public Task<string> GenerateAltTextAsync(string providerKey, string mimeType, MemoryStream image)
|
||||
{
|
||||
return _geminiService.GenerateContentAsync(providerKey, mimeType, image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
record GeminiRequest(
|
||||
Content SystemInstruction,
|
||||
Content[] Contents
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
record GeminiResponse(Candidate[] Candidates);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
static class GeminiRole
|
||||
{
|
||||
public const string System = "system";
|
||||
public const string User = "user";
|
||||
public const string Model = "model";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
class GeminiService(HttpClient httpClient) : IGeminiService
|
||||
{
|
||||
const string BaseUri = "https://generativelanguage.googleapis.com/v1beta/models";
|
||||
const string Method = "generateContent";
|
||||
const string ModelId = "gemini-1.5-flash";
|
||||
const string ApiKeyQueryKey = "key";
|
||||
|
||||
// TODO: Use Lazy<T> to load the prompt resource
|
||||
string _prompt = "";
|
||||
string Prompt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_prompt))
|
||||
{
|
||||
var resource = Assembly.GetExecutingAssembly()
|
||||
.GetManifestResourceStream("AltGen.API.Resources.prompt.txt") ?? throw new AltGenException("Failed to load the prompt resource.");
|
||||
using var reader = new StreamReader(resource);
|
||||
_prompt = reader.ReadToEnd();
|
||||
}
|
||||
|
||||
return _prompt;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new PartConverter() }
|
||||
};
|
||||
readonly HttpClient _httpClient = httpClient;
|
||||
|
||||
public async Task<string> GenerateContentAsync(string providerKey, string mimeType, MemoryStream image)
|
||||
{
|
||||
var requestUri = GetRequestUri(providerKey);
|
||||
var imageBase64 = ConvertToBase64(image);
|
||||
|
||||
var request = new GeminiRequest(
|
||||
new Content([new TextPart(Prompt)], GeminiRole.System),
|
||||
[new Content(
|
||||
[
|
||||
new TextPart(""),
|
||||
new InlineDataPart(new InlineData(mimeType, imageBase64))
|
||||
],
|
||||
GeminiRole.User
|
||||
)]
|
||||
);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(requestUri, request, Options);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (response.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new AltGenException("Failed to generate alt text.");
|
||||
}
|
||||
|
||||
var geminiResponse = JsonSerializer.Deserialize<GeminiResponse>(content, Options) ?? throw new AltGenException("Failed to deserialize the Gemini response.");
|
||||
var firstCandidate = geminiResponse.Candidates.FirstOrDefault() ?? throw new AltGenException("No candidates found in the Gemini response.");
|
||||
var altText = firstCandidate.Content.Parts
|
||||
.OfType<TextPart>()
|
||||
.Aggregate(new StringBuilder(), static (sb, part) => sb.Append(part.Text))
|
||||
.ToString();
|
||||
|
||||
return altText;
|
||||
}
|
||||
|
||||
static string GetRequestUri(string providerKey)
|
||||
{
|
||||
return $"{BaseUri}/{ModelId}:{Method}/?{ApiKeyQueryKey}={providerKey}";
|
||||
}
|
||||
|
||||
static string ConvertToBase64(MemoryStream image)
|
||||
{
|
||||
return Convert.ToBase64String(image.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
interface IGeminiService
|
||||
{
|
||||
Task<string> GenerateContentAsync(string providerKey, string mimeType, MemoryStream image);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
record Part();
|
||||
|
||||
record TextPart(string Text) : Part;
|
||||
|
||||
record InlineDataPart(InlineData InlineData) : Part;
|
||||
|
||||
record InlineData(string MimeType, string Data);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
class PartConverter : JsonConverter<Part>
|
||||
{
|
||||
public override Part? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
using var doc = JsonDocument.ParseValue(ref reader);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("text", out var text))
|
||||
{
|
||||
return JsonSerializer.Deserialize<TextPart>(root.GetRawText(), options);
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("inlineData", out var inlineData))
|
||||
{
|
||||
return JsonSerializer.Deserialize<InlineDataPart>(root.GetRawText(), options);
|
||||
}
|
||||
|
||||
throw new JsonException("Invalid part type.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Part value, JsonSerializerOptions options)
|
||||
{
|
||||
JsonSerializer.Serialize(writer, value, value.GetType(), options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace AltGen.API.Generate.Providers;
|
||||
|
||||
interface IAltTextProvider
|
||||
{
|
||||
Task<string> GenerateAltTextAsync(string providerKey, string mimeType, MemoryStream image);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace AltGen.API.Generate.Providers;
|
||||
|
||||
interface IAltTextProviderFactory
|
||||
{
|
||||
IAltTextProvider Create(string provider);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AltGen.API.Generate.Providers;
|
||||
|
||||
static class LLMProvider
|
||||
{
|
||||
public const string Gemini = "Gemini";
|
||||
|
||||
public static bool IsValid(string provider)
|
||||
{
|
||||
return provider is Gemini;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddHttpClient<IGeminiService, GeminiService>();
|
||||
builder.Services.AddSingleton<IAltTextProviderFactory, AltTextProviderFactory>();
|
||||
builder.Services.AddKeyedSingleton<IAltTextProvider, GeminiAltTextProvider>(LLMProvider.Gemini);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.MapPost("/generate", GenerateEndpoint.HandleAsync).DisableAntiforgery();
|
||||
|
||||
app.Run();
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7297;http://localhost:5005",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5005",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Generate descriptive alt text that captures the essential visual information of the image. Follow these guidelines:
|
||||
|
||||
1. Be concise, but comprehensive (1-2 sentences)
|
||||
2. Describe the most important visual elements
|
||||
3. Convey the image's purpose or key message
|
||||
4. Use objective language
|
||||
5. Avoid redundant phrases
|
||||
|
||||
Priority details to include:
|
||||
- Main subject(s)
|
||||
- Action or context
|
||||
- Color or distinctive visual characteristics
|
||||
- Emotional tone or artistic intent
|
||||
|
||||
Exclude unnecessary details like background minutiae or decorative elements unless they are crucial to understanding the image.
|
||||
|
||||
Please DO NOT respond with anything over than the alt text.
|
||||
@@ -0,0 +1,13 @@
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Reflection;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
global using AltGen.API.Common;
|
||||
global using AltGen.API.Generate;
|
||||
global using AltGen.API.Generate.Providers;
|
||||
global using AltGen.API.Generate.Providers.Gemini;
|
||||
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API", "AltGen.API\AltGen.API.csproj", "{2865924B-D42C-4126-856F-AD0B762710FB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API.Tests", "AltGen.API.Tests\AltGen.API.Tests.csproj", "{FC95942B-3579-4F83-A447-A96D8EEF8E45}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{2865924B-D42C-4126-856F-AD0B762710FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2865924B-D42C-4126-856F-AD0B762710FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2865924B-D42C-4126-856F-AD0B762710FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2865924B-D42C-4126-856F-AD0B762710FB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FC95942B-3579-4F83-A447-A96D8EEF8E45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FC95942B-3579-4F83-A447-A96D8EEF8E45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
Reference in New Issue
Block a user