From 76facc875848d5773141f0a8d71f48825e6adadb Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 17 Oct 2026 23:27:01 +0100 Subject: [PATCH] feat: save raw coupon OCR responses by Telegram file ID --- .../Services/CouponFlowHandler.fs | 11 ++- src/CouponHubBot/Services/CouponOcrEngine.fs | 27 +++++--- src/CouponHubBot/Services/DbService.fs | 11 +++ src/CouponHubBot/docs/DATABASE.md | 4 ++ .../migrations/V22__coupon_ocr.sql | 6 ++ tests/CouponHubBot.Tests/OcrAddFlowTests.fs | 67 +++++++++++++++++++ 6 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 src/coupon-hub-bot/migrations/V22__coupon_ocr.sql diff --git a/src/CouponHubBot/Services/CouponFlowHandler.fs b/src/CouponHubBot/Services/CouponFlowHandler.fs index 4fa8ca90..43de350b 100644 --- a/src/CouponHubBot/Services/CouponFlowHandler.fs +++ b/src/CouponHubBot/Services/CouponFlowHandler.fs @@ -44,6 +44,11 @@ type CouponFlowHandler( return Unchecked.defaultof } + let recognizePhoto (photoFileId: string) (bytes: byte[]) = + couponOcr.Recognize( + ReadOnlyMemory(bytes), + saveResponse = fun rawResponse -> db.SaveCouponOcr(photoFileId, rawResponse)) + // Best-effort wrappers for cosmetic Telegram calls whose failure should // never fail the surrounding flow (e.g. deleting an already-gone placeholder). let tryDeleteMessage (chatId: int64) (messageId: int64) : Task = @@ -143,7 +148,7 @@ type CouponFlowHandler( if int64 bytes.Length > ocrConfig.OcrMaxFileSizeBytes then return null else - let! ocr = couponOcr.Recognize(ReadOnlyMemory(bytes)) + let! ocr = recognizePhoto photoFileId bytes let barcodeText = if String.IsNullOrWhiteSpace ocr.barcode then null else ocr.barcode logger.LogInformation( @@ -269,7 +274,7 @@ type CouponFlowHandler( // throwing — so a slow Azure OCR can't crash the handler. The // barcode from ZXing may still be present, in which case the wizard // falls through to manual discount/min-check entry below. - let! ocr = couponOcr.Recognize(ReadOnlyMemory(bytes)) + let! ocr = recognizePhoto photoFileId bytes let valueOpt = if ocr.couponValue.HasValue then @@ -568,7 +573,7 @@ type CouponFlowHandler( // Transient/timeout failures are retried by the OCR HTTP // resilience pipeline; Recognize then degrades to a null-field // result, which falls through to needs_input below. - let! ocr = couponOcr.Recognize(ReadOnlyMemory(bytes)) + let! ocr = recognizePhoto photoFileId bytes // Barcode comes from ZXing on the raw image bytes — independent of // Azure OCR. If Azure fails (e.g. 403, VNet block) ZXing can still diff --git a/src/CouponHubBot/Services/CouponOcrEngine.fs b/src/CouponHubBot/Services/CouponOcrEngine.fs index ceb172ab..cbc1ab68 100644 --- a/src/CouponHubBot/Services/CouponOcrEngine.fs +++ b/src/CouponHubBot/Services/CouponOcrEngine.fs @@ -449,7 +449,7 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, ti couponValue, minCheck, validFrom, validTo - member _.Recognize(imageBytes: ReadOnlyMemory) = + member _.Recognize(imageBytes: ReadOnlyMemory, ?saveResponse: string -> Task) = task { use activity = botActivity.StartActivity("couponOcr.recognize") if not (isNull activity) then @@ -460,15 +460,22 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, ti let ocrTask = task { use azure = botActivity.StartActivity("couponOcr.azure") - try - let! a = azureTextOcr.AnalyzeImageBytes(imageBytes) - return a, false - with ex -> - if not (isNull azure) then - %azure.SetTag("error.type", ex.GetType().FullName) - %azure.SetStatus(ActivityStatusCode.Error) - logger.LogWarning(ex, "Azure OCR backend call failed after retries") - return (null: OcrAnalysis | null), true + let! analysis, failed = + task { + try + let! a = azureTextOcr.AnalyzeImageBytes(imageBytes) + return a, false + with ex -> + if not (isNull azure) then + %azure.SetTag("error.type", ex.GetType().FullName) + %azure.SetStatus(ActivityStatusCode.Error) + logger.LogWarning(ex, "Azure OCR backend call failed after retries") + return (null: OcrAnalysis | null), true + } + match Option.ofObj analysis, saveResponse with + | Some response, Some save -> do! save response.RawJson + | _ -> () + return analysis, failed } let barcode = tryDecodeBarcode imageBytes diff --git a/src/CouponHubBot/Services/DbService.fs b/src/CouponHubBot/Services/DbService.fs index 40fcb450..8c5e0170 100644 --- a/src/CouponHubBot/Services/DbService.fs +++ b/src/CouponHubBot/Services/DbService.fs @@ -195,6 +195,17 @@ VALUES (@coupon_id, @user_id, @event_type, @subject_user_id); """ conn.ExecuteAsync(sql, {| coupon_id = couponId; user_id = userId; event_type = eventType; subject_user_id = Option.toNullable subjectUserId |}, tx) |> taskIgnore + member _.SaveCouponOcr(photoFileId: string, rawResponse: string) = + task { + use! conn = openConn() + let sql = """ +INSERT INTO coupon_ocr (photo_file_id, raw_response) +VALUES (@photoFileId, CAST(@rawResponse AS jsonb)) +ON CONFLICT (photo_file_id) DO UPDATE SET raw_response = EXCLUDED.raw_response; +""" + do! conn.ExecuteAsync(sql, {| photoFileId = photoFileId; rawResponse = rawResponse |}) |> taskIgnore + } + member _.UpsertUser(user: DbUser) = task { use! conn = openConn() diff --git a/src/CouponHubBot/docs/DATABASE.md b/src/CouponHubBot/docs/DATABASE.md index 508d844a..f08718d9 100644 --- a/src/CouponHubBot/docs/DATABASE.md +++ b/src/CouponHubBot/docs/DATABASE.md @@ -21,6 +21,10 @@ Wizard state for `/add` flow: Event types: `added`, `taken`, `returned`, `used`, `voided` +### coupon_ocr table + +Stores the latest raw Azure OCR JSON response per Telegram `photo_file_id`, immediately after recognition and before parsing. Rows survive wizard cleanup and coupon changes. + ### chat_message table Stores messages from the community group chat for product analysis. Only the community chat (`COMMUNITY_CHAT_ID`) is monitored. Bot messages are excluded. diff --git a/src/coupon-hub-bot/migrations/V22__coupon_ocr.sql b/src/coupon-hub-bot/migrations/V22__coupon_ocr.sql new file mode 100644 index 00000000..2e678d0d --- /dev/null +++ b/src/coupon-hub-bot/migrations/V22__coupon_ocr.sql @@ -0,0 +1,6 @@ +CREATE TABLE coupon_ocr ( + photo_file_id TEXT PRIMARY KEY, + raw_response JSONB NOT NULL +); + +GRANT SELECT, INSERT, UPDATE ON coupon_ocr TO coupon_hub_bot_service; diff --git a/tests/CouponHubBot.Tests/OcrAddFlowTests.fs b/tests/CouponHubBot.Tests/OcrAddFlowTests.fs index 6198b06a..7bec586c 100644 --- a/tests/CouponHubBot.Tests/OcrAddFlowTests.fs +++ b/tests/CouponHubBot.Tests/OcrAddFlowTests.fs @@ -21,6 +21,73 @@ type OcrAddFlowTests(fixture: OcrCouponHubTestContainers) = let readAzureCacheJson (fileName: string) = File.ReadAllText(Path.Combine(solutionDirPath, "tests", "CouponHubBot.Ocr.Tests", "AzureCache", fileName + ".azure.json")) + [] + [] + [] + [] + let ``Raw OCR response is saved by photo before confirmation and survives coupon cleanup`` (flow: string) = + task { + do! fixture.ClearFakeCalls() + do! fixture.TruncateCoupons() + do! fixture.TruncateBatches() + do! fixture.ResetAzureOcr() + let userId = + match flow with + | "single" -> 681L + | "manual" -> 682L + | "album" -> 683L + | _ -> failwith "Unexpected flow" + let user = Tg.user(id = userId, username = "ocr_history", firstName = "History") + do! fixture.SetChatMemberStatus(user.Id, "member") + let fileName = "10_50_2026-01-17_2026-01-26_2706688198845.jpg" + let fileId = $"history-{flow}-{Guid.NewGuid():N}" + let bytes = readImageBytes fileName + let rawJson = readAzureCacheJson fileName + do! fixture.SetTelegramFile(fileId, bytes) + do! fixture.SetAzureOcrResponse(200, rawJson) + let makeUpdate () = + match flow with + | "single" -> Tg.dmPhotoWithCaption("", user, fileId = fileId) + | "manual" -> Tg.dmPhotoWithCaption("/add 5 25 2026-02-01", user, fileId = fileId) + | "album" -> Tg.dmAlbumPhoto(user, $"history-{Guid.NewGuid():N}", fileId = fileId) + | _ -> failwith "Unexpected flow" + let! response = fixture.SendUpdate(makeUpdate ()) + Assert.Equal(HttpStatusCode.OK, response.StatusCode) + if flow = "album" then + let! batchId = BatchTestHelpers.waitForBatchByUser fixture user.Id 5000 + do! BatchTestHelpers.waitForAllItemsTerminal fixture batchId 10000 + let sql = """ +SELECT raw_response = CAST(@raw AS jsonb) +FROM coupon_ocr WHERE photo_file_id = @photo; +""" + let! saved = fixture.QuerySingle(sql, {| raw = rawJson; photo = fileId |}) + Assert.True(saved) + if flow = "single" then + let! _ = fixture.SendUpdate(Tg.dmCallback("addflow:ocr:yes", user)) + () + if flow <> "album" then + let! linked = fixture.QuerySingle( + "SELECT count(*) FROM coupon c JOIN coupon_ocr o USING (photo_file_id) WHERE c.photo_file_id = @photo", + {| photo = fileId |}) + Assert.Equal(1L, linked) + do! fixture.TruncateBatches() + do! fixture.TruncateCoupons() + let! retained = fixture.QuerySingle("SELECT count(*) FROM coupon_ocr WHERE photo_file_id = @photo", {| photo = fileId |}) + Assert.Equal(1L, retained) + let updated = System.Text.Json.Nodes.JsonNode.Parse(rawJson) + updated["modelVersion"] <- System.Text.Json.Nodes.JsonValue.Create("updated-model") + let updatedJson = updated.ToJsonString() + do! fixture.SetAzureOcrResponse(200, updatedJson) + let! repeated = fixture.SendUpdate(makeUpdate ()) + Assert.Equal(HttpStatusCode.OK, repeated.StatusCode) + if flow = "album" then + let! batchId = BatchTestHelpers.waitForBatchByUser fixture user.Id 5000 + do! BatchTestHelpers.waitForAllItemsTerminal fixture batchId 10000 + let! replaced = fixture.QuerySingle(sql, {| raw = updatedJson; photo = fileId |}) + Assert.True(replaced) + do! fixture.TruncateBatches() + } + /// Returns Azure OCR JSON with any 13-digit barcode text line removed, /// so the OCR text fallback won't find a barcode either. let stripBarcodeFromAzureJson (azureJson: string) =