From 00f37d7e192f393d530c34743cbaf141d1f9b4ba Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 18 Mar 2026 19:12:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E6=8F=90=E9=86=92=E5=AE=8C=E6=95=B4=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=89=A7=E8=A1=8C=E3=80=81=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=87=8D=E6=9E=84=E3=80=81=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ReminderActionExecutor 处理取消/稍后提醒操作 - 新增 ReminderOutboxStore 本地存储待处理操作 - 重构 LocalNotificationService 支持聚合提醒和交互操作 - 新增 event_color_resolver 工具类统一颜色解析 - 新增 CalendarService.archiveEvent 归档方法 - 增强 ModelTracking 支持缓存命中、推理token和成本追踪 - 添加 qwen3.5-35b-a3b 模型配置 - 更新 AndroidManifest 全屏intent权限 - 补充相关单元测试和文档 --- apps/android/app/src/main/AndroidManifest.xml | 3 + apps/lib/core/di/injection.dart | 16 + .../local_notification_service.dart | 443 ++++++++++++----- .../startup/auth_session_bootstrapper.dart | 14 +- .../data/services/calendar_service.dart | 8 + .../reminders/models/reminder_action.dart | 17 + .../reminders/models/reminder_payload.dart | 174 +++++++ .../reminders/reminder_action_executor.dart | 106 +++++ .../reminders/reminder_outbox_store.dart | 202 ++++++++ .../reminders/reminder_overlap_policy.dart | 80 ++++ .../ui/screens/calendar_dayweek_screen.dart | 17 +- .../screens/calendar_event_detail_screen.dart | 24 +- .../ui/screens/calendar_month_screen.dart | 15 +- .../ui/utils/event_color_resolver.dart | 23 + .../ui/widgets/create_event_sheet.dart | 4 +- apps/lib/main.dart | 12 + .../auth_session_bootstrapper_test.dart | 45 +- .../models/reminder_payload_test.dart | 42 ++ .../reminder_action_executor_test.dart | 117 +++++ .../reminder_overlap_policy_test.dart | 48 ++ .../ui/event_color_resolver_test.dart | 25 + .../core/agentscope/runtime/model_tracking.py | 204 +++++++- .../config/static/database/llm_catalog.yaml | 97 ++-- backend/src/services/litellm/service.py | 83 +++- backend/src/v1/schedule_items/repository.py | 56 ++- backend/src/v1/schedule_items/service.py | 8 + .../agentscope/runtime/test_model_tracking.py | 126 +++++ .../unit/services/test_litellm_service.py | 79 ++- .../unit/v1/schedule_items/test_service.py | 128 ++++- ...2026-03-18-reminder-alert-archival-plan.md | 449 ++++++++++++++++++ docs/protocols/agent/api-endpoints.md | 1 + docs/protocols/agent/sse-events.md | 104 +++- .../calendar/reminder-alert-lifecycle.md | 143 ++++++ docs/todo/2026-03-17-asr.md | 6 +- pyproject.toml | 1 - 35 files changed, 2676 insertions(+), 244 deletions(-) create mode 100644 apps/lib/features/calendar/reminders/models/reminder_action.dart create mode 100644 apps/lib/features/calendar/reminders/models/reminder_payload.dart create mode 100644 apps/lib/features/calendar/reminders/reminder_action_executor.dart create mode 100644 apps/lib/features/calendar/reminders/reminder_outbox_store.dart create mode 100644 apps/lib/features/calendar/reminders/reminder_overlap_policy.dart create mode 100644 apps/lib/features/calendar/ui/utils/event_color_resolver.dart create mode 100644 apps/test/features/calendar/reminders/models/reminder_payload_test.dart create mode 100644 apps/test/features/calendar/reminders/reminder_action_executor_test.dart create mode 100644 apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart create mode 100644 apps/test/features/calendar/ui/event_color_resolver_test.dart create mode 100644 backend/tests/unit/core/agentscope/runtime/test_model_tracking.py create mode 100644 docs/plans/2026-03-18-reminder-alert-archival-plan.md create mode 100644 docs/protocols/calendar/reminder-alert-lifecycle.md diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index b8713dc..db2d5ab 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + @@ -34,6 +35,8 @@ android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" + android:showWhenLocked="true" + android:turnScreenOn="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 4d4199f..01d26ee 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../api/api_client.dart'; import '../api/i_api_client.dart'; import '../storage/token_storage.dart'; @@ -13,6 +14,8 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/calendar/data/calendar_api.dart'; import '../../features/calendar/data/services/calendar_service.dart'; +import '../../features/calendar/reminders/reminder_action_executor.dart'; +import '../../features/calendar/reminders/reminder_outbox_store.dart'; import '../../features/calendar/ui/calendar_state_manager.dart'; import '../../features/friends/data/friends_api.dart'; import '../../features/messages/data/inbox_api.dart'; @@ -49,6 +52,9 @@ Future configureDependencies() async { final authApi = AuthApi(apiClient); sl.registerSingleton(authApi); + final sharedPreferences = await SharedPreferences.getInstance(); + sl.registerSingleton(sharedPreferences); + final usersApi = UsersApi(apiClient); sl.registerSingleton(usersApi); @@ -58,8 +64,18 @@ Future configureDependencies() async { final calendarService = CalendarService(apiClient: apiClient); sl.registerSingleton(calendarService); + final reminderOutboxStore = ReminderOutboxStore(sharedPreferences); + sl.registerSingleton(reminderOutboxStore); + sl.registerSingleton(LocalNotificationService()); + final reminderActionExecutor = ReminderActionExecutor( + calendarService: calendarService, + notificationService: sl(), + outboxStore: reminderOutboxStore, + ); + sl.registerSingleton(reminderActionExecutor); + final friendsApi = FriendsApi(apiClient); sl.registerSingleton(friendsApi); diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 7f6df15..424b51d 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -1,26 +1,40 @@ -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'dart:convert'; + import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; import '../../features/calendar/data/models/schedule_item_model.dart'; +import '../../features/calendar/reminders/models/reminder_action.dart'; +import '../../features/calendar/reminders/models/reminder_payload.dart'; +import '../../features/calendar/reminders/reminder_overlap_policy.dart'; -class NotificationScheduleException implements Exception { - final String message; - - NotificationScheduleException(this.message); - - @override - String toString() => message; -} +typedef ReminderNotificationActionHandler = + Future Function({ + required ReminderAction action, + required ReminderPayload payload, + }); class LocalNotificationService { - final FlutterLocalNotificationsPlugin _plugin; - bool _initialized = false; - bool _exactAlarmPermissionRequested = false; + static const String _iosCategoryId = 'calendar_reminder_actions_v1'; + static const String _actionCancel = 'cancel'; + static const String _actionSnooze = 'snooze_10m'; - LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) - : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + final FlutterLocalNotificationsPlugin _plugin; + final ReminderOverlapPolicy _overlapPolicy; + bool _initialized = false; + ReminderNotificationActionHandler? _actionHandler; + + LocalNotificationService({ + FlutterLocalNotificationsPlugin? plugin, + ReminderOverlapPolicy? overlapPolicy, + }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), + _overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy(); + + void bindActionHandler(ReminderNotificationActionHandler handler) { + _actionHandler = handler; + } Future initialize() async { if (_initialized) { @@ -29,20 +43,40 @@ class LocalNotificationService { tz_data.initializeTimeZones(); const android = AndroidInitializationSettings('@mipmap/ic_launcher'); - const ios = DarwinInitializationSettings( + final ios = DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, + notificationCategories: [ + DarwinNotificationCategory( + _iosCategoryId, + actions: [ + DarwinNotificationAction.plain(_actionCancel, '取消'), + DarwinNotificationAction.plain( + _actionSnooze, + '稍后提醒', + options: { + DarwinNotificationActionOption.foreground, + }, + ), + ], + ), + ], ); - const settings = InitializationSettings(android: android, iOS: ios); + final settings = InitializationSettings(android: android, iOS: ios); - await _plugin.initialize(settings); + await _plugin.initialize( + settings, + onDidReceiveNotificationResponse: _onNotificationResponse, + ); final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); await androidImpl?.requestNotificationsPermission(); + await androidImpl?.requestExactAlarmsPermission(); + await androidImpl?.requestFullScreenIntentPermission(); final iosImpl = _plugin .resolvePlatformSpecificImplementation< @@ -55,115 +89,46 @@ class LocalNotificationService { Future upsertEventReminder(ScheduleItemModel event) async { await initialize(); - - final reminderMinutes = event.metadata?.reminderMinutes; - if (reminderMinutes == null) { + if (event.status != ScheduleStatus.active || + event.metadata?.reminderMinutes == null) { await cancelEventReminder(event.id); return; } - final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); - if (!fireAt.isAfter(DateTime.now())) { + final now = DateTime.now(); + final fireAt = _overlapPolicy.resolveFirstFireAt(event, now: now); + if (fireAt == null) { await cancelEventReminder(event.id); return; } - final notificationId = _notificationIdForEvent(event.id); - final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); - final androidImpl = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); + await cancelEventReminder(event.id); + await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); + } - var androidScheduleMode = AndroidScheduleMode.exactAllowWhileIdle; - if (defaultTargetPlatform == TargetPlatform.android && - androidImpl != null) { - var notificationsEnabled = - await androidImpl.areNotificationsEnabled() ?? false; - if (!notificationsEnabled) { - await androidImpl.requestNotificationsPermission(); - notificationsEnabled = - await androidImpl.areNotificationsEnabled() ?? false; - } - if (!notificationsEnabled) { - throw NotificationScheduleException('系统通知权限未开启,无法创建提醒'); - } - - try { - var canScheduleExact = - await androidImpl.canScheduleExactNotifications() ?? false; - if (!canScheduleExact && !_exactAlarmPermissionRequested) { - _exactAlarmPermissionRequested = true; - await androidImpl.requestExactAlarmsPermission(); - canScheduleExact = - await androidImpl.canScheduleExactNotifications() ?? false; - } - if (!canScheduleExact) { - androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle; - } - } catch (_) { - androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle; - } - } - - final details = NotificationDetails( - android: AndroidNotificationDetails( - 'calendar_reminder_channel', - '日历提醒', - channelDescription: '日历事件提醒通知', - importance: Importance.max, - priority: Priority.high, - enableVibration: true, - ), - iOS: const DarwinNotificationDetails( - presentAlert: true, - presentSound: true, - presentBadge: true, - ), - ); - - try { - await _plugin.zonedSchedule( - notificationId, - event.title, - _buildReminderBody(event, reminderMinutes), - scheduledAt, - details, - androidScheduleMode: androidScheduleMode, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ); - - final pending = await _plugin.pendingNotificationRequests(); - final scheduled = pending.any((item) => item.id == notificationId); - if (!scheduled) { - throw NotificationScheduleException('提醒未被系统接受,请检查系统通知和电池优化设置'); - } - } catch (error) { - if (error is NotificationScheduleException) { - rethrow; - } - await _plugin.zonedSchedule( - notificationId, - event.title, - _buildReminderBody(event, reminderMinutes), - scheduledAt, - details, - androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ); - - final pending = await _plugin.pendingNotificationRequests(); - final scheduled = pending.any((item) => item.id == notificationId); - if (!scheduled) { - throw NotificationScheduleException('提醒创建失败,请检查系统设置后重试'); - } - } + Future scheduleReminderAt( + ScheduleItemModel event, + DateTime fireAt, + ) async { + await initialize(); + await cancelEventReminder(event.id); + await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } Future cancelEventReminder(String eventId) async { await initialize(); + final pending = await _plugin.pendingNotificationRequests(); + for (final request in pending) { + final payload = _decodePayload(request.payload); + if (payload == null) { + continue; + } + if (payload.eventId == eventId || + payload.aggregateIds.contains(eventId)) { + await _plugin.cancel(request.id); + } + } + await _plugin.cancel(_notificationIdForEvent(eventId)); } @@ -171,8 +136,15 @@ class LocalNotificationService { Iterable events, ) async { await initialize(); - for (final event in events) { - await upsertEventReminder(event); + + final now = DateTime.now(); + final groups = _overlapPolicy.groupByMinute(events, now: now); + for (final group in groups) { + if (group.isAggregate) { + await _scheduleAggregateReminder(group.events, group.fireAt); + continue; + } + await upsertEventReminder(group.events.first); } } @@ -180,10 +152,243 @@ class LocalNotificationService { return eventId.hashCode & 0x7fffffff; } - String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) { - if (reminderMinutes == 0) { - return '日程现在开始:${event.title}'; + int _notificationIdForEventCycle( + String eventId, + DateTime fireAt, + ReminderPayloadMode mode, + ) { + final cycleMinute = + fireAt.millisecondsSinceEpoch ~/ + const Duration(minutes: 1).inMilliseconds; + return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff; + } + + Future _resolveAndroidScheduleMode() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return AndroidScheduleMode.exactAllowWhileIdle; + } + + final androidImpl = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (androidImpl == null) { + return AndroidScheduleMode.exactAllowWhileIdle; + } + + final canScheduleExact = + await androidImpl.canScheduleExactNotifications() ?? false; + return canScheduleExact + ? AndroidScheduleMode.exactAllowWhileIdle + : AndroidScheduleMode.inexactAllowWhileIdle; + } + + NotificationDetails _buildNotificationDetails() { + return NotificationDetails( + android: AndroidNotificationDetails( + 'calendar_alarm_channel_v2', + '日程闹钟提醒', + channelDescription: '日程到点闹钟式提醒通知', + importance: Importance.max, + priority: Priority.max, + category: AndroidNotificationCategory.alarm, + audioAttributesUsage: AudioAttributesUsage.alarm, + fullScreenIntent: true, + playSound: true, + enableVibration: true, + vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]), + timeoutAfter: 30000, + autoCancel: true, + actions: [ + AndroidNotificationAction(_actionCancel, '取消'), + AndroidNotificationAction( + _actionSnooze, + '稍后提醒', + showsUserInterface: true, + ), + ], + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentSound: true, + presentBadge: true, + categoryIdentifier: _iosCategoryId, + ), + ); + } + + Future _scheduleSingleReminder({ + required ScheduleItemModel event, + required DateTime fireAt, + }) async { + final notificationId = _notificationIdForEventCycle( + event.id, + fireAt, + ReminderPayloadMode.single, + ); + final payload = ReminderPayload( + eventId: event.id, + title: event.title, + startAt: event.startAt, + endAt: event.endAt, + timezone: event.timezone, + location: event.metadata?.location, + notes: event.metadata?.notes, + color: event.metadata?.color, + mode: ReminderPayloadMode.single, + version: 1, + ); + + final details = _buildNotificationDetails(); + final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); + final mode = await _resolveAndroidScheduleMode(); + + try { + await _plugin.zonedSchedule( + notificationId, + event.title, + _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0), + scheduledAt, + details, + payload: jsonEncode(payload.toJson()), + androidScheduleMode: mode, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } catch (_) { + await _plugin.zonedSchedule( + notificationId, + event.title, + _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0), + scheduledAt, + details, + payload: jsonEncode(payload.toJson()), + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } + } + + Future _scheduleRemindersFrom({ + required ScheduleItemModel event, + required DateTime firstFireAt, + }) async { + final endAt = event.endAt; + var cursor = firstFireAt; + if (endAt == null) { + await _scheduleSingleReminder(event: event, fireAt: cursor); + return; + } + + while (cursor.isBefore(endAt)) { + await _scheduleSingleReminder(event: event, fireAt: cursor); + cursor = cursor.add(const Duration(minutes: 10)); + } + } + + Future _scheduleAggregateReminder( + List events, + DateTime fireAt, + ) async { + if (events.isEmpty) { + return; + } + + final first = events.first; + final aggregateIds = events.map((event) => event.id).toList(); + for (final id in aggregateIds) { + await cancelEventReminder(id); + } + + final payload = ReminderPayload( + eventId: first.id, + title: '你有${events.length}个日程提醒', + startAt: first.startAt, + endAt: first.endAt, + timezone: first.timezone, + mode: ReminderPayloadMode.aggregate, + aggregateIds: aggregateIds, + version: 1, + ); + + final details = _buildNotificationDetails(); + final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); + final mode = await _resolveAndroidScheduleMode(); + final preview = events.take(3).map((item) => item.title).join('、'); + + await _plugin.zonedSchedule( + _notificationIdForEventCycle( + first.id, + fireAt, + ReminderPayloadMode.aggregate, + ), + '你有${events.length}个日程提醒', + preview, + scheduledAt, + details, + payload: jsonEncode(payload.toJson()), + androidScheduleMode: mode, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } + + ReminderPayload? _decodePayload(String? raw) { + if (raw == null || raw.isEmpty) { + return null; + } + try { + final json = Map.from(jsonDecode(raw) as Map); + return ReminderPayload.fromJson(json); + } catch (_) { + return null; + } + } + + String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) { + final when = reminderMinutes == 0 + ? '日程现在开始' + : '日程即将开始(提前$reminderMinutes分钟)'; + final location = event.metadata?.location; + final notes = event.metadata?.notes; + final buffer = StringBuffer(when); + if (location != null && location.isNotEmpty) { + buffer.write('\n地点:$location'); + } + if (notes != null && notes.isNotEmpty) { + buffer.write( + '\n备注:${notes.length > 30 ? '${notes.substring(0, 30)}...' : notes}', + ); + } + return buffer.toString(); + } + + Future _onNotificationResponse(NotificationResponse response) async { + final payloadRaw = response.payload; + if (payloadRaw == null || payloadRaw.isEmpty) { + return; + } + final handler = _actionHandler; + if (handler == null) { + return; + } + + try { + final payload = ReminderPayload.fromJson( + Map.from(jsonDecode(payloadRaw) as Map), + ); + final actionId = response.actionId; + if (actionId == _actionCancel) { + await handler(action: ReminderAction.cancel, payload: payload); + return; + } + if (actionId == _actionSnooze) { + await handler(action: ReminderAction.snooze10m, payload: payload); + } + } catch (_) { + debugPrint('failed to handle reminder notification response'); + return; } - return '日程即将开始(提前$reminderMinutes分钟):${event.title}'; } } diff --git a/apps/lib/core/startup/auth_session_bootstrapper.dart b/apps/lib/core/startup/auth_session_bootstrapper.dart index c499ca5..88e21a8 100644 --- a/apps/lib/core/startup/auth_session_bootstrapper.dart +++ b/apps/lib/core/startup/auth_session_bootstrapper.dart @@ -1,16 +1,20 @@ import '../../features/auth/presentation/bloc/auth_state.dart'; import '../../features/calendar/data/services/calendar_service.dart'; +import '../../features/calendar/reminders/reminder_action_executor.dart'; import '../notifications/local_notification_service.dart'; class AuthSessionBootstrapper { AuthSessionBootstrapper({ required CalendarService calendarService, required LocalNotificationService notificationService, + required ReminderActionExecutor reminderActionExecutor, }) : _calendarService = calendarService, - _notificationService = notificationService; + _notificationService = notificationService, + _reminderActionExecutor = reminderActionExecutor; final CalendarService _calendarService; final LocalNotificationService _notificationService; + final ReminderActionExecutor _reminderActionExecutor; String? _syncedUserId; @@ -24,13 +28,15 @@ class AuthSessionBootstrapper { return; } - _syncedUserId = state.user.id; - try { + await _reminderActionExecutor.replayPendingActions(); + final now = DateTime.now(); + final start = now.subtract(const Duration(days: 90)); final end = now.add(const Duration(days: 90)); - final events = await _calendarService.getEventsForRange(now, end); + final events = await _calendarService.getEventsForRange(start, end); await _notificationService.rebuildUpcomingReminders(events); + _syncedUserId = state.user.id; } catch (_) { // ignore reminder bootstrap failures } diff --git a/apps/lib/features/calendar/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart index 69be549..c1cf9e8 100644 --- a/apps/lib/features/calendar/data/services/calendar_service.dart +++ b/apps/lib/features/calendar/data/services/calendar_service.dart @@ -44,6 +44,14 @@ class CalendarService { return _api.update(event); } + Future archiveEvent(String id) async { + final event = await getEventById(id); + if (event == null) { + return null; + } + return updateEvent(event.copyWith(status: ScheduleStatus.archived)); + } + Future deleteEvent(String id) async { await _api.delete(id); } diff --git a/apps/lib/features/calendar/reminders/models/reminder_action.dart b/apps/lib/features/calendar/reminders/models/reminder_action.dart new file mode 100644 index 0000000..2fadcce --- /dev/null +++ b/apps/lib/features/calendar/reminders/models/reminder_action.dart @@ -0,0 +1,17 @@ +enum ReminderAction { + cancel('cancel'), + snooze10m('snooze_10m'), + timeout30s('timeout_30s'), + autoArchive('auto_archive'); + + const ReminderAction(this.value); + + final String value; + + static ReminderAction fromValue(String raw) { + return ReminderAction.values.firstWhere( + (item) => item.value == raw, + orElse: () => ReminderAction.timeout30s, + ); + } +} diff --git a/apps/lib/features/calendar/reminders/models/reminder_payload.dart b/apps/lib/features/calendar/reminders/models/reminder_payload.dart new file mode 100644 index 0000000..c427ea9 --- /dev/null +++ b/apps/lib/features/calendar/reminders/models/reminder_payload.dart @@ -0,0 +1,174 @@ +class ReminderPayload { + final String eventId; + final String title; + final DateTime startAt; + final DateTime? endAt; + final String timezone; + final String? location; + final String? notes; + final String? color; + final ReminderPayloadMode mode; + final List aggregateIds; + final int version; + + const ReminderPayload({ + required this.eventId, + required this.title, + required this.startAt, + required this.timezone, + this.endAt, + this.location, + this.notes, + this.color, + this.mode = ReminderPayloadMode.single, + this.aggregateIds = const [], + this.version = 1, + }); + + ReminderPayload copyWith({ + String? eventId, + String? title, + DateTime? startAt, + DateTime? endAt, + String? timezone, + String? location, + String? notes, + String? color, + ReminderPayloadMode? mode, + List? aggregateIds, + int? version, + }) { + return ReminderPayload( + eventId: eventId ?? this.eventId, + title: title ?? this.title, + startAt: startAt ?? this.startAt, + endAt: endAt ?? this.endAt, + timezone: timezone ?? this.timezone, + location: location ?? this.location, + notes: notes ?? this.notes, + color: color ?? this.color, + mode: mode ?? this.mode, + aggregateIds: aggregateIds ?? this.aggregateIds, + version: version ?? this.version, + ); + } + + Map toJson() { + return { + 'eventId': eventId, + 'title': title, + 'startAt': startAt.toIso8601String(), + 'endAt': endAt?.toIso8601String(), + 'timezone': timezone, + 'location': location, + 'notes': notes, + 'color': color, + 'mode': mode.value, + 'aggregateIds': aggregateIds, + 'version': version, + }; + } + + factory ReminderPayload.fromJson(Map json) { + final eventId = (json['eventId'] as String?) ?? ''; + if (eventId.isEmpty) { + throw const FormatException('eventId is required'); + } + + final startAtRaw = json['startAt'] as String?; + if (startAtRaw == null || startAtRaw.isEmpty) { + throw const FormatException('startAt is required'); + } + final parsedStartAt = DateTime.parse(startAtRaw); + + final mode = ReminderPayloadMode.fromValue( + (json['mode'] as String?) ?? 'single', + ); + final aggregateIds = (json['aggregateIds'] as List? ?? const []) + .map((item) => item.toString()) + .toList(); + if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) { + throw const FormatException('aggregateIds must contain at least 2 items'); + } + + return ReminderPayload( + eventId: eventId, + title: (json['title'] as String?) ?? '', + startAt: parsedStartAt, + endAt: json['endAt'] != null + ? DateTime.parse(json['endAt'] as String) + : null, + timezone: (json['timezone'] as String?) ?? 'UTC', + location: json['location'] as String?, + notes: json['notes'] as String?, + color: json['color'] as String?, + mode: mode, + aggregateIds: aggregateIds, + version: (json['version'] as int?) ?? 1, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is ReminderPayload && + other.eventId == eventId && + other.title == title && + other.startAt == startAt && + other.endAt == endAt && + other.timezone == timezone && + other.location == location && + other.notes == notes && + other.color == color && + other.mode == mode && + _listEquals(other.aggregateIds, aggregateIds) && + other.version == version; + } + + @override + int get hashCode { + return Object.hash( + eventId, + title, + startAt, + endAt, + timezone, + location, + notes, + color, + mode, + Object.hashAll(aggregateIds), + version, + ); + } +} + +enum ReminderPayloadMode { + single('single'), + aggregate('aggregate'); + + const ReminderPayloadMode(this.value); + + final String value; + + static ReminderPayloadMode fromValue(String raw) { + return ReminderPayloadMode.values.firstWhere( + (item) => item.value == raw, + orElse: () => ReminderPayloadMode.single, + ); + } +} + +bool _listEquals(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) { + return false; + } + } + return true; +} diff --git a/apps/lib/features/calendar/reminders/reminder_action_executor.dart b/apps/lib/features/calendar/reminders/reminder_action_executor.dart new file mode 100644 index 0000000..ee90689 --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart @@ -0,0 +1,106 @@ +import 'dart:math'; + +import '../data/services/calendar_service.dart'; +import '../../../core/notifications/local_notification_service.dart'; +import 'models/reminder_action.dart'; +import 'models/reminder_payload.dart'; +import 'reminder_outbox_store.dart'; + +class ReminderActionExecutor { + final CalendarService _calendarService; + final LocalNotificationService _notificationService; + final ReminderOutboxStore _outboxStore; + final Random _random; + + ReminderActionExecutor({ + required CalendarService calendarService, + required LocalNotificationService notificationService, + required ReminderOutboxStore outboxStore, + Random? random, + }) : _calendarService = calendarService, + _notificationService = notificationService, + _outboxStore = outboxStore, + _random = random ?? Random(); + + Future handleAction({ + required ReminderAction action, + required ReminderPayload payload, + }) async { + final ids = payload.mode == ReminderPayloadMode.aggregate + ? payload.aggregateIds + : [payload.eventId]; + + if (action == ReminderAction.cancel) { + for (final id in ids) { + await _notificationService.cancelEventReminder(id); + await _archiveEvent(id, ReminderAction.cancel); + } + return; + } + + if (action == ReminderAction.snooze10m || + action == ReminderAction.timeout30s) { + for (final id in ids) { + await _snoozeEvent(id); + } + } + } + + Future replayPendingActions() async { + final pending = await _outboxStore.listPending(); + for (final item in pending) { + if (item.targetStatus != 'archived') { + await _outboxStore.markDone(item.opId); + continue; + } + try { + await _calendarService.archiveEvent(item.eventId); + await _outboxStore.markDone(item.opId); + } catch (error) { + await _outboxStore.markRetry(item.opId, error.toString()); + } + } + } + + Future _snoozeEvent(String eventId) async { + final event = await _calendarService.getEventById(eventId); + if (event == null) { + return; + } + final now = DateTime.now(); + final endAt = event.endAt; + if (endAt != null && !now.isBefore(endAt)) { + await _notificationService.cancelEventReminder(eventId); + await _archiveEvent(eventId, ReminderAction.autoArchive); + return; + } + + final nextAt = now.add(const Duration(minutes: 10)); + if (endAt != null && !nextAt.isBefore(endAt)) { + await _notificationService.cancelEventReminder(eventId); + await _archiveEvent(eventId, ReminderAction.autoArchive); + return; + } + + await _notificationService.scheduleReminderAt(event, nextAt); + } + + Future _archiveEvent(String eventId, ReminderAction action) async { + final opId = + '${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}'; + final outboxItem = ReminderOutboxItem( + opId: opId, + eventId: eventId, + action: action, + targetStatus: 'archived', + occurredAt: DateTime.now(), + ); + await _outboxStore.enqueue(outboxItem); + try { + await _calendarService.archiveEvent(eventId); + await _outboxStore.markDone(opId); + } catch (error) { + await _outboxStore.markRetry(opId, error.toString()); + } + } +} diff --git a/apps/lib/features/calendar/reminders/reminder_outbox_store.dart b/apps/lib/features/calendar/reminders/reminder_outbox_store.dart new file mode 100644 index 0000000..7655e5d --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_outbox_store.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models/reminder_action.dart'; + +class ReminderOutboxItem { + final String opId; + final String eventId; + final ReminderAction action; + final String? targetStatus; + final DateTime occurredAt; + final int retryCount; + final DateTime? nextRetryAt; + final ReminderOutboxState state; + final String? lastError; + + const ReminderOutboxItem({ + required this.opId, + required this.eventId, + required this.action, + required this.occurredAt, + this.targetStatus, + this.retryCount = 0, + this.nextRetryAt, + this.state = ReminderOutboxState.pending, + this.lastError, + }); + + String get idempotencyBucket { + final bucket = + occurredAt.millisecondsSinceEpoch ~/ + const Duration(minutes: 1).inMilliseconds; + return '$eventId|${action.value}|$bucket'; + } + + ReminderOutboxItem copyWith({ + int? retryCount, + DateTime? nextRetryAt, + ReminderOutboxState? state, + String? lastError, + }) { + return ReminderOutboxItem( + opId: opId, + eventId: eventId, + action: action, + targetStatus: targetStatus, + occurredAt: occurredAt, + retryCount: retryCount ?? this.retryCount, + nextRetryAt: nextRetryAt ?? this.nextRetryAt, + state: state ?? this.state, + lastError: lastError ?? this.lastError, + ); + } + + Map toJson() { + return { + 'opId': opId, + 'eventId': eventId, + 'action': action.value, + 'targetStatus': targetStatus, + 'occurredAt': occurredAt.toIso8601String(), + 'retryCount': retryCount, + 'nextRetryAt': nextRetryAt?.toIso8601String(), + 'state': state.value, + 'lastError': lastError, + }; + } + + factory ReminderOutboxItem.fromJson(Map json) { + return ReminderOutboxItem( + opId: (json['opId'] as String?) ?? '', + eventId: (json['eventId'] as String?) ?? '', + action: ReminderAction.fromValue( + (json['action'] as String?) ?? 'timeout_30s', + ), + targetStatus: json['targetStatus'] as String?, + occurredAt: DateTime.parse(json['occurredAt'] as String), + retryCount: (json['retryCount'] as int?) ?? 0, + nextRetryAt: json['nextRetryAt'] != null + ? DateTime.parse(json['nextRetryAt'] as String) + : null, + state: ReminderOutboxState.fromValue( + (json['state'] as String?) ?? 'pending', + ), + lastError: json['lastError'] as String?, + ); + } +} + +enum ReminderOutboxState { + pending('pending'), + done('done'), + dead('dead'); + + const ReminderOutboxState(this.value); + final String value; + + static ReminderOutboxState fromValue(String raw) { + return ReminderOutboxState.values.firstWhere( + (item) => item.value == raw, + orElse: () => ReminderOutboxState.pending, + ); + } +} + +class ReminderOutboxStore { + static const String _key = 'calendar_reminder_outbox_v1'; + final SharedPreferences _prefs; + + ReminderOutboxStore(this._prefs); + + Future enqueue(ReminderOutboxItem item) async { + final current = await _readAll(); + final duplicated = current.any( + (existing) => + existing.state == ReminderOutboxState.pending && + existing.idempotencyBucket == item.idempotencyBucket, + ); + if (duplicated) { + return; + } + current.add(item); + await _writeAll(current); + } + + Future> listPending() async { + final all = await _readAll(); + final now = DateTime.now(); + return all + .where((item) => item.state == ReminderOutboxState.pending) + .where( + (item) => item.nextRetryAt == null || !item.nextRetryAt!.isAfter(now), + ) + .toList(); + } + + Future markDone(String opId) async { + final all = await _readAll(); + final updated = all + .map( + (item) => item.opId == opId + ? item.copyWith( + state: ReminderOutboxState.done, + nextRetryAt: null, + ) + : item, + ) + .toList(); + await _writeAll(updated); + } + + Future markRetry(String opId, String error) async { + final all = await _readAll(); + final updated = all.map((item) { + if (item.opId != opId) { + return item; + } + final nextRetryCount = item.retryCount + 1; + if (nextRetryCount >= 8) { + return item.copyWith( + retryCount: nextRetryCount, + state: ReminderOutboxState.dead, + lastError: error, + nextRetryAt: null, + ); + } + final delayMinutes = nextRetryCount == 1 ? 0 : 1 << (nextRetryCount - 1); + return item.copyWith( + retryCount: nextRetryCount, + lastError: error, + nextRetryAt: DateTime.now().add(Duration(minutes: delayMinutes)), + ); + }).toList(); + await _writeAll(updated); + } + + Future> _readAll() async { + try { + final raw = _prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return []; + } + final list = jsonDecode(raw) as List; + return list + .whereType() + .map( + (item) => + ReminderOutboxItem.fromJson(Map.from(item)), + ) + .toList(); + } catch (_) { + await _prefs.remove(_key); + return []; + } + } + + Future _writeAll(List items) async { + final raw = jsonEncode(items.map((item) => item.toJson()).toList()); + await _prefs.setString(_key, raw); + } +} diff --git a/apps/lib/features/calendar/reminders/reminder_overlap_policy.dart b/apps/lib/features/calendar/reminders/reminder_overlap_policy.dart new file mode 100644 index 0000000..bb1f0e5 --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_overlap_policy.dart @@ -0,0 +1,80 @@ +import '../data/models/schedule_item_model.dart'; + +class ReminderOverlapGroup { + final DateTime fireAt; + final List events; + + const ReminderOverlapGroup({required this.fireAt, required this.events}); + + bool get isAggregate => events.length > 1; +} + +class ReminderOverlapPolicy { + const ReminderOverlapPolicy(); + + List groupByMinute( + Iterable events, { + required DateTime now, + }) { + final buckets = >{}; + final minuteToFireAt = {}; + + for (final event in events) { + final fireAt = resolveFirstFireAt(event, now: now); + if (fireAt == null) { + continue; + } + final minute = DateTime( + fireAt.year, + fireAt.month, + fireAt.day, + fireAt.hour, + fireAt.minute, + ); + final key = minute.toIso8601String(); + buckets.putIfAbsent(key, () => []).add(event); + minuteToFireAt[key] = minuteToFireAt[key] ?? fireAt; + } + + final groups = buckets.entries + .map( + (entry) => ReminderOverlapGroup( + fireAt: minuteToFireAt[entry.key]!, + events: entry.value, + ), + ) + .toList(); + + groups.sort((left, right) => left.fireAt.compareTo(right.fireAt)); + return groups; + } + + DateTime? resolveFirstFireAt( + ScheduleItemModel event, { + required DateTime now, + }) { + if (event.status != ScheduleStatus.active) { + return null; + } + final reminderMinutes = event.metadata?.reminderMinutes; + if (reminderMinutes == null) { + return null; + } + final remindAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); + final endAt = event.endAt; + + if (endAt != null && !now.isBefore(endAt)) { + return null; + } + + if (now.isBefore(remindAt)) { + return remindAt; + } + + if (endAt != null && now.isBefore(endAt)) { + return now.add(const Duration(seconds: 5)); + } + + return null; + } +} diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 229d8bb..291890e 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -9,6 +9,7 @@ import '../../data/models/schedule_item_model.dart'; import '../../data/services/calendar_service.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; +import '../utils/event_color_resolver.dart'; import '../dayweek/day_event_layout_engine.dart'; import '../dayweek/day_timeline_metrics.dart'; import '../dayweek/day_view_scale.dart'; @@ -614,7 +615,10 @@ class _CalendarDayWeekScreenState extends State required DayEventLayout layout, required double boardHeight, }) { - final eventColor = _parseColor(layout.event.metadata?.color); + final eventColor = resolveEventColor( + status: layout.event.status, + colorHex: layout.event.metadata?.color, + ); final isCompact = layout.visualHeight < 20; final tapHeight = layout.visualHeight < _minEventTapHeight ? _minEventTapHeight @@ -687,17 +691,6 @@ class _CalendarDayWeekScreenState extends State ); } - Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) { - return AppColors.blue600; - } - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return AppColors.blue600; - } - } - Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.calendar, diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 7b3d548..9eb8aee 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -10,6 +10,7 @@ import '../../../../shared/widgets/detail_header_action_menu.dart'; import '../../../../shared/widgets/destructive_action_sheet.dart'; import '../../data/services/calendar_service.dart'; import '../../data/models/schedule_item_model.dart'; +import '../utils/event_color_resolver.dart'; import '../widgets/create_event_sheet.dart'; import '../widgets/calendar_share_dialog.dart'; @@ -223,7 +224,7 @@ class _CalendarEventDetailScreenState extends State { _formatReminderText(event.metadata?.reminderMinutes), ), const SizedBox(height: 14), - _buildColorField(event.metadata?.color), + _buildColorField(event), const SizedBox(height: 14), if (event.metadata?.location != null) ...[ _buildDetailField('地点', event.metadata!.location!), @@ -275,7 +276,10 @@ class _CalendarEventDetailScreenState extends State { width: 4, height: 20, decoration: BoxDecoration( - color: _parseColor(event.metadata?.color), + color: resolveEventColor( + status: event.status, + colorHex: event.metadata?.color, + ), borderRadius: BorderRadius.circular(2), ), ), @@ -343,8 +347,11 @@ class _CalendarEventDetailScreenState extends State { ); } - Widget _buildColorField(String? colorHex) { - final color = _parseColor(colorHex); + Widget _buildColorField(ScheduleItemModel event) { + final color = resolveEventColor( + status: event.status, + colorHex: event.metadata?.color, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -400,15 +407,6 @@ class _CalendarEventDetailScreenState extends State { ); } - Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return AppColors.blue600; - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return AppColors.blue600; - } - } - Widget _buildInputContainer() { return Container( height: 80, diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 3568143..cf71ffa 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -7,6 +7,7 @@ import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; +import '../utils/event_color_resolver.dart'; import '../widgets/bottom_dock.dart'; import '../widgets/create_event_sheet.dart'; import '../../data/models/schedule_item_model.dart'; @@ -360,7 +361,10 @@ class _CalendarMonthScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ ...displayEvents.map((event) { - final color = _parseColor(event.metadata?.color); + final color = resolveEventColor( + status: event.status, + colorHex: event.metadata?.color, + ); return AppPressable( borderRadius: BorderRadius.circular(AppRadius.sm), onTap: () { @@ -415,15 +419,6 @@ class _CalendarMonthScreenState extends State ); } - Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return AppColors.blue600; - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return AppColors.blue600; - } - } - void _showMonthPicker() { var selectedYear = _currentMonth.year; var selectedMonth = _currentMonth.month; diff --git a/apps/lib/features/calendar/ui/utils/event_color_resolver.dart b/apps/lib/features/calendar/ui/utils/event_color_resolver.dart new file mode 100644 index 0000000..2d630e0 --- /dev/null +++ b/apps/lib/features/calendar/ui/utils/event_color_resolver.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/design_tokens.dart'; +import '../../data/models/schedule_item_model.dart'; + +Color resolveEventColor({ + required ScheduleStatus status, + required String? colorHex, +}) { + if (status == ScheduleStatus.archived) { + return AppColors.slate400; + } + + if (colorHex == null || colorHex.isEmpty) { + return AppColors.blue600; + } + + try { + return Color(int.parse(colorHex.replaceFirst('#', '0xFF'))); + } catch (_) { + return AppColors.blue600; + } +} diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index c8c1907..fbb5760 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -669,9 +669,9 @@ class _CreateEventSheetState extends State try { final notificationService = sl(); await notificationService.upsertEventReminder(saved); - } catch (e) { + } catch (_) { if (mounted) { - Toast.show(context, '提醒创建失败:$e', type: ToastType.warning); + Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning); } } diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 99840c5..cf23d3e 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -13,12 +13,23 @@ import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/auth/presentation/bloc/auth_event.dart'; import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/calendar/data/services/calendar_service.dart'; +import 'features/calendar/reminders/reminder_action_executor.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await configureDependencies(); await AppConstants.init(); + sl().bindActionHandler(({ + required action, + required payload, + }) { + return sl().handleAction( + action: action, + payload: payload, + ); + }); + await sl().initialize(); final authBloc = sl(); authBloc.add(AuthStarted()); @@ -29,6 +40,7 @@ void main() async { sessionBootstrapper: AuthSessionBootstrapper( calendarService: sl(), notificationService: sl(), + reminderActionExecutor: sl(), ), ), ); diff --git a/apps/test/core/startup/auth_session_bootstrapper_test.dart b/apps/test/core/startup/auth_session_bootstrapper_test.dart index c7b5be3..997e3f9 100644 --- a/apps/test/core/startup/auth_session_bootstrapper_test.dart +++ b/apps/test/core/startup/auth_session_bootstrapper_test.dart @@ -4,23 +4,30 @@ import 'package:social_app/core/notifications/local_notification_service.dart'; import 'package:social_app/core/startup/auth_session_bootstrapper.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; import 'package:social_app/features/calendar/data/services/calendar_service.dart'; +import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart'; class MockCalendarService extends Mock implements CalendarService {} class MockLocalNotificationService extends Mock implements LocalNotificationService {} +class MockReminderActionExecutor extends Mock + implements ReminderActionExecutor {} + void main() { late MockCalendarService calendarService; late MockLocalNotificationService notificationService; + late MockReminderActionExecutor reminderActionExecutor; late AuthSessionBootstrapper bootstrapper; setUp(() { calendarService = MockCalendarService(); notificationService = MockLocalNotificationService(); + reminderActionExecutor = MockReminderActionExecutor(); bootstrapper = AuthSessionBootstrapper( calendarService: calendarService, notificationService: notificationService, + reminderActionExecutor: reminderActionExecutor, ); }); @@ -29,6 +36,7 @@ void main() { verifyNever(() => calendarService.getEventsForRange(any(), any())); verifyNever(() => notificationService.rebuildUpcomingReminders(any())); + verifyNever(() => reminderActionExecutor.replayPendingActions()); }); test('fetches upcoming events after authenticated state', () async { @@ -38,6 +46,41 @@ void main() { when( () => notificationService.rebuildUpcomingReminders(any()), ).thenAnswer((_) async {}); + when( + () => reminderActionExecutor.replayPendingActions(), + ).thenAnswer((_) async {}); + + await bootstrapper.syncForAuthState( + const AuthAuthenticated( + user: AuthUser(id: 'u1', email: 'a@test.com'), + ), + ); + + verify(() => calendarService.getEventsForRange(any(), any())).called(1); + verify(() => notificationService.rebuildUpcomingReminders(any())).called(1); + verify(() => reminderActionExecutor.replayPendingActions()).called(1); + }); + + test('retries sync when previous bootstrap failed', () async { + when( + () => reminderActionExecutor.replayPendingActions(), + ).thenThrow(Exception('offline')); + + await bootstrapper.syncForAuthState( + const AuthAuthenticated( + user: AuthUser(id: 'u1', email: 'a@test.com'), + ), + ); + + when( + () => reminderActionExecutor.replayPendingActions(), + ).thenAnswer((_) async {}); + when( + () => calendarService.getEventsForRange(any(), any()), + ).thenAnswer((_) async => []); + when( + () => notificationService.rebuildUpcomingReminders(any()), + ).thenAnswer((_) async {}); await bootstrapper.syncForAuthState( const AuthAuthenticated( @@ -45,7 +88,7 @@ void main() { ), ); + verify(() => reminderActionExecutor.replayPendingActions()).called(2); verify(() => calendarService.getEventsForRange(any(), any())).called(1); - verify(() => notificationService.rebuildUpcomingReminders(any())).called(1); }); } diff --git a/apps/test/features/calendar/reminders/models/reminder_payload_test.dart b/apps/test/features/calendar/reminders/models/reminder_payload_test.dart new file mode 100644 index 0000000..2478296 --- /dev/null +++ b/apps/test/features/calendar/reminders/models/reminder_payload_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; + +void main() { + group('ReminderPayload', () { + test('round-trips single payload', () { + final payload = ReminderPayload( + eventId: 'evt_1', + title: 'Daily Sync', + startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), + endAt: DateTime.parse('2026-03-18T17:00:00+08:00'), + timezone: 'Asia/Shanghai', + location: 'A101', + notes: 'Bring docs', + color: '#3B82F6', + mode: ReminderPayloadMode.single, + aggregateIds: const [], + version: 1, + ); + + final decoded = ReminderPayload.fromJson(payload.toJson()); + expect(decoded, payload); + }); + + test('round-trips aggregate payload', () { + final payload = ReminderPayload( + eventId: 'evt_group', + title: 'Overlap Reminder', + startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), + timezone: 'Asia/Shanghai', + mode: ReminderPayloadMode.aggregate, + aggregateIds: const ['evt_1', 'evt_2'], + version: 1, + ); + + final decoded = ReminderPayload.fromJson(payload.toJson()); + expect(decoded.mode, ReminderPayloadMode.aggregate); + expect(decoded.aggregateIds, const ['evt_1', 'evt_2']); + expect(decoded, payload); + }); + }); +} diff --git a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart new file mode 100644 index 0000000..248f50b --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/core/notifications/local_notification_service.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/data/services/calendar_service.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_action.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; +import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart'; +import 'package:social_app/features/calendar/reminders/reminder_outbox_store.dart'; + +class MockCalendarService extends Mock implements CalendarService {} + +class MockLocalNotificationService extends Mock + implements LocalNotificationService {} + +void main() { + late MockCalendarService calendarService; + late MockLocalNotificationService notificationService; + late ReminderOutboxStore outboxStore; + late ReminderActionExecutor executor; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + calendarService = MockCalendarService(); + notificationService = MockLocalNotificationService(); + outboxStore = ReminderOutboxStore(prefs); + executor = ReminderActionExecutor( + calendarService: calendarService, + notificationService: notificationService, + outboxStore: outboxStore, + ); + }); + + test('cancel archives remotely and cancels local reminder', () async { + when( + () => notificationService.cancelEventReminder('evt_1'), + ).thenAnswer((_) async {}); + when( + () => calendarService.archiveEvent('evt_1'), + ).thenAnswer((_) async => null); + + await executor.handleAction( + action: ReminderAction.cancel, + payload: ReminderPayload( + eventId: 'evt_1', + title: 'sync', + startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), + timezone: 'Asia/Shanghai', + ), + ); + + verify(() => notificationService.cancelEventReminder('evt_1')).called(1); + verify(() => calendarService.archiveEvent('evt_1')).called(1); + final pending = await outboxStore.listPending(); + expect(pending, isEmpty); + }); + + test('archive failure writes pending outbox item', () async { + when( + () => notificationService.cancelEventReminder('evt_1'), + ).thenAnswer((_) async {}); + when( + () => calendarService.archiveEvent('evt_1'), + ).thenThrow(Exception('offline')); + + await executor.handleAction( + action: ReminderAction.cancel, + payload: ReminderPayload( + eventId: 'evt_1', + title: 'sync', + startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), + timezone: 'Asia/Shanghai', + ), + ); + + final pending = await outboxStore.listPending(); + expect(pending.length, 1); + expect(pending.first.eventId, 'evt_1'); + expect(pending.first.state, ReminderOutboxState.pending); + }); + + test('snooze reschedules +10m when event not expired', () async { + final now = DateTime.now(); + final event = ScheduleItemModel( + id: 'evt_1', + ownerId: 'u1', + title: 'sync', + startAt: now.add(const Duration(minutes: 1)), + endAt: now.add(const Duration(hours: 1)), + metadata: ScheduleMetadata(reminderMinutes: 15), + ); + when( + () => calendarService.getEventById('evt_1'), + ).thenAnswer((_) async => event); + when( + () => notificationService.scheduleReminderAt(event, any()), + ).thenAnswer((_) async {}); + + await executor.handleAction( + action: ReminderAction.snooze10m, + payload: ReminderPayload( + eventId: 'evt_1', + title: 'sync', + startAt: event.startAt, + endAt: event.endAt, + timezone: 'Asia/Shanghai', + ), + ); + + verify( + () => notificationService.scheduleReminderAt(event, any()), + ).called(1); + verifyNever(() => calendarService.archiveEvent(any())); + }); +} diff --git a/apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart b/apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart new file mode 100644 index 0000000..2cb85a5 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/reminders/reminder_overlap_policy.dart'; + +void main() { + final policy = ReminderOverlapPolicy(); + + test('groups reminders in same minute bucket', () { + final now = DateTime(2026, 3, 18, 15, 40, 0); + final eventA = ScheduleItemModel( + id: 'a', + ownerId: 'u1', + title: 'A', + startAt: DateTime(2026, 3, 18, 16, 0, 0), + endAt: DateTime(2026, 3, 18, 17, 0, 0), + metadata: ScheduleMetadata(reminderMinutes: 15), + ); + final eventB = ScheduleItemModel( + id: 'b', + ownerId: 'u1', + title: 'B', + startAt: DateTime(2026, 3, 18, 16, 0, 20), + endAt: DateTime(2026, 3, 18, 17, 0, 0), + metadata: ScheduleMetadata(reminderMinutes: 15), + ); + + final groups = policy.groupByMinute([eventA, eventB], now: now); + expect(groups.length, 1); + expect(groups.first.events.length, 2); + expect(groups.first.isAggregate, isTrue); + }); + + test('returns compensation fire time when remindAt already passed', () { + final now = DateTime(2026, 3, 18, 15, 50, 0); + final event = ScheduleItemModel( + id: 'a', + ownerId: 'u1', + title: 'A', + startAt: DateTime(2026, 3, 18, 16, 0, 0), + endAt: DateTime(2026, 3, 18, 16, 30, 0), + metadata: ScheduleMetadata(reminderMinutes: 15), + ); + + final fireAt = policy.resolveFirstFireAt(event, now: now); + expect(fireAt, isNotNull); + expect(fireAt!.isAfter(now), isTrue); + }); +} diff --git a/apps/test/features/calendar/ui/event_color_resolver_test.dart b/apps/test/features/calendar/ui/event_color_resolver_test.dart new file mode 100644 index 0000000..ebce70d --- /dev/null +++ b/apps/test/features/calendar/ui/event_color_resolver_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/ui/utils/event_color_resolver.dart'; + +void main() { + test('returns gray for archived status regardless of custom color', () { + final color = resolveEventColor( + status: ScheduleStatus.archived, + colorHex: '#EF4444', + ); + + expect(color, AppColors.slate400); + }); + + test('returns parsed color for active status', () { + final color = resolveEventColor( + status: ScheduleStatus.active, + colorHex: '#3B82F6', + ); + + expect(color.value, const Color(0xFF3B82F6).value); + }); +} diff --git a/backend/src/core/agentscope/runtime/model_tracking.py b/backend/src/core/agentscope/runtime/model_tracking.py index 3cc1c19..7dfc852 100644 --- a/backend/src/core/agentscope/runtime/model_tracking.py +++ b/backend/src/core/agentscope/runtime/model_tracking.py @@ -15,8 +15,17 @@ class TrackingChatModel: self._inner = inner self._total_input_tokens = 0 self._total_output_tokens = 0 + self._total_tokens = 0 self._total_latency_ms = 0 self._cached_prompt_tokens = 0 + self._prompt_cache_hit_tokens = 0 + self._prompt_cache_miss_tokens = 0 + self._reasoning_tokens = 0 + self._direct_cost = 0.0 + self._direct_cost_observed = False + self._model_call_records = 0 + self._usage_records = 0 + self._direct_cost_records = 0 @property def stream(self) -> bool: @@ -31,18 +40,37 @@ class TrackingChatModel: async def __call__(self, *args: Any, **kwargs: Any) -> Any: self._log_model_call(kwargs) + self._model_call_records += 1 response = await self._inner(*args, **kwargs) if isinstance(response, AsyncGenerator): return self._track_stream(response) self._record_usage(getattr(response, "usage", None)) return response - def usage_summary(self) -> dict[str, int]: + def usage_summary(self) -> dict[str, int | float | str]: + direct_cost = self._direct_cost if self._direct_cost_observed else 0.0 + direct_cost_complete = ( + self._model_call_records > 0 + and self._model_call_records == self._direct_cost_records + ) return { "input_tokens": self._total_input_tokens, "output_tokens": self._total_output_tokens, + "total_tokens": self._total_tokens, "latency_ms": self._total_latency_ms, "cached_prompt_tokens": self._cached_prompt_tokens, + "prompt_cache_hit_tokens": self._prompt_cache_hit_tokens, + "prompt_cache_miss_tokens": self._prompt_cache_miss_tokens, + "reasoning_tokens": self._reasoning_tokens, + "direct_cost": direct_cost, + "direct_cost_observed": int(self._direct_cost_observed), + "direct_cost_complete": int(direct_cost_complete), + "model_call_records": self._model_call_records, + "usage_records": self._usage_records, + "direct_cost_records": self._direct_cost_records, + "cost_source": "provider" + if self._direct_cost_observed + else "catalog_fallback", } def _log_model_call(self, kwargs: dict[str, Any]) -> None: @@ -101,25 +129,167 @@ class TrackingChatModel: def _record_usage(self, usage: Any) -> None: if usage is None: return - self._total_input_tokens += max(int(getattr(usage, "input_tokens", 0) or 0), 0) - self._total_output_tokens += max( - int(getattr(usage, "output_tokens", 0) or 0), 0 + self._usage_records += 1 + usage_mapping = self._to_mapping(usage) + metadata = self._safe_get(usage, "metadata") + metadata_mapping = self._to_mapping(metadata) + + input_tokens = self._coerce_int( + self._first_non_null( + self._safe_get(usage, "input_tokens"), + usage_mapping.get("input_tokens"), + metadata_mapping.get("prompt_tokens"), + ) ) - self._total_latency_ms += max( - int(round(float(getattr(usage, "time", 0) or 0) * 1000)), 0 + output_tokens = self._coerce_int( + self._first_non_null( + self._safe_get(usage, "output_tokens"), + usage_mapping.get("output_tokens"), + metadata_mapping.get("completion_tokens"), + ) ) - metadata = getattr(usage, "metadata", None) - if metadata is None: - return - self._cached_prompt_tokens += max(self._extract_cached_tokens(metadata), 0) + total_tokens = self._coerce_int( + self._first_non_null( + self._safe_get(usage, "total_tokens"), + usage_mapping.get("total_tokens"), + metadata_mapping.get("total_tokens"), + input_tokens + output_tokens, + ) + ) + latency_ms = max( + int( + round( + self._coerce_float( + self._first_non_null( + self._safe_get(usage, "time"), + usage_mapping.get("time"), + 0.0, + ) + ) + * 1000 + ) + ), + 0, + ) + + prompt_tokens_details = self._to_mapping( + metadata_mapping.get("prompt_tokens_details") + ) + completion_tokens_details = self._to_mapping( + metadata_mapping.get("completion_tokens_details") + ) + + cached_prompt_tokens = self._coerce_int( + self._first_non_null( + prompt_tokens_details.get("cached_tokens"), + metadata_mapping.get("prompt_cache_hit_tokens"), + 0, + ) + ) + prompt_cache_hit_tokens = self._coerce_int( + self._first_non_null( + metadata_mapping.get("prompt_cache_hit_tokens"), + cached_prompt_tokens, + ) + ) + prompt_cache_miss_tokens = self._coerce_int( + self._first_non_null( + metadata_mapping.get("prompt_cache_miss_tokens"), + max(input_tokens - prompt_cache_hit_tokens, 0), + ) + ) + reasoning_tokens = self._coerce_int( + self._first_non_null(completion_tokens_details.get("reasoning_tokens"), 0) + ) + direct_cost = self._coerce_optional_float( + self._first_non_null( + self._safe_get(usage, "cost"), + usage_mapping.get("cost"), + metadata_mapping.get("cost"), + metadata_mapping.get("total_cost"), + ) + ) + + self._total_input_tokens += input_tokens + self._total_output_tokens += output_tokens + self._total_tokens += total_tokens + self._total_latency_ms += latency_ms + self._cached_prompt_tokens += cached_prompt_tokens + self._prompt_cache_hit_tokens += prompt_cache_hit_tokens + self._prompt_cache_miss_tokens += prompt_cache_miss_tokens + self._reasoning_tokens += reasoning_tokens + if direct_cost is not None: + self._direct_cost_observed = True + self._direct_cost_records += 1 + self._direct_cost += max(direct_cost, 0.0) @staticmethod - def _extract_cached_tokens(metadata: Any) -> int: - if isinstance(metadata, dict): - prompt_details = metadata.get("prompt_tokens_details") - if isinstance(prompt_details, dict): - return int(prompt_details.get("cached_tokens", 0) or 0) + def _safe_get(obj: Any, key: str) -> Any: + if obj is None: + return None + try: + if isinstance(obj, dict): + return obj.get(key) + return getattr(obj, key, None) + except Exception: + return None + + @classmethod + def _to_mapping(cls, obj: Any) -> dict[str, Any]: + if isinstance(obj, dict): + return dict(obj) + if obj is None: + return {} + model_dump = cls._safe_get(obj, "model_dump") + if callable(model_dump): + try: + dumped = model_dump() + except Exception: + dumped = None + if isinstance(dumped, dict): + return dumped + data = cls._safe_get(obj, "__dict__") + if isinstance(data, dict): + return data + return {} + + @staticmethod + def _first_non_null(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + @staticmethod + def _coerce_int(value: Any) -> int: + if value is None: + return 0 + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return max(value, 0) + try: + return max(int(float(value)), 0) + except Exception: return 0 - prompt_details = getattr(metadata, "prompt_tokens_details", None) - return int(getattr(prompt_details, "cached_tokens", 0) or 0) + @staticmethod + def _coerce_float(value: Any) -> float: + if value is None: + return 0.0 + try: + return max(float(value), 0.0) + except Exception: + return 0.0 + + @staticmethod + def _coerce_optional_float(value: Any) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except Exception: + return None + if parsed < 0: + return None + return parsed diff --git a/backend/src/core/config/static/database/llm_catalog.yaml b/backend/src/core/config/static/database/llm_catalog.yaml index 8475fd5..92d8055 100644 --- a/backend/src/core/config/static/database/llm_catalog.yaml +++ b/backend/src/core/config/static/database/llm_catalog.yaml @@ -1,52 +1,63 @@ factories: - - name: dashscope - request_url: https://dashscope.aliyuncs.com/compatible-mode/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png + - name: dashscope + request_url: https://dashscope.aliyuncs.com/compatible-mode/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png - - name: minimax - request_url: https://api.minimaxi.com/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png + - name: minimax + request_url: https://api.minimaxi.com/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png - - name: moonshot - request_url: https://api.moonshot.cn/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png + - name: moonshot + request_url: https://api.moonshot.cn/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png - - name: deepseek - request_url: https://api.deepseek.com/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png + - name: deepseek + request_url: https://api.deepseek.com/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png - - name: volcengine - request_url: https://ark.cn-beijing.volces.com/api/v3 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png + - name: volcengine + request_url: https://ark.cn-beijing.volces.com/api/v3 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png - - name: zai - request_url: https://api.z.ai/api/paas/v4 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png + - name: zai + request_url: https://api.z.ai/api/paas/v4 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png llms: - # qwen3.5-flash (3 tiers: 128K, 256K, 1M) - - model_code: qwen3.5-flash - factory_name: dashscope - litellm_model: dashscope/qwen3.5-flash - pricing_tiers: - - max_prompt_tokens: 128000 - input_cost_per_token: 0.0000002 - output_cost_per_token: 0.000002 - cache_hit_cost_per_token: 0.00000002 - - max_prompt_tokens: 256000 - input_cost_per_token: 0.0000008 - output_cost_per_token: 0.000008 - cache_hit_cost_per_token: 0.00000008 - - max_prompt_tokens: 1000000 - input_cost_per_token: 0.0000012 - output_cost_per_token: 0.000012 - cache_hit_cost_per_token: 0.00000012 + # qwen3.5-flash (3 tiers: 128K, 256K, 1M) + - model_code: qwen3.5-flash + factory_name: dashscope + litellm_model: dashscope/qwen3.5-flash + pricing_tiers: + - max_prompt_tokens: 128000 + input_cost_per_token: 0.0000002 + output_cost_per_token: 0.000002 + cache_hit_cost_per_token: 0.00000002 + - max_prompt_tokens: 256000 + input_cost_per_token: 0.0000008 + output_cost_per_token: 0.000008 + cache_hit_cost_per_token: 0.00000008 + - max_prompt_tokens: 1000000 + input_cost_per_token: 0.0000012 + output_cost_per_token: 0.000012 + cache_hit_cost_per_token: 0.00000012 - - model_code: deepseek-chat - factory_name: deepseek - litellm_model: deepseek/deepseek-chat - pricing_tiers: - - max_prompt_tokens: 128000 - input_cost_per_token: 0.000002 - output_cost_per_token: 0.000003 - cache_hit_cost_per_token: 0.0000002 + - model_code: qwen3.5-35b-a3b + factory_name: dashscope + litellm_model: dashscope/qwen3.5-35b-a3b + pricing_tiers: + - max_prompt_tokens: 128000 + input_cost_per_token: 0.0000004 + output_cost_per_token: 0.0000032 + - max_prompt_tokens: 256000 + input_cost_per_token: 0.0000016 + output_cost_per_token: 0.0000128 + + - model_code: deepseek-chat + factory_name: deepseek + litellm_model: deepseek/deepseek-chat + pricing_tiers: + - max_prompt_tokens: 128000 + input_cost_per_token: 0.000002 + output_cost_per_token: 0.000003 + cache_hit_cost_per_token: 0.0000002 diff --git a/backend/src/services/litellm/service.py b/backend/src/services/litellm/service.py index 6640479..92b3bb6 100644 --- a/backend/src/services/litellm/service.py +++ b/backend/src/services/litellm/service.py @@ -85,9 +85,15 @@ class LiteLLMService: selected_tier = tier break + cached_token_rate = ( + selected_tier.cache_hit_cost_per_token + if selected_tier.cache_hit_cost_per_token > 0 + else selected_tier.input_cost_per_token + ) + return float( uncached_prompt_tokens * selected_tier.input_cost_per_token - + normalized_cached_tokens * selected_tier.cache_hit_cost_per_token + + normalized_cached_tokens * cached_token_rate + normalized_completion_tokens * selected_tier.output_cost_per_token ) @@ -95,23 +101,86 @@ class LiteLLMService: self, *, model: str, - usage_summary: dict[str, int] | None, + usage_summary: dict[str, Any] | None, ) -> dict[str, Any]: summary = usage_summary or {} input_tokens = max(int(summary.get("input_tokens", 0) or 0), 0) output_tokens = max(int(summary.get("output_tokens", 0) or 0), 0) + total_tokens = max( + int(summary.get("total_tokens", input_tokens + output_tokens) or 0), 0 + ) latency_ms = max(int(summary.get("latency_ms", 0) or 0), 0) cached_prompt_tokens = max(int(summary.get("cached_prompt_tokens", 0) or 0), 0) - cost = self.calculate_cost( - model=model, - prompt_tokens=input_tokens, - completion_tokens=output_tokens, - cached_prompt_tokens=cached_prompt_tokens, + prompt_cache_hit_tokens = max( + int(summary.get("prompt_cache_hit_tokens", cached_prompt_tokens) or 0), 0 ) + prompt_cache_miss_tokens = max( + int( + summary.get( + "prompt_cache_miss_tokens", + max(input_tokens - prompt_cache_hit_tokens, 0), + ) + or 0 + ), + 0, + ) + reasoning_tokens = max(int(summary.get("reasoning_tokens", 0) or 0), 0) + direct_cost_raw = summary.get("direct_cost") + direct_cost_observed = bool(int(summary.get("direct_cost_observed", 0) or 0)) + direct_cost_complete = bool(int(summary.get("direct_cost_complete", 0) or 0)) + model_call_records = max(int(summary.get("model_call_records", 0) or 0), 0) + usage_records = max(int(summary.get("usage_records", 0) or 0), 0) + usage_complete = model_call_records == 0 or model_call_records == usage_records + direct_cost = self._coerce_non_negative_float(direct_cost_raw) + + if ( + usage_complete + and direct_cost_observed + and direct_cost_complete + and direct_cost is not None + ): + cost = direct_cost + cost_source = "provider" + else: + cost = self.calculate_cost( + model=model, + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + cached_prompt_tokens=cached_prompt_tokens, + ) + cost_source = ( + "incomplete_usage_fallback" + if not usage_complete + else ( + "catalog_fallback_incomplete_provider_cost" + if direct_cost_observed and not direct_cost_complete + else "catalog_fallback" + ) + ) + return { "model": model, "inputTokens": input_tokens, "outputTokens": output_tokens, + "totalTokens": total_tokens, + "cachedPromptTokens": cached_prompt_tokens, + "promptCacheHitTokens": prompt_cache_hit_tokens, + "promptCacheMissTokens": prompt_cache_miss_tokens, + "reasoningTokens": reasoning_tokens, "cost": cost, + "costSource": cost_source, + "usageComplete": usage_complete, "latencyMs": latency_ms, } + + @staticmethod + def _coerce_non_negative_float(value: Any) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if parsed < 0: + return None + return parsed diff --git a/backend/src/v1/schedule_items/repository.py b/backend/src/v1/schedule_items/repository.py index 5477a7f..3e3bc4b 100644 --- a/backend/src/v1/schedule_items/repository.py +++ b/backend/src/v1/schedule_items/repository.py @@ -9,7 +9,7 @@ from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository from core.logging import get_logger -from models.schedule_items import ScheduleItem +from models.schedule_items import ScheduleItem, ScheduleItemStatus from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus if TYPE_CHECKING: @@ -61,6 +61,11 @@ class ScheduleItemRepository(Protocol): start_at: datetime, end_at: datetime, ) -> Sequence[tuple[ScheduleItem, ScheduleSubscription]]: ... + async def archive_expired_subscribed_items( + self, + subscriber_id: UUID, + now_at: datetime, + ) -> int: ... class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): @@ -149,8 +154,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): select(ScheduleItem) .where(ScheduleItem.owner_id == owner_id) .where(ScheduleItem.deleted_at.is_(None)) - .where(ScheduleItem.start_at >= start_at) .where(ScheduleItem.start_at <= end_at) + .where( + or_( + ScheduleItem.end_at.is_(None), + ScheduleItem.end_at >= start_at, + ) + ) .order_by(ScheduleItem.start_at.asc()) ) result = await self._session.execute(stmt) @@ -308,8 +318,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): .where(ScheduleSubscription.subscriber_id == subscriber_id) .where(ScheduleSubscription.status == SubscriptionStatus.ACTIVE) .where(ScheduleItem.deleted_at.is_(None)) - .where(ScheduleItem.start_at >= start_at) .where(ScheduleItem.start_at <= end_at) + .where( + or_( + ScheduleItem.end_at.is_(None), + ScheduleItem.end_at >= start_at, + ) + ) .order_by(ScheduleItem.start_at.asc()) ) result = await self._session.execute(stmt) @@ -317,3 +332,38 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): except SQLAlchemyError: logger.exception("Failed to list subscribed items") raise + + async def archive_expired_subscribed_items( + self, + subscriber_id: UUID, + now_at: datetime, + ) -> int: + try: + item_ids_subquery = ( + select(ScheduleItem.id) + .join( + ScheduleSubscription, + ScheduleSubscription.item_id == ScheduleItem.id, + ) + .where(ScheduleSubscription.subscriber_id == subscriber_id) + .where(ScheduleSubscription.status == SubscriptionStatus.ACTIVE) + .where(ScheduleItem.deleted_at.is_(None)) + .where(ScheduleItem.status == ScheduleItemStatus.ACTIVE) + .where(ScheduleItem.end_at.is_not(None)) + .where(ScheduleItem.end_at <= now_at) + ) + + stmt = ( + update(ScheduleItem) + .where(ScheduleItem.id.in_(item_ids_subquery)) + .values(status=ScheduleItemStatus.ARCHIVED) + ) + result = await self._session.execute(stmt) + await self._session.flush() + return int(getattr(result, "rowcount", 0) or 0) + except SQLAlchemyError: + logger.exception( + "Failed to archive expired subscribed items", + subscriber_id=str(subscriber_id), + ) + raise diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 5eb4cb3..e2c4b28 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -240,6 +240,11 @@ class ScheduleItemService(BaseService): raise HTTPException(status_code=400, detail="end_at must be after start_at") try: + archived_count = await self._repository.archive_expired_subscribed_items( + user_id, + datetime.now(timezone.utc), + ) + subscribed_items = ( await self._repository.list_subscribed_items_by_date_range( user_id, normalized_start_at, normalized_end_at @@ -256,9 +261,12 @@ class ScheduleItemService(BaseService): ) results.sort(key=lambda x: x.start_at) + if archived_count > 0: + await self._session.commit() return results except SQLAlchemyError: + await self._session.rollback() logger.exception("Failed to list schedule items") raise HTTPException( status_code=503, detail="Schedule item store unavailable" diff --git a/backend/tests/unit/core/agentscope/runtime/test_model_tracking.py b/backend/tests/unit/core/agentscope/runtime/test_model_tracking.py new file mode 100644 index 0000000..8ca4b66 --- /dev/null +++ b/backend/tests/unit/core/agentscope/runtime/test_model_tracking.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any, cast + +from core.agentscope.runtime.model_tracking import TrackingChatModel + + +class _FakeMetadata: + def model_dump(self) -> dict[str, object]: + return { + "prompt_tokens": 120, + "completion_tokens": 30, + "total_tokens": 150, + "prompt_tokens_details": { + "cached_tokens": 80, + }, + "prompt_cache_hit_tokens": 80, + "prompt_cache_miss_tokens": 40, + "completion_tokens_details": { + "reasoning_tokens": 5, + }, + } + + +class _FakeUsage: + input_tokens = 120 + output_tokens = 30 + time = 1.234 + metadata = _FakeMetadata() + + +class _FakeResponse: + usage = _FakeUsage() + + +class _FakeModel: + stream = False + + async def __call__(self, *args: object, **kwargs: object) -> _FakeResponse: + return _FakeResponse() + + +class _FakeUsageWithProviderCost: + input_tokens = 50 + output_tokens = 10 + time = 0.5 + cost = 0.0123 + metadata = _FakeMetadata() + + +class _FakeResponseWithProviderCost: + usage = _FakeUsageWithProviderCost() + + +class _FakeModelWithProviderCost: + stream = False + + async def __call__( + self, *args: object, **kwargs: object + ) -> _FakeResponseWithProviderCost: + return _FakeResponseWithProviderCost() + + +class _FakeResponseWithoutUsage: + usage = None + + +class _FakeModelWithoutUsage: + stream = False + + async def __call__( + self, *args: object, **kwargs: object + ) -> _FakeResponseWithoutUsage: + return _FakeResponseWithoutUsage() + + +async def test_tracking_chat_model_collects_primary_usage_fields() -> None: + model = TrackingChatModel(cast(Any, _FakeModel())) + + await model("prompt") + + summary = model.usage_summary() + assert summary["input_tokens"] == 120 + assert summary["output_tokens"] == 30 + assert summary["total_tokens"] == 150 + assert summary["latency_ms"] == 1234 + assert summary["cached_prompt_tokens"] == 80 + assert summary["prompt_cache_hit_tokens"] == 80 + assert summary["prompt_cache_miss_tokens"] == 40 + assert summary["reasoning_tokens"] == 5 + assert summary["direct_cost"] == 0.0 + assert summary["direct_cost_observed"] == 0 + assert summary["direct_cost_complete"] == 0 + assert summary["model_call_records"] == 1 + assert summary["usage_records"] == 1 + assert summary["direct_cost_records"] == 0 + assert summary["cost_source"] == "catalog_fallback" + + +async def test_tracking_chat_model_prefers_provider_cost_when_available() -> None: + model = TrackingChatModel(cast(Any, _FakeModelWithProviderCost())) + + await model("prompt") + + summary = model.usage_summary() + assert summary["direct_cost"] == 0.0123 + assert summary["direct_cost_observed"] == 1 + assert summary["direct_cost_complete"] == 1 + assert summary["model_call_records"] == 1 + assert summary["usage_records"] == 1 + assert summary["direct_cost_records"] == 1 + assert summary["cost_source"] == "provider" + + +async def test_tracking_chat_model_marks_direct_cost_incomplete_when_usage_missing() -> ( + None +): + model = TrackingChatModel(cast(Any, _FakeModelWithoutUsage())) + + await model("prompt") + + summary = model.usage_summary() + assert summary["model_call_records"] == 1 + assert summary["usage_records"] == 0 + assert summary["direct_cost_records"] == 0 + assert summary["direct_cost_complete"] == 0 diff --git a/backend/tests/unit/services/test_litellm_service.py b/backend/tests/unit/services/test_litellm_service.py index e41f822..4089a09 100644 --- a/backend/tests/unit/services/test_litellm_service.py +++ b/backend/tests/unit/services/test_litellm_service.py @@ -44,10 +44,75 @@ def test_build_usage_metadata_calculates_cost_from_usage_summary() -> None: }, ) - assert metadata == { - "model": "dashscope/qwen3.5-flash", - "inputTokens": 2000, - "outputTokens": 100, - "cost": pytest.approx(0.00051), - "latencyMs": 321, - } + assert metadata["model"] == "dashscope/qwen3.5-flash" + assert metadata["inputTokens"] == 2000 + assert metadata["outputTokens"] == 100 + assert metadata["totalTokens"] == 2100 + assert metadata["cachedPromptTokens"] == 500 + assert metadata["promptCacheHitTokens"] == 500 + assert metadata["promptCacheMissTokens"] == 1500 + assert metadata["reasoningTokens"] == 0 + assert metadata["cost"] == pytest.approx(0.00051) + assert metadata["costSource"] == "catalog_fallback" + assert metadata["usageComplete"] is True + assert metadata["latencyMs"] == 321 + + +def test_build_usage_metadata_prefers_provider_direct_cost() -> None: + service = LiteLLMService() + + metadata = service.build_usage_metadata( + model="deepseek-chat", + usage_summary={ + "input_tokens": 1000, + "output_tokens": 100, + "latency_ms": 100, + "cached_prompt_tokens": 0, + "direct_cost": 0.1234, + "direct_cost_observed": 1, + "direct_cost_complete": 1, + }, + ) + + assert metadata["cost"] == pytest.approx(0.1234) + assert metadata["costSource"] == "provider" + assert metadata["usageComplete"] is True + + +def test_build_usage_metadata_falls_back_when_provider_cost_incomplete() -> None: + service = LiteLLMService() + + metadata = service.build_usage_metadata( + model="deepseek-chat", + usage_summary={ + "input_tokens": 1000, + "output_tokens": 100, + "latency_ms": 100, + "cached_prompt_tokens": 0, + "direct_cost": 0.1234, + "direct_cost_observed": 1, + "direct_cost_complete": 0, + }, + ) + + assert metadata["cost"] == pytest.approx(0.0023) + assert metadata["costSource"] == "catalog_fallback_incomplete_provider_cost" + + +def test_build_usage_metadata_marks_incomplete_usage_fallback() -> None: + service = LiteLLMService() + + metadata = service.build_usage_metadata( + model="deepseek-chat", + usage_summary={ + "input_tokens": 0, + "output_tokens": 0, + "latency_ms": 0, + "cached_prompt_tokens": 0, + "model_call_records": 1, + "usage_records": 0, + }, + ) + + assert metadata["costSource"] == "incomplete_usage_fallback" + assert metadata["usageComplete"] is False diff --git a/backend/tests/unit/v1/schedule_items/test_service.py b/backend/tests/unit/v1/schedule_items/test_service.py index 2472825..713f9b3 100644 --- a/backend/tests/unit/v1/schedule_items/test_service.py +++ b/backend/tests/unit/v1/schedule_items/test_service.py @@ -4,6 +4,7 @@ from uuid import UUID, uuid4 import pytest from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from models.schedule_items import ( @@ -13,6 +14,7 @@ from models.schedule_items import ( ) from v1.schedule_items.schemas import ( ScheduleItemCreateRequest, + ScheduleItemListRequest, ScheduleItemMetadata, ScheduleItemUpdateRequest, ) @@ -43,6 +45,7 @@ def _create_mock_schedule_item( class FakeRepo: def __init__(self, item: ScheduleItem | None) -> None: self._item = item + self.archive_expired_called = 0 async def get_by_item_id( self, item_id: UUID, owner_id: UUID @@ -89,8 +92,9 @@ class FakeRepo: *, page: int, page_size: int, + query: str | None = None, ) -> tuple[list[ScheduleItem], int]: - del owner_id, page, page_size + del owner_id, page, page_size, query return ([self._item] if self._item else [], 1 if self._item else 0) async def create_subscription(self, data: dict): @@ -104,7 +108,20 @@ class FakeRepo: end_at: datetime, ): del subscriber_id, start_at, end_at - return [] + if self._item is None: + return [] + subscription = MagicMock() + subscription.permission = 1 + return [(self._item, subscription)] + + async def archive_expired_subscribed_items( + self, + subscriber_id: UUID, + now_at: datetime, + ) -> int: + del subscriber_id, now_at + self.archive_expired_called += 1 + return 0 async def get_user_subscriptions(self, subscriber_id: UUID): del subscriber_id @@ -376,3 +393,110 @@ async def test_update_maps_null_metadata_to_extra_metadata_null( assert "extra_metadata" in captured assert captured["extra_metadata"] is None assert "metadata" not in captured + + +@pytest.mark.asyncio +async def test_list_by_date_range_archives_expired_before_query( + mock_session: AsyncMock, + mock_inbox_repository: MagicMock, +) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + repo = FakeRepo(item) + service = ScheduleItemService( + repository=repo, + session=mock_session, + current_user=CurrentUser(id=user_id), + inbox_repository=mock_inbox_repository, + ) + + await service.list_by_date_range( + request=ScheduleItemListRequest( + start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc), + ), + ) + + assert repo.archive_expired_called == 1 + + +@pytest.mark.asyncio +async def test_list_by_date_range_commits_when_archived_changed( + mock_session: AsyncMock, + mock_inbox_repository: MagicMock, +) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + + class ArchiveRepo(FakeRepo): + async def archive_expired_subscribed_items( + self, + subscriber_id: UUID, + now_at: datetime, + ) -> int: + del subscriber_id, now_at + self.archive_expired_called += 1 + return 2 + + repo = ArchiveRepo(item) + service = ScheduleItemService( + repository=repo, + session=mock_session, + current_user=CurrentUser(id=user_id), + inbox_repository=mock_inbox_repository, + ) + + await service.list_by_date_range( + request=ScheduleItemListRequest( + start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc), + ), + ) + + mock_session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_list_by_date_range_rolls_back_when_query_fails_after_archive( + mock_session: AsyncMock, + mock_inbox_repository: MagicMock, +) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + + class FailingRepo(FakeRepo): + async def archive_expired_subscribed_items( + self, + subscriber_id: UUID, + now_at: datetime, + ) -> int: + del subscriber_id, now_at + return 1 + + async def list_subscribed_items_by_date_range( + self, + subscriber_id: UUID, + start_at: datetime, + end_at: datetime, + ): + del subscriber_id, start_at, end_at + raise SQLAlchemyError("db unavailable") + + service = ScheduleItemService( + repository=FailingRepo(item), + session=mock_session, + current_user=CurrentUser(id=user_id), + inbox_repository=mock_inbox_repository, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.list_by_date_range( + request=ScheduleItemListRequest( + start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc), + ), + ) + + assert exc_info.value.status_code == 503 + mock_session.rollback.assert_awaited_once() + mock_session.commit.assert_not_awaited() diff --git a/docs/plans/2026-03-18-reminder-alert-archival-plan.md b/docs/plans/2026-03-18-reminder-alert-archival-plan.md new file mode 100644 index 0000000..da5dd5a --- /dev/null +++ b/docs/plans/2026-03-18-reminder-alert-archival-plan.md @@ -0,0 +1,449 @@ +# Reminder Alert Archival Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Deliver alarm-style reminder popups with cancel/snooze actions, 30s timeout auto-snooze, overlap handling, and archived/gray lifecycle consistency across Android and iOS. + +**Architecture:** Keep scheduling local on device (Flutter local notifications), persist user reminder actions with an app-side outbox for eventual backend sync, and use backend PATCH update for archive status as the source of truth. Add a backend safety net job to auto-archive expired active events so app-terminated scenarios still converge. Implement shared reminder payload and action handler with platform-specific notification configuration (Android full-screen intent, iOS category actions). + +**Tech Stack:** Flutter (`flutter_local_notifications`, existing calendar service), FastAPI schedule-items API, SQLAlchemy service layer, uv/pytest, Dart tests. + +--- + +### Task 1: Update protocol and state semantics first + +**Files:** +- Modify: `docs/protocols/` (create a new reminder interaction protocol doc or extend existing schedule protocol doc) +- Modify: `docs/runtime/runtime-route.md` + +**Step 1: Write failing doc checks (manual checklist as fail-first gate)** + +```text +Checklist fails until all are documented: +1) cancel action semantics +2) snooze +10 minutes semantics +3) timeout(30s) = ignore -> snooze +4) overlap aggregation semantics +5) archive + gray render semantics +6) iOS degraded behavior note +``` + +**Step 2: Run verification of checklist** + +Run: manual review (expect FAIL before edits) + +**Step 3: Write minimal protocol spec** + +Include exact payload keys and action enum: + +```json +{ + "eventId": "uuid", + "title": "string", + "startAt": "iso8601", + "endAt": "iso8601|null", + "timezone": "IANA", + "location": "string|null", + "notes": "string|null", + "color": "#RRGGBB|null", + "mode": "single|aggregate", + "aggregateIds": ["uuid"] +} +``` + +Actions: +- `cancel`: archive target events and stop reminders +- `snooze_10m`: reschedule +10m, stop when `now >= endAt` +- `timeout_30s`: same as `snooze_10m` + +**Step 4: Verify checklist passes** + +Run: manual review (expect PASS) + +**Step 5: Commit** + +```bash +git add docs/protocols docs/runtime/runtime-route.md +git commit -m "docs: define reminder interaction protocol and lifecycle semantics" +``` + +### Task 2: Add frontend reminder action models and payload codec + +**Files:** +- Create: `apps/lib/features/calendar/reminders/models/reminder_payload.dart` +- Create: `apps/lib/features/calendar/reminders/models/reminder_action.dart` +- Test: `apps/test/features/calendar/reminders/models/reminder_payload_test.dart` + +**Step 1: Write the failing test** + +```dart +test('round-trips payload with single and aggregate modes', () { + final payload = ReminderPayload(...); + expect(ReminderPayload.fromJson(payload.toJson()), payload); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test test/features/calendar/reminders/models/reminder_payload_test.dart` +Expected: FAIL (type/file missing) + +**Step 3: Write minimal implementation** + +Implement immutable model + json codec + enum parser. + +**Step 4: Run test to verify it passes** + +Run: `flutter test test/features/calendar/reminders/models/reminder_payload_test.dart` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/lib/features/calendar/reminders/models apps/test/features/calendar/reminders/models/reminder_payload_test.dart +git commit -m "feat: add reminder payload and action models" +``` + +### Task 3: Refactor local notification service for action-capable reminders (Android + iOS) + +**Files:** +- Modify: `apps/lib/core/notifications/local_notification_service.dart` +- Modify: `apps/lib/main.dart` +- Modify: `apps/ios/Runner/AppDelegate.swift` +- Test: `apps/test/core/notifications/local_notification_service_test.dart` + +**Step 1: Write failing tests** + +```dart +test('uses alarm-style Android details with actions and timeout', () async {}); +test('uses Darwin category actions for cancel/snooze', () async {}); +test('encodes payload in notification details', () async {}); +``` + +**Step 2: Run tests to verify failure** + +Run: `flutter test test/core/notifications/local_notification_service_test.dart` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: +- Android notification actions: `cancel`, `snooze_10m` +- `timeoutAfter: 30000` +- payload serialization +- iOS `DarwinNotificationCategory` + action identifiers +- initialize callback registration for action responses + +Also keep existing full-screen alarm setup and exact alarm fallback behavior. + +**Step 4: Run tests to verify pass** + +Run: `flutter test test/core/notifications/local_notification_service_test.dart` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/lib/core/notifications/local_notification_service.dart apps/lib/main.dart apps/ios/Runner/AppDelegate.swift apps/test/core/notifications/local_notification_service_test.dart +git commit -m "feat: support actionable reminder notifications on android and ios" +``` + +### Task 4: Implement reminder action executor + local outbox for eventual consistency + +**Files:** +- Create: `apps/lib/features/calendar/reminders/reminder_action_executor.dart` +- Create: `apps/lib/features/calendar/reminders/reminder_outbox_store.dart` +- Modify: `apps/lib/features/calendar/data/services/calendar_service.dart` +- Test: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart` + +**Step 1: Write failing tests** + +```dart +test('cancel archives remotely and cancels local reminders', () async {}); +test('network failure writes outbox item and keeps local state updated', () async {}); +test('snooze reschedules +10m and stops after endAt', () async {}); +``` + +**Step 2: Run tests (expect FAIL)** + +Run: `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` + +**Step 3: Write minimal implementation** + +Rules: +- Cancel: local cancel now, enqueue archive API job, best-effort immediate PATCH +- Snooze: schedule at `now + 10m`, if `next >= endAt` then archive path +- Timeout action uses same path as snooze + +**Step 4: Re-run tests (expect PASS)** + +Run: `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` + +**Step 5: Commit** + +```bash +git add apps/lib/features/calendar/reminders apps/lib/features/calendar/data/services/calendar_service.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart +git commit -m "feat: add reminder action executor with offline outbox" +``` + +### Task 5: Add startup reconciliation (replay outbox + rebuild reminders) + +**Files:** +- Modify: `apps/lib/core/startup/auth_session_bootstrapper.dart` +- Modify: `apps/lib/main.dart` +- Test: `apps/test/core/startup/auth_session_bootstrapper_test.dart` + +**Step 1: Write failing tests** + +```dart +test('replays pending reminder actions after login', () async {}); +test('rebuilds reminders after outbox replay', () async {}); +``` + +**Step 2: Run tests (expect FAIL)** + +Run: `flutter test test/core/startup/auth_session_bootstrapper_test.dart` + +**Step 3: Write minimal implementation** + +In authenticated startup flow: +1) replay outbox +2) fetch events with overlap semantics (active and not ended) +3) rebuild active reminders with compensation scheduling: + - `now < remindAt`: schedule at remindAt + - `remindAt <= now < endAt`: schedule immediate compensation reminder (e.g. +5s) + - `now >= endAt`: archive path +4) enforce reminder dedupe key to avoid duplicate reminders after reinstall/restart + +**Step 4: Re-run tests (expect PASS)** + +Run: `flutter test test/core/startup/auth_session_bootstrapper_test.dart` + +**Step 5: Commit** + +```bash +git add apps/lib/core/startup/auth_session_bootstrapper.dart apps/lib/main.dart apps/test/core/startup/auth_session_bootstrapper_test.dart +git commit -m "feat: replay reminder outbox on startup" +``` + +### Task 6: Implement overlap strategy (aggregate popup) + +**Files:** +- Create: `apps/lib/features/calendar/reminders/reminder_overlap_policy.dart` +- Modify: `apps/lib/core/notifications/local_notification_service.dart` +- Test: `apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart` + +**Step 1: Write failing tests** + +```dart +test('groups reminders whose fire time falls into same minute bucket', () {}); +test('creates aggregate payload with top-3 preview and ids', () {}); +``` + +**Step 2: Run tests (expect FAIL)** + +Run: `flutter test test/features/calendar/reminders/reminder_overlap_policy_test.dart` + +**Step 3: Write minimal implementation** + +Policy: +- same minute bucket => one aggregate popup +- actions apply to all members by default +- payload includes aggregateIds + +**Step 4: Re-run tests (expect PASS)** + +Run: `flutter test test/features/calendar/reminders/reminder_overlap_policy_test.dart` + +**Step 5: Commit** + +```bash +git add apps/lib/features/calendar/reminders/reminder_overlap_policy.dart apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart +git commit -m "feat: add overlap aggregation policy for reminders" +``` + +### Task 7: Render archived events as gray in calendar UI + +**Files:** +- Modify: `apps/lib/features/calendar/ui/**` (event color resolution points) +- Test: `apps/test/features/calendar/ui/*archived*test.dart` (new if missing) + +**Step 1: Write failing tests** + +```dart +testWidgets('archived events use gray token color', (tester) async {}); +``` + +**Step 2: Run tests (expect FAIL)** + +Run: `flutter test test/features/calendar/ui` + +**Step 3: Write minimal implementation** + +Rule: +- if `status == archived`, force token-based gray (do not mutate persisted `metadata.color`) + +**Step 4: Re-run tests (expect PASS)** + +Run: `flutter test test/features/calendar/ui` + +**Step 5: Commit** + +```bash +git add apps/lib/features/calendar/ui apps/test/features/calendar/ui +git commit -m "feat: render archived calendar events in gray" +``` + +### Task 8: Backend reuse route + add expired-event auto-archive safety job + +**Files:** +- Modify: `backend/src/v1/schedule_items/service.py` (if needed for stricter status transition) +- Modify: `backend/src/v1/schedule_items/repository.py` (range query to overlap query) +- Create: `backend/src/jobs/schedule_item_archive_job.py` (or existing worker module path) +- Modify: worker scheduler registration file under `backend/src/core/celery/` (actual existing path) +- Test: `backend/tests/unit/v1/schedule_items/test_service.py` +- Test: `backend/tests/unit/v1/schedule_items/test_repository.py` +- Test: `backend/tests/unit/jobs/test_schedule_item_archive_job.py` + +**Step 1: Write failing tests** + +```python +def test_patch_status_archived_allowed_for_owner() -> None: ... +def test_list_by_overlap_includes_started_but_not_ended_items() -> None: ... +def test_archive_job_marks_expired_active_items_archived() -> None: ... +``` + +**Step 2: Run tests (expect FAIL)** + +Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py backend/tests/unit/jobs/test_schedule_item_archive_job.py -q` + +**Step 3: Write minimal implementation** + +Implement/verify: +- route reuse: PATCH status archived works as-is for authorized user +- overlap query for bootstrap: `start_at <= window_end AND (end_at IS NULL OR end_at >= window_start)` and `status=active` +- periodic archive job: `end_at < now and status=active -> archived` + +**Step 4: Re-run tests (expect PASS)** + +Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py backend/tests/unit/jobs/test_schedule_item_archive_job.py -q` + +**Step 5: Commit** + +```bash +git add backend/src backend/tests +git commit -m "feat: add expired schedule auto-archive safety job" +``` + +### Task 9: End-to-end verification and release notes + +**Files:** +- Modify: `docs/runtime/runtime-runbook.md` +- Modify: `docs/protocols/` reminder doc from Task 1 + +**Step 1: Run frontend verification** + +Run: +- `flutter analyze` +- `flutter test` + +Expected: PASS + +**Step 2: Run backend verification** + +Run: +- `uv run pytest backend/tests/unit/v1/schedule_items -q` +- `uv run pytest backend/tests/unit/jobs/test_schedule_item_archive_job.py -q` + +Expected: PASS + +**Step 3: Manual device matrix** + +Android: +- app foreground/background/terminated for cancel/snooze/timeout +- overlap popup behavior +- endAt stop reminder + archive + +iOS: +- action button behavior in foreground/background/terminated +- timeout -> snooze behavior after relaunch sync +- archive sync after offline period + +**Step 4: Document operational caveats** + +Include: +- Android full-screen may degrade to heads-up by OEM policy +- iOS does not guarantee Android-style full-screen alarm behavior +- eventual consistency via outbox + startup replay + backend safety job + +**Step 5: Commit** + +```bash +git add docs/runtime/runtime-runbook.md docs/protocols +git commit -m "docs: add reminder action runbook and platform caveats" +``` + +## Notes on iOS parity + +- iOS supports actionable local notifications via Darwin categories; implement `cancel` and `snooze_10m` action identifiers with same payload model. +- iOS cannot guarantee Android-like forced full-screen alarm takeover; use lock-screen alert + sound + action buttons as equivalent UX. +- App-terminated network callback reliability is lower on iOS; therefore outbox + startup replay is mandatory for parity. + +## Data contracts and constraints (added) + +### Reminder payload contract + +```json +{ + "eventId": "uuid", + "title": "string", + "startAt": "iso8601-with-offset", + "endAt": "iso8601-with-offset|null", + "timezone": "IANA", + "location": "string|null", + "notes": "string|null", + "color": "#RRGGBB|null", + "mode": "single|aggregate", + "aggregateIds": ["uuid"] +} +``` + +Constraints: +- `eventId` required and valid UUID. +- `startAt` must be timezone-aware datetime. +- `mode=aggregate` requires `aggregateIds.length >= 2`. +- Payload versioning should be explicit if schema evolves. + +### Reminder outbox contract + +```json +{ + "opId": "uuid", + "eventId": "uuid", + "action": "cancel|snooze_10m|timeout_30s|auto_archive", + "targetStatus": "archived|null", + "occurredAt": "iso8601-with-offset", + "retryCount": 0, + "nextRetryAt": "iso8601-with-offset|null", + "state": "pending|done|dead", + "lastError": "string|null" +} +``` + +Constraints: +- Idempotency key: `(eventId, action, occurredAtBucket)`. +- Exponential backoff retries with capped max attempts. +- `cancel` and `auto_archive` both map to backend `status=archived` PATCH. + +### Uniqueness and dedupe rules + +- Notification identity uses deterministic key per event+cycle: `hash(eventId + cycleStartEpochMinutes + mode)`. +- Before scheduling any reminder, cancel existing pending reminders for same dedupe key. +- On bootstrap/reinstall, dedupe against local pending requests and outbox state before creating new schedules. +- Compensation reminder (`remindAt <= now < endAt`) must generate exactly one immediate reminder per cycle window. + +## Rollback plan + +1) Disable action handling by feature flag while keeping plain reminders. +2) Keep backend PATCH status route unchanged (safe rollback path). +3) Pause auto-archive job if unexpected archival spikes occur. diff --git a/docs/protocols/agent/api-endpoints.md b/docs/protocols/agent/api-endpoints.md index 5618210..ea4ad8d 100644 --- a/docs/protocols/agent/api-endpoints.md +++ b/docs/protocols/agent/api-endpoints.md @@ -76,6 +76,7 @@ Base URL: `/api/v1/agent` - `200 OK` - `Content-Type: text/event-stream` - 事件类型与字段见 `docs/protocols/agent/sse-events.md` +- usage 审计与成本回退策略见 `docs/protocols/agent/sse-events.md`(5) Usage 审计协议) - 空闲时会发送 keep-alive 注释行 `: keep-alive` ### 错误码 diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index fa7c39c..3e3164a 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -197,7 +197,107 @@ data: `inputTokens`、`outputTokens`、`cost`、`latencyMs`、`model` 属于后端内部统计字段,不在 SSE 对外协议中暴露。 -### 3.5 快照事件 +--- + +## 5) Usage 审计协议(后端内部) + +本节描述后端对 LLM usage 的内部审计与计费策略。该协议用于数据库持久化、成本统计与运行观测,不对 SSE 外部协议直接暴露。 + +### 5.1 当前厂商范围 + +- DashScope(Qwen) +- DeepSeek + +当前实现仅针对上述两家做深度适配。 + +### 5.2 原始字段采集(Provider -> Runtime) + +`TrackingChatModel` 会优先读取 provider 直接字段,读取不到时再从 metadata 补齐。 + +优先级如下: + +1. 直接字段(优先) + - `usage.input_tokens` + - `usage.output_tokens` + - `usage.total_tokens` + - `usage.time`(秒) + - `usage.cost`(若存在) +2. metadata 字段(补齐) + - `metadata.prompt_tokens` + - `metadata.completion_tokens` + - `metadata.total_tokens` + - `metadata.prompt_tokens_details.cached_tokens` + - `metadata.prompt_cache_hit_tokens` + - `metadata.prompt_cache_miss_tokens` + - `metadata.completion_tokens_details.reasoning_tokens` + - `metadata.cost` / `metadata.total_cost`(若存在) + +### 5.3 归一化后的内部 usage_summary 字段 + +`TrackingChatModel.usage_summary()` 当前输出: + +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `latency_ms`(由 `usage.time * 1000` 转换) +- `cached_prompt_tokens` +- `prompt_cache_hit_tokens` +- `prompt_cache_miss_tokens` +- `reasoning_tokens` +- `direct_cost` +- `direct_cost_observed`(0/1) +- `direct_cost_complete`(0/1) +- `model_call_records` +- `usage_records` +- `direct_cost_records` +- `cost_source`(`provider` | `catalog_fallback`) + +### 5.4 成本计算策略(严谨优先) + +核心原则:**能直接用 provider 返回就直接用;缺失才 fallback。** + +`LiteLLMService.build_usage_metadata()` 执行规则: + +1. 仅当以下条件同时满足时使用 provider 直出成本: + - `usageComplete == true`(`model_call_records == usage_records`) + - `direct_cost_observed == 1` + - `direct_cost_complete == 1` + - `direct_cost` 为有效非负数 +2. 否则使用 catalog 价格回退计算(`calculate_cost`) + +### 5.5 Fallback 计费细节 + +- 档位选择:按 `prompt_tokens` 命中 `pricing_tiers.max_prompt_tokens` +- 公式: + +```text +cost = uncached_prompt_tokens * input_cost_per_token + + cached_prompt_tokens * cached_token_rate + + completion_tokens * output_cost_per_token +``` + +- `cached_token_rate` 规则: + - 若 tier 配置了 `cache_hit_cost_per_token` 且 > 0,使用该值 + - 否则回退为 `input_cost_per_token` + +### 5.6 内部 costSource 语义 + +- `provider`: 使用 provider 直接成本 +- `catalog_fallback`: 正常使用价格表回退 +- `catalog_fallback_incomplete_provider_cost`: provider 返回了部分 direct cost,但不完整,回退价格表 +- `incomplete_usage_fallback`: usage 本身不完整,回退价格表 + +### 5.7 DeepSeek / DashScope 当前观测到的返回特征 + +根据当前线上探针与运行结果: + +- 两家都稳定返回:`input_tokens`、`output_tokens`、`time` +- `usage.total_tokens` 顶层可能为空,但 `metadata.total_tokens` 可用 +- DeepSeek 常见 `prompt_tokens_details.cached_tokens`、`prompt_cache_hit_tokens`、`prompt_cache_miss_tokens` +- DashScope 常见 `completion_tokens_details.reasoning_tokens`(可能为 `null`) +- 两家当前都未稳定提供直接 `cost` 字段,因此多数场景为 catalog fallback + +## 6) 快照事件 编码器支持以下 AG-UI 类型映射: @@ -208,7 +308,7 @@ data: --- -## 4) 字段命名约定 +## 7) 字段命名约定 - 事件顶层通用字段使用 AG-UI 风格:`type`、`threadId`、`runId` - 部分业务字段沿运行时模型历史命名保留下划线: diff --git a/docs/protocols/calendar/reminder-alert-lifecycle.md b/docs/protocols/calendar/reminder-alert-lifecycle.md new file mode 100644 index 0000000..d6d1925 --- /dev/null +++ b/docs/protocols/calendar/reminder-alert-lifecycle.md @@ -0,0 +1,143 @@ +# Calendar Reminder Alert Lifecycle Protocol + +## Version + +- Current: `1.0` +- Status: Draft + +--- + +## Goal + +定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、超时、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。 + +--- + +## Canonical Rules + +1. 提醒弹窗动作语义跨平台一致:`cancel`、`snooze_10m`、`timeout_30s`。 +2. `timeout_30s` 语义固定为忽略当前弹窗并按 10 分钟后再次提醒,不直接归档。 +3. `cancel` 必须归档对应日程(`status=archived`)。 +4. 到达事件结束时间后(`now >= end_at`)必须停止提醒并归档。 +5. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。 +6. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。 + +--- + +## Reminder Payload Contract + +```json +{ + "eventId": "uuid", + "title": "string", + "startAt": "iso8601-with-offset", + "endAt": "iso8601-with-offset|null", + "timezone": "IANA", + "location": "string|null", + "notes": "string|null", + "color": "#RRGGBB|null", + "mode": "single|aggregate", + "aggregateIds": ["uuid"], + "version": 1 +} +``` + +### Constraints + +- `eventId` 必填且为 UUID。 +- `startAt` 必须带时区偏移。 +- `mode=aggregate` 时,`aggregateIds` 至少包含 2 个 id。 +- `version` 必填,用于后续协议升级兼容。 + +--- + +## Action Contract + +动作枚举: + +- `cancel`: 用户取消提醒并归档事件 +- `snooze_10m`: 用户点击稍后提醒,重排到 `now + 10m` +- `timeout_30s`: 用户 30s 未处理,按 `snooze_10m` 处理 +- `auto_archive`: 系统判定事件已过期,自动归档 + +--- + +## Outbox Contract (Frontend) + +```json +{ + "opId": "uuid", + "eventId": "uuid", + "action": "cancel|snooze_10m|timeout_30s|auto_archive", + "targetStatus": "archived|null", + "occurredAt": "iso8601-with-offset", + "retryCount": 0, + "nextRetryAt": "iso8601-with-offset|null", + "state": "pending|done|dead", + "lastError": "string|null" +} +``` + +### Constraints + +- 幂等键:`(eventId, action, occurredAtBucket)`。 +- 重试策略:指数退避,最大重试次数可配置。 +- `cancel` 和 `auto_archive` 都映射到后端 `PATCH status=archived`。 +- Outbox 记录必须本地持久化,App 重启后可恢复。 + +--- + +## Scheduling and Compensation Rules + +### Normal schedule + +- `remindAt = startAt - reminderMinutes` + +### Bootstrap/reinstall compensation + +启动重建时对每个 active 事件执行: + +1. `now < remindAt`:按 `remindAt` 正常调度。 +2. `remindAt <= now < endAt`:立刻补偿提醒(建议 `+5s`),然后进入 10 分钟节奏。 +3. `now >= endAt`:不再提醒,走归档流程。 + +--- + +## Uniqueness and Dedupe Rules + +- 通知唯一键:`hash(eventId + cycleStartEpochMinutes + mode)`。 +- 每次创建提醒前必须取消同 dedupe key 的旧提醒(upsert 语义)。 +- 补偿提醒在同一 cycle 窗口内最多触发一次。 +- 启动恢复时要同时参考 pending notification 和 outbox 状态,避免重复调度。 + +--- + +## Overlap Rule + +- 同一分钟内多个提醒合并为一个 aggregate 弹窗。 +- aggregate 弹窗默认操作作用于全部成员事件。 +- aggregate 负载中必须包含 `aggregateIds` 以支持后续批处理。 + +--- + +## Backend Contract Reuse + +- 复用现有接口:`PATCH /schedule-items/{item_id}`,请求体传 `{"status":"archived"}`。 +- 建议提供/改造 overlap 查询语义用于启动补偿: + - `start_at <= window_end` + - `end_at IS NULL OR end_at >= window_start` + - `status=active` + +--- + +## Platform Notes + +### Android + +- 优先 full-screen intent;系统可能因策略降级为 heads-up/横幅。 +- 声音和振动受通知通道及系统设置影响。 + +### iOS + +- 支持动作按钮与本地提醒语义。 +- 不保证 Android 式强制全屏闹钟弹窗;以锁屏/横幅提醒为主。 diff --git a/docs/todo/2026-03-17-asr.md b/docs/todo/2026-03-17-asr.md index 1f8a32f..6be1e09 100644 --- a/docs/todo/2026-03-17-asr.md +++ b/docs/todo/2026-03-17-asr.md @@ -1 +1,5 @@ -当前项目有语音识别功能,但是语音识别的cost成本计算没有实现。目前我们用的模型是fun-asr-realtime-2026-02-28,价格是0.00033元/每秒。我希望把它做到backend/src/core/config/static/database/llm_catalog.yaml,加一个asr字段,引入model_code代替原agent router里的硬编码,通过加载配置获取模型信息和报价,然后根据后端路由接收到的音频长度然后来估算价格,或者看看dashscope的sdk是否会返回消耗token金额,将这个token金额看看如何审计 +- 当前项目有语音识别功能,但是语音识别的cost成本计算没有实现。目前我们用的模型是fun-asr-realtime-2026-02-28,价格是0.00033元/每秒。我希望把它做到backend/src/core/config/static/database/llm_catalog.yaml,加一个asr字段,引入model_code代替原agent router里的硬编码,通过加载配置获取模型信息和报价,然后根据后端路由接收到的音频长度然后来估算价格,或者看看dashscope的sdk是否会返回消耗token金额,将这个token金额看看如何审计 +- 路由细分,方便agent url的导航跳转 +- agent模式重构 +- 日历导出和导入 +- 手机号注册、登录 diff --git a/pyproject.toml b/pyproject.toml index 36c3738..4c8800c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "asyncpg>=0.31.0", "email-validator>=2.3.0", "fastapi>=0.128.0", - "litellm>=1.52.0", "pydantic>=2.11.0", "pydantic-settings>=2.10.0", "pyjwt>=2.10.1",