Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -214,5 +214,37 @@ public async Task AnalyzeImageAsync_NoChannels_ReturnsErrorMessage() {
var result = await _service.AnalyzeImageAsync("/tmp/test.jpg", 123, CancellationToken.None);
Assert.StartsWith("Error:", result);
}

[Fact]
public async Task AnalyzeImageAsync_WithCustomPrompt_ForwardsPromptToProvider() {
var providerMock = new Mock<ILLMService>();
var channel = new LLMChannel {
Name = "vision-channel",
Gateway = "https://example.com",
ApiKey = "key",
Provider = LLMProvider.OpenAI,
Parallel = 1,
Priority = 1
};

providerMock
.Setup(s => s.AnalyzeImageAsync("image.jpg", "vision-model", channel, GeneralLLMService.DefaultVisionOcrPrompt))
.ReturnsAsync("recognized text");

var results = new List<string>();
await foreach (var result in _service.AnalyzeImageAsync(
"image.jpg",
123,
"vision-model",
providerMock.Object,
channel,
GeneralLLMService.DefaultVisionOcrPrompt,
CancellationToken.None)) {
results.Add(result);
}

Assert.Single(results);
Assert.Equal("recognized text", results[0]);
}
}
}
2 changes: 2 additions & 0 deletions TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ public interface IGeneralLLMService {
IAsyncEnumerable<string> ResumeFromSnapshotAsync(LlmContinuationSnapshot snapshot, LlmExecutionContext executionContext, CancellationToken cancellationToken = default);

Task<string> AnalyzeImageAsync(string PhotoPath, long ChatId, CancellationToken cancellationToken = default);
Task<string> AnalyzeImageAsync(string PhotoPath, long ChatId, string prompt, CancellationToken cancellationToken = default);
IAsyncEnumerable<string> AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default);
IAsyncEnumerable<string> AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, string prompt, CancellationToken cancellationToken = default);
Task<float[]> GenerateEmbeddingsAsync(Message message, long ChatId);
Task<float[]> GenerateEmbeddingsAsync(string message, CancellationToken cancellationToken = default);
IAsyncEnumerable<float[]> GenerateEmbeddingsAsync(string message, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default);
Expand Down
2 changes: 1 addition & 1 deletion TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace TelegramSearchBot.Interface.AI.LLM {
public interface ILLMService {
public IAsyncEnumerable<string> ExecAsync(Message message, long ChatId, string modelName, LLMChannel channel,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default);

Check warning on line 13 in TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The EnumeratorCancellationAttribute applied to parameter 'cancellationToken' will have no effect. The attribute is only effective on a parameter of type CancellationToken in an async-iterator method returning IAsyncEnumerable

Check warning on line 13 in TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

The EnumeratorCancellationAttribute applied to parameter 'cancellationToken' will have no effect. The attribute is only effective on a parameter of type CancellationToken in an async-iterator method returning IAsyncEnumerable

/// <summary>
/// Execute LLM with an execution context that can signal iteration limit reached
Expand All @@ -18,7 +18,7 @@
/// </summary>
public IAsyncEnumerable<string> ExecAsync(Message message, long ChatId, string modelName, LLMChannel channel,
LlmExecutionContext executionContext,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) {

Check warning on line 21 in TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The EnumeratorCancellationAttribute applied to parameter 'cancellationToken' will have no effect. The attribute is only effective on a parameter of type CancellationToken in an async-iterator method returning IAsyncEnumerable

Check warning on line 21 in TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

The EnumeratorCancellationAttribute applied to parameter 'cancellationToken' will have no effect. The attribute is only effective on a parameter of type CancellationToken in an async-iterator method returning IAsyncEnumerable
// Default: delegate to the original ExecAsync (backward compatible)
return ExecAsync(message, ChatId, modelName, channel, cancellationToken);
}
Expand All @@ -29,7 +29,7 @@
/// </summary>
public IAsyncEnumerable<string> ResumeFromSnapshotAsync(LlmContinuationSnapshot snapshot, LLMChannel channel,
LlmExecutionContext executionContext,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) {

Check warning on line 32 in TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The EnumeratorCancellationAttribute applied to parameter 'cancellationToken' will have no effect. The attribute is only effective on a parameter of type CancellationToken in an async-iterator method returning IAsyncEnumerable

Check warning on line 32 in TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

The EnumeratorCancellationAttribute applied to parameter 'cancellationToken' will have no effect. The attribute is only effective on a parameter of type CancellationToken in an async-iterator method returning IAsyncEnumerable
// Default: empty stream (services that don't support it just return nothing)
return AsyncEnumerable.Empty<string>();
}
Expand All @@ -42,7 +42,7 @@
/// </summary>
public Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCapabilities(LLMChannel channel);

public Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel);
public Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null);
public virtual async Task<bool> IsHealthyAsync(LLMChannel channel) => ( await GetAllModels(channel) ).Any();
}
}
6 changes: 3 additions & 3 deletions TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -971,11 +971,13 @@ public Task<float[]> GenerateEmbeddingsAsync(string text, string modelName, LLMC

#region Image Analysis

public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) {
public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) {
if (string.IsNullOrWhiteSpace(modelName)) {
modelName = "claude-sonnet-4-20250514";
}

prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt;

if (channel == null || string.IsNullOrWhiteSpace(channel.ApiKey)) {
_logger.LogError("{ServiceName}: Channel or ApiKey is not configured.", ServiceName);
return $"Error: {ServiceName} channel/apikey is not configured.";
Expand All @@ -990,8 +992,6 @@ public async Task<string> AnalyzeImageAsync(string photoPath, string modelName,
var imgArray = imgData.ToArray();
var base64Image = Convert.ToBase64String(imgArray);

var prompt = "请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。";

var imageSource = new Base64ImageSource {
Data = base64Image,
MediaType = MediaType.ImagePng,
Expand Down
6 changes: 3 additions & 3 deletions TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,13 @@ public async Task<float[]> GenerateEmbeddingsAsync(string text, string modelName
}
}

public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) {
public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) {
if (string.IsNullOrWhiteSpace(modelName)) {
modelName = "gpt-4-vision-preview";
}

prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt;

if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) {
_logger.LogError("{ServiceName}: Channel, Gateway or ApiKey is not configured.", ServiceName);
return $"Error: {ServiceName} channel/gateway/apikey is not configured.";
Expand All @@ -471,8 +473,6 @@ public async Task<string> AnalyzeImageAsync(string photoPath, string modelName,
var googleAI = new GoogleAi(channel.ApiKey, client: _httpClientFactory.CreateClient());
var model = googleAI.CreateGenerativeModel("models/" + modelName);
try {
var prompt = $"请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。";

var chat = model.StartChat();


Expand Down
21 changes: 18 additions & 3 deletions TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
public const string MaxImageRetryCountKey = "LLM:MaxImageRetryCount";
public const string AltPhotoModelName = "LLM:AltPhotoModelName";
public const string EmbeddingModelName = "LLM:EmbeddingModelName";
public const string DefaultAltPhotoPrompt = "请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。";
public const string DefaultVisionOcrPrompt = "请使用视觉能力识别并提取这张图片里可见的文字内容,优先输出原文,并按从上到下、从左到右的自然阅读顺序整理。尽量保留换行和段落,不要改写、总结或解释;只有在完全没有可识别文字时,才用一句中文简要描述图片主要内容。不要添加“图中显示”或“这是一张图片”这类套话。";
public const int DefaultMaxRetryCount = 100;
public const int DefaultMaxImageRetryCount = 1000;

Expand Down Expand Up @@ -104,7 +106,7 @@
yield return e;
}
}
public async IAsyncEnumerable<string> ExecAsync(

Check warning on line 109 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Async-iterator 'GeneralLLMService.ExecAsync(Message, long, string, ILLMService, LLMChannel, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed

Check warning on line 109 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Async-iterator 'GeneralLLMService.ExecAsync(Message, long, string, ILLMService, LLMChannel, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
Model.Data.Message message,
long ChatId,
string modelName,
Expand Down Expand Up @@ -225,7 +227,13 @@
_logger.LogWarning($"所有{modelName}关联的渠道当前都已满载,重试{maxRetries}次后放弃");

}
public async Task<string> AnalyzeImageAsync(string PhotoPath, long ChatId, CancellationToken cancellationToken = default) {
public Task<string> AnalyzeImageAsync(string PhotoPath, long ChatId, CancellationToken cancellationToken = default) {
return AnalyzeImageAsync(PhotoPath, ChatId, DefaultAltPhotoPrompt, cancellationToken);
}

public async Task<string> AnalyzeImageAsync(string PhotoPath, long ChatId, string prompt, CancellationToken cancellationToken = default) {
prompt = string.IsNullOrWhiteSpace(prompt) ? DefaultAltPhotoPrompt : prompt;

// 1. 获取模型名称
var modelName = "gemma3:27b";
var config = await _dbContext.AppConfigurationItems
Expand All @@ -235,7 +243,7 @@
}

await using var enumerator = ExecOperationAsync<string>((service, channel, cancel) => {
return AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, cancel);
return AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, prompt, cancel);
}, modelName, cancellationToken).GetAsyncEnumerator();

if (await enumerator.MoveNextAsync()) {
Expand All @@ -245,8 +253,15 @@
_logger.LogWarning($"未能获取 {modelName} 模型的图片分析结果");
return $"Error:未能获取 {modelName} 模型的图片分析结果";
}
public async IAsyncEnumerable<string> AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default) {

Check warning on line 256 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Async-iterator 'GeneralLLMService.AnalyzeImageAsync(string, long, string, ILLMService, LLMChannel, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed

Check warning on line 256 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Async-iterator 'GeneralLLMService.AnalyzeImageAsync(string, long, string, ILLMService, LLMChannel, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
yield return await service.AnalyzeImageAsync(PhotoPath, modelName, channel);
await foreach (var result in AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, DefaultAltPhotoPrompt, cancellationToken)) {
yield return result;
}
}

public async IAsyncEnumerable<string> AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, string prompt, CancellationToken cancellationToken = default) {

Check warning on line 262 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Async-iterator 'GeneralLLMService.AnalyzeImageAsync(string, long, string, ILLMService, LLMChannel, string, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed

Check warning on line 262 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Async-iterator 'GeneralLLMService.AnalyzeImageAsync(string, long, string, ILLMService, LLMChannel, string, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
prompt = string.IsNullOrWhiteSpace(prompt) ? DefaultAltPhotoPrompt : prompt;
yield return await service.AnalyzeImageAsync(PhotoPath, modelName, channel, prompt);
yield break;
}
Comment on lines +262 to 266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cancellation token is dropped when calling the provider.

The new streaming overload accepts cancellationToken but doesn't forward it — service.AnalyzeImageAsync(PhotoPath, modelName, channel, prompt) is awaited without cancellation support. If ILLMService.AnalyzeImageAsync has a cancellation-aware overload, pass the token through; otherwise consider adding one, since image analysis can be a long HTTP call and the rest of this service plumbs CancellationToken consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs` around lines 262 -
266, The AnalyzeImageAsync method in GeneralLLMService is dropping the provided
CancellationToken when calling the provider; update the call to forward the
token to the provider's cancellation-aware overload (i.e., call
ILLMService.AnalyzeImageAsync(PhotoPath, modelName, channel, prompt,
cancellationToken)) and, if that overload doesn't exist on ILLMService, add a
cancellation-aware overload there and implement it in concrete providers so
long-running image analysis can be cancelled; ensure the method still yields the
provider result and respects the token for cooperative cancellation.


Expand Down Expand Up @@ -280,7 +295,7 @@
_logger.LogWarning($"未能获取 {modelName} 模型的嵌入向量");
return Array.Empty<float>();
}
public async IAsyncEnumerable<float[]> GenerateEmbeddingsAsync(string message, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellationToken = default) {

Check warning on line 298 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Async-iterator 'GeneralLLMService.GenerateEmbeddingsAsync(string, string, ILLMService, LLMChannel, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed

Check warning on line 298 in TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Async-iterator 'GeneralLLMService.GenerateEmbeddingsAsync(string, string, ILLMService, LLMChannel, CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
yield return await service.GenerateEmbeddingsAsync(message, modelName, channel);
yield break;
}
Expand Down
5 changes: 3 additions & 2 deletions TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -389,16 +389,17 @@ public async Task<float[]> GenerateEmbeddingsAsync(string text, string modelName
}
}

public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) {
public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) {
if (string.IsNullOrWhiteSpace(modelName)) {
modelName = "gemma3:27b";
}

prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt;

var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient();
httpClient.BaseAddress = new Uri(channel.Gateway);
var ollama = new OllamaApiClient(httpClient, modelName);
ollama.SelectedModel = modelName;
var prompt = "请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。";
var chat = new Chat(ollama);
chat.Options = new RequestOptions();
chat.Options.Temperature = 0.1f;
Expand Down
6 changes: 3 additions & 3 deletions TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1461,11 +1461,13 @@ public async Task<string> GetModel(long ChatId) {
return ModelName;
}

public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel) {
public async Task<string> AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) {
if (string.IsNullOrWhiteSpace(modelName)) {
modelName = "gpt-4-vision-preview";
}

prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt;
Comment on lines +1464 to +1469

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Is OpenAI gpt-4-vision-preview still available in 2026 or has it been retired?

💡 Result:

No, OpenAI's gpt-4-vision-preview model has been retired and is no longer available as of 2026. It was deprecated with a shutdown date of December 6, 2024, and is not listed in current OpenAI API models or deprecations for 2026. Modern models like gpt-4o, gpt-5.4 series support vision capabilities.

Citations:


🏁 Script executed:

# Search for the OpenAIService class definition and interface implementation
fd -e cs "OpenAIService" | head -20

Repository: ModerRAS/TelegramSearchBot

Length of output: 123


🏁 Script executed:

# Check if the class implements ILLMService
rg "class OpenAIService" -A 5 --type cs

Repository: ModerRAS/TelegramSearchBot

Length of output: 615


🏁 Script executed:

# Look for MaxToolCycles in the codebase
rg "MaxToolCycles" --type cs -B 2 -A 2

Repository: ModerRAS/TelegramSearchBot

Length of output: 11785


🏁 Script executed:

# Check how Env.OpenAIModelName is used for configuration pattern
rg "Env\.OpenAI" --type cs -B 2 -A 2 | head -40

Repository: ModerRAS/TelegramSearchBot

Length of output: 1301


🏁 Script executed:

# Look for Injectable attribute usage in the codebase
rg "Injectable.*Singleton" --type cs -B 1 -A 1 | head -30

Repository: ModerRAS/TelegramSearchBot

Length of output: 2774


🏁 Script executed:

# Get the full class definition with attributes
rg -B 5 "public class OpenAIService" --type cs

Repository: ModerRAS/TelegramSearchBot

Length of output: 641


🏁 Script executed:

# Search for vision model or image analysis related configuration in Env
rg "Vision|Image|Photo" TelegramSearchBot.Common/Env.cs --type cs -B 1 -A 1

Repository: ModerRAS/TelegramSearchBot

Length of output: 52


🏁 Script executed:

sed -n '1464,1475p' TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs | cat -n

Repository: ModerRAS/TelegramSearchBot

Length of output: 858


Fix the deprecated vision model and correct the service lifetime registration.

The hardcoded "gpt-4-vision-preview" model has been retired by OpenAI (shutdown: December 6, 2024) and is no longer available. Replace it with the configuration pattern used elsewhere in the class: default to Env.OpenAIModelName when modelName is not supplied. Additionally, change the class-level [Injectable(ServiceLifetime.Transient)] to [Injectable(ServiceLifetime.Singleton)] to comply with the LLM service guideline for TelegramSearchBot.LLM/**/Service/AI/LLM/**/*.cs files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs` around lines 1464 -
1469, Replace the retired hardcoded model in AnalyzeImageAsync by defaulting
modelName to Env.OpenAIModelName when null/whitespace (update the logic in
AnalyzeImageAsync), and change the class-level attribute from
[Injectable(ServiceLifetime.Transient)] to
[Injectable(ServiceLifetime.Singleton)] on the OpenAIService class so the
service is registered as a singleton; update any related constructor-injected
dependencies if necessary to be singleton-safe.


if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) {
_logger.LogError("{ServiceName}: Channel, Gateway or ApiKey is not configured.", ServiceName);
return $"Error: {ServiceName} channel/gateway/apikey is not configured.";
Expand All @@ -1488,8 +1490,6 @@ public async Task<string> AnalyzeImageAsync(string photoPath, string modelName,
var tg_img_arr = tg_img_data.ToArray();
var base64Image = Convert.ToBase64String(tg_img_arr);

var prompt = $"请根据这张图片生成一句准确、详尽的中文alt文本,说明画面中重要的元素、场景和含义,避免使用'图中显示'或'这是一张图片'这类通用表达。";

var messages = new List<ChatMessage> {
new UserChatMessage(new List<ChatMessageContentPart>() {
ChatMessageContentPart.CreateTextPart(prompt),
Expand Down
115 changes: 115 additions & 0 deletions TelegramSearchBot.Test/Manage/EditOCRConfServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using StackExchange.Redis;
using Telegram.Bot.Types.ReplyMarkups;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Model;
using TelegramSearchBot.Model.Data;
using TelegramSearchBot.Service.Manage;
using Xunit;

namespace TelegramSearchBot.Test.Manage {
public class EditOCRConfServiceTests {
private readonly DataDbContext _dbContext;
private readonly Mock<IConnectionMultiplexer> _redisMock;
private readonly Mock<IDatabase> _dbMock;
private readonly Dictionary<string, RedisValue> _redisStore = new();
private readonly EditOCRConfService _service;

public EditOCRConfServiceTests() {
var options = new DbContextOptionsBuilder<DataDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_dbContext = new DataDbContext(options);

_redisMock = new Mock<IConnectionMultiplexer>();
_dbMock = new Mock<IDatabase>();
_redisMock.Setup(r => r.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(_dbMock.Object);

_dbMock.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync((RedisKey key, CommandFlags flags) =>
_redisStore.TryGetValue(key.ToString(), out var value) ? value : RedisValue.Null);

_dbMock.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<When>()))
.ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? expiry, When when) => {
_redisStore[key.ToString()] = value;
return true;
});

_dbMock.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) => {
_redisStore[key.ToString()] = value;
return true;
});

_dbMock.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? expiry, bool keepTtl, When when, CommandFlags flags) => {
_redisStore[key.ToString()] = value;
return true;
});

_dbMock.Setup(d => d.KeyDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync((RedisKey key, CommandFlags flags) => _redisStore.Remove(key.ToString()));

var loggerMock = new Mock<ILogger<EditOCRConfService>>();
_service = new EditOCRConfService(_dbContext, _redisMock.Object, loggerMock.Object);
}

[Fact]
public async Task ExecuteAsync_UnrelatedCommandWithoutActiveSession_ReturnsFalse() {
var (status, message) = await _service.ExecuteAsync("你好", 12345);

Assert.False(status);
Assert.Equal(string.Empty, message);
}

[Fact]
public async Task ExecuteAsync_SwitchEngineFlow_UsesReplyKeyboardAndTransitionsState() {
_dbContext.LLMChannels.Add(new LLMChannel {
Name = "vision-channel",
Gateway = "https://example.com",
ApiKey = "key",
Provider = LLMProvider.OpenAI,
Parallel = 1,
Priority = 1
});
await _dbContext.SaveChangesAsync();

_redisStore["ocrconf:100:state"] = OCRConfState.MainMenu.GetDescription();
var mainMenuMarkup = await _service.GetReplyMarkupAsync(100);
var mainMenuKeyboard = Assert.IsType<ReplyKeyboardMarkup>(mainMenuMarkup);
Assert.Contains(mainMenuKeyboard.Keyboard, row => row.Any(button => button.Text == "切换OCR引擎"));

var (switched, engineMessage) = await _service.ExecuteAsync("切换OCR引擎", 100);
Assert.True(switched);
Assert.Contains("请选择 OCR 引擎", engineMessage);

_redisStore["ocrconf:100:state"] = OCRConfState.SelectingEngine.GetDescription();
var engineMarkup = await _service.GetReplyMarkupAsync(100);
var engineKeyboard = Assert.IsType<ReplyKeyboardMarkup>(engineMarkup);
Assert.Contains(engineKeyboard.Keyboard, row => row.Any(button => button.Text == "PaddleOCR"));
Assert.Contains(engineKeyboard.Keyboard, row => row.Any(button => button.Text == "LLM"));
}
}
}
43 changes: 43 additions & 0 deletions TelegramSearchBot.Test/Service/AI/OCR/LLMOCRServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using SkiaSharp;
using TelegramSearchBot.Interface.AI.LLM;
using TelegramSearchBot.Service.AI.LLM;
using TelegramSearchBot.Service.AI.OCR;
using Xunit;

namespace TelegramSearchBot.Test.Service.AI.OCR {
public class LLMOCRServiceTests {
[Fact]
public async Task ExecuteAsync_UsesVisionOcrPrompt() {
var llmServiceMock = new Mock<IGeneralLLMService>();
llmServiceMock
.Setup(s => s.AnalyzeImageAsync(
It.Is<string>(path => path.EndsWith(".jpg")),
0,
GeneralLLMService.DefaultVisionOcrPrompt,
It.IsAny<CancellationToken>()))
.ReturnsAsync("recognized text");

var loggerMock = new Mock<ILogger<LLMOCRService>>();
var service = new LLMOCRService(llmServiceMock.Object, loggerMock.Object);

using var bitmap = new SKBitmap(8, 8);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
using var stream = new MemoryStream(data.ToArray());

var result = await service.ExecuteAsync(stream);

Assert.Equal("recognized text", result);
llmServiceMock.Verify(s => s.AnalyzeImageAsync(
It.Is<string>(path => path.EndsWith(".jpg")),
0,
GeneralLLMService.DefaultVisionOcrPrompt,
It.IsAny<CancellationToken>()), Times.Once);
}
}
}
Loading
Loading