feat: async product/receipt recognition via Kafka
Backend:
- Migration 002: product_recognition_jobs table with JSONB images column
and job_type CHECK ('receipt' | 'products')
- New Kafka topics: ai.products.paid / ai.products.free
- ProductJob model, ProductJobRepository (mirrors dish job pattern)
- itemEnricher extracted from Handler — shared by HTTP handler and worker
- ProductSSEBroker: PG LISTEN on product_job_update channel
- ProductWorkerPool: 5 workers, branches on job_type to call
RecognizeReceipt or RecognizeProducts per image in parallel
- Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted
instead of blocking; 4 new endpoints: GET /ai/product-jobs,
/product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream
- cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool
- cmd/server: wires productJobRepository + productSSEBroker; both SSE
brokers started in App.Start()
Flutter client:
- ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent
models + submitReceiptRecognition/submitProductsRecognition/stream methods
- Shared _openSseStream helper eliminates duplicate SSE parsing loop
- ScanScreen: replace blocking AI calls with async submit + navigate to
ProductJobWatchScreen
- ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm
when done, shows error on failure
- ProductsScreen: prepends _RecentScansSection (hidden when empty); compact
horizontal list of recent scans with "See all" → history
- ProductJobHistoryScreen: full list of all product recognition jobs
- New routes: /scan/product-job-watch, /products/job-history
- L10n: 7 new keys in all 12 ARB files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -142,7 +141,110 @@ class DishResult {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async job models
|
||||
// Product job models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of a completed product or receipt recognition job.
|
||||
class ProductJobResult {
|
||||
final String jobType;
|
||||
final List<RecognizedItem> items;
|
||||
final List<UnrecognizedItem> unrecognized;
|
||||
|
||||
const ProductJobResult({
|
||||
required this.jobType,
|
||||
required this.items,
|
||||
required this.unrecognized,
|
||||
});
|
||||
|
||||
factory ProductJobResult.fromJson(Map<String, dynamic> json) {
|
||||
return ProductJobResult(
|
||||
jobType: json['job_type'] as String? ?? '',
|
||||
items: (json['items'] as List<dynamic>? ?? [])
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
unrecognized: (json['unrecognized'] as List<dynamic>? ?? [])
|
||||
.map((element) => UnrecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 202 response from POST /ai/recognize-receipt or /ai/recognize-products.
|
||||
class ProductJobCreated {
|
||||
final String jobId;
|
||||
final int queuePosition;
|
||||
final int estimatedSeconds;
|
||||
|
||||
const ProductJobCreated({
|
||||
required this.jobId,
|
||||
required this.queuePosition,
|
||||
required this.estimatedSeconds,
|
||||
});
|
||||
|
||||
factory ProductJobCreated.fromJson(Map<String, dynamic> json) {
|
||||
return ProductJobCreated(
|
||||
jobId: json['job_id'] as String,
|
||||
queuePosition: json['queue_position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A lightweight summary of a product recognition job for list endpoints.
|
||||
class ProductJobSummary {
|
||||
final String id;
|
||||
final String jobType;
|
||||
final String status;
|
||||
final ProductJobResult? result;
|
||||
final String? error;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ProductJobSummary({
|
||||
required this.id,
|
||||
required this.jobType,
|
||||
required this.status,
|
||||
this.result,
|
||||
this.error,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProductJobSummary.fromJson(Map<String, dynamic> json) {
|
||||
return ProductJobSummary(
|
||||
id: json['id'] as String,
|
||||
jobType: json['job_type'] as String? ?? '',
|
||||
status: json['status'] as String? ?? '',
|
||||
result: json['result'] != null
|
||||
? ProductJobResult.fromJson(json['result'] as Map<String, dynamic>)
|
||||
: null,
|
||||
error: json['error'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by the SSE stream for a product recognition job.
|
||||
sealed class ProductJobEvent {}
|
||||
|
||||
class ProductJobQueued extends ProductJobEvent {
|
||||
final int position;
|
||||
final int estimatedSeconds;
|
||||
ProductJobQueued({required this.position, required this.estimatedSeconds});
|
||||
}
|
||||
|
||||
class ProductJobProcessing extends ProductJobEvent {}
|
||||
|
||||
class ProductJobDone extends ProductJobEvent {
|
||||
final ProductJobResult result;
|
||||
ProductJobDone(this.result);
|
||||
}
|
||||
|
||||
class ProductJobFailed extends ProductJobEvent {
|
||||
final String error;
|
||||
ProductJobFailed(this.error);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dish job models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A lightweight summary of a dish recognition job (no image payload).
|
||||
@@ -239,32 +341,71 @@ class RecognitionService {
|
||||
final AppConfig _appConfig;
|
||||
final String Function() _languageGetter;
|
||||
|
||||
/// Recognizes food items from a receipt photo.
|
||||
Future<ReceiptResult> recognizeReceipt(XFile image) async {
|
||||
/// Submits a receipt image for async recognition.
|
||||
/// Returns immediately with a [ProductJobCreated] containing the job ID.
|
||||
Future<ProductJobCreated> submitReceiptRecognition(XFile image) async {
|
||||
final payload = await _buildImagePayload(image);
|
||||
final data = await _client.post('/ai/recognize-receipt', data: payload);
|
||||
return ReceiptResult(
|
||||
items: (data['items'] as List<dynamic>? ?? [])
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||||
.map((element) => UnrecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
return ProductJobCreated.fromJson(data);
|
||||
}
|
||||
|
||||
/// Recognizes food items from 1–3 product photos.
|
||||
Future<List<RecognizedItem>> recognizeProducts(List<XFile> images) async {
|
||||
/// Submits 1–3 product images for async recognition.
|
||||
/// Returns immediately with a [ProductJobCreated] containing the job ID.
|
||||
Future<ProductJobCreated> submitProductsRecognition(List<XFile> images) async {
|
||||
final imageList = await Future.wait(images.map(_buildImagePayload));
|
||||
final data = await _client.post(
|
||||
'/ai/recognize-products',
|
||||
data: {'images': imageList},
|
||||
);
|
||||
return (data['items'] as List<dynamic>? ?? [])
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
return ProductJobCreated.fromJson(data);
|
||||
}
|
||||
|
||||
/// Returns product recognition jobs from the last 7 days.
|
||||
Future<List<ProductJobSummary>> listRecentProductJobs() async {
|
||||
final data = await _client.getList('/ai/product-jobs');
|
||||
return data
|
||||
.map((element) =>
|
||||
ProductJobSummary.fromJson(element as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Returns all product recognition jobs for the current user, newest first.
|
||||
Future<List<ProductJobSummary>> listAllProductJobs() async {
|
||||
final data = await _client.getList('/ai/product-jobs/history');
|
||||
return data
|
||||
.map((element) =>
|
||||
ProductJobSummary.fromJson(element as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Opens an SSE stream for product job [jobId] and emits [ProductJobEvent]s
|
||||
/// until the job reaches a terminal state or the stream is cancelled.
|
||||
Stream<ProductJobEvent> streamProductJobEvents(String jobId) async* {
|
||||
final streamUri = Uri.parse('${_appConfig.apiBaseUrl}/ai/product-jobs/$jobId/stream');
|
||||
await for (final parsed in _openSseStream(streamUri)) {
|
||||
final eventName = parsed.$1;
|
||||
final json = parsed.$2;
|
||||
ProductJobEvent? event;
|
||||
switch (eventName) {
|
||||
case 'queued':
|
||||
event = ProductJobQueued(
|
||||
position: json['position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
case 'processing':
|
||||
event = ProductJobProcessing();
|
||||
case 'done':
|
||||
event = ProductJobDone(ProductJobResult.fromJson(json));
|
||||
case 'failed':
|
||||
event = ProductJobFailed(json['error'] as String? ?? 'Recognition failed');
|
||||
}
|
||||
if (event != null) {
|
||||
yield event;
|
||||
if (event is ProductJobDone || event is ProductJobFailed) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Submits a dish image for async recognition.
|
||||
/// Returns a [DishJobCreated] with the job ID and queue position.
|
||||
Future<DishJobCreated> submitDishRecognition(
|
||||
@@ -298,21 +439,45 @@ class RecognitionService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Opens an SSE stream for job [jobId] and emits [DishJobEvent]s until the
|
||||
/// job reaches a terminal state (done or failed) or the stream is cancelled.
|
||||
/// Opens an SSE stream for dish job [jobId] and emits [DishJobEvent]s until
|
||||
/// the job reaches a terminal state (done or failed) or the stream is cancelled.
|
||||
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
|
||||
final streamUri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream');
|
||||
await for (final parsed in _openSseStream(streamUri)) {
|
||||
final eventName = parsed.$1;
|
||||
final json = parsed.$2;
|
||||
DishJobEvent? event;
|
||||
switch (eventName) {
|
||||
case 'queued':
|
||||
event = DishJobQueued(
|
||||
position: json['position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
case 'processing':
|
||||
event = DishJobProcessing();
|
||||
case 'done':
|
||||
event = DishJobDone(DishResult.fromJson(json));
|
||||
case 'failed':
|
||||
event = DishJobFailed(json['error'] as String? ?? 'Recognition failed');
|
||||
}
|
||||
if (event != null) {
|
||||
yield event;
|
||||
if (event is DishJobDone || event is DishJobFailed) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a raw SSE connection and emits (eventName, jsonData) pairs.
|
||||
///
|
||||
/// Uses [http.Client] instead of Dio because on Flutter Web Dio relies on
|
||||
/// XHR which does not support SSE streaming. [http.BrowserClient] reads the
|
||||
/// response via XHR onProgress events and delivers chunks before the
|
||||
/// connection is closed.
|
||||
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
|
||||
/// XHR which does not support SSE streaming.
|
||||
Stream<(String, Map<String, dynamic>)> _openSseStream(Uri streamUri) async* {
|
||||
final token = await _storage.getAccessToken();
|
||||
final language = _languageGetter();
|
||||
final uri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream');
|
||||
|
||||
final httpClient = http.Client();
|
||||
try {
|
||||
final request = http.Request('GET', uri)
|
||||
final request = http.Request('GET', streamUri)
|
||||
..headers['Authorization'] = token != null ? 'Bearer $token' : ''
|
||||
..headers['Accept'] = 'text/event-stream'
|
||||
..headers['Accept-Language'] = language
|
||||
@@ -329,7 +494,6 @@ class RecognitionService {
|
||||
buffer.write(chunk);
|
||||
final text = buffer.toString();
|
||||
|
||||
// Process complete SSE messages (terminated by \n\n).
|
||||
int doubleNewlineIndex;
|
||||
var remaining = text;
|
||||
while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) {
|
||||
@@ -341,10 +505,13 @@ class RecognitionService {
|
||||
currentEventName = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
final dataPayload = line.substring(5).trim();
|
||||
final event = _parseSseEvent(currentEventName, dataPayload);
|
||||
if (event != null) {
|
||||
yield event;
|
||||
if (event is DishJobDone || event is DishJobFailed) return;
|
||||
try {
|
||||
final jsonData = jsonDecode(dataPayload) as Map<String, dynamic>;
|
||||
if (currentEventName != null) {
|
||||
yield (currentEventName, jsonData);
|
||||
}
|
||||
} catch (_) {
|
||||
// Malformed JSON — skip this message.
|
||||
}
|
||||
currentEventName = null;
|
||||
}
|
||||
@@ -360,29 +527,6 @@ class RecognitionService {
|
||||
}
|
||||
}
|
||||
|
||||
DishJobEvent? _parseSseEvent(String? eventName, String dataPayload) {
|
||||
try {
|
||||
final json = jsonDecode(dataPayload) as Map<String, dynamic>;
|
||||
switch (eventName) {
|
||||
case 'queued':
|
||||
return DishJobQueued(
|
||||
position: json['position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
case 'processing':
|
||||
return DishJobProcessing();
|
||||
case 'done':
|
||||
return DishJobDone(DishResult.fromJson(json));
|
||||
case 'failed':
|
||||
return DishJobFailed(json['error'] as String? ?? 'Recognition failed');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _buildImagePayload(XFile image) async {
|
||||
final bytes = await image.readAsBytes();
|
||||
final base64Data = base64Encode(bytes);
|
||||
|
||||
Reference in New Issue
Block a user