feat: async dish recognition (Kafka/Watermill/SSE) + remove Wire + consolidate migrations
Async recognition pipeline:
- POST /ai/recognize-dish → 202 {job_id, queue_position, estimated_seconds}
- GET /ai/jobs/{id}/stream — SSE stream: queued → processing → done/failed
- Kafka topics: ai.recognize.paid (3 partitions) + ai.recognize.free (1 partition)
- 5-worker WorkerPool with priority loop (paid consumers first)
- SSEBroker via PostgreSQL LISTEN/NOTIFY
- Kafka adapter migrated from franz-go to Watermill (watermill-kafka/v2)
- Docker Compose: added Kafka + Zookeeper + kafka-init service
- Flutter: recognition_service.dart uses SSE; home_screen shows live job status
Remove google/wire (archived):
- Deleted wire.go (wireinject spec) and wire_gen.go
- Added cmd/server/init.go — plain Go manual DI, same initApp() logic
- Removed github.com/google/wire from go.mod
Consolidate migrations:
- Merged 001_initial_schema + 002_seed_data + 003_recognition_jobs into single 001_initial_schema.sql
- Deleted 002_seed_data.sql and 003_recognition_jobs.sql
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../core/auth/secure_storage.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/locale/language_provider.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
@@ -135,14 +140,68 @@ class DishResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async job models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The 202 response from POST /ai/recognize-dish.
|
||||
class DishJobCreated {
|
||||
final String jobId;
|
||||
final int queuePosition;
|
||||
final int estimatedSeconds;
|
||||
|
||||
const DishJobCreated({
|
||||
required this.jobId,
|
||||
required this.queuePosition,
|
||||
required this.estimatedSeconds,
|
||||
});
|
||||
|
||||
factory DishJobCreated.fromJson(Map<String, dynamic> json) {
|
||||
return DishJobCreated(
|
||||
jobId: json['job_id'] as String,
|
||||
queuePosition: json['queue_position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by the SSE stream for a dish recognition job.
|
||||
sealed class DishJobEvent {}
|
||||
|
||||
class DishJobQueued extends DishJobEvent {
|
||||
final int position;
|
||||
final int estimatedSeconds;
|
||||
DishJobQueued({required this.position, required this.estimatedSeconds});
|
||||
}
|
||||
|
||||
class DishJobProcessing extends DishJobEvent {}
|
||||
|
||||
class DishJobDone extends DishJobEvent {
|
||||
final DishResult result;
|
||||
DishJobDone(this.result);
|
||||
}
|
||||
|
||||
class DishJobFailed extends DishJobEvent {
|
||||
final String error;
|
||||
DishJobFailed(this.error);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecognitionService {
|
||||
const RecognitionService(this._client);
|
||||
const RecognitionService(
|
||||
this._client,
|
||||
this._storage,
|
||||
this._appConfig,
|
||||
this._languageGetter,
|
||||
);
|
||||
|
||||
final ApiClient _client;
|
||||
final SecureStorageService _storage;
|
||||
final AppConfig _appConfig;
|
||||
final String Function() _languageGetter;
|
||||
|
||||
/// Recognizes food items from a receipt photo.
|
||||
Future<ReceiptResult> recognizeReceipt(XFile image) async {
|
||||
@@ -150,10 +209,10 @@ class RecognitionService {
|
||||
final data = await _client.post('/ai/recognize-receipt', data: payload);
|
||||
return ReceiptResult(
|
||||
items: (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||||
.map((e) => UnrecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.map((element) => UnrecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -166,15 +225,102 @@ class RecognitionService {
|
||||
data: {'images': imageList},
|
||||
);
|
||||
return (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Recognizes a dish and estimates its nutritional content.
|
||||
Future<DishResult> recognizeDish(XFile image) async {
|
||||
/// Submits a dish image for async recognition.
|
||||
/// Returns a [DishJobCreated] with the job ID and queue position.
|
||||
Future<DishJobCreated> submitDishRecognition(XFile image) async {
|
||||
final payload = await _buildImagePayload(image);
|
||||
final data = await _client.post('/ai/recognize-dish', data: payload);
|
||||
return DishResult.fromJson(data);
|
||||
return DishJobCreated.fromJson(data);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
|
||||
final token = await _storage.getAccessToken();
|
||||
final language = _languageGetter();
|
||||
final url = '${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream';
|
||||
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
));
|
||||
|
||||
final response = await dio.get<ResponseBody>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
'Accept': 'text/event-stream',
|
||||
'Accept-Language': language,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final stream = response.data!.stream;
|
||||
final buffer = StringBuffer();
|
||||
String? currentEventName;
|
||||
|
||||
await for (final chunk in stream.map(utf8.decode)) {
|
||||
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) {
|
||||
final message = remaining.substring(0, doubleNewlineIndex);
|
||||
remaining = remaining.substring(doubleNewlineIndex + 2);
|
||||
|
||||
for (final line in message.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
currentEventName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..clear()
|
||||
..write(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -188,5 +334,12 @@ class RecognitionService {
|
||||
}
|
||||
|
||||
final recognitionServiceProvider = Provider<RecognitionService>((ref) {
|
||||
return RecognitionService(ref.read(apiClientProvider));
|
||||
final config = ref.read(appConfigProvider);
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
return RecognitionService(
|
||||
ref.read(apiClientProvider),
|
||||
storage,
|
||||
config,
|
||||
() => ref.read(languageProvider),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user