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:
dbastrikin
2026-03-18 16:32:06 +02:00
parent ad00998344
commit 39193ec13c
22 changed files with 1574 additions and 582 deletions

View File

@@ -735,7 +735,7 @@ Future<void> _pickAndShowDishResult(
WidgetRef ref,
String mealTypeId,
) async {
// 1. Choose image source
// 1. Choose image source.
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (_) => SafeArea(
@@ -758,7 +758,7 @@ Future<void> _pickAndShowDishResult(
);
if (source == null || !context.mounted) return;
// 2. Pick image
// 2. Pick image.
final image = await ImagePicker().pickImage(
source: source,
imageQuality: 70,
@@ -767,47 +767,66 @@ Future<void> _pickAndShowDishResult(
);
if (image == null || !context.mounted) return;
// 3. Show loading
// Capture root navigator now (before await) to avoid using the wrong one later.
// showDialog defaults to useRootNavigator: true; Navigator.pop(context) would resolve
// to GoRouter's inner navigator instead, which only has /home and would crash.
// 3. Show progress dialog.
// Capture root navigator before await to avoid GoRouter inner-navigator issues.
final rootNavigator = Navigator.of(context, rootNavigator: true);
final progressNotifier = _DishProgressNotifier();
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Распознаём...'),
],
),
),
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
);
// 4. Call API
// 4. Submit image and listen to SSE stream.
final service = ref.read(recognitionServiceProvider);
try {
final dish = await ref.read(recognitionServiceProvider).recognizeDish(image);
final jobCreated = await service.submitDishRecognition(image);
if (!context.mounted) return;
rootNavigator.pop(); // close loading
// 5. Show result as bottom sheet
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: dish,
preselectedMealType: mealTypeId,
onAdded: () => Navigator.pop(sheetContext),
),
);
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
if (!context.mounted) break;
switch (event) {
case DishJobQueued():
progressNotifier.update(
message: 'Вы в очереди #${event.position + 1} · ~${event.estimatedSeconds} сек',
showUpgrade: event.position > 0,
);
case DishJobProcessing():
progressNotifier.update(message: 'Обрабатываем...');
case DishJobDone():
rootNavigator.pop(); // close dialog
if (!context.mounted) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: event.result,
preselectedMealType: mealTypeId,
onAdded: () => Navigator.pop(sheetContext),
),
);
return;
case DishJobFailed():
rootNavigator.pop(); // close dialog
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(event.error),
action: SnackBarAction(
label: 'Повторить',
onPressed: () => _pickAndShowDishResult(context, ref, mealTypeId),
),
),
);
return;
}
}
} catch (recognitionError) {
debugPrint('Dish recognition error: $recognitionError');
if (context.mounted) {
rootNavigator.pop(); // close loading
rootNavigator.pop(); // close dialog
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
@@ -817,6 +836,67 @@ Future<void> _pickAndShowDishResult(
}
}
// ---------------------------------------------------------------------------
// Async recognition progress dialog
// ---------------------------------------------------------------------------
class _DishProgressState {
final String message;
final bool showUpgrade;
const _DishProgressState({
required this.message,
this.showUpgrade = false,
});
}
class _DishProgressNotifier extends ChangeNotifier {
_DishProgressState _state = const _DishProgressState(message: 'Анализируем фото...');
_DishProgressState get state => _state;
void update({required String message, bool showUpgrade = false}) {
_state = _DishProgressState(message: message, showUpgrade: showUpgrade);
notifyListeners();
}
}
class _DishProgressDialog extends StatelessWidget {
final _DishProgressNotifier notifier;
const _DishProgressDialog({required this.notifier});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: notifier,
builder: (context, _) {
final state = notifier.state;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(state.message, textAlign: TextAlign.center),
if (state.showUpgrade) ...[
const SizedBox(height: 12),
Text(
'Хотите без очереди? Upgrade →',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
],
),
);
},
);
}
}
class _MealCard extends ConsumerWidget {
final MealTypeOption mealTypeOption;
final List<DiaryEntry> entries;

View File

@@ -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),
);
});