-
Notifications
You must be signed in to change notification settings - Fork 2
feat(OCR): 添加基于大模型的OCR作为PaddleOCR的替代方案 #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9c45b75
fd02e16
33c89b3
b8431f8
8505808
0ce76ac
e45f6b8
d1a4380
0d23015
ced6117
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 -20Repository: ModerRAS/TelegramSearchBot Length of output: 123 🏁 Script executed: # Check if the class implements ILLMService
rg "class OpenAIService" -A 5 --type csRepository: ModerRAS/TelegramSearchBot Length of output: 615 🏁 Script executed: # Look for MaxToolCycles in the codebase
rg "MaxToolCycles" --type cs -B 2 -A 2Repository: 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 -40Repository: 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 -30Repository: ModerRAS/TelegramSearchBot Length of output: 2774 🏁 Script executed: # Get the full class definition with attributes
rg -B 5 "public class OpenAIService" --type csRepository: 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 1Repository: ModerRAS/TelegramSearchBot Length of output: 52 🏁 Script executed: sed -n '1464,1475p' TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs | cat -nRepository: ModerRAS/TelegramSearchBot Length of output: 858 Fix the deprecated vision model and correct the service lifetime registration. The hardcoded 🤖 Prompt for AI Agents |
||
|
|
||
| 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."; | ||
|
|
@@ -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), | ||
|
|
||
| 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")); | ||
| } | ||
| } | ||
| } |
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cancellation token is dropped when calling the provider.
The new streaming overload accepts
cancellationTokenbut doesn't forward it —service.AnalyzeImageAsync(PhotoPath, modelName, channel, prompt)is awaited without cancellation support. IfILLMService.AnalyzeImageAsynchas 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 plumbsCancellationTokenconsistently.🤖 Prompt for AI Agents