Files
food-ai/client/lib/features/scan/recognition_service.dart
dbastrikin 39193ec13c 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>
2026-03-18 16:32:06 +02:00

346 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// ---------------------------------------------------------------------------
class RecognizedItem {
final String name;
double quantity;
String unit;
final String category;
final double confidence;
final String? mappingId;
final int storageDays;
RecognizedItem({
required this.name,
required this.quantity,
required this.unit,
required this.category,
required this.confidence,
this.mappingId,
required this.storageDays,
});
factory RecognizedItem.fromJson(Map<String, dynamic> json) {
return RecognizedItem(
name: json['name'] as String? ?? '',
quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
unit: json['unit'] as String? ?? 'шт',
category: json['category'] as String? ?? 'other',
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
mappingId: json['mapping_id'] as String?,
storageDays: json['storage_days'] as int? ?? 7,
);
}
}
class UnrecognizedItem {
final String rawText;
final double? price;
const UnrecognizedItem({required this.rawText, this.price});
factory UnrecognizedItem.fromJson(Map<String, dynamic> json) {
return UnrecognizedItem(
rawText: json['raw_text'] as String? ?? '',
price: (json['price'] as num?)?.toDouble(),
);
}
}
class ReceiptResult {
final List<RecognizedItem> items;
final List<UnrecognizedItem> unrecognized;
const ReceiptResult({required this.items, required this.unrecognized});
}
/// A single dish recognition candidate with estimated nutrition for the portion in the photo.
class DishCandidate {
final String? dishId;
final String dishName;
final int weightGrams;
final double calories;
final double proteinG;
final double fatG;
final double carbsG;
final double confidence;
const DishCandidate({
this.dishId,
required this.dishName,
required this.weightGrams,
required this.calories,
required this.proteinG,
required this.fatG,
required this.carbsG,
required this.confidence,
});
factory DishCandidate.fromJson(Map<String, dynamic> json) {
return DishCandidate(
dishId: json['dish_id'] as String?,
dishName: json['dish_name'] as String? ?? '',
weightGrams: json['weight_grams'] as int? ?? 0,
calories: (json['calories'] as num?)?.toDouble() ?? 0,
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
);
}
}
/// Result of dish recognition: ordered list of candidates (best match first).
class DishResult {
final List<DishCandidate> candidates;
const DishResult({required this.candidates});
/// The best matching candidate.
DishCandidate get best => candidates.first;
// Convenience getters delegating to the best candidate.
String get dishName => best.dishName;
int get weightGrams => best.weightGrams;
double get calories => best.calories;
double get proteinG => best.proteinG;
double get fatG => best.fatG;
double get carbsG => best.carbsG;
double get confidence => best.confidence;
factory DishResult.fromJson(Map<String, dynamic> json) {
// New format: {"candidates": [...]}
if (json['candidates'] is List) {
final candidatesList = (json['candidates'] as List<dynamic>)
.map((element) => DishCandidate.fromJson(element as Map<String, dynamic>))
.toList();
return DishResult(candidates: candidatesList);
}
// Legacy flat format: {"dish_name": "...", "calories": ..., ...}
if (json['dish_name'] != null) {
return DishResult(candidates: [DishCandidate.fromJson(json)]);
}
return const DishResult(candidates: []);
}
}
// ---------------------------------------------------------------------------
// 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,
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 {
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(),
);
}
/// Recognizes food items from 13 product photos.
Future<List<RecognizedItem>> recognizeProducts(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>))
.toList();
}
/// 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 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 {
final bytes = await image.readAsBytes();
final base64Data = base64Encode(bytes);
// XFile.mimeType may be null on some platforms; fall back to path extension.
final mimeType = image.mimeType ??
(image.path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg');
return {'image_base64': base64Data, 'mime_type': mimeType};
}
}
final recognitionServiceProvider = Provider<RecognitionService>((ref) {
final config = ref.read(appConfigProvider);
final storage = ref.read(secureStorageProvider);
return RecognitionService(
ref.read(apiClientProvider),
storage,
config,
() => ref.read(languageProvider),
);
});