Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/CouponHubBot/Services/CouponFlowHandler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ type CouponFlowHandler(
return Unchecked.defaultof<byte[]>
}

let recognizePhoto (photoFileId: string) (bytes: byte[]) =
couponOcr.Recognize(
ReadOnlyMemory<byte>(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 =
Expand Down Expand Up @@ -143,7 +148,7 @@ type CouponFlowHandler(
if int64 bytes.Length > ocrConfig.OcrMaxFileSizeBytes then
return null
else
let! ocr = couponOcr.Recognize(ReadOnlyMemory<byte>(bytes))
let! ocr = recognizePhoto photoFileId bytes
let barcodeText =
if String.IsNullOrWhiteSpace ocr.barcode then null else ocr.barcode
logger.LogInformation(
Expand Down Expand Up @@ -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<byte>(bytes))
let! ocr = recognizePhoto photoFileId bytes

let valueOpt =
if ocr.couponValue.HasValue then
Expand Down Expand Up @@ -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<byte>(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
Expand Down
27 changes: 17 additions & 10 deletions src/CouponHubBot/Services/CouponOcrEngine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti

couponValue, minCheck, validFrom, validTo

member _.Recognize(imageBytes: ReadOnlyMemory<byte>) =
member _.Recognize(imageBytes: ReadOnlyMemory<byte>, ?saveResponse: string -> Task<unit>) =
task {
use activity = botActivity.StartActivity("couponOcr.recognize")
if not (isNull activity) then
Expand All @@ -460,15 +460,22 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, 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
Expand Down
11 changes: 11 additions & 0 deletions src/CouponHubBot/Services/DbService.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions src/CouponHubBot/docs/DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/coupon-hub-bot/migrations/V22__coupon_ocr.sql
Original file line number Diff line number Diff line change
@@ -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;
67 changes: 67 additions & 0 deletions tests/CouponHubBot.Tests/OcrAddFlowTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

[<Theory>]
[<InlineData("single")>]
[<InlineData("manual")>]
[<InlineData("album")>]
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<bool>(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<int64>(
"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<int64>("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<bool>(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) =
Expand Down
Loading