From b42de7d9ccc07fe3487577a9ac3effe1d3ba0377 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 6 Mar 2026 20:47:06 +0100 Subject: [PATCH 01/58] Implement voice memos transcription using local Whisper. --- zswatch_app/android/app/build.gradle.kts | 2 +- zswatch_app/android/build.gradle.kts | 89 + zswatch_app/lib/app.dart | 4 + .../lib/data/database/app_database.dart | 120 +- .../lib/data/database/app_database.g.dart | 1127 ++++++++++++ .../database/tables/voice_memos_table.dart | 51 + zswatch_app/lib/data/models/voice_memo.dart | 135 ++ .../repositories/voice_memo_repository.dart | 168 ++ zswatch_app/lib/main.dart | 4 + .../lib/providers/settings_providers.dart | 37 + .../lib/providers/voice_memo_providers.dart | 309 ++++ .../services/voice_memo/ogg_opus_writer.dart | 242 +++ .../voice_memo/transcription_engine.dart | 688 ++++++++ .../voice_memo/voice_memo_sync_service.dart | 494 ++++++ .../services/voice_memo/zsw_opus_parser.dart | 165 ++ zswatch_app/lib/services/watch_service.dart | 59 +- zswatch_app/lib/ui/navigation/app_router.dart | 30 +- .../ui/screens/settings/settings_screen.dart | 410 +++++ .../voice_memos/voice_memos_screen.dart | 1569 +++++++++++++++++ .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 6 + zswatch_app/pubspec.lock | 88 +- zswatch_app/pubspec.yaml | 8 + zswatch_app/test/zsw_opus_parser_test.dart | 474 +++++ .../windows/flutter/generated_plugins.cmake | 1 + 25 files changed, 6266 insertions(+), 15 deletions(-) create mode 100644 zswatch_app/lib/data/database/tables/voice_memos_table.dart create mode 100644 zswatch_app/lib/data/models/voice_memo.dart create mode 100644 zswatch_app/lib/data/repositories/voice_memo_repository.dart create mode 100644 zswatch_app/lib/providers/voice_memo_providers.dart create mode 100644 zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart create mode 100644 zswatch_app/lib/services/voice_memo/transcription_engine.dart create mode 100644 zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart create mode 100644 zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart create mode 100644 zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart create mode 100644 zswatch_app/test/zsw_opus_parser_test.dart diff --git a/zswatch_app/android/app/build.gradle.kts b/zswatch_app/android/app/build.gradle.kts index 316723a..3dcbcbb 100644 --- a/zswatch_app/android/app/build.gradle.kts +++ b/zswatch_app/android/app/build.gradle.kts @@ -54,7 +54,7 @@ val hasReleaseKeystore = releaseKeystoreConfig != null android { namespace = "dev.zswatch.app" compileSdk = 36 - ndkVersion = flutter.ndkVersion + ndkVersion = "29.0.13113456" // Required by whisper_ggml_plus (highest NDK among all plugins) compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/zswatch_app/android/build.gradle.kts b/zswatch_app/android/build.gradle.kts index dbee657..568ca3e 100644 --- a/zswatch_app/android/build.gradle.kts +++ b/zswatch_app/android/build.gradle.kts @@ -15,6 +15,95 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } +// Fix whisper_ggml_plus v1.3.5 Android bugs (see https://github.com/DDULDDUCK/whisper_ggml_plus/issues/15): +// Bug 1: Missing GGML_USE_CPU define → CPU backend never registered → SIGABRT on model load +// Bug 2: Missing arch-specific sources (ARM NEON quants) → linker errors after Bug 1 fix +// Bug 3: Missing -llog link → undefined __android_log_print +// Bug 4: -fvisibility=hidden hides FFI "request" symbol → Failed to lookup symbol +// Plus: main.cpp hardcodes use_gpu=true and flash_attn=true (no GPU backend on Android) +subprojects { + if (project.name == "whisper_ggml_plus") { + project.afterEvaluate { + // Fix Bug 4: Remove -fvisibility=hidden from Gradle-level flags + extensions.findByType(com.android.build.gradle.LibraryExtension::class.java)?.apply { + defaultConfig.externalNativeBuild.cmake.apply { + val fixedC = cFlags.map { + it.replace("-fvisibility=hidden", "") + .replace("-fvisibility-inlines-hidden", "") + } + cFlags.clear() + cFlags.addAll(fixedC) + + val fixedCpp = cppFlags.map { + it.replace("-fvisibility=hidden", "") + .replace("-fvisibility-inlines-hidden", "") + } + cppFlags.clear() + cppFlags.addAll(fixedCpp) + } + } + + // Fix Bugs 1-3: Patch CMakeLists.txt to add GGML_USE_CPU, arch sources, and -llog + val cmakeLists = project.file("src/whisper/CMakeLists.txt") + if (cmakeLists.exists()) { + var cmake = cmakeLists.readText() + var modified = false + + // Bug 1+2: Add GGML_USE_CPU=1 define and architecture-specific source files + if (!cmake.contains("GGML_USE_CPU")) { + val archSources = """ +# Architecture-specific source files (ARM NEON / x86 SSE-AVX) +if (ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "armeabi-v7a") + file(GLOB GGML_CPU_ARCH_SRC + "${'$'}{GGML_CPU_SRC_DIR}/arch/arm/*.cpp" + "${'$'}{GGML_CPU_SRC_DIR}/arch/arm/*.c" + ) +elseif (ANDROID_ABI STREQUAL "x86" OR ANDROID_ABI STREQUAL "x86_64") + file(GLOB GGML_CPU_ARCH_SRC + "${'$'}{GGML_CPU_SRC_DIR}/arch/x86/*.cpp" + "${'$'}{GGML_CPU_SRC_DIR}/arch/x86/*.c" + ) +endif() + +""" + cmake = cmake.replace( + "add_library(whisper \${WHISPER_SRC})", + archSources + "add_library(whisper \${WHISPER_SRC} \${GGML_CPU_ARCH_SRC})\ntarget_compile_definitions(whisper PRIVATE GGML_USE_CPU=1)" + ) + modified = true + } + + // Bug 3: Add log library to linker + if (cmake.contains("target_link_libraries(whisper_flutter PRIVATE whisper)") && + !cmake.contains("whisper log)")) { + cmake = cmake.replace( + "target_link_libraries(whisper_flutter PRIVATE whisper)", + "target_link_libraries(whisper_flutter PRIVATE whisper log)" + ) + modified = true + } + + if (modified) { + cmakeLists.writeText(cmake) + logger.lifecycle("[ZSWatch] Patched whisper_ggml_plus CMakeLists.txt: added GGML_USE_CPU=1, arch sources, -llog") + } + } + + // Patch main.cpp: disable flash_attn and use_gpu (no GPU backend on Android) + val mainCpp = project.file("src/whisper/main.cpp") + if (mainCpp.exists()) { + var src = mainCpp.readText() + val patched = src + .replace("cparams.flash_attn = true;", "cparams.flash_attn = false;") + .replace("cparams.use_gpu = true;", "cparams.use_gpu = false;") + if (patched != src) { + mainCpp.writeText(patched) + logger.lifecycle("[ZSWatch] Patched whisper_ggml_plus main.cpp: disabled flash_attn and use_gpu") + } + } + } + } +} subprojects { project.evaluationDependsOn(":app") } diff --git a/zswatch_app/lib/app.dart b/zswatch_app/lib/app.dart index d3c8659..cfd09b0 100644 --- a/zswatch_app/lib/app.dart +++ b/zswatch_app/lib/app.dart @@ -9,6 +9,7 @@ import 'providers/gps_providers.dart'; import 'providers/http_providers.dart'; import 'providers/notification_providers.dart'; import 'providers/permission_providers.dart'; +import 'providers/voice_memo_providers.dart'; import 'providers/watch_service_provider.dart'; import 'ui/navigation/app_router.dart'; @@ -59,6 +60,9 @@ class _ZSWatchAppState extends ConsumerState { // Initialize watch info persistence to sync firmware version and lastConnectedAt to database // This listens to watch info and connection state changes and persists them ref.read(watchInfoPersistenceProvider); + // Initialize voice memo sync service to handle recording sync from watch + // This subscribes to watch messages for new recording notifications + ref.read(voiceMemoSyncServiceProvider); } catch (e) { debugPrint('BLE initialization error: $e'); } diff --git a/zswatch_app/lib/data/database/app_database.dart b/zswatch_app/lib/data/database/app_database.dart index 61e8271..0c54b36 100644 --- a/zswatch_app/lib/data/database/app_database.dart +++ b/zswatch_app/lib/data/database/app_database.dart @@ -9,6 +9,7 @@ import 'tables/battery_readings_table.dart'; import 'tables/comm_log_entries_table.dart'; import 'tables/connection_events_table.dart'; import 'tables/health_samples_table.dart'; +import 'tables/voice_memos_table.dart'; import 'tables/watches_table.dart'; part 'app_database.g.dart'; @@ -22,14 +23,14 @@ part 'app_database.g.dart'; /// - CommLogEntries: BLE communication logs for debugging /// - ConnectionEvents: Connection/disconnection events for analytics @DriftDatabase( - tables: [Watches, HealthSamples, BatteryReadings, CommLogEntries, ConnectionEvents], + tables: [Watches, HealthSamples, BatteryReadings, CommLogEntries, ConnectionEvents, VoiceMemos], ) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); /// Database schema version - increment when making schema changes @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration { @@ -47,6 +48,10 @@ class AppDatabase extends _$AppDatabase { // Add connection events table for analytics (US9) await m.createTable(connectionEvents); } + if (from < 4) { + // Add voice memos table for voice recording sync + await m.createTable(voiceMemos); + } }, ); } @@ -305,6 +310,117 @@ class AppDatabase extends _$AppDatabase { .watch(); } + // ==================== Voice Memo Operations ==================== + + /// Get all voice memos, newest first + Future> getAllVoiceMemos() { + return (select(voiceMemos) + ..orderBy([(v) => OrderingTerm.desc(v.timestampUtc)])) + .get(); + } + + /// Watch all voice memos (reactive stream), newest first + Stream> watchAllVoiceMemos() { + return (select(voiceMemos) + ..orderBy([(v) => OrderingTerm.desc(v.timestampUtc)])) + .watch(); + } + + /// Get voice memo by filename + Future getVoiceMemoByFilename(String filename) async { + final rows = await (select(voiceMemos) + ..where((v) => v.filename.equals(filename))) + .get(); + if (rows.length > 1) { + // Clean up stale duplicates (can occur from race conditions on double + // BLE notification delivery). Keep the first row, delete the rest. + for (final extra in rows.skip(1)) { + await (delete(voiceMemos)..where((v) => v.id.equals(extra.id))).go(); + } + } + return rows.isEmpty ? null : rows.first; + } + + /// Get voice memos not yet downloaded + Future> getUndownloadedVoiceMemos() { + return (select(voiceMemos) + ..where((v) => v.syncedFromWatch.equals(false)) + ..orderBy([(v) => OrderingTerm.asc(v.timestampUtc)])) + .get(); + } + + /// Get voice memos that are synced but not yet transcribed + Future> getUntranscribedVoiceMemos() { + return (select(voiceMemos) + ..where((v) => + v.syncedFromWatch.equals(true) & v.transcription.isNull()) + ..orderBy([(v) => OrderingTerm.asc(v.timestampUtc)])) + .get(); + } + + /// Insert or update a voice memo (upsert by filename) + Future upsertVoiceMemo(VoiceMemosCompanion memo) async { + // getVoiceMemoByFilename also deduplicates if stale duplicates exist. + final existing = await getVoiceMemoByFilename(memo.filename.value); + if (existing != null) { + await (update(voiceMemos) + ..where((v) => v.filename.equals(memo.filename.value))) + .write(memo); + } else { + await into(voiceMemos).insert(memo); + } + } + + /// Mark a voice memo as downloaded + Future updateVoiceMemoDownloaded({ + required String filename, + required String localFilePath, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion( + syncedFromWatch: const Value(true), + localFilePath: Value(localFilePath), + downloadedAt: Value(DateTime.now()), + )); + } + + /// Mark a voice memo as deleted on the watch + Future updateVoiceMemoDeletedOnWatch(String filename) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(const VoiceMemosCompanion( + deletedOnWatch: Value(true), + )); + } + + /// Update transcription for a voice memo + Future updateVoiceMemoTranscription({ + required String filename, + required String transcription, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion( + transcription: Value(transcription), + transcribedAt: Value(DateTime.now()), + )); + } + + /// Update converted file path for a voice memo + Future updateVoiceMemoConvertedPath({ + required String filename, + required String convertedFilePath, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion( + convertedFilePath: Value(convertedFilePath), + )); + } + + /// Delete a voice memo by filename + Future deleteVoiceMemo(String filename) { + return (delete(voiceMemos)..where((v) => v.filename.equals(filename))) + .go(); + } + // ==================== Data Retention ==================== /// Clean up old data (60-day retention) diff --git a/zswatch_app/lib/data/database/app_database.g.dart b/zswatch_app/lib/data/database/app_database.g.dart index 9da9e7f..508acd9 100644 --- a/zswatch_app/lib/data/database/app_database.g.dart +++ b/zswatch_app/lib/data/database/app_database.g.dart @@ -2457,6 +2457,784 @@ class ConnectionEventsCompanion extends UpdateCompanion { } } +class $VoiceMemosTable extends VoiceMemos + with TableInfo<$VoiceMemosTable, VoiceMemoEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $VoiceMemosTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _filenameMeta = const VerificationMeta( + 'filename', + ); + @override + late final GeneratedColumn filename = GeneratedColumn( + 'filename', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _timestampUtcMeta = const VerificationMeta( + 'timestampUtc', + ); + @override + late final GeneratedColumn timestampUtc = GeneratedColumn( + 'timestamp_utc', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _durationMsMeta = const VerificationMeta( + 'durationMs', + ); + @override + late final GeneratedColumn durationMs = GeneratedColumn( + 'duration_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _sizeBytesMeta = const VerificationMeta( + 'sizeBytes', + ); + @override + late final GeneratedColumn sizeBytes = GeneratedColumn( + 'size_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _localFilePathMeta = const VerificationMeta( + 'localFilePath', + ); + @override + late final GeneratedColumn localFilePath = GeneratedColumn( + 'local_file_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _transcriptionMeta = const VerificationMeta( + 'transcription', + ); + @override + late final GeneratedColumn transcription = GeneratedColumn( + 'transcription', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _syncedFromWatchMeta = const VerificationMeta( + 'syncedFromWatch', + ); + @override + late final GeneratedColumn syncedFromWatch = GeneratedColumn( + 'synced_from_watch', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("synced_from_watch" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _deletedOnWatchMeta = const VerificationMeta( + 'deletedOnWatch', + ); + @override + late final GeneratedColumn deletedOnWatch = GeneratedColumn( + 'deleted_on_watch', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("deleted_on_watch" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _downloadedAtMeta = const VerificationMeta( + 'downloadedAt', + ); + @override + late final GeneratedColumn downloadedAt = GeneratedColumn( + 'downloaded_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _transcribedAtMeta = const VerificationMeta( + 'transcribedAt', + ); + @override + late final GeneratedColumn transcribedAt = + GeneratedColumn( + 'transcribed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _convertedFilePathMeta = const VerificationMeta( + 'convertedFilePath', + ); + @override + late final GeneratedColumn convertedFilePath = + GeneratedColumn( + 'converted_file_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + filename, + timestampUtc, + durationMs, + sizeBytes, + localFilePath, + transcription, + syncedFromWatch, + deletedOnWatch, + downloadedAt, + transcribedAt, + convertedFilePath, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'voice_memos'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('filename')) { + context.handle( + _filenameMeta, + filename.isAcceptableOrUnknown(data['filename']!, _filenameMeta), + ); + } else if (isInserting) { + context.missing(_filenameMeta); + } + if (data.containsKey('timestamp_utc')) { + context.handle( + _timestampUtcMeta, + timestampUtc.isAcceptableOrUnknown( + data['timestamp_utc']!, + _timestampUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_timestampUtcMeta); + } + if (data.containsKey('duration_ms')) { + context.handle( + _durationMsMeta, + durationMs.isAcceptableOrUnknown(data['duration_ms']!, _durationMsMeta), + ); + } else if (isInserting) { + context.missing(_durationMsMeta); + } + if (data.containsKey('size_bytes')) { + context.handle( + _sizeBytesMeta, + sizeBytes.isAcceptableOrUnknown(data['size_bytes']!, _sizeBytesMeta), + ); + } else if (isInserting) { + context.missing(_sizeBytesMeta); + } + if (data.containsKey('local_file_path')) { + context.handle( + _localFilePathMeta, + localFilePath.isAcceptableOrUnknown( + data['local_file_path']!, + _localFilePathMeta, + ), + ); + } + if (data.containsKey('transcription')) { + context.handle( + _transcriptionMeta, + transcription.isAcceptableOrUnknown( + data['transcription']!, + _transcriptionMeta, + ), + ); + } + if (data.containsKey('synced_from_watch')) { + context.handle( + _syncedFromWatchMeta, + syncedFromWatch.isAcceptableOrUnknown( + data['synced_from_watch']!, + _syncedFromWatchMeta, + ), + ); + } + if (data.containsKey('deleted_on_watch')) { + context.handle( + _deletedOnWatchMeta, + deletedOnWatch.isAcceptableOrUnknown( + data['deleted_on_watch']!, + _deletedOnWatchMeta, + ), + ); + } + if (data.containsKey('downloaded_at')) { + context.handle( + _downloadedAtMeta, + downloadedAt.isAcceptableOrUnknown( + data['downloaded_at']!, + _downloadedAtMeta, + ), + ); + } + if (data.containsKey('transcribed_at')) { + context.handle( + _transcribedAtMeta, + transcribedAt.isAcceptableOrUnknown( + data['transcribed_at']!, + _transcribedAtMeta, + ), + ); + } + if (data.containsKey('converted_file_path')) { + context.handle( + _convertedFilePathMeta, + convertedFilePath.isAcceptableOrUnknown( + data['converted_file_path']!, + _convertedFilePathMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + VoiceMemoEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return VoiceMemoEntity( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + filename: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}filename'], + )!, + timestampUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp_utc'], + )!, + durationMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_ms'], + )!, + sizeBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}size_bytes'], + )!, + localFilePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}local_file_path'], + ), + transcription: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}transcription'], + ), + syncedFromWatch: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}synced_from_watch'], + )!, + deletedOnWatch: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}deleted_on_watch'], + )!, + downloadedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}downloaded_at'], + ), + transcribedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}transcribed_at'], + ), + convertedFilePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}converted_file_path'], + ), + ); + } + + @override + $VoiceMemosTable createAlias(String alias) { + return $VoiceMemosTable(attachedDatabase, alias); + } +} + +class VoiceMemoEntity extends DataClass implements Insertable { + /// Auto-incrementing row identifier + final int id; + + /// Original filename on the watch (e.g., "20260304_143022") + final String filename; + + /// Recording timestamp as Unix epoch seconds (UTC) + final int timestampUtc; + + /// Recording duration in milliseconds + final int durationMs; + + /// File size in bytes (Opus-encoded .zsw_opus) + final int sizeBytes; + + /// Local file path after download (null = not yet downloaded) + final String? localFilePath; + + /// Transcription text (null = not yet transcribed) + final String? transcription; + + /// Whether the file has been synced (downloaded) from the watch + final bool syncedFromWatch; + + /// Whether the file has been deleted on the watch after sync + final bool deletedOnWatch; + + /// When the file was downloaded to the phone + final DateTime? downloadedAt; + + /// When the transcription was completed + final DateTime? transcribedAt; + + /// Path to converted audio file (WAV/Ogg) for playback/transcription + final String? convertedFilePath; + const VoiceMemoEntity({ + required this.id, + required this.filename, + required this.timestampUtc, + required this.durationMs, + required this.sizeBytes, + this.localFilePath, + this.transcription, + required this.syncedFromWatch, + required this.deletedOnWatch, + this.downloadedAt, + this.transcribedAt, + this.convertedFilePath, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['filename'] = Variable(filename); + map['timestamp_utc'] = Variable(timestampUtc); + map['duration_ms'] = Variable(durationMs); + map['size_bytes'] = Variable(sizeBytes); + if (!nullToAbsent || localFilePath != null) { + map['local_file_path'] = Variable(localFilePath); + } + if (!nullToAbsent || transcription != null) { + map['transcription'] = Variable(transcription); + } + map['synced_from_watch'] = Variable(syncedFromWatch); + map['deleted_on_watch'] = Variable(deletedOnWatch); + if (!nullToAbsent || downloadedAt != null) { + map['downloaded_at'] = Variable(downloadedAt); + } + if (!nullToAbsent || transcribedAt != null) { + map['transcribed_at'] = Variable(transcribedAt); + } + if (!nullToAbsent || convertedFilePath != null) { + map['converted_file_path'] = Variable(convertedFilePath); + } + return map; + } + + VoiceMemosCompanion toCompanion(bool nullToAbsent) { + return VoiceMemosCompanion( + id: Value(id), + filename: Value(filename), + timestampUtc: Value(timestampUtc), + durationMs: Value(durationMs), + sizeBytes: Value(sizeBytes), + localFilePath: localFilePath == null && nullToAbsent + ? const Value.absent() + : Value(localFilePath), + transcription: transcription == null && nullToAbsent + ? const Value.absent() + : Value(transcription), + syncedFromWatch: Value(syncedFromWatch), + deletedOnWatch: Value(deletedOnWatch), + downloadedAt: downloadedAt == null && nullToAbsent + ? const Value.absent() + : Value(downloadedAt), + transcribedAt: transcribedAt == null && nullToAbsent + ? const Value.absent() + : Value(transcribedAt), + convertedFilePath: convertedFilePath == null && nullToAbsent + ? const Value.absent() + : Value(convertedFilePath), + ); + } + + factory VoiceMemoEntity.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return VoiceMemoEntity( + id: serializer.fromJson(json['id']), + filename: serializer.fromJson(json['filename']), + timestampUtc: serializer.fromJson(json['timestampUtc']), + durationMs: serializer.fromJson(json['durationMs']), + sizeBytes: serializer.fromJson(json['sizeBytes']), + localFilePath: serializer.fromJson(json['localFilePath']), + transcription: serializer.fromJson(json['transcription']), + syncedFromWatch: serializer.fromJson(json['syncedFromWatch']), + deletedOnWatch: serializer.fromJson(json['deletedOnWatch']), + downloadedAt: serializer.fromJson(json['downloadedAt']), + transcribedAt: serializer.fromJson(json['transcribedAt']), + convertedFilePath: serializer.fromJson( + json['convertedFilePath'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'filename': serializer.toJson(filename), + 'timestampUtc': serializer.toJson(timestampUtc), + 'durationMs': serializer.toJson(durationMs), + 'sizeBytes': serializer.toJson(sizeBytes), + 'localFilePath': serializer.toJson(localFilePath), + 'transcription': serializer.toJson(transcription), + 'syncedFromWatch': serializer.toJson(syncedFromWatch), + 'deletedOnWatch': serializer.toJson(deletedOnWatch), + 'downloadedAt': serializer.toJson(downloadedAt), + 'transcribedAt': serializer.toJson(transcribedAt), + 'convertedFilePath': serializer.toJson(convertedFilePath), + }; + } + + VoiceMemoEntity copyWith({ + int? id, + String? filename, + int? timestampUtc, + int? durationMs, + int? sizeBytes, + Value localFilePath = const Value.absent(), + Value transcription = const Value.absent(), + bool? syncedFromWatch, + bool? deletedOnWatch, + Value downloadedAt = const Value.absent(), + Value transcribedAt = const Value.absent(), + Value convertedFilePath = const Value.absent(), + }) => VoiceMemoEntity( + id: id ?? this.id, + filename: filename ?? this.filename, + timestampUtc: timestampUtc ?? this.timestampUtc, + durationMs: durationMs ?? this.durationMs, + sizeBytes: sizeBytes ?? this.sizeBytes, + localFilePath: localFilePath.present + ? localFilePath.value + : this.localFilePath, + transcription: transcription.present + ? transcription.value + : this.transcription, + syncedFromWatch: syncedFromWatch ?? this.syncedFromWatch, + deletedOnWatch: deletedOnWatch ?? this.deletedOnWatch, + downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt, + transcribedAt: transcribedAt.present + ? transcribedAt.value + : this.transcribedAt, + convertedFilePath: convertedFilePath.present + ? convertedFilePath.value + : this.convertedFilePath, + ); + VoiceMemoEntity copyWithCompanion(VoiceMemosCompanion data) { + return VoiceMemoEntity( + id: data.id.present ? data.id.value : this.id, + filename: data.filename.present ? data.filename.value : this.filename, + timestampUtc: data.timestampUtc.present + ? data.timestampUtc.value + : this.timestampUtc, + durationMs: data.durationMs.present + ? data.durationMs.value + : this.durationMs, + sizeBytes: data.sizeBytes.present ? data.sizeBytes.value : this.sizeBytes, + localFilePath: data.localFilePath.present + ? data.localFilePath.value + : this.localFilePath, + transcription: data.transcription.present + ? data.transcription.value + : this.transcription, + syncedFromWatch: data.syncedFromWatch.present + ? data.syncedFromWatch.value + : this.syncedFromWatch, + deletedOnWatch: data.deletedOnWatch.present + ? data.deletedOnWatch.value + : this.deletedOnWatch, + downloadedAt: data.downloadedAt.present + ? data.downloadedAt.value + : this.downloadedAt, + transcribedAt: data.transcribedAt.present + ? data.transcribedAt.value + : this.transcribedAt, + convertedFilePath: data.convertedFilePath.present + ? data.convertedFilePath.value + : this.convertedFilePath, + ); + } + + @override + String toString() { + return (StringBuffer('VoiceMemoEntity(') + ..write('id: $id, ') + ..write('filename: $filename, ') + ..write('timestampUtc: $timestampUtc, ') + ..write('durationMs: $durationMs, ') + ..write('sizeBytes: $sizeBytes, ') + ..write('localFilePath: $localFilePath, ') + ..write('transcription: $transcription, ') + ..write('syncedFromWatch: $syncedFromWatch, ') + ..write('deletedOnWatch: $deletedOnWatch, ') + ..write('downloadedAt: $downloadedAt, ') + ..write('transcribedAt: $transcribedAt, ') + ..write('convertedFilePath: $convertedFilePath') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + filename, + timestampUtc, + durationMs, + sizeBytes, + localFilePath, + transcription, + syncedFromWatch, + deletedOnWatch, + downloadedAt, + transcribedAt, + convertedFilePath, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is VoiceMemoEntity && + other.id == this.id && + other.filename == this.filename && + other.timestampUtc == this.timestampUtc && + other.durationMs == this.durationMs && + other.sizeBytes == this.sizeBytes && + other.localFilePath == this.localFilePath && + other.transcription == this.transcription && + other.syncedFromWatch == this.syncedFromWatch && + other.deletedOnWatch == this.deletedOnWatch && + other.downloadedAt == this.downloadedAt && + other.transcribedAt == this.transcribedAt && + other.convertedFilePath == this.convertedFilePath); +} + +class VoiceMemosCompanion extends UpdateCompanion { + final Value id; + final Value filename; + final Value timestampUtc; + final Value durationMs; + final Value sizeBytes; + final Value localFilePath; + final Value transcription; + final Value syncedFromWatch; + final Value deletedOnWatch; + final Value downloadedAt; + final Value transcribedAt; + final Value convertedFilePath; + const VoiceMemosCompanion({ + this.id = const Value.absent(), + this.filename = const Value.absent(), + this.timestampUtc = const Value.absent(), + this.durationMs = const Value.absent(), + this.sizeBytes = const Value.absent(), + this.localFilePath = const Value.absent(), + this.transcription = const Value.absent(), + this.syncedFromWatch = const Value.absent(), + this.deletedOnWatch = const Value.absent(), + this.downloadedAt = const Value.absent(), + this.transcribedAt = const Value.absent(), + this.convertedFilePath = const Value.absent(), + }); + VoiceMemosCompanion.insert({ + this.id = const Value.absent(), + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + this.localFilePath = const Value.absent(), + this.transcription = const Value.absent(), + this.syncedFromWatch = const Value.absent(), + this.deletedOnWatch = const Value.absent(), + this.downloadedAt = const Value.absent(), + this.transcribedAt = const Value.absent(), + this.convertedFilePath = const Value.absent(), + }) : filename = Value(filename), + timestampUtc = Value(timestampUtc), + durationMs = Value(durationMs), + sizeBytes = Value(sizeBytes); + static Insertable custom({ + Expression? id, + Expression? filename, + Expression? timestampUtc, + Expression? durationMs, + Expression? sizeBytes, + Expression? localFilePath, + Expression? transcription, + Expression? syncedFromWatch, + Expression? deletedOnWatch, + Expression? downloadedAt, + Expression? transcribedAt, + Expression? convertedFilePath, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (filename != null) 'filename': filename, + if (timestampUtc != null) 'timestamp_utc': timestampUtc, + if (durationMs != null) 'duration_ms': durationMs, + if (sizeBytes != null) 'size_bytes': sizeBytes, + if (localFilePath != null) 'local_file_path': localFilePath, + if (transcription != null) 'transcription': transcription, + if (syncedFromWatch != null) 'synced_from_watch': syncedFromWatch, + if (deletedOnWatch != null) 'deleted_on_watch': deletedOnWatch, + if (downloadedAt != null) 'downloaded_at': downloadedAt, + if (transcribedAt != null) 'transcribed_at': transcribedAt, + if (convertedFilePath != null) 'converted_file_path': convertedFilePath, + }); + } + + VoiceMemosCompanion copyWith({ + Value? id, + Value? filename, + Value? timestampUtc, + Value? durationMs, + Value? sizeBytes, + Value? localFilePath, + Value? transcription, + Value? syncedFromWatch, + Value? deletedOnWatch, + Value? downloadedAt, + Value? transcribedAt, + Value? convertedFilePath, + }) { + return VoiceMemosCompanion( + id: id ?? this.id, + filename: filename ?? this.filename, + timestampUtc: timestampUtc ?? this.timestampUtc, + durationMs: durationMs ?? this.durationMs, + sizeBytes: sizeBytes ?? this.sizeBytes, + localFilePath: localFilePath ?? this.localFilePath, + transcription: transcription ?? this.transcription, + syncedFromWatch: syncedFromWatch ?? this.syncedFromWatch, + deletedOnWatch: deletedOnWatch ?? this.deletedOnWatch, + downloadedAt: downloadedAt ?? this.downloadedAt, + transcribedAt: transcribedAt ?? this.transcribedAt, + convertedFilePath: convertedFilePath ?? this.convertedFilePath, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (filename.present) { + map['filename'] = Variable(filename.value); + } + if (timestampUtc.present) { + map['timestamp_utc'] = Variable(timestampUtc.value); + } + if (durationMs.present) { + map['duration_ms'] = Variable(durationMs.value); + } + if (sizeBytes.present) { + map['size_bytes'] = Variable(sizeBytes.value); + } + if (localFilePath.present) { + map['local_file_path'] = Variable(localFilePath.value); + } + if (transcription.present) { + map['transcription'] = Variable(transcription.value); + } + if (syncedFromWatch.present) { + map['synced_from_watch'] = Variable(syncedFromWatch.value); + } + if (deletedOnWatch.present) { + map['deleted_on_watch'] = Variable(deletedOnWatch.value); + } + if (downloadedAt.present) { + map['downloaded_at'] = Variable(downloadedAt.value); + } + if (transcribedAt.present) { + map['transcribed_at'] = Variable(transcribedAt.value); + } + if (convertedFilePath.present) { + map['converted_file_path'] = Variable(convertedFilePath.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('VoiceMemosCompanion(') + ..write('id: $id, ') + ..write('filename: $filename, ') + ..write('timestampUtc: $timestampUtc, ') + ..write('durationMs: $durationMs, ') + ..write('sizeBytes: $sizeBytes, ') + ..write('localFilePath: $localFilePath, ') + ..write('transcription: $transcription, ') + ..write('syncedFromWatch: $syncedFromWatch, ') + ..write('deletedOnWatch: $deletedOnWatch, ') + ..write('downloadedAt: $downloadedAt, ') + ..write('transcribedAt: $transcribedAt, ') + ..write('convertedFilePath: $convertedFilePath') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -2469,6 +3247,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ConnectionEventsTable connectionEvents = $ConnectionEventsTable( this, ); + late final $VoiceMemosTable voiceMemos = $VoiceMemosTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -2479,6 +3258,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { batteryReadings, commLogEntries, connectionEvents, + voiceMemos, ]; } @@ -4386,6 +5166,351 @@ typedef $$ConnectionEventsTableProcessedTableManager = ConnectionEventEntity, PrefetchHooks Function({bool watchId}) >; +typedef $$VoiceMemosTableCreateCompanionBuilder = + VoiceMemosCompanion Function({ + Value id, + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + Value localFilePath, + Value transcription, + Value syncedFromWatch, + Value deletedOnWatch, + Value downloadedAt, + Value transcribedAt, + Value convertedFilePath, + }); +typedef $$VoiceMemosTableUpdateCompanionBuilder = + VoiceMemosCompanion Function({ + Value id, + Value filename, + Value timestampUtc, + Value durationMs, + Value sizeBytes, + Value localFilePath, + Value transcription, + Value syncedFromWatch, + Value deletedOnWatch, + Value downloadedAt, + Value transcribedAt, + Value convertedFilePath, + }); + +class $$VoiceMemosTableFilterComposer + extends Composer<_$AppDatabase, $VoiceMemosTable> { + $$VoiceMemosTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get filename => $composableBuilder( + column: $table.filename, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestampUtc => $composableBuilder( + column: $table.timestampUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sizeBytes => $composableBuilder( + column: $table.sizeBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localFilePath => $composableBuilder( + column: $table.localFilePath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get transcription => $composableBuilder( + column: $table.transcription, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncedFromWatch => $composableBuilder( + column: $table.syncedFromWatch, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedOnWatch => $composableBuilder( + column: $table.deletedOnWatch, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get transcribedAt => $composableBuilder( + column: $table.transcribedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get convertedFilePath => $composableBuilder( + column: $table.convertedFilePath, + builder: (column) => ColumnFilters(column), + ); +} + +class $$VoiceMemosTableOrderingComposer + extends Composer<_$AppDatabase, $VoiceMemosTable> { + $$VoiceMemosTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get filename => $composableBuilder( + column: $table.filename, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timestampUtc => $composableBuilder( + column: $table.timestampUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sizeBytes => $composableBuilder( + column: $table.sizeBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localFilePath => $composableBuilder( + column: $table.localFilePath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get transcription => $composableBuilder( + column: $table.transcription, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncedFromWatch => $composableBuilder( + column: $table.syncedFromWatch, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedOnWatch => $composableBuilder( + column: $table.deletedOnWatch, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get transcribedAt => $composableBuilder( + column: $table.transcribedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get convertedFilePath => $composableBuilder( + column: $table.convertedFilePath, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$VoiceMemosTableAnnotationComposer + extends Composer<_$AppDatabase, $VoiceMemosTable> { + $$VoiceMemosTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get filename => + $composableBuilder(column: $table.filename, builder: (column) => column); + + GeneratedColumn get timestampUtc => $composableBuilder( + column: $table.timestampUtc, + builder: (column) => column, + ); + + GeneratedColumn get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => column, + ); + + GeneratedColumn get sizeBytes => + $composableBuilder(column: $table.sizeBytes, builder: (column) => column); + + GeneratedColumn get localFilePath => $composableBuilder( + column: $table.localFilePath, + builder: (column) => column, + ); + + GeneratedColumn get transcription => $composableBuilder( + column: $table.transcription, + builder: (column) => column, + ); + + GeneratedColumn get syncedFromWatch => $composableBuilder( + column: $table.syncedFromWatch, + builder: (column) => column, + ); + + GeneratedColumn get deletedOnWatch => $composableBuilder( + column: $table.deletedOnWatch, + builder: (column) => column, + ); + + GeneratedColumn get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => column, + ); + + GeneratedColumn get transcribedAt => $composableBuilder( + column: $table.transcribedAt, + builder: (column) => column, + ); + + GeneratedColumn get convertedFilePath => $composableBuilder( + column: $table.convertedFilePath, + builder: (column) => column, + ); +} + +class $$VoiceMemosTableTableManager + extends + RootTableManager< + _$AppDatabase, + $VoiceMemosTable, + VoiceMemoEntity, + $$VoiceMemosTableFilterComposer, + $$VoiceMemosTableOrderingComposer, + $$VoiceMemosTableAnnotationComposer, + $$VoiceMemosTableCreateCompanionBuilder, + $$VoiceMemosTableUpdateCompanionBuilder, + ( + VoiceMemoEntity, + BaseReferences<_$AppDatabase, $VoiceMemosTable, VoiceMemoEntity>, + ), + VoiceMemoEntity, + PrefetchHooks Function() + > { + $$VoiceMemosTableTableManager(_$AppDatabase db, $VoiceMemosTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$VoiceMemosTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$VoiceMemosTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$VoiceMemosTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value filename = const Value.absent(), + Value timestampUtc = const Value.absent(), + Value durationMs = const Value.absent(), + Value sizeBytes = const Value.absent(), + Value localFilePath = const Value.absent(), + Value transcription = const Value.absent(), + Value syncedFromWatch = const Value.absent(), + Value deletedOnWatch = const Value.absent(), + Value downloadedAt = const Value.absent(), + Value transcribedAt = const Value.absent(), + Value convertedFilePath = const Value.absent(), + }) => VoiceMemosCompanion( + id: id, + filename: filename, + timestampUtc: timestampUtc, + durationMs: durationMs, + sizeBytes: sizeBytes, + localFilePath: localFilePath, + transcription: transcription, + syncedFromWatch: syncedFromWatch, + deletedOnWatch: deletedOnWatch, + downloadedAt: downloadedAt, + transcribedAt: transcribedAt, + convertedFilePath: convertedFilePath, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + Value localFilePath = const Value.absent(), + Value transcription = const Value.absent(), + Value syncedFromWatch = const Value.absent(), + Value deletedOnWatch = const Value.absent(), + Value downloadedAt = const Value.absent(), + Value transcribedAt = const Value.absent(), + Value convertedFilePath = const Value.absent(), + }) => VoiceMemosCompanion.insert( + id: id, + filename: filename, + timestampUtc: timestampUtc, + durationMs: durationMs, + sizeBytes: sizeBytes, + localFilePath: localFilePath, + transcription: transcription, + syncedFromWatch: syncedFromWatch, + deletedOnWatch: deletedOnWatch, + downloadedAt: downloadedAt, + transcribedAt: transcribedAt, + convertedFilePath: convertedFilePath, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$VoiceMemosTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $VoiceMemosTable, + VoiceMemoEntity, + $$VoiceMemosTableFilterComposer, + $$VoiceMemosTableOrderingComposer, + $$VoiceMemosTableAnnotationComposer, + $$VoiceMemosTableCreateCompanionBuilder, + $$VoiceMemosTableUpdateCompanionBuilder, + ( + VoiceMemoEntity, + BaseReferences<_$AppDatabase, $VoiceMemosTable, VoiceMemoEntity>, + ), + VoiceMemoEntity, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -4400,4 +5525,6 @@ class $AppDatabaseManager { $$CommLogEntriesTableTableManager(_db, _db.commLogEntries); $$ConnectionEventsTableTableManager get connectionEvents => $$ConnectionEventsTableTableManager(_db, _db.connectionEvents); + $$VoiceMemosTableTableManager get voiceMemos => + $$VoiceMemosTableTableManager(_db, _db.voiceMemos); } diff --git a/zswatch_app/lib/data/database/tables/voice_memos_table.dart b/zswatch_app/lib/data/database/tables/voice_memos_table.dart new file mode 100644 index 0000000..2e1296d --- /dev/null +++ b/zswatch_app/lib/data/database/tables/voice_memos_table.dart @@ -0,0 +1,51 @@ +import 'package:drift/drift.dart'; + +/// Voice memos table - recordings synced from ZSWatch +/// +/// Stores metadata about voice recordings captured on the watch, +/// their sync status, local file paths after download, and +/// transcription results. +@DataClassName('VoiceMemoEntity') +class VoiceMemos extends Table { + /// Auto-incrementing row identifier + IntColumn get id => integer().autoIncrement()(); + + /// Original filename on the watch (e.g., "20260304_143022") + TextColumn get filename => text()(); + + /// Recording timestamp as Unix epoch seconds (UTC) + IntColumn get timestampUtc => integer().named('timestamp_utc')(); + + /// Recording duration in milliseconds + IntColumn get durationMs => integer().named('duration_ms')(); + + /// File size in bytes (Opus-encoded .zsw_opus) + IntColumn get sizeBytes => integer().named('size_bytes')(); + + /// Local file path after download (null = not yet downloaded) + TextColumn get localFilePath => + text().nullable().named('local_file_path')(); + + /// Transcription text (null = not yet transcribed) + TextColumn get transcription => text().nullable()(); + + /// Whether the file has been synced (downloaded) from the watch + BoolColumn get syncedFromWatch => + boolean().withDefault(const Constant(false)).named('synced_from_watch')(); + + /// Whether the file has been deleted on the watch after sync + BoolColumn get deletedOnWatch => + boolean().withDefault(const Constant(false)).named('deleted_on_watch')(); + + /// When the file was downloaded to the phone + DateTimeColumn get downloadedAt => + dateTime().nullable().named('downloaded_at')(); + + /// When the transcription was completed + DateTimeColumn get transcribedAt => + dateTime().nullable().named('transcribed_at')(); + + /// Path to converted audio file (WAV/Ogg) for playback/transcription + TextColumn get convertedFilePath => + text().nullable().named('converted_file_path')(); +} diff --git a/zswatch_app/lib/data/models/voice_memo.dart b/zswatch_app/lib/data/models/voice_memo.dart new file mode 100644 index 0000000..d8311bd --- /dev/null +++ b/zswatch_app/lib/data/models/voice_memo.dart @@ -0,0 +1,135 @@ +import 'package:equatable/equatable.dart'; + +/// Sync state of a voice memo +enum VoiceMemoSyncStatus { + /// Only exists on the watch, not yet downloaded + onWatchOnly, + + /// Currently being downloaded from the watch + downloading, + + /// Downloaded to phone, verified, watch copy may still exist + synced, + + /// Download failed — will retry on next sync + downloadFailed, + + /// Transcription completed + transcribed, +} + +/// Domain model for a voice memo recording +class VoiceMemo extends Equatable { + final int id; + final String filename; + final DateTime timestampUtc; + final int durationMs; + final int sizeBytes; + final String? localFilePath; + final String? transcription; + final bool syncedFromWatch; + final bool deletedOnWatch; + final DateTime? downloadedAt; + final DateTime? transcribedAt; + final String? convertedFilePath; + + const VoiceMemo({ + required this.id, + required this.filename, + required this.timestampUtc, + required this.durationMs, + required this.sizeBytes, + this.localFilePath, + this.transcription, + this.syncedFromWatch = false, + this.deletedOnWatch = false, + this.downloadedAt, + this.transcribedAt, + this.convertedFilePath, + }); + + /// Computed sync status based on field values + VoiceMemoSyncStatus get syncStatus { + if (transcription != null) return VoiceMemoSyncStatus.transcribed; + if (syncedFromWatch && localFilePath != null) { + return VoiceMemoSyncStatus.synced; + } + return VoiceMemoSyncStatus.onWatchOnly; + } + + /// Duration formatted as MM:SS + String get formattedDuration { + final totalSeconds = durationMs ~/ 1000; + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + /// File size formatted as human-readable string + String get formattedSize { + if (sizeBytes < 1024) return '$sizeBytes B'; + if (sizeBytes < 1024 * 1024) { + return '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// Relative time display (e.g., "2 min ago", "Yesterday") + String get relativeTime { + final now = DateTime.now().toUtc(); + final diff = now.difference(timestampUtc); + + if (diff.inSeconds < 60) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes} min ago'; + if (diff.inHours < 24) return '${diff.inHours} hr ago'; + if (diff.inDays == 1) return 'Yesterday'; + if (diff.inDays < 7) return '${diff.inDays} days ago'; + return '${timestampUtc.month}/${timestampUtc.day}/${timestampUtc.year}'; + } + + VoiceMemo copyWith({ + int? id, + String? filename, + DateTime? timestampUtc, + int? durationMs, + int? sizeBytes, + String? localFilePath, + String? transcription, + bool? syncedFromWatch, + bool? deletedOnWatch, + DateTime? downloadedAt, + DateTime? transcribedAt, + String? convertedFilePath, + }) { + return VoiceMemo( + id: id ?? this.id, + filename: filename ?? this.filename, + timestampUtc: timestampUtc ?? this.timestampUtc, + durationMs: durationMs ?? this.durationMs, + sizeBytes: sizeBytes ?? this.sizeBytes, + localFilePath: localFilePath ?? this.localFilePath, + transcription: transcription ?? this.transcription, + syncedFromWatch: syncedFromWatch ?? this.syncedFromWatch, + deletedOnWatch: deletedOnWatch ?? this.deletedOnWatch, + downloadedAt: downloadedAt ?? this.downloadedAt, + transcribedAt: transcribedAt ?? this.transcribedAt, + convertedFilePath: convertedFilePath ?? this.convertedFilePath, + ); + } + + @override + List get props => [ + id, + filename, + timestampUtc, + durationMs, + sizeBytes, + localFilePath, + transcription, + syncedFromWatch, + deletedOnWatch, + downloadedAt, + transcribedAt, + convertedFilePath, + ]; +} diff --git a/zswatch_app/lib/data/repositories/voice_memo_repository.dart b/zswatch_app/lib/data/repositories/voice_memo_repository.dart new file mode 100644 index 0000000..f43d658 --- /dev/null +++ b/zswatch_app/lib/data/repositories/voice_memo_repository.dart @@ -0,0 +1,168 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import '../database/app_database.dart'; +import '../models/voice_memo.dart'; + +/// Repository for voice memo data operations +/// +/// Provides a clean interface for CRUD operations on voice memos, +/// abstracting the database layer from the rest of the app. +class VoiceMemoRepository { + final AppDatabase _db; + + VoiceMemoRepository(this._db); + + // ==================== Read Operations ==================== + + /// Get all voice memos, newest first + Future> getAllMemos() async { + final entities = await _db.getAllVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Watch all voice memos (reactive stream), newest first + Stream> watchAllMemos() { + return _db.watchAllVoiceMemos().map( + (entities) => entities.map(_entityToModel).toList(), + ); + } + + /// Get a voice memo by filename + Future getMemoByFilename(String filename) async { + final entity = await _db.getVoiceMemoByFilename(filename); + return entity != null ? _entityToModel(entity) : null; + } + + /// Get memos that haven't been downloaded yet + Future> getUndownloadedMemos() async { + final entities = await _db.getUndownloadedVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Get memos that are synced but not yet transcribed + Future> getUntranscribedMemos() async { + final entities = await _db.getUntranscribedVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + + /// Get memos that have a local audio file and can be transcribed. + Future> getTranscribableMemos() async { + final allMemos = await getAllMemos(); + return allMemos + .where((memo) => + memo.convertedFilePath != null || memo.localFilePath != null) + .toList(); + } + + // ==================== Write Operations ==================== + + /// Insert or update a voice memo from watch metadata + Future upsertFromWatch({ + required String filename, + required int timestampUtc, + required int durationMs, + required int sizeBytes, + }) async { + await _db.upsertVoiceMemo(VoiceMemosCompanion( + filename: Value(filename), + timestampUtc: Value(timestampUtc), + durationMs: Value(durationMs), + sizeBytes: Value(sizeBytes), + )); + } + + /// Mark a memo as downloaded + Future markDownloaded({ + required String filename, + required String localFilePath, + }) async { + await _db.updateVoiceMemoDownloaded( + filename: filename, + localFilePath: localFilePath, + ); + } + + /// Mark a memo as deleted on the watch + Future markDeletedOnWatch(String filename) async { + await _db.updateVoiceMemoDeletedOnWatch(filename); + } + + /// Update transcription result + Future updateTranscription({ + required String filename, + required String transcription, + }) async { + await _db.updateVoiceMemoTranscription( + filename: filename, + transcription: transcription, + ); + } + + /// Update converted file path + Future updateConvertedPath({ + required String filename, + required String convertedFilePath, + }) async { + await _db.updateVoiceMemoConvertedPath( + filename: filename, + convertedFilePath: convertedFilePath, + ); + } + + /// Delete a voice memo by filename (deletes local files and DB entry) + Future deleteMemo(String filename) async { + // Get the memo first so we can clean up local files + final entity = await _db.getVoiceMemoByFilename(filename); + if (entity != null) { + // Delete local .zsw_opus file + if (entity.localFilePath != null) { + try { + final file = File(entity.localFilePath!); + if (await file.exists()) { + await file.delete(); + debugPrint('[VoiceMemoRepository] Deleted local file: ${entity.localFilePath}'); + } + } catch (e) { + debugPrint('[VoiceMemoRepository] Failed to delete local file: $e'); + } + } + // Delete converted .ogg file + if (entity.convertedFilePath != null) { + try { + final file = File(entity.convertedFilePath!); + if (await file.exists()) { + await file.delete(); + debugPrint('[VoiceMemoRepository] Deleted converted file: ${entity.convertedFilePath}'); + } + } catch (e) { + debugPrint('[VoiceMemoRepository] Failed to delete converted file: $e'); + } + } + } + await _db.deleteVoiceMemo(filename); + } + + // ==================== Private Helpers ==================== + + VoiceMemo _entityToModel(VoiceMemoEntity entity) { + return VoiceMemo( + id: entity.id, + filename: entity.filename, + timestampUtc: + DateTime.fromMillisecondsSinceEpoch(entity.timestampUtc * 1000, + isUtc: true), + durationMs: entity.durationMs, + sizeBytes: entity.sizeBytes, + localFilePath: entity.localFilePath, + transcription: entity.transcription, + syncedFromWatch: entity.syncedFromWatch, + deletedOnWatch: entity.deletedOnWatch, + downloadedAt: entity.downloadedAt, + transcribedAt: entity.transcribedAt, + convertedFilePath: entity.convertedFilePath, + ); + } +} diff --git a/zswatch_app/lib/main.dart b/zswatch_app/lib/main.dart index f4c8026..53a3539 100644 --- a/zswatch_app/lib/main.dart +++ b/zswatch_app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:whisper_ggml_plus_ffmpeg/whisper_ggml_plus_ffmpeg.dart'; import 'app.dart'; @@ -8,6 +9,9 @@ void main() async { // Ensure Flutter bindings are initialized WidgetsFlutterBinding.ensureInitialized(); + // Register Whisper FFmpeg converter for Ogg/Opus → WAV transcription support + WhisperFFmpegConverter.register(); + // Set preferred orientations (portrait only for mobile) await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index a3235e5..bd7df6a 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../services/voice_memo/transcription_engine.dart'; + /// Keys for SharedPreferences abstract final class SettingsKeys { static const String developerModeEnabled = 'developer_mode_enabled'; @@ -13,6 +15,7 @@ abstract final class SettingsKeys { static const String onboardingCompleted = 'onboarding_completed'; static const String keepScreenOnDuringDfu = 'keep_screen_on_during_dfu'; static const String backgroundConnectionEnabled = 'background_connection_enabled'; + static const String transcriptionEngineType = 'transcription_engine_type'; } /// Provider for SharedPreferences instance @@ -279,3 +282,37 @@ final settingsManagerProvider = Provider((ref) { return SettingsManager(prefsValue); }); +// --------------------------------------------------------------------------- +// Transcription engine type +// --------------------------------------------------------------------------- + +/// Which offline Whisper engine variant to use for voice memo transcription. +/// Persisted in SharedPreferences. +final transcriptionEngineTypeProvider = + StateNotifierProvider( + (ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return TranscriptionEngineTypeNotifier(prefs.valueOrNull); + }, +); + +class TranscriptionEngineTypeNotifier + extends StateNotifier { + final SharedPreferences? _prefs; + + TranscriptionEngineTypeNotifier(this._prefs) + : super(_parseType(_prefs?.getString(SettingsKeys.transcriptionEngineType))); + + static TranscriptionEngineType _parseType(String? value) { + if (value == TranscriptionEngineType.kbWhisperBase.name) { + return TranscriptionEngineType.kbWhisperBase; + } + return TranscriptionEngineType.whisperTinyEn; + } + + void setType(TranscriptionEngineType type) { + state = type; + _prefs?.setString(SettingsKeys.transcriptionEngineType, type.name); + } +} + diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart new file mode 100644 index 0000000..739115c --- /dev/null +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -0,0 +1,309 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/voice_memo.dart'; +import '../data/repositories/voice_memo_repository.dart'; +import '../services/voice_memo/transcription_engine.dart'; +import '../services/voice_memo/voice_memo_sync_service.dart'; +import 'settings_providers.dart'; +import 'watch_providers.dart'; +import 'watch_service_provider.dart'; + +// ==================== Repository Provider ==================== + +/// Provider for the voice memo repository singleton +final voiceMemoRepositoryProvider = Provider((ref) { + final db = ref.watch(databaseProvider); + return VoiceMemoRepository(db); +}); + +// ==================== Transcription Engine Provider ==================== + +/// Provider for the transcription engine singleton. +/// Recreated automatically when the user changes the engine type in settings. +final transcriptionEngineProvider = Provider((ref) { + final engineType = ref.watch(transcriptionEngineTypeProvider); + final engine = createTranscriptionEngine(engineType); + ref.onDispose(() => engine.dispose()); + return engine; +}); + +/// Stream of transcription engine state +final transcriptionEngineStateProvider = + StreamProvider((ref) { + final engine = ref.watch(transcriptionEngineProvider); + return engine.stateStream; +}); + +class TranscriptionModelLocalStatus { + final bool downloaded; + final int? localSizeBytes; + final String localPath; + + const TranscriptionModelLocalStatus({ + required this.downloaded, + required this.localSizeBytes, + required this.localPath, + }); +} + +final transcriptionModelStatusProvider = + FutureProvider.family( + (ref, type) async { + final engine = createTranscriptionEngine(type); + try { + final localPath = await engine.modelFilePath(); + final file = File(localPath); + final downloaded = file.existsSync(); + + return TranscriptionModelLocalStatus( + downloaded: downloaded, + localSizeBytes: downloaded ? file.lengthSync() : null, + localPath: localPath, + ); + } finally { + engine.dispose(); + } +}); + +final transcriptionConfiguredProvider = FutureProvider((ref) async { + final selected = ref.watch(transcriptionEngineTypeProvider); + final status = await ref.watch(transcriptionModelStatusProvider(selected).future); + return status.downloaded; +}); + +// ==================== Sync Service Provider ==================== + +/// Provider for the voice memo sync service singleton +final voiceMemoSyncServiceProvider = Provider((ref) { + final watchService = ref.watch(watchServiceProvider); + final repository = ref.watch(voiceMemoRepositoryProvider); + + final service = VoiceMemoSyncService( + watchService: watchService, + repository: repository, + ); + + // Wire up auto-transcription after sync completes + final engine = ref.watch(transcriptionEngineProvider); + service.onSyncCompleted = (downloadedCount) { + debugPrint( + '[VoiceMemoProviders] Sync completed ($downloadedCount new). ' + 'Starting auto-transcription.'); + _autoTranscribe(repository, engine); + }; + + ref.onDispose(() => service.dispose()); + return service; +}); + +/// Auto-transcribe all untranscribed memos after sync +Future _autoTranscribe( + VoiceMemoRepository repository, + TranscriptionEngine engine, +) async { + try { + final untranscribed = await repository.getUntranscribedMemos(); + if (untranscribed.isEmpty) return; + + debugPrint( + '[VoiceMemoProviders] Auto-transcribing ${untranscribed.length} memos'); + + for (final memo in untranscribed) { + try { + final audioPath = memo.convertedFilePath ?? memo.localFilePath; + if (audioPath == null) continue; + + final text = await engine.transcribe(audioPath); + await repository.updateTranscription( + filename: memo.filename, + transcription: text.isEmpty ? '[No speech detected]' : text, + ); + debugPrint( + '[VoiceMemoProviders] Auto-transcribed: ${memo.filename}'); + } catch (e) { + debugPrint( + '[VoiceMemoProviders] Failed to transcribe ${memo.filename}: $e'); + } + } + } catch (e) { + debugPrint('[VoiceMemoProviders] Auto-transcription error: $e'); + } +} + +// ==================== Voice Memo List Provider ==================== + +/// Stream of all voice memos (reactive, newest first) +final voiceMemoListProvider = StreamProvider>((ref) { + final repository = ref.watch(voiceMemoRepositoryProvider); + return repository.watchAllMemos(); +}); + +/// Stream a single voice memo by database id. +final voiceMemoByIdProvider = StreamProvider.family((ref, id) { + final repository = ref.watch(voiceMemoRepositoryProvider); + return repository.watchAllMemos().map((memos) { + for (final memo in memos) { + if (memo.id == id) { + return memo; + } + } + return null; + }); +}); + +// ==================== Sync State Provider ==================== + +/// Stream of sync state updates +final voiceMemoSyncStateProvider = StreamProvider((ref) { + final service = ref.watch(voiceMemoSyncServiceProvider); + return service.syncState; +}); + +// ==================== Voice Memo Actions ==================== + +/// Notifier for voice memo actions (sync, delete, transcribe) +class VoiceMemoActionsNotifier extends StateNotifier> { + final VoiceMemoSyncService _syncService; + final VoiceMemoRepository _repository; + final TranscriptionEngine _transcriptionEngine; + + VoiceMemoActionsNotifier({ + required VoiceMemoSyncService syncService, + required VoiceMemoRepository repository, + required TranscriptionEngine transcriptionEngine, + }) : _syncService = syncService, + _repository = repository, + _transcriptionEngine = transcriptionEngine, + super(const AsyncData(null)); + + /// Trigger a sync of voice memos from the watch + Future sync() async { + state = const AsyncLoading(); + try { + await _syncService.syncRecordings(); + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[VoiceMemoActions] Sync error: $e'); + state = AsyncError(e, st); + } + } + + /// Delete a voice memo locally + Future delete(String filename) async { + state = const AsyncLoading(); + try { + await _repository.deleteMemo(filename); + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[VoiceMemoActions] Delete error: $e'); + state = AsyncError(e, st); + } + } + + /// Transcribe a single voice memo + /// + /// Uses the Ogg file (converted from .zsw_opus) as input. + /// The FFmpeg converter registered at startup handles Ogg → WAV conversion + /// for Whisper automatically. + Future transcribe(VoiceMemo memo) async { + state = const AsyncLoading(); + try { + await _transcribeMemo(memo); + + debugPrint('[VoiceMemoActions] Transcription saved for ${memo.filename}'); + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[VoiceMemoActions] Transcription error: $e'); + state = AsyncError(e, st); + } + } + + /// Re-transcribe a memo using the currently selected transcription model. + /// + /// Existing transcription text is overwritten. + Future retranscribe(VoiceMemo memo) => transcribe(memo); + + /// Transcribe all synced but untranscribed memos + Future transcribeAll() async { + state = const AsyncLoading(); + try { + final untranscribed = await _repository.getUntranscribedMemos(); + debugPrint( + '[VoiceMemoActions] Transcribing ${untranscribed.length} memos'); + + for (final memo in untranscribed) { + try { + await transcribe(memo); + } catch (e) { + debugPrint( + '[VoiceMemoActions] Failed to transcribe ${memo.filename}: $e'); + // Continue with next memo + } + } + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[VoiceMemoActions] TranscribeAll error: $e'); + state = AsyncError(e, st); + } + } + + /// Re-transcribe all downloaded memos using the currently selected model. + /// + /// Returns the number of memos attempted. + Future retranscribeAll() async { + state = const AsyncLoading(); + try { + final memos = await _repository.getTranscribableMemos(); + debugPrint( + '[VoiceMemoActions] Re-transcribing ${memos.length} memos'); + + for (final memo in memos) { + try { + await _transcribeMemo(memo); + } catch (e) { + debugPrint( + '[VoiceMemoActions] Failed to re-transcribe ${memo.filename}: $e'); + } + } + + state = const AsyncData(null); + return memos.length; + } catch (e, st) { + debugPrint('[VoiceMemoActions] RetranscribeAll error: $e'); + state = AsyncError(e, st); + rethrow; + } + } + + Future _transcribeMemo(VoiceMemo memo) async { + final audioPath = memo.convertedFilePath ?? memo.localFilePath; + if (audioPath == null) { + throw Exception('No audio file available for transcription'); + } + + debugPrint('[VoiceMemoActions] Transcribing: ${memo.filename}'); + final text = await _transcriptionEngine.transcribe(audioPath); + + await _repository.updateTranscription( + filename: memo.filename, + transcription: text.isEmpty ? '[No speech detected]' : text, + ); + } +} + +/// Provider for voice memo actions +final voiceMemoActionsProvider = + StateNotifierProvider>((ref) { + final syncService = ref.watch(voiceMemoSyncServiceProvider); + final repository = ref.watch(voiceMemoRepositoryProvider); + final transcriptionEngine = ref.watch(transcriptionEngineProvider); + return VoiceMemoActionsNotifier( + syncService: syncService, + repository: repository, + transcriptionEngine: transcriptionEngine, + ); +}); diff --git a/zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart b/zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart new file mode 100644 index 0000000..9fc7684 --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/ogg_opus_writer.dart @@ -0,0 +1,242 @@ +import 'dart:typed_data'; + +import 'zsw_opus_parser.dart'; + +/// Converts ZSWatch custom .zsw_opus files to standard Ogg/Opus format +/// for playback with standard audio players. +/// +/// Ogg container spec: RFC 3533 +/// Opus in Ogg spec: RFC 7845 +/// +/// The ZSWatch firmware encodes audio as: +/// - 16 kHz, mono, 32 kbps, 10 ms frames +/// - OPUS_APPLICATION_RESTRICTED_LOWDELAY mode +/// - Pre-skip ≈ 120 samples at 48 kHz +class OggOpusWriter { + // Samples per Opus frame at the 48 kHz Ogg granule rate. + // 10 ms frame at 48 kHz = 480 samples. + static const int _samplesPerFrame48k = 480; + + // Pre-skip for OPUS_APPLICATION_RESTRICTED_LOWDELAY at 16 kHz. + // The encoder's algorithmic delay is ~2.5 ms ≈ 120 samples at 48 kHz. + static const int _preSkip = 120; + + // Maximum Opus frames per Ogg page (~2 s of audio at 10 ms/frame). + static const int _framesPerPage = 200; + + // Arbitrary but fixed serial number for the logical bitstream. + static const int _serialNumber = 0x5A535731; // "ZSW1" + + /// Convert a parsed .zsw_opus result into a standard Ogg/Opus byte buffer + /// that can be played by any Opus-capable audio player. + static Uint8List convert(ZswOpusParseResult parsed) { + final builder = BytesBuilder(copy: false); + int pageSeq = 0; + + // ── Page 1: OpusHead (BOS) ────────────────────────────── + final opusHead = _buildOpusHead( + channels: 1, + preSkip: _preSkip, + inputSampleRate: parsed.header.sampleRate, + ); + builder.add(_buildOggPage( + granulePosition: 0, + serialNumber: _serialNumber, + pageSequence: pageSeq++, + headerType: 0x02, // BOS + packets: [opusHead], + )); + + // ── Page 2: OpusTags ──────────────────────────────────── + final opusTags = _buildOpusTags(); + builder.add(_buildOggPage( + granulePosition: 0, + serialNumber: _serialNumber, + pageSequence: pageSeq++, + headerType: 0x00, + packets: [opusTags], + )); + + // ── Pages 3+: Audio data ──────────────────────────────── + int totalSamples = 0; + + for (int i = 0; i < parsed.frames.length; i += _framesPerPage) { + final end = (i + _framesPerPage).clamp(0, parsed.frames.length); + final pageFrames = parsed.frames.sublist(i, end); + totalSamples += pageFrames.length * _samplesPerFrame48k; + + final isLast = end >= parsed.frames.length; + builder.add(_buildOggPage( + granulePosition: totalSamples, + serialNumber: _serialNumber, + pageSequence: pageSeq++, + headerType: isLast ? 0x04 : 0x00, // EOS on last page + packets: pageFrames.map((f) => f.data).toList(), + )); + } + + return builder.toBytes(); + } + + // ════════════════════════════════════════════════════════════ + // Opus header packets + // ════════════════════════════════════════════════════════════ + + /// Build the 19-byte OpusHead identification header (RFC 7845 §5.1). + static Uint8List _buildOpusHead({ + required int channels, + required int preSkip, + required int inputSampleRate, + }) { + final buf = ByteData(19); + // "OpusHead" magic + final magic = 'OpusHead'.codeUnits; + for (int i = 0; i < 8; i++) { + buf.setUint8(i, magic[i]); + } + buf.setUint8(8, 1); // Version + buf.setUint8(9, channels); // Channel count + buf.setUint16(10, preSkip, Endian.little); // Pre-skip + buf.setUint32(12, inputSampleRate, Endian.little); // Input sample rate + buf.setInt16(16, 0, Endian.little); // Output gain (dB Q7.8) + buf.setUint8(18, 0); // Channel mapping family (0 = mono/stereo) + return buf.buffer.asUint8List(); + } + + /// Build the OpusTags comment header (RFC 7845 §5.2). + static Uint8List _buildOpusTags() { + const vendor = 'ZSWatch'; + final vendorBytes = vendor.codeUnits; + // 8 (magic) + 4 (vendor len) + vendor + 4 (comment count) + final length = 8 + 4 + vendorBytes.length + 4; + final buf = ByteData(length); + + // "OpusTags" magic + final magic = 'OpusTags'.codeUnits; + for (int i = 0; i < 8; i++) { + buf.setUint8(i, magic[i]); + } + buf.setUint32(8, vendorBytes.length, Endian.little); + for (int i = 0; i < vendorBytes.length; i++) { + buf.setUint8(12 + i, vendorBytes[i]); + } + buf.setUint32(12 + vendorBytes.length, 0, Endian.little); // No comments + + return buf.buffer.asUint8List(); + } + + // ════════════════════════════════════════════════════════════ + // Ogg page builder + // ════════════════════════════════════════════════════════════ + + /// Build a single Ogg page containing one or more packets. + /// + /// [headerType] flags: 0x01 = continuation, 0x02 = BOS, 0x04 = EOS. + static Uint8List _buildOggPage({ + required int granulePosition, + required int serialNumber, + required int pageSequence, + required int headerType, + required List packets, + }) { + // Build segment table: each packet is split into 255-byte segments + // with a final segment < 255 (or 0 if exactly a multiple of 255). + final segmentTable = []; + for (final packet in packets) { + int remaining = packet.length; + while (remaining >= 255) { + segmentTable.add(255); + remaining -= 255; + } + segmentTable.add(remaining); // final segment (0–254) + } + + final numSegments = segmentTable.length; + final headerSize = 27 + numSegments; + final dataSize = packets.fold(0, (sum, p) => sum + p.length); + final pageSize = headerSize + dataSize; + + final page = Uint8List(pageSize); + final bd = ByteData.sublistView(page); + + // Capture pattern: "OggS" + page[0] = 0x4F; // O + page[1] = 0x67; // g + page[2] = 0x67; // g + page[3] = 0x53; // S + + // Stream structure version + bd.setUint8(4, 0); + + // Header type flag + bd.setUint8(5, headerType); + + // Granule position (64-bit LE) + bd.setUint32(6, granulePosition & 0xFFFFFFFF, Endian.little); + bd.setUint32(10, (granulePosition >> 32) & 0xFFFFFFFF, Endian.little); + + // Serial number + bd.setUint32(14, serialNumber, Endian.little); + + // Page sequence number + bd.setUint32(18, pageSequence, Endian.little); + + // CRC32 — set to 0 initially, computed over the full page + bd.setUint32(22, 0, Endian.little); + + // Number of page segments + bd.setUint8(26, numSegments); + + // Segment table + for (int i = 0; i < numSegments; i++) { + page[27 + i] = segmentTable[i]; + } + + // Packet data + int offset = headerSize; + for (final packet in packets) { + page.setRange(offset, offset + packet.length, packet); + offset += packet.length; + } + + // Compute and insert CRC32 + final crc = _oggCrc32(page); + bd.setUint32(22, crc, Endian.little); + + return page; + } + + // ════════════════════════════════════════════════════════════ + // Ogg CRC-32 + // ════════════════════════════════════════════════════════════ + + /// Ogg uses CRC-32 with polynomial 0x04C11DB7 (unreflected), + /// initial value 0, no final XOR — different from the common + /// "CRC-32" (ISO 3309 / ITU-T V.42) which uses reflected I/O. + static int _oggCrc32(Uint8List data) { + int crc = 0; + for (final byte in data) { + crc = (_crcTable[((crc >> 24) ^ byte) & 0xFF] ^ (crc << 8)) & + 0xFFFFFFFF; + } + return crc; + } + + static final List _crcTable = _generateCrcTable(); + + static List _generateCrcTable() { + final table = List.filled(256, 0); + for (int i = 0; i < 256; i++) { + int crc = i << 24; + for (int j = 0; j < 8; j++) { + if (crc & 0x80000000 != 0) { + crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF; + } else { + crc = (crc << 1) & 0xFFFFFFFF; + } + } + table[i] = crc; + } + return table; + } +} diff --git a/zswatch_app/lib/services/voice_memo/transcription_engine.dart b/zswatch_app/lib/services/voice_memo/transcription_engine.dart new file mode 100644 index 0000000..44788c2 --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/transcription_engine.dart @@ -0,0 +1,688 @@ +import 'dart:io'; + +import 'package:ffmpeg_kit_flutter_new_min/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_min/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_min/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:whisper_ggml_plus/whisper_ggml_plus.dart'; + +/// Discriminates between available offline transcription engine variants. +/// +/// Persisted in SharedPreferences so the user's choice survives restarts. +enum TranscriptionEngineType { + /// Tiny English-only Whisper model (~75 MB). Downloaded from whisper.cpp CDN. + whisperTinyEn, + + /// KB-Whisper Base fine-tuned on 50 k+ hours of Swedish speech (~147 MB, + /// q5_0 quantised). Downloaded from HuggingFace on first use. + kbWhisperBase, +} + +/// Static metadata for a selectable transcription model. +class TranscriptionModelInfo { + final TranscriptionEngineType type; + final String name; + final String language; + final String sourceUrl; + final String fileName; + final int expectedSizeBytes; + + const TranscriptionModelInfo({ + required this.type, + required this.name, + required this.language, + required this.sourceUrl, + required this.fileName, + required this.expectedSizeBytes, + }); +} + +/// Catalog of selectable offline models shown in settings. +abstract final class TranscriptionModelCatalog { + static const _tinyEn = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperTinyEn, + name: 'Whisper Tiny (English)', + language: 'en', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin', + fileName: 'ggml-tiny.en.bin', + expectedSizeBytes: 75 * 1024 * 1024, + ); + + static const _kbWhisperBase = TranscriptionModelInfo( + type: TranscriptionEngineType.kbWhisperBase, + name: 'KB-Whisper Base (Swedish)', + language: 'sv', + sourceUrl: + 'https://huggingface.co/KBLab/kb-whisper-base/resolve/main/ggml-model-q5_0.bin', + fileName: 'ggml-kb-whisper-base-q5_0.bin', + expectedSizeBytes: 147 * 1024 * 1024, + ); + + static const List all = [ + _tinyEn, + _kbWhisperBase, + ]; + + static TranscriptionModelInfo info(TranscriptionEngineType type) { + switch (type) { + case TranscriptionEngineType.kbWhisperBase: + return _kbWhisperBase; + case TranscriptionEngineType.whisperTinyEn: + return _tinyEn; + } + } +} + +TranscriptionEngine createTranscriptionEngine(TranscriptionEngineType type) { + switch (type) { + case TranscriptionEngineType.kbWhisperBase: + return KbWhisperEngines.base(); + case TranscriptionEngineType.whisperTinyEn: + return WhisperEngine(); + } +} + +/// Abstract interface for speech-to-text transcription engines +/// +/// Implementations: +/// - [WhisperEngine]: Offline transcription using whisper_ggml_plus (default) +/// - [CustomGgmlWhisperEngine]: Any custom GGML model file via download URL +/// - Future: CloudSttEngine for Google Cloud STT / OpenAI Whisper API +/// - Future: PlatformSttEngine for iOS SFSpeechRecognizer +abstract class TranscriptionEngine { + /// Transcribe audio from a file path. + /// + /// [audioFilePath] can be a 16 kHz 16-bit mono WAV file, or any format + /// supported by the engine (Ogg/Opus if FFmpeg converter is registered). + /// + /// Returns the transcribed text, or empty string if nothing was recognized. + /// Throws on engine errors (model not loaded, file not found, etc.). + Future transcribe(String audioFilePath); + + /// Whether this engine is currently available (model downloaded, etc.) + Future isAvailable(); + + /// Human-readable engine name for display + String get engineName; + + /// Stream of engine status changes (model downloading, ready, etc.) + Stream get stateStream; + + /// Current state + TranscriptionEngineState get currentState; + + /// Ensure the engine is ready (download model if needed) + Future initialize(); + + /// Clean up resources + void dispose(); + + /// URL of the model source used for downloads. + String get modelSourceUrl; + + /// Expected model size (bytes) shown in setup UI. + int get expectedModelSizeBytes; + + /// Local path to the model file. + Future modelFilePath(); + + /// Delete local model file if present. + Future deleteModel(); +} + +/// State of a transcription engine +enum TranscriptionEngineStatus { + /// Not initialized yet + uninitialized, + + /// Model is being downloaded + downloading, + + /// Ready to transcribe + ready, + + /// An error occurred (model download failed, etc.) + error, + + /// Currently transcribing + transcribing, +} + +/// Transcription engine state with progress info +class TranscriptionEngineState { + final TranscriptionEngineStatus status; + final double downloadProgress; // 0.0 - 1.0, only valid during downloading + final String? errorMessage; + + const TranscriptionEngineState({ + this.status = TranscriptionEngineStatus.uninitialized, + this.downloadProgress = 0.0, + this.errorMessage, + }); + + TranscriptionEngineState copyWith({ + TranscriptionEngineStatus? status, + double? downloadProgress, + String? errorMessage, + }) { + return TranscriptionEngineState( + status: status ?? this.status, + downloadProgress: downloadProgress ?? this.downloadProgress, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +/// Offline Whisper engine using whisper_ggml_plus +/// +/// Downloads the tiny.en model (~75 MB) on first use. Subsequent +/// transcriptions use the cached model. The WhisperController keeps +/// the model in memory for fast back-to-back transcriptions. +class WhisperEngine implements TranscriptionEngine { + static const WhisperModel _defaultModel = WhisperModel.tinyEn; + + final WhisperController _controller = WhisperController(); + final _state = BehaviorSubject.seeded( + const TranscriptionEngineState(), + ); + + @override + String get engineName => 'Whisper (Offline)'; + + @override + String get modelSourceUrl => _defaultModel.modelUri.toString(); + + @override + int get expectedModelSizeBytes => + TranscriptionModelCatalog.info(TranscriptionEngineType.whisperTinyEn) + .expectedSizeBytes; + + @override + Stream get stateStream => _state.stream; + + @override + TranscriptionEngineState get currentState => _state.value; + + @override + Future isAvailable() async { + try { + final modelFile = File(await modelFilePath()); + return modelFile.existsSync(); + } catch (_) { + return false; + } + } + + @override + Future initialize() async { + if (currentState.status == TranscriptionEngineStatus.downloading) return; + + try { + final available = await isAvailable(); + if (available) { + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + )); + return; + } + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + )); + + await _downloadModel(); + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + )); + } catch (e) { + _state.add(TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + )); + } + } + + @override + Future transcribe(String audioFilePath) async { + if (!File(audioFilePath).existsSync()) { + throw Exception('Audio file not found: $audioFilePath'); + } + + if (!await isAvailable()) { + throw Exception('Transcription model not downloaded. Configure in Settings > Voice Memos.'); + } + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.transcribing, + )); + + try { + debugPrint('[WhisperEngine] Transcribing: $audioFilePath'); + + final result = await _controller.transcribe( + model: _defaultModel, + audioPath: audioFilePath, + lang: 'en', + ); + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + )); + + if (result == null) { + debugPrint('[WhisperEngine] Transcription returned null'); + return ''; + } + + final text = result.transcription.text.trim(); + debugPrint('[WhisperEngine] Result: $text'); + return text; + } catch (e) { + debugPrint('[WhisperEngine] Error: $e'); + _state.add(TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + )); + rethrow; + } + } + + @override + Future modelFilePath() async { + return _controller.getPath(_defaultModel); + } + + Future _downloadModel() async { + final filePath = await modelFilePath(); + final tmpPath = '$filePath.${DateTime.now().microsecondsSinceEpoch}.tmp'; + final tmpFile = File(tmpPath); + final finalFile = File(filePath); + + if (!finalFile.parent.existsSync()) { + finalFile.parent.createSync(recursive: true); + } + + final client = http.Client(); + IOSink? sink; + try { + debugPrint('[WhisperEngine] Downloading ${_defaultModel.modelName} from ${_defaultModel.modelUri}'); + final request = http.Request('GET', _defaultModel.modelUri); + final response = await client.send(request); + + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode} downloading model'); + } + + final totalBytes = response.contentLength ?? 0; + var received = 0; + + sink = tmpFile.openWrite(); + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + if (totalBytes > 0) { + _state.add(TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + downloadProgress: received / totalBytes, + )); + } + } + await sink.close(); + sink = null; + + if (finalFile.existsSync()) { + await finalFile.delete(); + } + + if (tmpFile.existsSync()) { + await tmpFile.rename(filePath); + } else if (!finalFile.existsSync()) { + throw Exception('Downloaded model temp file missing before finalize'); + } + + debugPrint('[WhisperEngine] Model saved to $filePath'); + } catch (e) { + if (sink != null) { + await sink.close(); + } + if (tmpFile.existsSync()) { + tmpFile.deleteSync(); + } + rethrow; + } finally { + client.close(); + } + } + + @override + Future deleteModel() async { + final modelFile = File(await modelFilePath()); + if (modelFile.existsSync()) { + await modelFile.delete(); + } + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.uninitialized, + )); + } + + @override + void dispose() { + _state.close(); + } +} + +// --------------------------------------------------------------------------- +// Generic GGML engine — downloads any .bin from a URL on first use +// --------------------------------------------------------------------------- + +/// Offline Whisper engine for custom GGML models downloaded at runtime. +/// +/// The model file is downloaded from [modelUrl] to app-support/whisper_models/ +/// on the first call to [initialize] (or lazily on first [transcribe]). +/// Subsequent runs reuse the cached file. +/// +/// Use [KbWhisperEngines] for pre-configured Swedish KB-Whisper variants. +class CustomGgmlWhisperEngine implements TranscriptionEngine { + final String _modelUrl; + final String _modelFileName; + final String _languageCode; + final String _displayName; + + final _state = BehaviorSubject.seeded( + const TranscriptionEngineState(), + ); + + CustomGgmlWhisperEngine({ + required String modelUrl, + required String modelFileName, + required String languageCode, + required String displayName, + }) : _modelUrl = modelUrl, + _modelFileName = modelFileName, + _languageCode = languageCode, + _displayName = displayName; + + @override + String get engineName => _displayName; + + @override + String get modelSourceUrl => _modelUrl; + + @override + int get expectedModelSizeBytes { + if (_modelFileName == 'ggml-kb-whisper-base-q5_0.bin') { + return TranscriptionModelCatalog + .info(TranscriptionEngineType.kbWhisperBase) + .expectedSizeBytes; + } + return 0; + } + + @override + Stream get stateStream => _state.stream; + + @override + TranscriptionEngineState get currentState => _state.value; + + @override + Future isAvailable() async { + try { + return File(await _modelFilePath()).existsSync(); + } catch (_) { + return false; + } + } + + @override + Future initialize() async { + if (currentState.status == TranscriptionEngineStatus.ready) return; + if (currentState.status == TranscriptionEngineStatus.downloading) return; + + try { + if (await isAvailable()) { + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + )); + return; + } + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + )); + + await _downloadModel(); + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + )); + } catch (e) { + debugPrint('[CustomGgmlWhisperEngine] Download error: $e'); + _state.add(TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + )); + } + } + + Future _downloadModel() async { + final filePath = await _modelFilePath(); + final tmpPath = '$filePath.${DateTime.now().microsecondsSinceEpoch}.tmp'; + final tmpFile = File(tmpPath); + final finalFile = File(filePath); + + if (finalFile.parent.existsSync() == false) { + finalFile.parent.createSync(recursive: true); + } + + final client = http.Client(); + try { + debugPrint('[CustomGgmlWhisperEngine] Downloading $_modelFileName from $_modelUrl'); + final request = http.Request('GET', Uri.parse(_modelUrl)); + final response = await client.send(request); + + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode} downloading model'); + } + + final totalBytes = response.contentLength ?? 0; + int received = 0; + + final sink = tmpFile.openWrite(); + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + if (totalBytes > 0) { + _state.add(TranscriptionEngineState( + status: TranscriptionEngineStatus.downloading, + downloadProgress: received / totalBytes, + )); + } + } + await sink.close(); + + // Atomically rename tmp → final + if (finalFile.existsSync()) { + await finalFile.delete(); + } + + if (tmpFile.existsSync()) { + await tmpFile.rename(filePath); + } else if (!finalFile.existsSync()) { + throw Exception('Downloaded model temp file missing before finalize'); + } + + debugPrint('[CustomGgmlWhisperEngine] Model saved to $filePath'); + } catch (e) { + // Clean up incomplete download + if (tmpFile.existsSync()) tmpFile.deleteSync(); + rethrow; + } finally { + client.close(); + } + } + + @override + Future transcribe(String audioFilePath) async { + if (!File(audioFilePath).existsSync()) { + throw Exception('Audio file not found: $audioFilePath'); + } + + final modelPath = await _modelFilePath(); + if (!File(modelPath).existsSync()) { + throw Exception('Transcription model not downloaded. Configure in Settings > Voice Memos.'); + } + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.transcribing, + )); + + String? convertedAudioPath; + try { + final transcriptionAudioPath = await _prepareAudioForWhisper(audioFilePath); + if (transcriptionAudioPath != audioFilePath) { + convertedAudioPath = transcriptionAudioPath; + } + + debugPrint( + '[CustomGgmlWhisperEngine] Transcribing: $transcriptionAudioPath'); + + // Use Whisper directly so we can pass an arbitrary modelPath string. + // WhisperController.transcribe() only accepts a WhisperModel enum, but + // Whisper.transcribe() accepts any modelPath — which is what we need for + // custom GGML models downloaded from HuggingFace. + const whisper = Whisper(model: WhisperModel.base); + final response = await whisper.transcribe( + transcribeRequest: TranscribeRequest( + audio: transcriptionAudioPath, + language: _languageCode, + ), + modelPath: modelPath, + ); + + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.ready, + )); + + final text = response.text.trim(); + debugPrint('[CustomGgmlWhisperEngine] Result: $text'); + return text; + } catch (e) { + debugPrint('[CustomGgmlWhisperEngine] Error: $e'); + _state.add(TranscriptionEngineState( + status: TranscriptionEngineStatus.error, + errorMessage: e.toString(), + )); + rethrow; + } finally { + if (convertedAudioPath != null) { + try { + final convertedFile = File(convertedAudioPath); + if (convertedFile.existsSync()) { + convertedFile.deleteSync(); + } + } catch (e) { + debugPrint( + '[CustomGgmlWhisperEngine] Failed to delete temp WAV: $e'); + } + } + } + } + + Future _prepareAudioForWhisper(String audioFilePath) async { + if (audioFilePath.toLowerCase().endsWith('.wav')) { + return audioFilePath; + } + + final input = File(audioFilePath); + final outputPath = '${input.path}.wav'; + final output = File(outputPath); + + if (output.existsSync()) { + output.deleteSync(); + } + + final arguments = [ + '-y', + '-i', + '"${input.path}"', + '-ar', + '16000', + '-ac', + '1', + '-c:a', + 'pcm_s16le', + '"$outputPath"', + ]; + + debugPrint( + '[CustomGgmlWhisperEngine] Converting audio for Whisper: ${input.path} -> $outputPath'); + + final FFmpegSession session = + await FFmpegKit.execute(arguments.join(' ')); + final ReturnCode? returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode) && output.existsSync()) { + return outputPath; + } + + final logs = await session.getOutput(); + throw Exception( + 'Audio conversion failed (returnCode=${returnCode?.getValue()}): ${logs ?? 'no FFmpeg logs'}', + ); + } + + Future _modelFilePath() async { + final appDir = await getApplicationSupportDirectory(); + final modelDir = Directory('${appDir.path}/whisper_models'); + if (!modelDir.existsSync()) { + modelDir.createSync(recursive: true); + } + return '${modelDir.path}/$_modelFileName'; + } + + @override + Future modelFilePath() => _modelFilePath(); + + @override + Future deleteModel() async { + final modelFile = File(await _modelFilePath()); + if (modelFile.existsSync()) { + await modelFile.delete(); + } + _state.add(const TranscriptionEngineState( + status: TranscriptionEngineStatus.uninitialized, + )); + } + + @override + void dispose() { + _state.close(); + } +} + +// --------------------------------------------------------------------------- +// Pre-configured KB-Whisper engines (National Library of Sweden) +// --------------------------------------------------------------------------- + +/// Factory for KBLab/KB-Whisper model variants. +/// +/// KB-Whisper is trained on 50 000+ hours of Swedish speech and vastly +/// outperforms stock OpenAI Whisper on Swedish (WER: 9.1 vs 39.6 for base). +/// Apache-2.0 licensed. Models are hosted on HuggingFace. +abstract final class KbWhisperEngines { + static const String _hfBase = 'https://huggingface.co/KBLab/kb-whisper-base/resolve/main'; + + /// Base model, q5_0 quantised (~147 MB). + /// Recommended: good accuracy, reasonable size for mobile. + static CustomGgmlWhisperEngine base() => CustomGgmlWhisperEngine( + modelUrl: '$_hfBase/ggml-model-q5_0.bin', + modelFileName: 'ggml-kb-whisper-base-q5_0.bin', + languageCode: 'sv', + displayName: 'KB-Whisper Base (Swedish)', + ); +} diff --git a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart new file mode 100644 index 0000000..749f99d --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart @@ -0,0 +1,494 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data/models/connection.dart'; +import '../../data/models/voice_memo.dart'; +import '../../data/repositories/voice_memo_repository.dart'; +import '../watch_service.dart'; +import 'ogg_opus_writer.dart'; +import 'zsw_opus_parser.dart'; + +/// State of the voice memo sync process +enum VoiceMemoSyncPhase { + idle, + fetchingList, + downloading, + verifying, + deleting, + completed, + failed, +} + +/// Current sync state +class VoiceMemoSyncState { + final VoiceMemoSyncPhase phase; + final String? currentFilename; + final int totalToSync; + final int completedCount; + final double downloadProgress; // 0.0 - 1.0 + final String? errorMessage; + + const VoiceMemoSyncState({ + this.phase = VoiceMemoSyncPhase.idle, + this.currentFilename, + this.totalToSync = 0, + this.completedCount = 0, + this.downloadProgress = 0.0, + this.errorMessage, + }); + + VoiceMemoSyncState copyWith({ + VoiceMemoSyncPhase? phase, + String? currentFilename, + int? totalToSync, + int? completedCount, + double? downloadProgress, + String? errorMessage, + }) { + return VoiceMemoSyncState( + phase: phase ?? this.phase, + currentFilename: currentFilename ?? this.currentFilename, + totalToSync: totalToSync ?? this.totalToSync, + completedCount: completedCount ?? this.completedCount, + downloadProgress: downloadProgress ?? this.downloadProgress, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isSyncing => + phase != VoiceMemoSyncPhase.idle && + phase != VoiceMemoSyncPhase.completed && + phase != VoiceMemoSyncPhase.failed; +} + +/// Recording metadata from watch list response +class WatchRecordingInfo { + final String filename; + final int durationMs; + final int sizeBytes; + final int timestamp; + + const WatchRecordingInfo({ + required this.filename, + required this.durationMs, + required this.sizeBytes, + required this.timestamp, + }); + + factory WatchRecordingInfo.fromJson(Map json) { + return WatchRecordingInfo( + filename: json['filename'] as String, + durationMs: json['duration_ms'] as int, + sizeBytes: json['size_bytes'] as int, + timestamp: json['timestamp'] as int, + ); + } +} + +/// Service for syncing voice memos from ZSWatch via MCUmgr FS +/// +/// Uses a hybrid protocol: +/// - Extended API (JSON over NUS) for metadata (list, delete, new notification) +/// - MCUmgr FS for actual binary file download +/// +/// Sync flow: +/// 1. Request recording list from watch +/// 2. For each new recording, download via MCUmgr FS +/// 3. Verify downloaded file (size check + header validation) +/// 4. Send delete command to watch after successful verification +class VoiceMemoSyncService { + static const String _recordingDir = '/user/recordings'; + + final WatchService _watchService; + final VoiceMemoRepository _repository; + + /// Called after sync completes with the number of newly downloaded memos. + /// Used to trigger auto-transcription from the provider layer. + void Function(int downloadedCount)? onSyncCompleted; + + final _syncState = BehaviorSubject.seeded( + const VoiceMemoSyncState(), + ); + + StreamSubscription>? _messageSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _downloadSubscription; + FsManager? _fsManager; + Completer>? _listCompleter; + Completer? _downloadCompleter; + bool _hasAutoSynced = false; + + VoiceMemoSyncService({ + required WatchService watchService, + required VoiceMemoRepository repository, + }) : _watchService = watchService, + _repository = repository { + _messageSubscription = _watchService.incomingMessages.listen(_handleMessage); + _connectionSubscription = _watchService.connectionStream.listen(_handleConnectionChange); + } + + void _handleConnectionChange(Connection connection) { + if (connection.isConnected && !_hasAutoSynced) { + _hasAutoSynced = true; + // Delay slightly to allow BLE services to settle + Future.delayed(const Duration(seconds: 3), () { + _log('Auto-syncing voice memos on watch connect'); + syncRecordings(); + }); + } else if (connection.isDisconnected) { + _hasAutoSynced = false; + } + } + + /// Stream of sync state changes + Stream get syncState => _syncState.stream; + + /// Current sync state + VoiceMemoSyncState get currentState => _syncState.value; + + /// Handle a new recording notification from the watch + Future handleNewRecording(Map message) async { + final info = WatchRecordingInfo.fromJson(message); + await _repository.upsertFromWatch( + filename: info.filename, + timestampUtc: info.timestamp, + durationMs: info.durationMs, + sizeBytes: info.sizeBytes, + ); + _log('New recording notification: ${info.filename}'); + + // Auto-download the new recording + if (_watchService.isConnected && !currentState.isSyncing) { + _log('Auto-downloading new recording: ${info.filename}'); + syncRecordings(); + } + } + + /// Handle recording list response from the watch + Future handleListResult(Map message) async { + // Guard against duplicate BLE notifications — FBP can deliver the same + // NUS RX packet twice. Grab and null the completer synchronously (before + // the first await) so any second call finds null and exits immediately. + if (_listCompleter == null) return; + final completer = _listCompleter!; + _listCompleter = null; + + final recordings = (message['recordings'] as List?) + ?.map((r) => WatchRecordingInfo.fromJson(r as Map)) + .toList() ?? + []; + + _log('Received ${recordings.length} recordings from watch'); + + // Update database with watch recordings + try { + for (final rec in recordings) { + await _repository.upsertFromWatch( + filename: rec.filename, + timestampUtc: rec.timestamp, + durationMs: rec.durationMs, + sizeBytes: rec.sizeBytes, + ); + } + } catch (e) { + _log('Error upserting recordings to DB: $e'); + } + + completer.complete(recordings); + } + + /// Request recording list from the watch and sync new recordings + Future syncRecordings() async { + if (currentState.isSyncing) { + _log('Sync already in progress, skipping'); + return; + } + + if (!_watchService.isConnected) { + _log('Not connected, cannot sync'); + return; + } + + try { + _updateState(const VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.fetchingList, + )); + + // Request recording list from watch + await _watchService.sendVoiceMemoCommand('list'); + + // Wait for list response (with timeout) + _listCompleter = Completer>(); + final recordings = await _listCompleter!.future + .timeout(const Duration(seconds: 10), onTimeout: () { + _log('List request timed out'); + return []; + }); + _log('Fetched ${recordings.length} recordings from list'); + + // Find recordings not yet downloaded + final undownloaded = await _repository.getUndownloadedMemos(); + if (undownloaded.isEmpty) { + _log('All recordings already synced'); + _updateState(const VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.completed, + )); + _resetStateAfterDelay(); + return; + } + + _updateState(VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.downloading, + totalToSync: undownloaded.length, + completedCount: 0, + )); + + // Enable SMP server on the watch (required for MCUmgr file transfers) + _log('Enabling SMP server for file download'); + await _watchService.enableSmp(); + // Give the watch time to register the SMP transport before rediscovering. + await Future.delayed(const Duration(seconds: 2)); + final smpReady = await _watchService.rediscoverServices(); + if (!smpReady) { + throw Exception( + 'SMP service did not become available after enabling it on the watch', + ); + } + + // Download each new recording + int completed = 0; + try { + for (final memo in undownloaded) { + _updateState(currentState.copyWith( + currentFilename: memo.filename, + downloadProgress: 0.0, + )); + + final success = await _downloadRecording(memo); + if (success) { + completed++; + _updateState(currentState.copyWith( + completedCount: completed, + )); + } + } + } finally { + // Always disable SMP server when done, even on error + _log('Disabling SMP server'); + try { + await _watchService.disableSmp(); + } catch (e) { + _log('Failed to disable SMP: $e'); + } + } + + _updateState(VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.completed, + totalToSync: undownloaded.length, + completedCount: completed, + )); + + // Notify listeners that new memos were downloaded (triggers auto-transcribe) + if (completed > 0) { + onSyncCompleted?.call(completed); + } + } catch (e) { + _log('Sync failed: $e'); + _updateState(VoiceMemoSyncState( + phase: VoiceMemoSyncPhase.failed, + errorMessage: e.toString(), + )); + } + + _resetStateAfterDelay(); + } + + /// Download a specific recording from the watch via MCUmgr FS + Future _downloadRecording(VoiceMemo memo) async { + try { + // Initialize FsManager if needed + final device = _watchService.device; + if (device == null) { + _log('No device connected'); + return false; + } + + _fsManager ??= FsManager(device.remoteId.str); + + final remotePath = '$_recordingDir/${memo.filename}.zsw_opus'; + _log('Downloading: $remotePath'); + + // Set up download completion listener + _downloadCompleter = Completer(); + await _downloadSubscription?.cancel(); + _downloadSubscription = _fsManager!.downloadCallbacks.listen((callback) { + switch (callback) { + case OnDownloadProgressChanged(): + final progress = callback.total > 0 + ? callback.current / callback.total + : 0.0; + _updateState(currentState.copyWith(downloadProgress: progress)); + case OnDownloadCompleted(): + _downloadCompleter?.complete(callback.data); + _downloadCompleter = null; + case OnDownloadFailed(): + _log('Download failed: ${callback.cause}'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + case OnDownloadCancelled(): + _log('Download cancelled'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; + } + }); + + // Start download + await _fsManager!.download(remotePath); + + // Wait for completion + final data = await _downloadCompleter!.future + .timeout(const Duration(minutes: 5), onTimeout: () { + _log('Download timed out'); + return null; + }); + + if (data == null) { + _log('Download returned no data'); + return false; + } + + // Verify download + _updateState(currentState.copyWith( + phase: VoiceMemoSyncPhase.verifying, + )); + + if (!ZswOpusParser.validateDownload(data, + expectedSizeBytes: memo.sizeBytes)) { + _log('Download verification failed for ${memo.filename}'); + return false; + } + + // Save to local storage + final localPath = await _saveToLocalStorage(memo.filename, data); + + // Convert to Ogg/Opus for standard playback + String? convertedPath; + try { + convertedPath = await _convertToOgg(memo.filename, data); + _log('Converted to Ogg: $convertedPath'); + } catch (e) { + _log('Ogg conversion failed (non-fatal): $e'); + } + + // Update database + await _repository.markDownloaded( + filename: memo.filename, + localFilePath: localPath, + ); + + if (convertedPath != null) { + await _repository.updateConvertedPath( + filename: memo.filename, + convertedFilePath: convertedPath, + ); + } + + // Delete from watch after successful verification + _updateState(currentState.copyWith( + phase: VoiceMemoSyncPhase.deleting, + )); + + await _watchService.sendVoiceMemoCommand( + 'delete', + extraData: {'filename': memo.filename}, + ); + await _repository.markDeletedOnWatch(memo.filename); + + _log('Successfully synced: ${memo.filename}'); + return true; + } catch (e) { + _log('Error downloading ${memo.filename}: $e'); + return false; + } + } + + /// Save downloaded recording to app's local storage + Future _saveToLocalStorage( + String filename, Uint8List data) async { + final appDir = await getApplicationDocumentsDirectory(); + final voiceDir = Directory(p.join(appDir.path, 'voice_memos')); + if (!voiceDir.existsSync()) { + voiceDir.createSync(recursive: true); + } + + final file = File(p.join(voiceDir.path, '$filename.zsw_opus')); + await file.writeAsBytes(data); + return file.path; + } + + /// Convert .zsw_opus to standard Ogg/Opus for playback + Future _convertToOgg(String filename, Uint8List zswOpusData) async { + final parsed = ZswOpusParser.parse(zswOpusData); + if (parsed == null || !parsed.isValid) { + throw Exception('Failed to parse .zsw_opus data for conversion'); + } + + final oggData = OggOpusWriter.convert(parsed); + + final appDir = await getApplicationDocumentsDirectory(); + final voiceDir = Directory(p.join(appDir.path, 'voice_memos')); + if (!voiceDir.existsSync()) { + voiceDir.createSync(recursive: true); + } + + final file = File(p.join(voiceDir.path, '$filename.ogg')); + await file.writeAsBytes(oggData); + return file.path; + } + + void _handleMessage(Map message) { + final type = message['t'] as String?; + if (type != 'voice_memo') return; + + final action = message['action'] as String?; + switch (action) { + case 'new': + handleNewRecording(message); + case 'list_result': + handleListResult(message); + } + } + + void _updateState(VoiceMemoSyncState state) { + _syncState.add(state); + } + + void _resetStateAfterDelay() { + Future.delayed(const Duration(seconds: 3), () { + if (!currentState.isSyncing) { + _updateState(const VoiceMemoSyncState()); + } + }); + } + + void _log(String message) { + debugPrint('[VoiceMemoSync] $message'); + } + + /// Clean up resources + void dispose() { + _messageSubscription?.cancel(); + _connectionSubscription?.cancel(); + _downloadSubscription?.cancel(); + _downloadCompleter?.complete(null); + _listCompleter?.complete([]); + _fsManager?.kill(); + _syncState.close(); + } +} diff --git a/zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart b/zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart new file mode 100644 index 0000000..85f02c1 --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/zsw_opus_parser.dart @@ -0,0 +1,165 @@ +import 'dart:typed_data'; + +/// Parsed header from a .zsw_opus file +class ZswOpusHeader { + /// File format magic bytes (should be "ZSWO") + final String magic; + + /// Format version (currently 1) + final int version; + + /// Audio sample rate in Hz (normally 16000) + final int sampleRate; + + /// Samples per Opus frame (normally 160) + final int frameSize; + + /// Encoding bitrate in bps (normally 32000) + final int bitrate; + + /// Recording start timestamp (Unix epoch seconds) + final int timestamp; + + /// Total encoded frames (0xFFFFFFFF if dirty stop) + final int totalFrames; + + /// Recording duration in ms (0xFFFFFFFF if dirty stop) + final int durationMs; + + const ZswOpusHeader({ + required this.magic, + required this.version, + required this.sampleRate, + required this.frameSize, + required this.bitrate, + required this.timestamp, + required this.totalFrames, + required this.durationMs, + }); + + /// Whether this file had a dirty stop (crash/reset during recording) + bool get isDirtyStop => totalFrames == 0xFFFFFFFF || durationMs == 0xFFFFFFFF; +} + +/// A single Opus frame extracted from a .zsw_opus file +class OpusFrame { + /// Offset in the file where this frame starts (including length prefix) + final int fileOffset; + + /// Encoded Opus data bytes + final Uint8List data; + + const OpusFrame({required this.fileOffset, required this.data}); +} + +/// Result of parsing a .zsw_opus file +class ZswOpusParseResult { + final ZswOpusHeader header; + final List frames; + + /// Computed duration in milliseconds (from frame count, not header) + int get computedDurationMs { + if (header.sampleRate == 0) return 0; + return (frames.length * header.frameSize * 1000) ~/ header.sampleRate; + } + + /// Whether the file appears valid + bool get isValid => header.magic == 'ZSWO' && frames.isNotEmpty; + + const ZswOpusParseResult({required this.header, required this.frames}); +} + +/// Parser for the ZSWatch .zsw_opus custom container format +/// +/// File layout: +/// Header (32 bytes, fixed): +/// [4B magic "ZSWO"] +/// [2B version LE] +/// [2B sample_rate LE] +/// [2B frame_size LE] +/// [2B reserved] +/// [4B bitrate LE] +/// [4B timestamp LE] +/// [4B total_frames LE] +/// [4B duration_ms LE] +/// [4B reserved] +/// Body (packed frames): +/// [2B frame_length LE][N bytes opus data] ... +class ZswOpusParser { + static const int headerSize = 32; + static const String expectedMagic = 'ZSWO'; + + /// Parse a .zsw_opus file from raw bytes. + /// + /// Returns null if the file is too small or has an invalid magic. + static ZswOpusParseResult? parse(Uint8List data) { + if (data.length < headerSize) return null; + + final header = _parseHeader(data); + if (header == null) return null; + + final frames = _parseFrames(data); + return ZswOpusParseResult(header: header, frames: frames); + } + + /// Parse only the header (for quick validation without reading all frames). + static ZswOpusHeader? parseHeader(Uint8List data) { + if (data.length < headerSize) return null; + return _parseHeader(data); + } + + /// Validate file integrity: parse header + walk all frames. + /// Returns true if magic is valid and all frames are well-formed. + static bool validate(Uint8List data) { + final result = parse(data); + if (result == null) return false; + if (result.header.magic != expectedMagic) return false; + // Verify frame data covers the full body (no trailing garbage beyond + // what could be a partial frame from a dirty stop) + return result.frames.isNotEmpty; + } + + /// Validate that a downloaded file matches expected metadata. + /// Used for post-download verification before deleting from watch. + static bool validateDownload(Uint8List data, {required int expectedSizeBytes}) { + if (data.length != expectedSizeBytes) return false; + return validate(data); + } + + static ZswOpusHeader? _parseHeader(Uint8List data) { + final bd = ByteData.sublistView(data, 0, headerSize); + + final magic = String.fromCharCodes(data.sublist(0, 4)); + if (magic != expectedMagic) return null; + + return ZswOpusHeader( + magic: magic, + version: bd.getUint16(4, Endian.little), + sampleRate: bd.getUint16(6, Endian.little), + frameSize: bd.getUint16(8, Endian.little), + bitrate: bd.getUint32(12, Endian.little), + timestamp: bd.getUint32(16, Endian.little), + totalFrames: bd.getUint32(20, Endian.little), + durationMs: bd.getUint32(24, Endian.little), + ); + } + + static List _parseFrames(Uint8List data) { + final frames = []; + int offset = headerSize; + + while (offset + 2 <= data.length) { + final bd = ByteData.sublistView(data, offset, offset + 2); + final frameLen = bd.getUint16(0, Endian.little); + + if (frameLen == 0) break; // Zero-length frame = end marker or corruption + if (offset + 2 + frameLen > data.length) break; // Truncated frame (dirty stop) + + final frameData = Uint8List.sublistView(data, offset + 2, offset + 2 + frameLen); + frames.add(OpusFrame(fileOffset: offset, data: frameData)); + offset += 2 + frameLen; + } + + return frames; + } +} diff --git a/zswatch_app/lib/services/watch_service.dart b/zswatch_app/lib/services/watch_service.dart index 4c14544..bab00d7 100644 --- a/zswatch_app/lib/services/watch_service.dart +++ b/zswatch_app/lib/services/watch_service.dart @@ -231,7 +231,7 @@ class WatchService { timeout: const Duration(seconds: 0), mtu: null, // autoConnect is incompatible with mtu autoConnect: true, - ).catchError((e) { + ).catchError((Object e) { // Ignore errors for autoConnect - connection state listener handles everything debugPrint('[WatchService] AutoConnect error (ignored): $e'); _isWaitingForAutoConnect = false; @@ -550,14 +550,14 @@ class WatchService { } } - /// Filter out ... sections from incoming data. + /// Filter out `BLELOG` wrapper sections from incoming data. /// These sections contain firmware debug logs that shouldn't be parsed as JSON. /// Handles sections that span multiple BLE packets. String _filterBleLogSections(String chunk) { const bleLogStart = ''; const bleLogEnd = ''; - var result = StringBuffer(); + final result = StringBuffer(); var remaining = chunk; while (remaining.isNotEmpty) { @@ -759,6 +759,12 @@ class WatchService { _updateConnection(currentConnection.copyWith(isCharging: isCharging)); } break; + + case 'voice_memo': + // Voice memo metadata — routed to listeners via incomingMessages stream. + // VoiceMemoSyncService handles 'new' and 'list_result' actions. + debugPrint('[WatchService] Voice memo message: ${message['action']}'); + break; } } @@ -857,7 +863,7 @@ class WatchService { // Small delay between chunks to allow BLE stack to process if (end < bytes.length) { - await Future.delayed(const Duration(milliseconds: 10)); + await Future.delayed(const Duration(milliseconds: 10)); } offset = end; @@ -1036,6 +1042,49 @@ class WatchService { /// Disable log streaming from watch Future disableLogStreaming() => setLogStreaming(false); + /// Send a voice memo command to the watch + /// + /// Actions: + /// - "list": Request recording list → watch responds with "list_result" + /// - "delete": Delete a recording → requires extraData: {"filename": "..."} + Future sendVoiceMemoCommand(String action, + {Map? extraData}) async { + final data = { + 't': 'voice_memo', + 'action': action, + }; + if (extraData != null) { + data.addAll(extraData); + } + await _sendGb(data); + } + + // ==================== SMP (MCUmgr) Management ==================== + + /// Enable MCUmgr/SMP on the watch via Gadgetbridge. + /// + /// Sends `{"t":"smp","status":true}`. The watch registers the SMP BLE + /// transport, switches to fast advertising/short connection interval, and + /// starts a 3-minute auto-disable timer. + /// + /// After calling this, wait ~2 seconds then call [rediscoverServices] so + /// Android picks up the newly-registered SMP GATT service. + Future enableSmp() => _sendGb({'t': 'smp', 'status': true}); + + /// Disable MCUmgr/SMP on the watch via Gadgetbridge. + /// + /// Sends `{"t":"smp","status":false}`. The watch unregisters SMP and + /// restores default BLE parameters. + Future disableSmp() => _sendGb({'t': 'smp', 'status': false}); + + // ==================== Watch Reset ==================== + + /// Request the watch to perform a cold reboot. + /// + /// Sends `{"t":"reset"}` via Gadgetbridge. The watch reboots after a short + /// delay to allow the BLE ACK to be sent first. + Future resetWatch() => _sendGb({'t': 'reset'}); + void _handleConnectionStateChange( BluetoothConnectionState state, String watchId, @@ -1096,7 +1145,7 @@ class WatchService { void _handleDisconnect(String watchId, String name) { debugPrint('[WatchService:$hashCode] _handleDisconnect: _isCancelled=$_isCancelled, _autoReconnect=$_autoReconnect, _reconnectAttempts=$_reconnectAttempts, _isReconnecting=$_isReconnecting, _isSettingUp=$_isSettingUp, _isWaitingForAutoConnect=$_isWaitingForAutoConnect, _isInitiatingConnection=$_isInitiatingConnection, _isInBackgroundReconnect=$_isInBackgroundReconnect'); - + // If user has cancelled, don't attempt reconnection if (_isCancelled) { debugPrint('[WatchService:$hashCode] Disconnect ignored - cancelled by user'); diff --git a/zswatch_app/lib/ui/navigation/app_router.dart b/zswatch_app/lib/ui/navigation/app_router.dart index 7a4981f..4bf1c19 100644 --- a/zswatch_app/lib/ui/navigation/app_router.dart +++ b/zswatch_app/lib/ui/navigation/app_router.dart @@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import '../../data/models/connection_state.dart'; +import '../../data/models/voice_memo.dart'; import '../../providers/auto_reconnect_provider.dart'; import '../../providers/ble_providers.dart'; import '../../providers/foreground_service_providers.dart'; @@ -23,6 +24,7 @@ import '../screens/notifications/notification_settings_screen.dart'; import '../screens/onboarding/permission_onboarding_screen.dart'; import '../screens/settings/settings_screen.dart'; import '../screens/start/start_page_screen.dart'; +import '../screens/voice_memos/voice_memos_screen.dart'; /// Route names for the app abstract final class AppRoutes { @@ -52,6 +54,8 @@ abstract final class AppRoutes { // Voice routes (placeholder) static const String voiceMemos = '/voice-memos'; + + static String voiceMemoDetail(int id) => '$voiceMemos/$id'; } /// App router configuration using go_router @@ -168,12 +172,32 @@ class AppRouter { ], ), - // Voice memos (placeholder) + // Voice memos GoRoute( path: AppRoutes.voiceMemos, name: 'voice-memos', - builder: (context, state) => - const _PlaceholderScreen(title: 'Voice Memos'), + builder: (context, state) => const VoiceMemosScreen(), + routes: [ + GoRoute( + path: ':memoId', + name: 'voice-memo-detail', + builder: (context, state) { + final id = int.tryParse(state.pathParameters['memoId'] ?? ''); + if (id == null) { + return const _PlaceholderScreen(title: 'Voice Note Not Found'); + } + + final initialMemo = state.extra is VoiceMemo + ? state.extra! as VoiceMemo + : null; + + return VoiceMemoDetailScreen( + memoId: id, + initialMemo: initialMemo, + ); + }, + ), + ], ), ], errorBuilder: (context, state) => _ErrorScreen(error: state.error), diff --git a/zswatch_app/lib/ui/screens/settings/settings_screen.dart b/zswatch_app/lib/ui/screens/settings/settings_screen.dart index 85a5184..cf202d2 100644 --- a/zswatch_app/lib/ui/screens/settings/settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/settings_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -12,6 +13,8 @@ import '../../../core/theme/app_theme.dart'; import '../../../providers/demo_mode_provider.dart'; import '../../../providers/permission_providers.dart'; import '../../../providers/settings_providers.dart'; +import '../../../providers/voice_memo_providers.dart'; +import '../../../services/voice_memo/transcription_engine.dart'; import '../onboarding/permission_onboarding_screen.dart'; /// Settings screen for app configuration @@ -105,6 +108,12 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 32), + // Voice Memos / Transcription Settings + _SectionHeader(title: 'Voice Memos'), + _TranscriptionModelsSection(), + + const Divider(height: 32), + // About Section _SectionHeader(title: 'About'), _SettingsTile( @@ -355,6 +364,407 @@ class _InfoRow extends StatelessWidget { } } +class _TranscriptionModelsSection extends ConsumerWidget { + const _TranscriptionModelsSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedType = ref.watch(transcriptionEngineTypeProvider); + final actionsState = ref.watch(voiceMemoActionsProvider); + final isBusy = actionsState.isLoading; + + return Column( + children: [ + for (final info in TranscriptionModelCatalog.all) + _TranscriptionModelTile( + info: info, + isSelected: selectedType == info.type, + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: isBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: Text(isBusy + ? 'Re-transcribing...' + : 'Re-transcribe all with selected model'), + onPressed: isBusy + ? null + : () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Re-transcribe all memos?'), + content: const Text( + 'This will overwrite existing transcriptions ' + 'using the currently selected language/model.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Re-transcribe'), + ), + ], + ), + ) ?? + false; + + if (!confirmed || !context.mounted) return; + + try { + final count = await ref + .read(voiceMemoActionsProvider.notifier) + .retranscribeAll(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count == 0 + ? 'No downloaded memos to re-transcribe' + : 'Started re-transcribing $count memos', + ), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Re-transcription failed: $e'), + ), + ); + } + } + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Text( + 'Use this after changing the language/model to regenerate old transcriptions.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ); + } +} + +class _TranscriptionModelTile extends ConsumerStatefulWidget { + final TranscriptionModelInfo info; + final bool isSelected; + + const _TranscriptionModelTile({ + required this.info, + required this.isSelected, + }); + + @override + ConsumerState<_TranscriptionModelTile> createState() => + _TranscriptionModelTileState(); +} + +class _TranscriptionModelTileState extends ConsumerState<_TranscriptionModelTile> { + bool _isDownloading = false; + double _downloadProgress = 0; + + void _selectModel(WidgetRef ref) { + ref + .read(transcriptionEngineTypeProvider.notifier) + .setType(widget.info.type); + ref.invalidate(transcriptionConfiguredProvider); + } + + static String _formatBytes(int bytes) { + const kb = 1024; + const mb = kb * 1024; + if (bytes >= mb) { + return '${(bytes / mb).toStringAsFixed(1)} MB'; + } + if (bytes >= kb) { + return '${(bytes / kb).toStringAsFixed(1)} KB'; + } + return '$bytes B'; + } + + Future _downloadModel(BuildContext context, WidgetRef ref) async { + final engine = createTranscriptionEngine(widget.info.type); + StreamSubscription? sub; + + try { + if (mounted) { + setState(() { + _isDownloading = true; + _downloadProgress = 0; + }); + } + + sub = engine.stateStream.listen((state) { + if (!mounted) return; + if (state.status == TranscriptionEngineStatus.downloading) { + setState(() { + _isDownloading = true; + _downloadProgress = state.downloadProgress; + }); + } + }); + + await engine.initialize(); + + final downloaded = await engine.isAvailable(); + if (!downloaded) { + throw Exception('Model file not found after download'); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloaded ${widget.info.name}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $e')), + ); + } + } finally { + await sub?.cancel(); + if (mounted) { + setState(() { + _isDownloading = false; + _downloadProgress = 0; + }); + } + + engine.dispose(); + ref.invalidate(transcriptionModelStatusProvider(widget.info.type)); + ref.invalidate(transcriptionConfiguredProvider); + ref.invalidate(transcriptionEngineProvider); + ref.invalidate(transcriptionEngineStateProvider); + } + } + + Future _deleteModel(BuildContext context, WidgetRef ref) async { + final shouldDelete = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete model?'), + content: Text('Delete ${widget.info.name} from local storage?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) return; + + final engine = createTranscriptionEngine(widget.info.type); + try { + await engine.deleteModel(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted ${widget.info.name}')), + ); + } + } finally { + engine.dispose(); + ref.invalidate(transcriptionModelStatusProvider(widget.info.type)); + ref.invalidate(transcriptionConfiguredProvider); + ref.invalidate(transcriptionEngineProvider); + ref.invalidate(transcriptionEngineStateProvider); + } + } + + @override + Widget build(BuildContext context) { + final statusAsync = ref.watch(transcriptionModelStatusProvider(widget.info.type)); + + return statusAsync.when( + data: (status) { + final downloadedSize = status.localSizeBytes != null + ? _formatBytes(status.localSizeBytes!) + : 'Not downloaded'; + + return Column( + children: [ + ListTile( + onTap: () => _selectModel(ref), + leading: Icon( + Icons.memory, + color: widget.isSelected ? AppTheme.primaryColor : AppTheme.textSecondary, + ), + title: Text(widget.info.name), + subtitle: Text( + 'Language: ${widget.info.language.toUpperCase()}\n' + 'Size: ${_formatBytes(widget.info.expectedSizeBytes)}\n' + 'Local: $downloadedSize', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + isThreeLine: true, + trailing: Checkbox( + value: widget.isSelected, + onChanged: (_) => _selectModel(ref), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(AppTheme.spacingSm), + decoration: BoxDecoration( + color: Colors.black12, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Source URL', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + SelectableText( + widget.info.sourceUrl, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + if (_isDownloading) + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppTheme.spacingSm), + LinearProgressIndicator( + value: _downloadProgress > 0 ? _downloadProgress : null, + ), + const SizedBox(height: 4), + Text( + _downloadProgress > 0 + ? 'Downloading... ${(_downloadProgress * 100).toStringAsFixed(0)}%' + : 'Downloading...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (!status.downloaded) + TextButton.icon( + onPressed: _isDownloading + ? null + : () => _downloadModel(context, ref), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + icon: _isDownloading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download, size: 16), + label: Text(_isDownloading ? 'Downloading' : 'Download'), + ) + else + TextButton.icon( + onPressed: _isDownloading + ? null + : () => _deleteModel(context, ref), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('Delete'), + ), + TextButton.icon( + onPressed: () => launchUrl( + Uri.parse(widget.info.sourceUrl), + mode: LaunchMode.externalApplication, + ), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('Open source'), + ), + ], + ), + ), + const SizedBox(height: AppTheme.spacingSm), + ], + ); + }, + loading: () => const ListTile( + leading: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + title: Text('Loading model info...'), + ), + error: (e, _) => ListTile( + leading: const Icon(Icons.error, color: AppTheme.errorColor), + title: Text(widget.info.name), + subtitle: Text('Error loading model status: $e'), + ), + ); + } +} + /// Consolidated permissions summary tile /// /// Shows an overview of permission status and allows users to manage all permissions diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart new file mode 100644 index 0000000..480b67c --- /dev/null +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -0,0 +1,1569 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:just_audio/just_audio.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/models/voice_memo.dart'; +import '../../../providers/voice_memo_providers.dart'; +import '../../../providers/watch_service_provider.dart'; +import '../../../services/voice_memo/transcription_engine.dart'; +import '../../../services/voice_memo/voice_memo_sync_service.dart'; +import '../../navigation/app_router.dart'; + +/// Transcript-first timeline view for synced voice notes. +class VoiceMemosScreen extends ConsumerStatefulWidget { + const VoiceMemosScreen({super.key}); + + @override + ConsumerState createState() => _VoiceMemosScreenState(); +} + +class _VoiceMemosScreenState extends ConsumerState { + late final TextEditingController _searchController; + String _query = ''; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController() + ..addListener(() { + if (!mounted) { + return; + } + setState(() => _query = _searchController.text.trim().toLowerCase()); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoSync(); + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _autoSync() { + final isConnected = ref.read(isWatchConnectedProvider); + if (isConnected) { + ref.read(voiceMemoActionsProvider.notifier).sync(); + } + } + + void _openMemo(VoiceMemo memo) { + context.push(AppRoutes.voiceMemoDetail(memo.id), extra: memo); + } + + @override + Widget build(BuildContext context) { + final memosAsync = ref.watch(voiceMemoListProvider); + final syncStateAsync = ref.watch(voiceMemoSyncStateProvider); + final transcriptionConfiguredAsync = + ref.watch(transcriptionConfiguredProvider); + final isConnected = ref.watch(isWatchConnectedProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Voice Notes'), + actions: [ + if (isConnected) + IconButton( + icon: const Icon(Icons.sync), + tooltip: 'Sync from watch', + onPressed: () => ref.read(voiceMemoActionsProvider.notifier).sync(), + ), + ], + ), + body: Column( + children: [ + syncStateAsync.when( + data: (syncState) => _SyncProgressBar(state: syncState), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + if (!isConnected) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + color: Colors.orange.withValues(alpha: 0.15), + child: Row( + children: [ + const Icon( + Icons.bluetooth_disabled, + size: 16, + color: Colors.orange, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + 'Connect to your watch to sync new notes', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.orange, + ), + ), + ), + ], + ), + ), + transcriptionConfiguredAsync.when( + data: (configured) { + if (configured) { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: AppTheme.spacingSm), + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + color: AppTheme.warningColor.withValues(alpha: 0.15), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.settings_suggest, + size: 16, + color: AppTheme.warningColor, + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Transcription model not configured', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: AppTheme.warningColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Choose and download a model in Settings > Voice Memos.', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: AppTheme.warningColor), + ), + ], + ), + ), + TextButton( + onPressed: () => context.push(AppRoutes.settings), + child: const Text('Setup'), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingMd, + 0, + ), + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search voice notes...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _query.isEmpty + ? null + : IconButton( + onPressed: _searchController.clear, + icon: const Icon(Icons.close), + ), + ), + ), + ), + Expanded( + child: memosAsync.when( + data: (memos) { + final filteredMemos = _filterMemos(memos, _query); + + return RefreshIndicator( + onRefresh: () => + ref.read(voiceMemoActionsProvider.notifier).sync(), + child: filteredMemos.isEmpty + ? _EmptyState(hasQuery: _query.isNotEmpty) + : _VoiceMemoTimeline( + memos: filteredMemos, + onOpenMemo: _openMemo, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Error loading notes: $error', + style: const TextStyle(color: AppTheme.errorColor), + ), + ), + ), + ), + ], + ), + ); + } +} + +class VoiceMemoDetailScreen extends ConsumerStatefulWidget { + final int memoId; + final VoiceMemo? initialMemo; + + const VoiceMemoDetailScreen({ + super.key, + required this.memoId, + this.initialMemo, + }); + + @override + ConsumerState createState() => + _VoiceMemoDetailScreenState(); +} + +class _VoiceMemoDetailScreenState extends ConsumerState { + late final TextEditingController _transcriptController; + bool _isEditing = false; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _transcriptController = TextEditingController( + text: widget.initialMemo?.transcription ?? '', + ); + } + + @override + void dispose() { + _transcriptController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final memoAsync = ref.watch(voiceMemoByIdProvider(widget.memoId)); + + return Scaffold( + appBar: AppBar( + title: const Text('Voice Note'), + ), + body: memoAsync.when( + data: (memo) { + final effectiveMemo = memo ?? widget.initialMemo; + if (effectiveMemo == null) { + return const _MissingNoteState(); + } + + final currentTranscript = effectiveMemo.transcription ?? ''; + if (!_isEditing && _transcriptController.text != currentTranscript) { + _transcriptController.text = currentTranscript; + } + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + 12, + 12, + 12, + 16, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final showSideBySide = constraints.maxWidth >= 430; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TopSummarySection( + memo: effectiveMemo, + sideBySide: showSideBySide, + ), + const SizedBox(height: 12), + _SectionCard( + title: 'Transcript', + trailing: IconButton( + tooltip: _isEditing ? 'Cancel editing' : 'Edit transcript', + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 32, + height: 32, + ), + onPressed: () { + setState(() { + _isEditing = !_isEditing; + if (!_isEditing) { + _transcriptController.text = currentTranscript; + } + }); + }, + icon: Icon( + _isEditing ? Icons.close_rounded : Icons.edit_outlined, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isEditing) ...[ + TextField( + controller: _transcriptController, + minLines: 6, + maxLines: null, + decoration: const InputDecoration( + hintText: 'Edit transcript text...', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton( + style: _compactOutlinedButtonStyle(), + onPressed: _isSaving + ? null + : () { + setState(() { + _isEditing = false; + _transcriptController.text = + currentTranscript; + }); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: AppTheme.spacingSm), + FilledButton( + style: _compactFilledButtonStyle(), + onPressed: _isSaving + ? null + : () => _saveTranscript(effectiveMemo), + child: Text(_isSaving ? 'Saving...' : 'Save'), + ), + ], + ), + ] else ...[ + SelectableText( + currentTranscript.trim().isEmpty + ? 'Transcription will appear here after sync and transcription finish.' + : currentTranscript, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.45, + color: currentTranscript.trim().isEmpty + ? AppTheme.textSecondary + : AppTheme.textPrimary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: currentTranscript.trim().isEmpty + ? null + : () async { + await Clipboard.setData( + ClipboardData(text: currentTranscript), + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Transcript copied to clipboard'), + ), + ); + }, + icon: const Icon(Icons.copy_all_outlined), + label: const Text('Copy text'), + ), + _TranscribeButton( + memo: effectiveMemo, + expand: false, + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 12), + _SectionCard( + title: 'Actions', + child: Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => _deleteMemo(effectiveMemo), + icon: const Icon(Icons.delete_outline), + label: const Text('Delete'), + ), + if (!_hasLocalAudio(effectiveMemo)) + FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: ref.watch(isWatchConnectedProvider) + ? () => ref + .read(voiceMemoActionsProvider.notifier) + .sync() + : null, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + ], + ), + ), + ], + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Unable to load voice note: $error', + style: const TextStyle(color: AppTheme.errorColor), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + + Future _saveTranscript(VoiceMemo memo) async { + setState(() => _isSaving = true); + try { + await ref.read(voiceMemoRepositoryProvider).updateTranscription( + filename: memo.filename, + transcription: _transcriptController.text.trim(), + ); + if (!mounted) { + return; + } + setState(() => _isEditing = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Transcript updated')), + ); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + Future _deleteMemo(VoiceMemo memo) async { + final shouldDelete = await _confirmDelete(context, memo); + if (shouldDelete != true || !mounted) { + return; + } + + await ref.read(voiceMemoActionsProvider.notifier).delete(memo.filename); + if (!mounted) { + return; + } + context.pop(); + } +} + +class _SyncProgressBar extends StatelessWidget { + final VoiceMemoSyncState state; + + const _SyncProgressBar({required this.state}); + + @override + Widget build(BuildContext context) { + if (!state.isSyncing) { + return const SizedBox.shrink(); + } + + final phaseText = switch (state.phase) { + VoiceMemoSyncPhase.fetchingList => 'Fetching recording list...', + VoiceMemoSyncPhase.downloading => + 'Downloading ${state.currentFilename ?? ''}...', + VoiceMemoSyncPhase.verifying => 'Verifying download...', + VoiceMemoSyncPhase.deleting => 'Cleaning up watch storage...', + _ => 'Syncing...', + }; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + color: AppTheme.primaryColor.withValues(alpha: 0.1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Text( + phaseText, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (state.totalToSync > 0) + Text( + '${state.completedCount}/${state.totalToSync}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + if (state.phase == VoiceMemoSyncPhase.downloading) + Padding( + padding: const EdgeInsets.only(top: AppTheme.spacingXs), + child: LinearProgressIndicator( + value: state.downloadProgress, + backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.2), + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final bool hasQuery; + + const _EmptyState({this.hasQuery = false}); + + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.55, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasQuery + ? Icons.search_off_rounded + : Icons.mic_none_rounded, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: AppTheme.spacingMd), + Text( + hasQuery ? 'No matching notes' : 'No voice notes yet', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + hasQuery + ? 'Try a different search term or clear the filter.' + : 'Press record on the watch to create your first note.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ); + } +} + +class _VoiceMemoTimeline extends ConsumerWidget { + final List memos; + final ValueChanged onOpenMemo; + + const _VoiceMemoTimeline({ + required this.memos, + required this.onOpenMemo, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sections = _groupMemosByDay(memos); + + return ListView( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingLg, + ), + children: [ + for (final section in sections) ...[ + Padding( + padding: const EdgeInsets.only( + top: AppTheme.spacingSm, + bottom: AppTheme.spacingSm, + ), + child: Text( + section.label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + for (final memo in section.memos) + _VoiceNoteCard( + memo: memo, + onOpen: () => onOpenMemo(memo), + ), + ], + ], + ); + } +} + +class _VoiceNoteCard extends ConsumerWidget { + final VoiceMemo memo; + final VoidCallback onOpen; + + const _VoiceNoteCard({ + required this.memo, + required this.onOpen, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final previewText = _memoPreviewText(memo); + final canPlay = _hasLocalAudio(memo); + + return Dismissible( + key: ValueKey('voice-note-${memo.id}'), + direction: DismissDirection.endToStart, + background: Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + decoration: BoxDecoration( + color: AppTheme.errorColor, + borderRadius: BorderRadius.circular(AppTheme.radiusLarge), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: AppTheme.spacingLg), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + confirmDismiss: (_) => _confirmDelete(context, memo), + onDismissed: (_) { + ref.read(voiceMemoActionsProvider.notifier).delete(memo.filename); + }, + child: Card( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _timelineTimestampLabel(memo.timestampUtc.toLocal()), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppTheme.textSecondary, + ), + ], + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + previewText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.35, + color: memo.transcription?.trim().isNotEmpty == true + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Text( + 'Tap to view full note', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: AppTheme.spacingMd), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + _MetaChip( + icon: _syncStatusIcon(memo.syncStatus), + label: _syncStatusLabel(memo), + color: _syncStatusColor(memo.syncStatus), + ), + if (memo.syncedFromWatch) + const _MetaChip( + icon: Icons.smartphone_outlined, + label: 'Phone', + color: AppTheme.primaryColor, + ), + if (!memo.deletedOnWatch) + const _MetaChip( + icon: Icons.watch_outlined, + label: 'On watch', + color: AppTheme.warningColor, + ), + ], + ), + const SizedBox(height: AppTheme.spacingMd), + Row( + children: [ + Text( + memo.formattedDuration, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + memo.formattedSize, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const Spacer(), + Icon( + canPlay + ? Icons.play_circle_fill_rounded + : Icons.cloud_download_outlined, + color: canPlay + ? AppTheme.primaryColor + : AppTheme.warningColor, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MissingNoteState extends StatelessWidget { + const _MissingNoteState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.description_outlined, size: 56), + const SizedBox(height: AppTheme.spacingMd), + Text( + 'Voice note not found', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ); + } +} + +class _TopSummarySection extends StatelessWidget { + final VoiceMemo memo; + final bool sideBySide; + + const _TopSummarySection({ + required this.memo, + required this.sideBySide, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + final compactWidth = constraints.maxWidth < 380; + final rightColumnWidth = compactWidth ? 128.0 : 164.0; + final audioWidget = _hasLocalAudio(memo) + ? _AudioPlayerCard( + memo: memo, + compact: true, + alignRight: true, + ) + : _SyncPromptCard( + memo: memo, + compact: true, + alignRight: true, + ); + + if (!sideBySide && constraints.maxWidth < 320) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VoiceNoteHeaderContent(memo: memo), + const SizedBox(height: 10), + audioWidget, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _VoiceNoteHeaderContent(memo: memo)), + const SizedBox(width: 10), + SizedBox( + width: rightColumnWidth, + child: audioWidget, + ), + ], + ); + }, + ), + ), + ); + } +} + +class _VoiceNoteHeaderContent extends StatelessWidget { + final VoiceMemo memo; + + const _VoiceNoteHeaderContent({required this.memo}); + + @override + Widget build(BuildContext context) { + final local = memo.timestampUtc.toLocal(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('MMMM d · HH:mm').format(local), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '${memo.formattedDuration} · ${memo.formattedSize}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + _MetaChip( + icon: _syncStatusIcon(memo.syncStatus), + label: _syncStatusLabel(memo), + color: _syncStatusColor(memo.syncStatus), + ), + if (memo.syncedFromWatch) + const _MetaChip( + icon: Icons.smartphone_outlined, + label: 'Synced', + color: AppTheme.primaryColor, + ), + if (!memo.deletedOnWatch) + const _MetaChip( + icon: Icons.watch_outlined, + label: 'Still on watch', + color: AppTheme.warningColor, + ), + ], + ), + ], + ); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final Widget child; + final Widget? trailing; + + const _SectionCard({ + required this.title, + required this.child, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} + +class _AudioPlayerCard extends ConsumerStatefulWidget { + final VoiceMemo memo; + final bool compact; + final bool alignRight; + + const _AudioPlayerCard({ + required this.memo, + this.compact = false, + this.alignRight = false, + }); + + @override + ConsumerState<_AudioPlayerCard> createState() => _AudioPlayerCardState(); +} + +class _AudioPlayerCardState extends ConsumerState<_AudioPlayerCard> { + AudioPlayer? _player; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + bool _isPlaying = false; + String? _error; + + @override + void initState() { + super.initState(); + _initPlayer(); + } + + Future _initPlayer() async { + final path = widget.memo.convertedFilePath ?? widget.memo.localFilePath; + if (path == null || !File(path).existsSync()) { + setState(() => _error = 'Audio file not found'); + return; + } + + try { + _player = AudioPlayer(); + final duration = await _player!.setFilePath(path); + if (duration != null && mounted) { + setState(() => _duration = duration); + } + + _player!.positionStream.listen((position) { + if (mounted) { + setState(() => _position = position); + } + }); + + _player!.playerStateStream.listen((state) { + if (!mounted) { + return; + } + setState(() => _isPlaying = state.playing); + if (state.processingState == ProcessingState.completed) { + _player!.seek(Duration.zero); + _player!.pause(); + } + }); + } catch (error) { + if (mounted) { + setState(() => _error = 'Failed to load audio: $error'); + } + } + } + + @override + void dispose() { + _player?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_error != null) { + return Text( + _error!, + style: const TextStyle(color: AppTheme.errorColor), + ); + } + + return Column( + crossAxisAlignment: + widget.alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + widget.alignRight ? MainAxisAlignment.end : MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + constraints: BoxConstraints.tightFor( + width: widget.compact ? 30 : 36, + height: widget.compact ? 30 : 36, + ), + onPressed: () { + final next = _position - const Duration(seconds: 10); + _player?.seek(next < Duration.zero ? Duration.zero : next); + }, + icon: const Icon(Icons.replay_10_rounded), + ), + SizedBox(width: widget.compact ? 4 : 8), + IconButton.filled( + style: IconButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: Size(widget.compact ? 34 : 40, widget.compact ? 34 : 40), + padding: EdgeInsets.zero, + ), + iconSize: widget.compact ? 24 : 28, + onPressed: () { + if (_isPlaying) { + _player?.pause(); + } else { + _player?.play(); + } + }, + icon: Icon( + _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + ), + ), + SizedBox(width: widget.compact ? 4 : 8), + IconButton( + visualDensity: VisualDensity.compact, + constraints: BoxConstraints.tightFor( + width: widget.compact ? 30 : 36, + height: widget.compact ? 30 : 36, + ), + onPressed: () { + final next = _position + const Duration(seconds: 10); + _player?.seek(next > _duration ? _duration : next); + }, + icon: const Icon(Icons.forward_10_rounded), + ), + ], + ), + SizedBox(height: widget.compact ? 4 : AppTheme.spacingSm), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatDuration(_position), + style: Theme.of(context).textTheme.bodySmall, + ), + SizedBox( + width: widget.compact ? 56 : 120, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: widget.compact ? 2 : null, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: widget.compact ? 5 : 8, + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: widget.compact ? 10 : 16, + ), + ), + child: Slider( + padding: widget.compact ? EdgeInsets.zero : null, + value: _duration.inMilliseconds == 0 + ? 0 + : _position.inMilliseconds + .clamp(0, _duration.inMilliseconds) + .toDouble(), + max: _duration.inMilliseconds == 0 + ? 1 + : _duration.inMilliseconds.toDouble(), + onChanged: (value) => + _player?.seek(Duration(milliseconds: value.toInt())), + ), + ), + ), + Text( + _formatDuration(_duration), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ); + } +} + +class _TranscribeButton extends ConsumerWidget { + final VoiceMemo memo; + final bool expand; + + const _TranscribeButton({ + required this.memo, + this.expand = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final engineStateAsync = ref.watch(transcriptionEngineStateProvider); + final configuredAsync = ref.watch(transcriptionConfiguredProvider); + + return configuredAsync.when( + data: (configured) { + if (!configured) { + return _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: const Icon(Icons.settings, size: 18), + label: const Text('Set up transcription model'), + onPressed: () => context.push(AppRoutes.settings), + ), + ); + } + + return engineStateAsync.when( + data: (engineState) { + final isTranscribing = + engineState.status == TranscriptionEngineStatus.transcribing; + final buttonLabel = + memo.transcription == null ? 'Transcribe' : 'Re-transcribe'; + + return _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.transcribe), + label: Text(isTranscribing ? 'Transcribing...' : buttonLabel), + onPressed: isTranscribing + ? null + : () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: const Icon(Icons.transcribe, size: 18), + label: + Text(memo.transcription == null ? 'Transcribe' : 'Re-transcribe'), + onPressed: () => + ref.read(voiceMemoActionsProvider.notifier).retranscribe(memo), + ), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => engineStateAsync.when( + data: (engineState) { + final isTranscribing = + engineState.status == TranscriptionEngineStatus.transcribing; + + return _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.transcribe), + label: Text( + isTranscribing + ? 'Transcribing...' + : (memo.transcription == null + ? 'Transcribe' + : 'Re-transcribe'), + ), + onPressed: isTranscribing + ? null + : () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => _ButtonBox( + expand: expand, + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: const Icon(Icons.transcribe, size: 18), + label: + Text(memo.transcription == null ? 'Transcribe' : 'Re-transcribe'), + onPressed: () => + ref.read(voiceMemoActionsProvider.notifier).retranscribe(memo), + ), + ), + ), + ); + } +} + +class _SyncPromptCard extends ConsumerWidget { + final VoiceMemo memo; + final bool compact; + final bool alignRight; + + const _SyncPromptCard({ + required this.memo, + this.compact = false, + this.alignRight = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isConnected = ref.watch(isWatchConnectedProvider); + final syncStateAsync = ref.watch(voiceMemoSyncStateProvider); + + return Column( + crossAxisAlignment: + alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.all(compact ? 6 : AppTheme.spacingSm), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + ), + child: Row( + children: [ + const Icon(Icons.cloud_download_outlined, color: Colors.orange), + SizedBox(width: compact ? 6 : AppTheme.spacingSm), + Expanded( + child: Text( + 'This note is still on the watch. Sync it to enable playback and transcription.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: compact ? 11 : null, + ), + ), + ), + ], + ), + ), + SizedBox(height: compact ? 8 : AppTheme.spacingMd), + syncStateAsync.when( + data: (state) { + if (!state.isSyncing) { + return const SizedBox.shrink(); + } + + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LinearProgressIndicator(), + const SizedBox(height: AppTheme.spacingXs), + Text( + 'Syncing in progress...', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + SizedBox( + width: compact && alignRight ? null : double.infinity, + child: FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: isConnected + ? () => ref.read(voiceMemoActionsProvider.notifier).sync() + : null, + icon: const Icon(Icons.sync), + label: const Text('Sync now'), + ), + ), + if (!isConnected) ...[ + SizedBox(height: compact ? 6 : AppTheme.spacingSm), + Text( + 'Connect to your watch to sync this note.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: compact ? 11 : null, + ), + ), + ], + ], + ); + } +} + +class _MetaChip extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + + const _MetaChip({ + required this.icon, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 3), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10.5, + ), + ), + ], + ), + ); + } +} + +class _ButtonBox extends StatelessWidget { + final bool expand; + final Widget child; + + const _ButtonBox({required this.expand, required this.child}); + + @override + Widget build(BuildContext context) { + if (expand) { + return SizedBox(width: double.infinity, child: child); + } + + return Align(alignment: Alignment.centerLeft, child: child); + } +} + +class _VoiceMemoTimelineSection { + final String label; + final List memos; + + const _VoiceMemoTimelineSection({ + required this.label, + required this.memos, + }); +} + +List _filterMemos(List memos, String query) { + if (query.isEmpty) { + return memos; + } + + return memos.where((memo) => _matchesQuery(memo, query)).toList(); +} + +List<_VoiceMemoTimelineSection> _groupMemosByDay(List memos) { + final grouped = >{}; + + for (final memo in memos) { + final local = memo.timestampUtc.toLocal(); + final key = DateTime(local.year, local.month, local.day) + .millisecondsSinceEpoch + .toString(); + grouped.putIfAbsent(key, () => []).add(memo); + } + + final keys = grouped.keys.toList() + ..sort((a, b) => int.parse(b).compareTo(int.parse(a))); + + return keys.map((key) { + final firstMemo = grouped[key]!.first; + return _VoiceMemoTimelineSection( + label: _dayGroupLabel(firstMemo.timestampUtc.toLocal()), + memos: grouped[key]!, + ); + }).toList(); +} + +bool _matchesQuery(VoiceMemo memo, String query) { + final local = memo.timestampUtc.toLocal(); + final haystack = [ + memo.filename, + memo.transcription ?? '', + _dayGroupLabel(local), + _timelineTimestampLabel(local), + DateFormat.yMMMMd().format(local), + DateFormat('MMMM d yyyy').format(local), + ].join(' ').toLowerCase(); + + return haystack.contains(query); +} + +String _memoPreviewText(VoiceMemo memo) { + final transcript = memo.transcription?.trim(); + if (transcript != null && transcript.isNotEmpty) { + return transcript; + } + + if (memo.syncedFromWatch) { + return 'Audio synced. Transcription pending.'; + } + + return 'On watch only. Sync to download and transcribe this note.'; +} + +bool _hasLocalAudio(VoiceMemo memo) { + final path = memo.convertedFilePath ?? memo.localFilePath; + return path != null && File(path).existsSync(); +} + +String _timelineTimestampLabel(DateTime dateTime) { + return '${_dayGroupLabel(dateTime)} · ${DateFormat.Hm().format(dateTime)}'; +} + +String _dayGroupLabel(DateTime dateTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final target = DateTime(dateTime.year, dateTime.month, dateTime.day); + final difference = today.difference(target).inDays; + + if (difference == 0) { + return 'Today'; + } + if (difference == 1) { + return 'Yesterday'; + } + return DateFormat.MMMMEEEEd().format(dateTime); +} + +String _syncStatusLabel(VoiceMemo memo) { + if (memo.transcription?.trim().isNotEmpty == true) { + return 'Ready'; + } + if (memo.syncedFromWatch) { + return 'Synced'; + } + return 'On watch'; +} + +IconData _syncStatusIcon(VoiceMemoSyncStatus status) { + return switch (status) { + VoiceMemoSyncStatus.onWatchOnly => Icons.watch_outlined, + VoiceMemoSyncStatus.downloading => Icons.downloading_rounded, + VoiceMemoSyncStatus.synced => Icons.check_circle_outline, + VoiceMemoSyncStatus.downloadFailed => Icons.error_outline, + VoiceMemoSyncStatus.transcribed => Icons.text_snippet_outlined, + }; +} + +Color _syncStatusColor(VoiceMemoSyncStatus status) { + return switch (status) { + VoiceMemoSyncStatus.onWatchOnly => AppTheme.warningColor, + VoiceMemoSyncStatus.downloading => AppTheme.primaryColor, + VoiceMemoSyncStatus.synced => AppTheme.successColor, + VoiceMemoSyncStatus.downloadFailed => AppTheme.errorColor, + VoiceMemoSyncStatus.transcribed => AppTheme.primaryColor, + }; +} + +Future _confirmDelete(BuildContext context, VoiceMemo memo) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete recording?'), + content: Text( + 'Transcript and audio will be removed.\n\n${memo.formattedDuration} · ${_timelineTimestampLabel(memo.timestampUtc.toLocal())}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: AppTheme.errorColor), + child: const Text('Delete'), + ), + ], + ), + ); +} + +String _formatDuration(Duration duration) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; +} + +ButtonStyle _compactOutlinedButtonStyle() { + return OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ); +} + +ButtonStyle _compactFilledButtonStyle() { + return FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ); +} diff --git a/zswatch_app/linux/flutter/generated_plugins.cmake b/zswatch_app/linux/flutter/generated_plugins.cmake index 2aa89bb..92a2e6f 100644 --- a/zswatch_app/linux/flutter/generated_plugins.cmake +++ b/zswatch_app/linux/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + whisper_ggml_plus ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift b/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift index 590b7d0..66750f2 100644 --- a/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,13 @@ import FlutterMacOS import Foundation +import audio_session +import ffmpeg_kit_flutter_new_min import file_picker import flutter_blue_plus_darwin import flutter_secure_storage_macos import geolocator_apple +import just_audio import package_info_plus import path_provider_foundation import shared_preferences_foundation @@ -17,10 +20,13 @@ import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + FFmpegKitFlutterPlugin.register(with: registry.registrar(forPlugin: "FFmpegKitFlutterPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index db03b7b..e75bd97 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" + source: hosted + version: "0.1.25" bluez: dependency: transitive description: @@ -285,10 +293,26 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" + ffmpeg_kit_flutter_new_min: + dependency: "direct main" + description: + name: ffmpeg_kit_flutter_new_min + sha256: "15640bf8f177c5c3169a07490635ec0e2fe3816aeb31813eaa747ef0fbd271a6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" file: dependency: transitive description: @@ -604,10 +628,34 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -1100,6 +1148,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1140,6 +1196,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" url_launcher: dependency: "direct main" description: @@ -1300,6 +1364,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + whisper_ggml_plus: + dependency: "direct main" + description: + name: whisper_ggml_plus + sha256: "67ffeae30a4e9a5f3b54651fb0d6a27c7de23ea6855aa54118bb894e2b32e205" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + whisper_ggml_plus_ffmpeg: + dependency: "direct main" + description: + name: whisper_ggml_plus_ffmpeg + sha256: "29224753c76822b09b8ef69391ae218f8df5b8db82ec32b6d43173df01782383" + url: "https://pub.dev" + source: hosted + version: "1.0.0" win32: dependency: transitive description: diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index 3295463..13a9167 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -59,6 +59,14 @@ dependencies: file_picker: ^8.1.6 wakelock_plus: ^1.2.8 + # Audio Playback (voice memo Ogg/Opus) + just_audio: ^0.9.42 + + # Speech-to-Text (offline Whisper inference) + whisper_ggml_plus: ^1.3.5 + whisper_ggml_plus_ffmpeg: ^1.0.0 + ffmpeg_kit_flutter_new_min: ^3.1.0 + # Location (for GPS requests from watch) geolocator: ^13.0.2 diff --git a/zswatch_app/test/zsw_opus_parser_test.dart b/zswatch_app/test/zsw_opus_parser_test.dart new file mode 100644 index 0000000..055a36c --- /dev/null +++ b/zswatch_app/test/zsw_opus_parser_test.dart @@ -0,0 +1,474 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:zswatch_app/services/voice_memo/zsw_opus_parser.dart'; +import 'package:zswatch_app/services/voice_memo/ogg_opus_writer.dart'; +import 'package:zswatch_app/data/models/voice_memo.dart'; + +/// Build a valid .zsw_opus binary blob for testing. +/// +/// Returns raw bytes with a 32-byte header + [frameCount] frames, +/// each containing [frameLenBytes] bytes of dummy Opus data. +Uint8List buildZswOpusFile({ + String magic = 'ZSWO', + int version = 1, + int sampleRate = 16000, + int frameSize = 160, + int bitrate = 32000, + int timestamp = 1700000000, + int? totalFrames, + int? durationMs, + int frameCount = 10, + int frameLenBytes = 20, +}) { + totalFrames ??= frameCount; + durationMs ??= (frameCount * frameSize * 1000) ~/ sampleRate; + + // Header: 32 bytes + final bodySize = frameCount * (2 + frameLenBytes); + final buf = ByteData(32 + bodySize); + + // Magic (4 bytes) + for (var i = 0; i < 4; i++) { + buf.setUint8(i, magic.length > i ? magic.codeUnitAt(i) : 0); + } + buf.setUint16(4, version, Endian.little); + buf.setUint16(6, sampleRate, Endian.little); + buf.setUint16(8, frameSize, Endian.little); + buf.setUint16(10, 0, Endian.little); // reserved + buf.setUint32(12, bitrate, Endian.little); + buf.setUint32(16, timestamp, Endian.little); + buf.setUint32(20, totalFrames, Endian.little); + buf.setUint32(24, durationMs, Endian.little); + buf.setUint32(28, 0, Endian.little); // reserved + + // Frames + var offset = 32; + for (var f = 0; f < frameCount; f++) { + buf.setUint16(offset, frameLenBytes, Endian.little); + offset += 2; + for (var b = 0; b < frameLenBytes; b++) { + buf.setUint8(offset + b, (f + b) & 0xFF); + } + offset += frameLenBytes; + } + + return buf.buffer.asUint8List(); +} + +void main() { + group('ZswOpusParser', () { + test('parses valid file', () { + final data = buildZswOpusFile(); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.header.magic, 'ZSWO'); + expect(result.header.version, 1); + expect(result.header.sampleRate, 16000); + expect(result.header.frameSize, 160); + expect(result.header.bitrate, 32000); + expect(result.header.timestamp, 1700000000); + expect(result.header.totalFrames, 10); + expect(result.header.isDirtyStop, false); + expect(result.frames.length, 10); + expect(result.isValid, true); + }); + + test('returns null for data smaller than header', () { + final data = Uint8List(16); // Too small + expect(ZswOpusParser.parse(data), isNull); + expect(ZswOpusParser.parseHeader(data), isNull); + }); + + test('returns null for wrong magic', () { + final data = buildZswOpusFile(magic: 'NOPE'); + expect(ZswOpusParser.parse(data), isNull); + }); + + test('parses header-only (no frames) as invalid', () { + // Build just the 32-byte header with no frames + final data = buildZswOpusFile(frameCount: 0); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.isValid, false); // No frames → isValid = false + expect(result.frames, isEmpty); + }); + + test('detects dirty stop (0xFFFFFFFF in totalFrames)', () { + final data = buildZswOpusFile(totalFrames: 0xFFFFFFFF); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.header.isDirtyStop, true); + }); + + test('detects dirty stop (0xFFFFFFFF in durationMs)', () { + final data = buildZswOpusFile(durationMs: 0xFFFFFFFF); + final result = ZswOpusParser.parse(data); + + expect(result, isNotNull); + expect(result!.header.isDirtyStop, true); + }); + + test('handles truncated frame (dirty stop mid-write)', () { + final fullData = buildZswOpusFile(frameCount: 5, frameLenBytes: 30); + // Chop off last 10 bytes → last frame is truncated + final truncated = Uint8List.sublistView(fullData, 0, fullData.length - 10); + final result = ZswOpusParser.parse(truncated); + + expect(result, isNotNull); + // Should have 4 complete frames (5th is truncated) + expect(result!.frames.length, 4); + }); + + test('handles zero-length frame as end marker', () { + final data = buildZswOpusFile(frameCount: 3, frameLenBytes: 10); + // Insert a zero-length frame after the 2nd frame + // offset of 3rd frame = 32 + 2*(2+10) = 56 + final modified = Uint8List.fromList(data); + modified[56] = 0; + modified[57] = 0; + + final result = ZswOpusParser.parse(modified); + expect(result, isNotNull); + expect(result!.frames.length, 2); // Stops at zero-length marker + }); + + test('computed duration matches expected value', () { + final data = buildZswOpusFile( + frameCount: 100, + frameSize: 160, + sampleRate: 16000, + ); + final result = ZswOpusParser.parse(data); + + // 100 frames * 160 samples / 16000 Hz = 1.0 seconds = 1000 ms + expect(result!.computedDurationMs, 1000); + }); + + test('validate returns true for valid file', () { + final data = buildZswOpusFile(); + expect(ZswOpusParser.validate(data), true); + }); + + test('validate returns false for wrong magic', () { + final data = buildZswOpusFile(magic: 'XXXX'); + expect(ZswOpusParser.validate(data), false); + }); + + test('validate returns false for empty data', () { + expect(ZswOpusParser.validate(Uint8List(0)), false); + }); + + test('validateDownload checks size match', () { + final data = buildZswOpusFile(); + expect( + ZswOpusParser.validateDownload(data, expectedSizeBytes: data.length), + true, + ); + expect( + ZswOpusParser.validateDownload(data, expectedSizeBytes: data.length + 1), + false, + ); + }); + + test('parseHeader returns header without parsing frames', () { + final data = buildZswOpusFile( + timestamp: 1234567890, + bitrate: 24000, + ); + final header = ZswOpusParser.parseHeader(data); + + expect(header, isNotNull); + expect(header!.timestamp, 1234567890); + expect(header.bitrate, 24000); + }); + + test('frame data content is correct', () { + final data = buildZswOpusFile(frameCount: 2, frameLenBytes: 5); + final result = ZswOpusParser.parse(data); + + expect(result!.frames.length, 2); + // First frame: bytes are (0+0)&0xFF, (0+1)&0xFF, ... + expect(result.frames[0].data.length, 5); + expect(result.frames[0].data[0], 0); + expect(result.frames[0].data[1], 1); + // Second frame: bytes are (1+0)&0xFF, (1+1)&0xFF, ... + expect(result.frames[1].data[0], 1); + expect(result.frames[1].data[1], 2); + }); + + test('frame fileOffset values are sequential', () { + final data = buildZswOpusFile(frameCount: 3, frameLenBytes: 10); + final result = ZswOpusParser.parse(data); + + expect(result!.frames[0].fileOffset, 32); // Right after header + expect(result.frames[1].fileOffset, 32 + 12); // 2 + 10 + expect(result.frames[2].fileOffset, 32 + 24); // 2*(2+10) + }); + }); + + group('VoiceMemo model', () { + test('syncStatus returns onWatchOnly when not synced', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + ); + expect(memo.syncStatus, VoiceMemoSyncStatus.onWatchOnly); + }); + + test('syncStatus returns synced when downloaded', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + syncedFromWatch: true, + localFilePath: '/path/to/file', + ); + expect(memo.syncStatus, VoiceMemoSyncStatus.synced); + }); + + test('syncStatus returns transcribed when transcription exists', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + syncedFromWatch: true, + localFilePath: '/path/to/file', + transcription: 'Hello world', + ); + expect(memo.syncStatus, VoiceMemoSyncStatus.transcribed); + }); + + test('formattedDuration formats correctly', () { + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 65000, + sizeBytes: 0, + ).formattedDuration, + '1:05', + ); + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 3000, + sizeBytes: 0, + ).formattedDuration, + '0:03', + ); + }); + + test('formattedSize formats correctly', () { + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 512, + ).formattedSize, + '512 B', + ); + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 2048, + ).formattedSize, + '2.0 KB', + ); + expect( + VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 2 * 1024 * 1024, + ).formattedSize, + '2.0 MB', + ); + }); + + test('copyWith creates a correct copy', () { + final memo = VoiceMemo( + id: 1, + filename: 'test.zsw_opus', + timestampUtc: DateTime.utc(2025, 1, 1), + durationMs: 5000, + sizeBytes: 1024, + ); + + final updated = memo.copyWith( + transcription: 'Hello', + syncedFromWatch: true, + ); + + expect(updated.id, 1); + expect(updated.filename, 'test.zsw_opus'); + expect(updated.transcription, 'Hello'); + expect(updated.syncedFromWatch, true); + expect(updated.durationMs, 5000); // Unchanged + }); + + test('Equatable equality works', () { + final a = VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 0, + ); + final b = VoiceMemo( + id: 1, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 0, + ); + final c = VoiceMemo( + id: 2, + filename: 'a', + timestampUtc: DateTime.utc(2025), + durationMs: 0, + sizeBytes: 0, + ); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // OggOpusWriter tests + // ═══════════════════════════════════════════════════════════ + + group('OggOpusWriter', () { + test('convert produces valid Ogg stream starting with OggS', () { + final data = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Must start with "OggS" capture pattern + expect(ogg[0], 0x4F); // O + expect(ogg[1], 0x67); // g + expect(ogg[2], 0x67); // g + expect(ogg[3], 0x53); // S + }); + + test('convert output contains OpusHead and OpusTags', () { + final data = buildZswOpusFile(frameCount: 3, frameLenBytes: 10); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + final str = String.fromCharCodes(ogg); + + expect(str.contains('OpusHead'), isTrue); + expect(str.contains('OpusTags'), isTrue); + }); + + test('convert produces at least 3 Ogg pages (head, tags, audio)', () { + final data = buildZswOpusFile(frameCount: 10, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Count OggS markers + int pageCount = 0; + for (int i = 0; i <= ogg.length - 4; i++) { + if (ogg[i] == 0x4F && + ogg[i + 1] == 0x67 && + ogg[i + 2] == 0x67 && + ogg[i + 3] == 0x53) { + pageCount++; + } + } + expect(pageCount, greaterThanOrEqualTo(3)); + }); + + test('first page has BOS flag set', () { + final data = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Byte 5 is the header type flag; BOS = 0x02 + expect(ogg[5] & 0x02, equals(0x02)); + }); + + test('last page has EOS flag set', () { + final data = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Find the last OggS marker + int lastPageOffset = -1; + for (int i = ogg.length - 4; i >= 0; i--) { + if (ogg[i] == 0x4F && + ogg[i + 1] == 0x67 && + ogg[i + 2] == 0x67 && + ogg[i + 3] == 0x53) { + lastPageOffset = i; + break; + } + } + expect(lastPageOffset, greaterThan(0)); + // Byte 5 is header type; EOS = 0x04 + expect(ogg[lastPageOffset + 5] & 0x04, equals(0x04)); + }); + + test('OpusHead contains correct channel count and sample rate', () { + final data = buildZswOpusFile( + frameCount: 3, + frameLenBytes: 10, + sampleRate: 16000, + ); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Find "OpusHead" in the output + final str = String.fromCharCodes(ogg); + final headIdx = str.indexOf('OpusHead'); + expect(headIdx, greaterThan(0)); + + // OpusHead layout: magic(8) version(1) channels(1) preskip(2) samplerate(4) + expect(ogg[headIdx + 8], 1); // version + expect(ogg[headIdx + 9], 1); // 1 channel (mono) + + // Input sample rate at offset 12 (LE) + final bd = ByteData.sublistView(ogg, headIdx + 12, headIdx + 16); + expect(bd.getUint32(0, Endian.little), 16000); + }); + + test('convert output grows with more frames', () { + final small = buildZswOpusFile(frameCount: 5, frameLenBytes: 20); + final large = buildZswOpusFile(frameCount: 500, frameLenBytes: 20); + final oggSmall = OggOpusWriter.convert(ZswOpusParser.parse(small)!); + final oggLarge = OggOpusWriter.convert(ZswOpusParser.parse(large)!); + + expect(oggLarge.length, greaterThan(oggSmall.length)); + }); + + test('convert handles single frame', () { + final data = buildZswOpusFile(frameCount: 1, frameLenBytes: 40); + final parsed = ZswOpusParser.parse(data)!; + final ogg = OggOpusWriter.convert(parsed); + + // Should still produce valid output with OggS pages + expect(ogg.length, greaterThan(0)); + expect(ogg[0], 0x4F); // O + }); + }); +} diff --git a/zswatch_app/windows/flutter/generated_plugins.cmake b/zswatch_app/windows/flutter/generated_plugins.cmake index 7c9e425..e7a7167 100644 --- a/zswatch_app/windows/flutter/generated_plugins.cmake +++ b/zswatch_app/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + whisper_ggml_plus ) set(PLUGIN_BUNDLED_LIBRARIES) From bb0454c138db7a5f8ce6e300474d61a2be68d342 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sun, 8 Mar 2026 09:03:14 +0100 Subject: [PATCH 02/58] feat: Add on-device AI processing for voice memos - LLM-based transcription correction using fllama (llama.cpp FFI) - AI classification & summarization with structured action extraction (schedule, reminder, todo) from voice memo transcriptions - Live debug view with real-time token streaming during AI processing - Word-level diff highlighting between original and corrected transcription - Per-file debug info scoping in debug sheet - GGUF model import/management in Settings (file picker, delete, select) - AI startup self-test (4 test cases) on first launch - Extracted actions DB table + repository for structured data - 500ms delay between fllama inference calls to prevent NativeCallable use-after-free crash (SIGABRT workaround) - Tappable 'Processing with AI...' spinner opens debug bottom sheet - Handles loading/correcting/classifying phases with live UI updates --- specs/ai-tasks/ai-enchanced-voice-notes.md | 607 ++++++ zswatch_app/lib/app.dart | 7 + .../lib/data/database/app_database.dart | 131 +- .../lib/data/database/app_database.g.dart | 1841 ++++++++++++++++- .../tables/extracted_actions_table.dart | 54 + .../database/tables/voice_memos_table.dart | 32 + .../lib/data/models/extracted_action.dart | 121 ++ zswatch_app/lib/data/models/voice_memo.dart | 124 ++ .../extracted_action_repository.dart | 104 + .../repositories/voice_memo_repository.dart | 40 + zswatch_app/lib/providers/ai_providers.dart | 169 ++ .../lib/providers/settings_providers.dart | 77 + .../lib/providers/voice_memo_providers.dart | 34 +- .../lib/services/ai/ai_startup_test.dart | 73 + zswatch_app/lib/services/ai/llm_service.dart | 881 ++++++++ .../services/ai/voice_note_ai_pipeline.dart | 271 +++ .../lib/services/ble/ble_service_impl.dart | 2 + .../ui/screens/settings/settings_screen.dart | 578 ++++++ .../voice_memos/voice_memos_screen.dart | 1159 ++++++++++- .../linux/flutter/generated_plugins.cmake | 1 + zswatch_app/pubspec.lock | 65 + zswatch_app/pubspec.yaml | 6 + .../windows/flutter/generated_plugins.cmake | 1 + 23 files changed, 6358 insertions(+), 20 deletions(-) create mode 100644 specs/ai-tasks/ai-enchanced-voice-notes.md create mode 100644 zswatch_app/lib/data/database/tables/extracted_actions_table.dart create mode 100644 zswatch_app/lib/data/models/extracted_action.dart create mode 100644 zswatch_app/lib/data/repositories/extracted_action_repository.dart create mode 100644 zswatch_app/lib/providers/ai_providers.dart create mode 100644 zswatch_app/lib/services/ai/ai_startup_test.dart create mode 100644 zswatch_app/lib/services/ai/llm_service.dart create mode 100644 zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart diff --git a/specs/ai-tasks/ai-enchanced-voice-notes.md b/specs/ai-tasks/ai-enchanced-voice-notes.md new file mode 100644 index 0000000..ab3ca01 --- /dev/null +++ b/specs/ai-tasks/ai-enchanced-voice-notes.md @@ -0,0 +1,607 @@ +# AI-Enhanced Voice Notes Specification + +## Status + +Draft v1 + +## Summary + +Bring local on-device AI to the companion app for synced watch voice memos. + +The feature will: + +- transcribe voice memos +- summarize them +- categorize them +- extract possible actions +- let the user review and confirm before creating calendar or reminder/task items + +The selected local model for v1 is: + +- `Qwen2.5-1.5B-Instruct-Q4_K_M` + +This model was chosen as the best mobile tradeoff across: + +- English +- Swedish +- German +- structured JSON reliability +- action extraction quality +- Android/iOS feasibility + +## Product Goals + +1. Turn raw voice recordings into useful structured notes. +2. Keep the original transcript and audio always accessible. +3. Build trust by requiring explicit confirmation before any OS action is created. +4. Work safely when the app is backgrounded or the phone is locked. +5. Keep the architecture flexible for future automation once trust is established. + +## Non-Goals for v1 + +- no automatic creation of calendar events or tasks without review +- no Google Tasks integration yet +- no background dialog presentation while phone is locked +- no server-side AI +- no support for arbitrary model selection in v1 + +## User Experience Overview + +The feature evolves voice memos into a capture pipeline: + +```text +Record on watch +↓ +Sync to phone +↓ +Convert audio +↓ +Transcribe +↓ +LLM summarize +↓ +LLM categorize +↓ +LLM extract actions +↓ +Persist results +↓ +User reviews and confirms actions +↓ +Create OS calendar/reminder entry +``` + +## Core UX Rules + +1. The original transcript must always be visible. +2. The original audio must always remain playable. +3. AI output is assistive, not authoritative. +4. OS actions must require explicit user confirmation. +5. If the app is backgrounded or locked, AI may continue processing, but confirmation must be deferred until foreground. + +## Voice Note Object Model + +Each synced memo becomes a voice note with AI-derived fields. + +```text +VoiceNote + ├ audio + ├ transcript + ├ summary + ├ category + ├ processing_status + ├ extracted_actions + │ ├ tasks/reminders + │ └ calendar_events + ├ task_created + ├ calendar_event_created + └ ai_metadata +``` + +## Categories + +Primary categories for v1: + +- `idea` +- `task` +- `reminder` +- `meeting` +- `note` + +LLM internals may still use the simpler benchmark categories: + +- `TODO` +- `EVENT` +- `NOTE` + +UI can map them as: + +- `TODO` → task or reminder +- `EVENT` → meeting or calendar event +- `NOTE` → idea or note + +## Main Screen + +Route: + +```text +/voice-notes +``` + +The screen becomes a summary-first timeline rather than a transcript-first memo list. + +### Timeline layout + +```text +Today + [voice note card] + [voice note card] + +Yesterday + [voice note card] + +March 2 + [voice note card] +``` + +Sorting: + +- descending by timestamp + +### Voice note card + +Each card should show: + +- summary as primary text +- timestamp +- category icon/tag +- audio duration +- play button +- processing status if not ready + +Example: + +```text +🗓 Today · 14:30 + +Call Erik about PCB panel order + +Task detected + +00:14 ▶ +``` + +Optional secondary text: + +- `Original note available` + +### Category icons + +| Category | Icon | +|---|---| +| Idea | 💡 | +| Task | ✔ | +| Reminder | ⏰ | +| Meeting | 📅 | +| Note | 📝 | + +### Card status indicators + +- `⬇ Downloading` +- `🧠 Processing` +- `✓ Ready` +- `⚠ Failed` + +## Voice Note Detail Screen + +Tapping a card opens the full note. + +Section order: + +1. Summary +2. Category +3. Transcript +4. Audio playback +5. Detected actions +6. Action status + +Example: + +```text +Summary +Call Erik about PCB panels + +Category +Task + +Transcript +Call Erik tomorrow about the PCB panel order. + +Audio +▶ Play +00:14 + +Detected Actions +[ Create Task ] +[ Create Calendar Event ] +``` + +## Action Review UX + +### Important rule + +For v1, AI must never write directly to calendar/tasks/reminders without explicit confirmation. + +### Detected actions section + +Display extracted actions as editable suggestions. + +Example: + +```text +Detected actions + +✔ Call Erik about PCB panels +📅 Meeting with Erik about panels +``` + +Each detected action can show: + +- type +- title +- time/due date +- location +- current status + +Available actions: + +- `Create Task` +- `Create Calendar Event` +- `Dismiss` + +### Task creation flow + +When user taps `Create Task`: + +1. show preview/edit dialog or sheet +2. allow editing of title and due date +3. confirm with explicit `Create` + +Preview example: + +```text +Create Task + +Title +Call Erik about PCB panels + +Due date +Tomorrow + +Cancel / Create +``` + +### Calendar event flow + +When user taps `Create Calendar Event`: + +1. show preview/edit dialog or sheet +2. allow editing of title, time, duration, location +3. confirm with explicit `Create` + +Preview example: + +```text +Create Calendar Event + +Title +Meeting with Erik + +Time +Tomorrow + +Duration +30 min + +Cancel / Create +``` + +### Post-creation status + +After creation, show status on the note: + +- `✔ Task created` +- `📅 Event created` + +This is needed to avoid duplicate creation. + +## Settings UX + +Add a new section under Voice Memo settings for Local AI. + +### New settings + +- `Enable local AI for voice notes` +- `Selected AI model` +- `Download model` +- `Delete model` +- `Retry download` +- `Auto-process new voice notes` + +Optional future settings, not required for v1: + +- process only on Wi‑Fi +- process only while charging +- allow background processing toggle + +### Settings behavior + +When local AI is enabled: + +1. app checks whether the required model exists locally +2. if not, the user is prompted to download it +3. download progress is shown clearly +4. state survives navigation and restart + +### Required download UI states + +- not downloaded +- preparing +- downloading +- paused or interrupted +- failed +- ready +- deleting + +The UI must show: + +- progress bar +- percentage +- downloaded size / total size +- current state text + +## Background and Locked-Phone Behavior + +This is a key requirement. + +### v1 approach + +If a voice memo arrives while the app is backgrounded or the phone is locked: + +1. sync may continue if platform/background conditions allow it +2. transcription and AI processing may continue when feasible +3. extracted actions are persisted as pending review +4. no modal dialog is shown immediately +5. when the app returns to foreground, the app surfaces pending AI suggestions for review + +### Why + +- locked/background state is not safe for dialogs +- calendar/reminder creation requires user trust and context +- this preserves automation benefits while keeping user control + +### Foreground re-entry behavior + +When app becomes active and there are pending AI actions: + +- show a lightweight banner, sheet, or entry point +- allow user to open the review flow +- do not force immediate interruption if the user is doing something else + +## Platform Integration Strategy + +### iOS + +Planned integrations: + +- calendar events via EventKit event flow +- reminders/tasks via EventKit reminders flow + +### Android + +Planned integrations for v1: + +- calendar events via calendar provider / insert flow +- TODO/reminder items initially handled as calendar/reminder-style entries + +### Future Android support + +Design the internal action schema so Google Tasks or similar service integrations can be added later without changing the AI output contract. + +## AI Output Contract + +The app should define a universal internal action schema, independent of platform. + +Suggested fields: + +- `category` +- `title` +- `body` +- `startTime` +- `endTime` +- `dueDate` +- `location` +- `actionItems` +- `priority` +- `reminderMinutes` +- `status` + +This schema should be persisted and used by the review UI. + +## Data Model Changes + +The current voice memo persistence must be extended. + +### New voice note fields + +- `summary` +- `category` +- `processingStatus` +- `aiModel` +- `aiProcessedAt` +- `taskCreated` +- `calendarEventCreated` +- `actionReviewState` + +### Structured action storage + +Use a normalized approach for actions if practical. + +Preferred direction: + +- keep `voice_memos` / `voice_notes` as parent records +- store extracted actions in a related table + +Each action record should include: + +- memo id +- action type +- title +- notes/body +- start time +- end time +- due date +- location +- reminder offset +- created flag +- dismissed flag +- created platform target id if available + +## Processing Pipeline States + +The UI must update incrementally as processing progresses. + +Proposed states: + +- `onWatchOnly` +- `downloading` +- `downloaded` +- `converting` +- `transcribing` +- `summarizing` +- `categorizing` +- `extractingActions` +- `ready` +- `failed` + +## Search and Filtering + +Search should match: + +- summary +- transcript +- category + +Add quick filters above the timeline: + +- `All` +- `Tasks` +- `Ideas` +- `Meetings` +- `Notes` + +## Failure and Fallback Behavior + +### If AI is disabled + +- sync and transcription still work +- no summaries/categories/actions are generated + +### If model is missing + +- show status in settings +- notes remain accessible without AI enrichment + +### If AI processing fails + +- transcript/audio remain accessible +- note shows failed status +- allow retry later + +### If permissions are denied + +- extracted action remains visible +- user can retry action creation later +- no AI output is discarded + +## Trust-Building Policy for v1 + +To build trust gradually: + +1. AI suggestions are visible and editable. +2. All task/calendar creation requires confirmation. +3. The app shows what the AI understood before any OS action happens. +4. The user can dismiss AI suggestions. +5. Duplicate prevention is visible. + +Future versions may reduce friction once accuracy is trusted, but v1 must stay explicit and review-based. + +## Recommended Technical Phases + +### Phase 1: Data model and spec foundation + +- add DB fields/tables +- add domain models +- add processing states + +### Phase 2: Local AI settings and model download + +- settings toggle +- model download management +- persistent progress/state + +### Phase 3: AI processing pipeline + +- add local LLM service +- run summarize/categorize/extract actions after transcription +- persist results + +### Phase 4: Voice notes UI refresh + +- summary-first timeline +- detail screen with actions +- filters and search updates + +### Phase 5: Action review and OS integrations + +- review dialogs/sheets +- event/reminder/task creation +- created-status tracking + +### Phase 6: Background-safe pending review flow + +- persist pending actions +- foreground surfacing UX +- polish + +## Acceptance Criteria for v1 + +1. User can enable Local AI in Settings. +2. User can download the required LLM and see progress. +3. Voice memos continue to work normally if Local AI is disabled. +4. After transcription, each processed memo can show summary and category. +5. If the memo contains actionable content, extracted actions are shown in the detail screen. +6. Creating a task/event always requires explicit confirmation. +7. If a memo is processed while app is backgrounded/locked, actions are deferred for later review. +8. After an action is created, the UI shows created status and prevents duplicate creation. +9. Transcript and audio remain accessible even if AI output is wrong or missing. +10. The system works with the selected local model `Qwen2.5-1.5B-Instruct-Q4_K_M`. + +## Open Questions for Later + +- whether reminders and tasks should remain unified in UI or split clearly +- whether Android should later support Google Tasks directly +- whether local notifications should be added for pending AI reviews +- whether users should be able to re-run AI processing on old memos in bulk +- whether AI confidence should be surfaced in UI + +## Final v1 Decision Summary + +- use local on-device AI +- use `Qwen2.5-1.5B-Instruct-Q4_K_M` +- show summaries and categories in the voice notes UI +- extract calendar/task suggestions automatically +- require confirmation before any OS write +- process in background when feasible +- defer confirmations until foreground +- store structured results persistently diff --git a/zswatch_app/lib/app.dart b/zswatch_app/lib/app.dart index cfd09b0..cc73d12 100644 --- a/zswatch_app/lib/app.dart +++ b/zswatch_app/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,6 +11,7 @@ import 'providers/gps_providers.dart'; import 'providers/http_providers.dart'; import 'providers/notification_providers.dart'; import 'providers/permission_providers.dart'; +import 'providers/ai_providers.dart'; import 'providers/voice_memo_providers.dart'; import 'providers/watch_service_provider.dart'; import 'ui/navigation/app_router.dart'; @@ -63,6 +66,10 @@ class _ZSWatchAppState extends ConsumerState { // Initialize voice memo sync service to handle recording sync from watch // This subscribes to watch messages for new recording notifications ref.read(voiceMemoSyncServiceProvider); + + // [DEV] Run AI pipeline self-test on startup to validate model / inference + // TODO: Remove before release — this is a development-time smoke test + unawaited(runAiStartupTest(ref)); } catch (e) { debugPrint('BLE initialization error: $e'); } diff --git a/zswatch_app/lib/data/database/app_database.dart b/zswatch_app/lib/data/database/app_database.dart index 0c54b36..85c56f6 100644 --- a/zswatch_app/lib/data/database/app_database.dart +++ b/zswatch_app/lib/data/database/app_database.dart @@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart'; import 'tables/battery_readings_table.dart'; import 'tables/comm_log_entries_table.dart'; import 'tables/connection_events_table.dart'; +import 'tables/extracted_actions_table.dart'; import 'tables/health_samples_table.dart'; import 'tables/voice_memos_table.dart'; import 'tables/watches_table.dart'; @@ -23,14 +24,14 @@ part 'app_database.g.dart'; /// - CommLogEntries: BLE communication logs for debugging /// - ConnectionEvents: Connection/disconnection events for analytics @DriftDatabase( - tables: [Watches, HealthSamples, BatteryReadings, CommLogEntries, ConnectionEvents, VoiceMemos], + tables: [Watches, HealthSamples, BatteryReadings, CommLogEntries, ConnectionEvents, VoiceMemos, ExtractedActions], ) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); /// Database schema version - increment when making schema changes @override - int get schemaVersion => 4; + int get schemaVersion => 5; @override MigrationStrategy get migration { @@ -52,6 +53,18 @@ class AppDatabase extends _$AppDatabase { // Add voice memos table for voice recording sync await m.createTable(voiceMemos); } + if (from < 5) { + // Add AI-enhanced voice notes fields and extracted actions table + await m.createTable(extractedActions); + await m.addColumn(voiceMemos, voiceMemos.summary); + await m.addColumn(voiceMemos, voiceMemos.category); + await m.addColumn(voiceMemos, voiceMemos.processingStatus); + await m.addColumn(voiceMemos, voiceMemos.aiModel); + await m.addColumn(voiceMemos, voiceMemos.aiProcessedAt); + await m.addColumn(voiceMemos, voiceMemos.taskCreated); + await m.addColumn(voiceMemos, voiceMemos.calendarEventCreated); + await m.addColumn(voiceMemos, voiceMemos.actionReviewState); + } }, ); } @@ -358,6 +371,18 @@ class AppDatabase extends _$AppDatabase { .get(); } + /// Get voice memos that are transcribed but not yet AI-processed + Future> getUnprocessedVoiceMemos() { + return (select(voiceMemos) + ..where((v) => + v.transcription.isNotNull() & + v.summary.isNull() & + (v.processingStatus.isNull() | + v.processingStatus.equals('failed'))) + ..orderBy([(v) => OrderingTerm.asc(v.timestampUtc)])) + .get(); + } + /// Insert or update a voice memo (upsert by filename) Future upsertVoiceMemo(VoiceMemosCompanion memo) async { // getVoiceMemoByFilename also deduplicates if stale duplicates exist. @@ -415,12 +440,114 @@ class AppDatabase extends _$AppDatabase { )); } + /// Update AI processing results for a voice memo + Future updateVoiceMemoAiResults({ + required String filename, + required String summary, + required String category, + required String aiModel, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion( + summary: Value(summary), + category: Value(category), + processingStatus: const Value('ready'), + aiModel: Value(aiModel), + aiProcessedAt: Value(DateTime.now()), + )); + } + + /// Update AI processing status + Future updateVoiceMemoProcessingStatus({ + required String filename, + required String status, + }) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(VoiceMemosCompanion( + processingStatus: Value(status), + )); + } + + /// Mark task created for a voice memo + Future updateVoiceMemoTaskCreated(String filename) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(const VoiceMemosCompanion( + taskCreated: Value(true), + )); + } + + /// Mark calendar event created for a voice memo + Future updateVoiceMemoCalendarEventCreated(String filename) { + return (update(voiceMemos)..where((v) => v.filename.equals(filename))) + .write(const VoiceMemosCompanion( + calendarEventCreated: Value(true), + )); + } + /// Delete a voice memo by filename Future deleteVoiceMemo(String filename) { return (delete(voiceMemos)..where((v) => v.filename.equals(filename))) .go(); } + // ==================== Extracted Action Operations ==================== + + /// Get all extracted actions for a voice memo + Future> getActionsForMemo(int memoId) { + return (select(extractedActions) + ..where((a) => a.memoId.equals(memoId)) + ..orderBy([(a) => OrderingTerm.asc(a.id)])) + .get(); + } + + /// Watch extracted actions for a voice memo (reactive stream) + Stream> watchActionsForMemo(int memoId) { + return (select(extractedActions) + ..where((a) => a.memoId.equals(memoId)) + ..orderBy([(a) => OrderingTerm.asc(a.id)])) + .watch(); + } + + /// Insert an extracted action + Future insertExtractedAction(ExtractedActionsCompanion action) { + return into(extractedActions).insert(action); + } + + /// Update an extracted action as created + Future markExtractedActionCreated({ + required int actionId, + String? platformTargetId, + }) { + return (update(extractedActions)..where((a) => a.id.equals(actionId))) + .write(ExtractedActionsCompanion( + created: const Value(true), + platformTargetId: Value(platformTargetId), + createdAt: Value(DateTime.now()), + )); + } + + /// Dismiss an extracted action + Future dismissExtractedAction(int actionId) { + return (update(extractedActions)..where((a) => a.id.equals(actionId))) + .write(const ExtractedActionsCompanion( + dismissed: Value(true), + )); + } + + /// Delete all extracted actions for a memo + Future deleteActionsForMemo(int memoId) { + return (delete(extractedActions)..where((a) => a.memoId.equals(memoId))) + .go(); + } + + /// Get all pending (not created, not dismissed) extracted actions + Future> getPendingActions() { + return (select(extractedActions) + ..where( + (a) => a.created.equals(false) & a.dismissed.equals(false))) + .get(); + } + // ==================== Data Retention ==================== /// Clean up old data (60-day retention) diff --git a/zswatch_app/lib/data/database/app_database.g.dart b/zswatch_app/lib/data/database/app_database.g.dart index 508acd9..6721354 100644 --- a/zswatch_app/lib/data/database/app_database.g.dart +++ b/zswatch_app/lib/data/database/app_database.g.dart @@ -2607,6 +2607,103 @@ class $VoiceMemosTable extends VoiceMemos type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _summaryMeta = const VerificationMeta( + 'summary', + ); + @override + late final GeneratedColumn summary = GeneratedColumn( + 'summary', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _categoryMeta = const VerificationMeta( + 'category', + ); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _processingStatusMeta = const VerificationMeta( + 'processingStatus', + ); + @override + late final GeneratedColumn processingStatus = GeneratedColumn( + 'processing_status', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _aiModelMeta = const VerificationMeta( + 'aiModel', + ); + @override + late final GeneratedColumn aiModel = GeneratedColumn( + 'ai_model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _aiProcessedAtMeta = const VerificationMeta( + 'aiProcessedAt', + ); + @override + late final GeneratedColumn aiProcessedAt = + GeneratedColumn( + 'ai_processed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _taskCreatedMeta = const VerificationMeta( + 'taskCreated', + ); + @override + late final GeneratedColumn taskCreated = GeneratedColumn( + 'task_created', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("task_created" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _calendarEventCreatedMeta = + const VerificationMeta('calendarEventCreated'); + @override + late final GeneratedColumn calendarEventCreated = GeneratedColumn( + 'calendar_event_created', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("calendar_event_created" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _actionReviewStateMeta = const VerificationMeta( + 'actionReviewState', + ); + @override + late final GeneratedColumn actionReviewState = + GeneratedColumn( + 'action_review_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -2621,6 +2718,14 @@ class $VoiceMemosTable extends VoiceMemos downloadedAt, transcribedAt, convertedFilePath, + summary, + category, + processingStatus, + aiModel, + aiProcessedAt, + taskCreated, + calendarEventCreated, + actionReviewState, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2735,6 +2840,69 @@ class $VoiceMemosTable extends VoiceMemos ), ); } + if (data.containsKey('summary')) { + context.handle( + _summaryMeta, + summary.isAcceptableOrUnknown(data['summary']!, _summaryMeta), + ); + } + if (data.containsKey('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } + if (data.containsKey('processing_status')) { + context.handle( + _processingStatusMeta, + processingStatus.isAcceptableOrUnknown( + data['processing_status']!, + _processingStatusMeta, + ), + ); + } + if (data.containsKey('ai_model')) { + context.handle( + _aiModelMeta, + aiModel.isAcceptableOrUnknown(data['ai_model']!, _aiModelMeta), + ); + } + if (data.containsKey('ai_processed_at')) { + context.handle( + _aiProcessedAtMeta, + aiProcessedAt.isAcceptableOrUnknown( + data['ai_processed_at']!, + _aiProcessedAtMeta, + ), + ); + } + if (data.containsKey('task_created')) { + context.handle( + _taskCreatedMeta, + taskCreated.isAcceptableOrUnknown( + data['task_created']!, + _taskCreatedMeta, + ), + ); + } + if (data.containsKey('calendar_event_created')) { + context.handle( + _calendarEventCreatedMeta, + calendarEventCreated.isAcceptableOrUnknown( + data['calendar_event_created']!, + _calendarEventCreatedMeta, + ), + ); + } + if (data.containsKey('action_review_state')) { + context.handle( + _actionReviewStateMeta, + actionReviewState.isAcceptableOrUnknown( + data['action_review_state']!, + _actionReviewStateMeta, + ), + ); + } return context; } @@ -2792,6 +2960,38 @@ class $VoiceMemosTable extends VoiceMemos DriftSqlType.string, data['${effectivePrefix}converted_file_path'], ), + summary: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summary'], + ), + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + ), + processingStatus: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}processing_status'], + ), + aiModel: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}ai_model'], + ), + aiProcessedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}ai_processed_at'], + ), + taskCreated: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}task_created'], + )!, + calendarEventCreated: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}calendar_event_created'], + )!, + actionReviewState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_review_state'], + ), ); } @@ -2837,6 +3037,31 @@ class VoiceMemoEntity extends DataClass implements Insertable { /// Path to converted audio file (WAV/Ogg) for playback/transcription final String? convertedFilePath; + + /// AI-generated summary of the voice note + final String? summary; + + /// AI-assigned category: 'idea', 'task', 'reminder', 'meeting', 'note' + final String? category; + + /// Current AI processing status: 'pending', 'summarizing', 'categorizing', + /// 'extractingActions', 'ready', 'failed' + final String? processingStatus; + + /// Which AI model was used for processing + final String? aiModel; + + /// When AI processing completed + final DateTime? aiProcessedAt; + + /// Whether a task has been created from this memo's suggestions + final bool taskCreated; + + /// Whether a calendar event has been created from this memo's suggestions + final bool calendarEventCreated; + + /// Review state for extracted actions: 'pending', 'reviewed', 'dismissed' + final String? actionReviewState; const VoiceMemoEntity({ required this.id, required this.filename, @@ -2850,6 +3075,14 @@ class VoiceMemoEntity extends DataClass implements Insertable { this.downloadedAt, this.transcribedAt, this.convertedFilePath, + this.summary, + this.category, + this.processingStatus, + this.aiModel, + this.aiProcessedAt, + required this.taskCreated, + required this.calendarEventCreated, + this.actionReviewState, }); @override Map toColumns(bool nullToAbsent) { @@ -2876,6 +3109,26 @@ class VoiceMemoEntity extends DataClass implements Insertable { if (!nullToAbsent || convertedFilePath != null) { map['converted_file_path'] = Variable(convertedFilePath); } + if (!nullToAbsent || summary != null) { + map['summary'] = Variable(summary); + } + if (!nullToAbsent || category != null) { + map['category'] = Variable(category); + } + if (!nullToAbsent || processingStatus != null) { + map['processing_status'] = Variable(processingStatus); + } + if (!nullToAbsent || aiModel != null) { + map['ai_model'] = Variable(aiModel); + } + if (!nullToAbsent || aiProcessedAt != null) { + map['ai_processed_at'] = Variable(aiProcessedAt); + } + map['task_created'] = Variable(taskCreated); + map['calendar_event_created'] = Variable(calendarEventCreated); + if (!nullToAbsent || actionReviewState != null) { + map['action_review_state'] = Variable(actionReviewState); + } return map; } @@ -2903,6 +3156,26 @@ class VoiceMemoEntity extends DataClass implements Insertable { convertedFilePath: convertedFilePath == null && nullToAbsent ? const Value.absent() : Value(convertedFilePath), + summary: summary == null && nullToAbsent + ? const Value.absent() + : Value(summary), + category: category == null && nullToAbsent + ? const Value.absent() + : Value(category), + processingStatus: processingStatus == null && nullToAbsent + ? const Value.absent() + : Value(processingStatus), + aiModel: aiModel == null && nullToAbsent + ? const Value.absent() + : Value(aiModel), + aiProcessedAt: aiProcessedAt == null && nullToAbsent + ? const Value.absent() + : Value(aiProcessedAt), + taskCreated: Value(taskCreated), + calendarEventCreated: Value(calendarEventCreated), + actionReviewState: actionReviewState == null && nullToAbsent + ? const Value.absent() + : Value(actionReviewState), ); } @@ -2926,6 +3199,18 @@ class VoiceMemoEntity extends DataClass implements Insertable { convertedFilePath: serializer.fromJson( json['convertedFilePath'], ), + summary: serializer.fromJson(json['summary']), + category: serializer.fromJson(json['category']), + processingStatus: serializer.fromJson(json['processingStatus']), + aiModel: serializer.fromJson(json['aiModel']), + aiProcessedAt: serializer.fromJson(json['aiProcessedAt']), + taskCreated: serializer.fromJson(json['taskCreated']), + calendarEventCreated: serializer.fromJson( + json['calendarEventCreated'], + ), + actionReviewState: serializer.fromJson( + json['actionReviewState'], + ), ); } @override @@ -2944,6 +3229,14 @@ class VoiceMemoEntity extends DataClass implements Insertable { 'downloadedAt': serializer.toJson(downloadedAt), 'transcribedAt': serializer.toJson(transcribedAt), 'convertedFilePath': serializer.toJson(convertedFilePath), + 'summary': serializer.toJson(summary), + 'category': serializer.toJson(category), + 'processingStatus': serializer.toJson(processingStatus), + 'aiModel': serializer.toJson(aiModel), + 'aiProcessedAt': serializer.toJson(aiProcessedAt), + 'taskCreated': serializer.toJson(taskCreated), + 'calendarEventCreated': serializer.toJson(calendarEventCreated), + 'actionReviewState': serializer.toJson(actionReviewState), }; } @@ -2960,6 +3253,14 @@ class VoiceMemoEntity extends DataClass implements Insertable { Value downloadedAt = const Value.absent(), Value transcribedAt = const Value.absent(), Value convertedFilePath = const Value.absent(), + Value summary = const Value.absent(), + Value category = const Value.absent(), + Value processingStatus = const Value.absent(), + Value aiModel = const Value.absent(), + Value aiProcessedAt = const Value.absent(), + bool? taskCreated, + bool? calendarEventCreated, + Value actionReviewState = const Value.absent(), }) => VoiceMemoEntity( id: id ?? this.id, filename: filename ?? this.filename, @@ -2981,6 +3282,20 @@ class VoiceMemoEntity extends DataClass implements Insertable { convertedFilePath: convertedFilePath.present ? convertedFilePath.value : this.convertedFilePath, + summary: summary.present ? summary.value : this.summary, + category: category.present ? category.value : this.category, + processingStatus: processingStatus.present + ? processingStatus.value + : this.processingStatus, + aiModel: aiModel.present ? aiModel.value : this.aiModel, + aiProcessedAt: aiProcessedAt.present + ? aiProcessedAt.value + : this.aiProcessedAt, + taskCreated: taskCreated ?? this.taskCreated, + calendarEventCreated: calendarEventCreated ?? this.calendarEventCreated, + actionReviewState: actionReviewState.present + ? actionReviewState.value + : this.actionReviewState, ); VoiceMemoEntity copyWithCompanion(VoiceMemosCompanion data) { return VoiceMemoEntity( @@ -3014,6 +3329,24 @@ class VoiceMemoEntity extends DataClass implements Insertable { convertedFilePath: data.convertedFilePath.present ? data.convertedFilePath.value : this.convertedFilePath, + summary: data.summary.present ? data.summary.value : this.summary, + category: data.category.present ? data.category.value : this.category, + processingStatus: data.processingStatus.present + ? data.processingStatus.value + : this.processingStatus, + aiModel: data.aiModel.present ? data.aiModel.value : this.aiModel, + aiProcessedAt: data.aiProcessedAt.present + ? data.aiProcessedAt.value + : this.aiProcessedAt, + taskCreated: data.taskCreated.present + ? data.taskCreated.value + : this.taskCreated, + calendarEventCreated: data.calendarEventCreated.present + ? data.calendarEventCreated.value + : this.calendarEventCreated, + actionReviewState: data.actionReviewState.present + ? data.actionReviewState.value + : this.actionReviewState, ); } @@ -3031,7 +3364,15 @@ class VoiceMemoEntity extends DataClass implements Insertable { ..write('deletedOnWatch: $deletedOnWatch, ') ..write('downloadedAt: $downloadedAt, ') ..write('transcribedAt: $transcribedAt, ') - ..write('convertedFilePath: $convertedFilePath') + ..write('convertedFilePath: $convertedFilePath, ') + ..write('summary: $summary, ') + ..write('category: $category, ') + ..write('processingStatus: $processingStatus, ') + ..write('aiModel: $aiModel, ') + ..write('aiProcessedAt: $aiProcessedAt, ') + ..write('taskCreated: $taskCreated, ') + ..write('calendarEventCreated: $calendarEventCreated, ') + ..write('actionReviewState: $actionReviewState') ..write(')')) .toString(); } @@ -3050,6 +3391,14 @@ class VoiceMemoEntity extends DataClass implements Insertable { downloadedAt, transcribedAt, convertedFilePath, + summary, + category, + processingStatus, + aiModel, + aiProcessedAt, + taskCreated, + calendarEventCreated, + actionReviewState, ); @override bool operator ==(Object other) => @@ -3066,7 +3415,15 @@ class VoiceMemoEntity extends DataClass implements Insertable { other.deletedOnWatch == this.deletedOnWatch && other.downloadedAt == this.downloadedAt && other.transcribedAt == this.transcribedAt && - other.convertedFilePath == this.convertedFilePath); + other.convertedFilePath == this.convertedFilePath && + other.summary == this.summary && + other.category == this.category && + other.processingStatus == this.processingStatus && + other.aiModel == this.aiModel && + other.aiProcessedAt == this.aiProcessedAt && + other.taskCreated == this.taskCreated && + other.calendarEventCreated == this.calendarEventCreated && + other.actionReviewState == this.actionReviewState); } class VoiceMemosCompanion extends UpdateCompanion { @@ -3082,6 +3439,14 @@ class VoiceMemosCompanion extends UpdateCompanion { final Value downloadedAt; final Value transcribedAt; final Value convertedFilePath; + final Value summary; + final Value category; + final Value processingStatus; + final Value aiModel; + final Value aiProcessedAt; + final Value taskCreated; + final Value calendarEventCreated; + final Value actionReviewState; const VoiceMemosCompanion({ this.id = const Value.absent(), this.filename = const Value.absent(), @@ -3095,6 +3460,14 @@ class VoiceMemosCompanion extends UpdateCompanion { this.downloadedAt = const Value.absent(), this.transcribedAt = const Value.absent(), this.convertedFilePath = const Value.absent(), + this.summary = const Value.absent(), + this.category = const Value.absent(), + this.processingStatus = const Value.absent(), + this.aiModel = const Value.absent(), + this.aiProcessedAt = const Value.absent(), + this.taskCreated = const Value.absent(), + this.calendarEventCreated = const Value.absent(), + this.actionReviewState = const Value.absent(), }); VoiceMemosCompanion.insert({ this.id = const Value.absent(), @@ -3109,6 +3482,14 @@ class VoiceMemosCompanion extends UpdateCompanion { this.downloadedAt = const Value.absent(), this.transcribedAt = const Value.absent(), this.convertedFilePath = const Value.absent(), + this.summary = const Value.absent(), + this.category = const Value.absent(), + this.processingStatus = const Value.absent(), + this.aiModel = const Value.absent(), + this.aiProcessedAt = const Value.absent(), + this.taskCreated = const Value.absent(), + this.calendarEventCreated = const Value.absent(), + this.actionReviewState = const Value.absent(), }) : filename = Value(filename), timestampUtc = Value(timestampUtc), durationMs = Value(durationMs), @@ -3126,6 +3507,14 @@ class VoiceMemosCompanion extends UpdateCompanion { Expression? downloadedAt, Expression? transcribedAt, Expression? convertedFilePath, + Expression? summary, + Expression? category, + Expression? processingStatus, + Expression? aiModel, + Expression? aiProcessedAt, + Expression? taskCreated, + Expression? calendarEventCreated, + Expression? actionReviewState, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -3140,6 +3529,15 @@ class VoiceMemosCompanion extends UpdateCompanion { if (downloadedAt != null) 'downloaded_at': downloadedAt, if (transcribedAt != null) 'transcribed_at': transcribedAt, if (convertedFilePath != null) 'converted_file_path': convertedFilePath, + if (summary != null) 'summary': summary, + if (category != null) 'category': category, + if (processingStatus != null) 'processing_status': processingStatus, + if (aiModel != null) 'ai_model': aiModel, + if (aiProcessedAt != null) 'ai_processed_at': aiProcessedAt, + if (taskCreated != null) 'task_created': taskCreated, + if (calendarEventCreated != null) + 'calendar_event_created': calendarEventCreated, + if (actionReviewState != null) 'action_review_state': actionReviewState, }); } @@ -3156,6 +3554,14 @@ class VoiceMemosCompanion extends UpdateCompanion { Value? downloadedAt, Value? transcribedAt, Value? convertedFilePath, + Value? summary, + Value? category, + Value? processingStatus, + Value? aiModel, + Value? aiProcessedAt, + Value? taskCreated, + Value? calendarEventCreated, + Value? actionReviewState, }) { return VoiceMemosCompanion( id: id ?? this.id, @@ -3170,6 +3576,14 @@ class VoiceMemosCompanion extends UpdateCompanion { downloadedAt: downloadedAt ?? this.downloadedAt, transcribedAt: transcribedAt ?? this.transcribedAt, convertedFilePath: convertedFilePath ?? this.convertedFilePath, + summary: summary ?? this.summary, + category: category ?? this.category, + processingStatus: processingStatus ?? this.processingStatus, + aiModel: aiModel ?? this.aiModel, + aiProcessedAt: aiProcessedAt ?? this.aiProcessedAt, + taskCreated: taskCreated ?? this.taskCreated, + calendarEventCreated: calendarEventCreated ?? this.calendarEventCreated, + actionReviewState: actionReviewState ?? this.actionReviewState, ); } @@ -3212,6 +3626,32 @@ class VoiceMemosCompanion extends UpdateCompanion { if (convertedFilePath.present) { map['converted_file_path'] = Variable(convertedFilePath.value); } + if (summary.present) { + map['summary'] = Variable(summary.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (processingStatus.present) { + map['processing_status'] = Variable(processingStatus.value); + } + if (aiModel.present) { + map['ai_model'] = Variable(aiModel.value); + } + if (aiProcessedAt.present) { + map['ai_processed_at'] = Variable(aiProcessedAt.value); + } + if (taskCreated.present) { + map['task_created'] = Variable(taskCreated.value); + } + if (calendarEventCreated.present) { + map['calendar_event_created'] = Variable( + calendarEventCreated.value, + ); + } + if (actionReviewState.present) { + map['action_review_state'] = Variable(actionReviewState.value); + } return map; } @@ -3229,7 +3669,853 @@ class VoiceMemosCompanion extends UpdateCompanion { ..write('deletedOnWatch: $deletedOnWatch, ') ..write('downloadedAt: $downloadedAt, ') ..write('transcribedAt: $transcribedAt, ') - ..write('convertedFilePath: $convertedFilePath') + ..write('convertedFilePath: $convertedFilePath, ') + ..write('summary: $summary, ') + ..write('category: $category, ') + ..write('processingStatus: $processingStatus, ') + ..write('aiModel: $aiModel, ') + ..write('aiProcessedAt: $aiProcessedAt, ') + ..write('taskCreated: $taskCreated, ') + ..write('calendarEventCreated: $calendarEventCreated, ') + ..write('actionReviewState: $actionReviewState') + ..write(')')) + .toString(); + } +} + +class $ExtractedActionsTable extends ExtractedActions + with TableInfo<$ExtractedActionsTable, ExtractedActionEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ExtractedActionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _memoIdMeta = const VerificationMeta('memoId'); + @override + late final GeneratedColumn memoId = GeneratedColumn( + 'memo_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _actionTypeMeta = const VerificationMeta( + 'actionType', + ); + @override + late final GeneratedColumn actionType = GeneratedColumn( + 'action_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _notesMeta = const VerificationMeta('notes'); + @override + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _startTimeMeta = const VerificationMeta( + 'startTime', + ); + @override + late final GeneratedColumn startTime = GeneratedColumn( + 'start_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _endTimeMeta = const VerificationMeta( + 'endTime', + ); + @override + late final GeneratedColumn endTime = GeneratedColumn( + 'end_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _dueDateMeta = const VerificationMeta( + 'dueDate', + ); + @override + late final GeneratedColumn dueDate = GeneratedColumn( + 'due_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _locationMeta = const VerificationMeta( + 'location', + ); + @override + late final GeneratedColumn location = GeneratedColumn( + 'location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _reminderMinutesMeta = const VerificationMeta( + 'reminderMinutes', + ); + @override + late final GeneratedColumn reminderMinutes = GeneratedColumn( + 'reminder_minutes', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdMeta = const VerificationMeta( + 'created', + ); + @override + late final GeneratedColumn created = GeneratedColumn( + 'created', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("created" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _dismissedMeta = const VerificationMeta( + 'dismissed', + ); + @override + late final GeneratedColumn dismissed = GeneratedColumn( + 'dismissed', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("dismissed" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _platformTargetIdMeta = const VerificationMeta( + 'platformTargetId', + ); + @override + late final GeneratedColumn platformTargetId = GeneratedColumn( + 'platform_target_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + memoId, + actionType, + title, + notes, + startTime, + endTime, + dueDate, + location, + reminderMinutes, + created, + dismissed, + platformTargetId, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'extracted_actions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('memo_id')) { + context.handle( + _memoIdMeta, + memoId.isAcceptableOrUnknown(data['memo_id']!, _memoIdMeta), + ); + } else if (isInserting) { + context.missing(_memoIdMeta); + } + if (data.containsKey('action_type')) { + context.handle( + _actionTypeMeta, + actionType.isAcceptableOrUnknown(data['action_type']!, _actionTypeMeta), + ); + } else if (isInserting) { + context.missing(_actionTypeMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('notes')) { + context.handle( + _notesMeta, + notes.isAcceptableOrUnknown(data['notes']!, _notesMeta), + ); + } + if (data.containsKey('start_time')) { + context.handle( + _startTimeMeta, + startTime.isAcceptableOrUnknown(data['start_time']!, _startTimeMeta), + ); + } + if (data.containsKey('end_time')) { + context.handle( + _endTimeMeta, + endTime.isAcceptableOrUnknown(data['end_time']!, _endTimeMeta), + ); + } + if (data.containsKey('due_date')) { + context.handle( + _dueDateMeta, + dueDate.isAcceptableOrUnknown(data['due_date']!, _dueDateMeta), + ); + } + if (data.containsKey('location')) { + context.handle( + _locationMeta, + location.isAcceptableOrUnknown(data['location']!, _locationMeta), + ); + } + if (data.containsKey('reminder_minutes')) { + context.handle( + _reminderMinutesMeta, + reminderMinutes.isAcceptableOrUnknown( + data['reminder_minutes']!, + _reminderMinutesMeta, + ), + ); + } + if (data.containsKey('created')) { + context.handle( + _createdMeta, + created.isAcceptableOrUnknown(data['created']!, _createdMeta), + ); + } + if (data.containsKey('dismissed')) { + context.handle( + _dismissedMeta, + dismissed.isAcceptableOrUnknown(data['dismissed']!, _dismissedMeta), + ); + } + if (data.containsKey('platform_target_id')) { + context.handle( + _platformTargetIdMeta, + platformTargetId.isAcceptableOrUnknown( + data['platform_target_id']!, + _platformTargetIdMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ExtractedActionEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ExtractedActionEntity( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + memoId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}memo_id'], + )!, + actionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_type'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + notes: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}notes'], + ), + startTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}start_time'], + ), + endTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}end_time'], + ), + dueDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}due_date'], + ), + location: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}location'], + ), + reminderMinutes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}reminder_minutes'], + ), + created: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}created'], + )!, + dismissed: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}dismissed'], + )!, + platformTargetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}platform_target_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + ), + ); + } + + @override + $ExtractedActionsTable createAlias(String alias) { + return $ExtractedActionsTable(attachedDatabase, alias); + } +} + +class ExtractedActionEntity extends DataClass + implements Insertable { + /// Auto-incrementing row identifier + final int id; + + /// Foreign key to the parent voice memo + final int memoId; + + /// Action type: 'task', 'calendar_event', 'reminder' + final String actionType; + + /// AI-generated title for the action + final String title; + + /// Optional notes / body text + final String? notes; + + /// Suggested start time (for calendar events) + final DateTime? startTime; + + /// Suggested end time (for calendar events) + final DateTime? endTime; + + /// Suggested due date (for tasks / reminders) + final DateTime? dueDate; + + /// Optional location + final String? location; + + /// Reminder offset in minutes before the event + final int? reminderMinutes; + + /// Whether this action has been created in the OS (calendar / reminders) + final bool created; + + /// Whether the user dismissed this suggestion + final bool dismissed; + + /// Platform-specific ID after creation (e.g. calendar event ID) + final String? platformTargetId; + + /// When this action was created in the OS + final DateTime? createdAt; + const ExtractedActionEntity({ + required this.id, + required this.memoId, + required this.actionType, + required this.title, + this.notes, + this.startTime, + this.endTime, + this.dueDate, + this.location, + this.reminderMinutes, + required this.created, + required this.dismissed, + this.platformTargetId, + this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['memo_id'] = Variable(memoId); + map['action_type'] = Variable(actionType); + map['title'] = Variable(title); + if (!nullToAbsent || notes != null) { + map['notes'] = Variable(notes); + } + if (!nullToAbsent || startTime != null) { + map['start_time'] = Variable(startTime); + } + if (!nullToAbsent || endTime != null) { + map['end_time'] = Variable(endTime); + } + if (!nullToAbsent || dueDate != null) { + map['due_date'] = Variable(dueDate); + } + if (!nullToAbsent || location != null) { + map['location'] = Variable(location); + } + if (!nullToAbsent || reminderMinutes != null) { + map['reminder_minutes'] = Variable(reminderMinutes); + } + map['created'] = Variable(created); + map['dismissed'] = Variable(dismissed); + if (!nullToAbsent || platformTargetId != null) { + map['platform_target_id'] = Variable(platformTargetId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + return map; + } + + ExtractedActionsCompanion toCompanion(bool nullToAbsent) { + return ExtractedActionsCompanion( + id: Value(id), + memoId: Value(memoId), + actionType: Value(actionType), + title: Value(title), + notes: notes == null && nullToAbsent + ? const Value.absent() + : Value(notes), + startTime: startTime == null && nullToAbsent + ? const Value.absent() + : Value(startTime), + endTime: endTime == null && nullToAbsent + ? const Value.absent() + : Value(endTime), + dueDate: dueDate == null && nullToAbsent + ? const Value.absent() + : Value(dueDate), + location: location == null && nullToAbsent + ? const Value.absent() + : Value(location), + reminderMinutes: reminderMinutes == null && nullToAbsent + ? const Value.absent() + : Value(reminderMinutes), + created: Value(created), + dismissed: Value(dismissed), + platformTargetId: platformTargetId == null && nullToAbsent + ? const Value.absent() + : Value(platformTargetId), + createdAt: createdAt == null && nullToAbsent + ? const Value.absent() + : Value(createdAt), + ); + } + + factory ExtractedActionEntity.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ExtractedActionEntity( + id: serializer.fromJson(json['id']), + memoId: serializer.fromJson(json['memoId']), + actionType: serializer.fromJson(json['actionType']), + title: serializer.fromJson(json['title']), + notes: serializer.fromJson(json['notes']), + startTime: serializer.fromJson(json['startTime']), + endTime: serializer.fromJson(json['endTime']), + dueDate: serializer.fromJson(json['dueDate']), + location: serializer.fromJson(json['location']), + reminderMinutes: serializer.fromJson(json['reminderMinutes']), + created: serializer.fromJson(json['created']), + dismissed: serializer.fromJson(json['dismissed']), + platformTargetId: serializer.fromJson(json['platformTargetId']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'memoId': serializer.toJson(memoId), + 'actionType': serializer.toJson(actionType), + 'title': serializer.toJson(title), + 'notes': serializer.toJson(notes), + 'startTime': serializer.toJson(startTime), + 'endTime': serializer.toJson(endTime), + 'dueDate': serializer.toJson(dueDate), + 'location': serializer.toJson(location), + 'reminderMinutes': serializer.toJson(reminderMinutes), + 'created': serializer.toJson(created), + 'dismissed': serializer.toJson(dismissed), + 'platformTargetId': serializer.toJson(platformTargetId), + 'createdAt': serializer.toJson(createdAt), + }; + } + + ExtractedActionEntity copyWith({ + int? id, + int? memoId, + String? actionType, + String? title, + Value notes = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value dueDate = const Value.absent(), + Value location = const Value.absent(), + Value reminderMinutes = const Value.absent(), + bool? created, + bool? dismissed, + Value platformTargetId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ExtractedActionEntity( + id: id ?? this.id, + memoId: memoId ?? this.memoId, + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes.present ? notes.value : this.notes, + startTime: startTime.present ? startTime.value : this.startTime, + endTime: endTime.present ? endTime.value : this.endTime, + dueDate: dueDate.present ? dueDate.value : this.dueDate, + location: location.present ? location.value : this.location, + reminderMinutes: reminderMinutes.present + ? reminderMinutes.value + : this.reminderMinutes, + created: created ?? this.created, + dismissed: dismissed ?? this.dismissed, + platformTargetId: platformTargetId.present + ? platformTargetId.value + : this.platformTargetId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + ); + ExtractedActionEntity copyWithCompanion(ExtractedActionsCompanion data) { + return ExtractedActionEntity( + id: data.id.present ? data.id.value : this.id, + memoId: data.memoId.present ? data.memoId.value : this.memoId, + actionType: data.actionType.present + ? data.actionType.value + : this.actionType, + title: data.title.present ? data.title.value : this.title, + notes: data.notes.present ? data.notes.value : this.notes, + startTime: data.startTime.present ? data.startTime.value : this.startTime, + endTime: data.endTime.present ? data.endTime.value : this.endTime, + dueDate: data.dueDate.present ? data.dueDate.value : this.dueDate, + location: data.location.present ? data.location.value : this.location, + reminderMinutes: data.reminderMinutes.present + ? data.reminderMinutes.value + : this.reminderMinutes, + created: data.created.present ? data.created.value : this.created, + dismissed: data.dismissed.present ? data.dismissed.value : this.dismissed, + platformTargetId: data.platformTargetId.present + ? data.platformTargetId.value + : this.platformTargetId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('ExtractedActionEntity(') + ..write('id: $id, ') + ..write('memoId: $memoId, ') + ..write('actionType: $actionType, ') + ..write('title: $title, ') + ..write('notes: $notes, ') + ..write('startTime: $startTime, ') + ..write('endTime: $endTime, ') + ..write('dueDate: $dueDate, ') + ..write('location: $location, ') + ..write('reminderMinutes: $reminderMinutes, ') + ..write('created: $created, ') + ..write('dismissed: $dismissed, ') + ..write('platformTargetId: $platformTargetId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + memoId, + actionType, + title, + notes, + startTime, + endTime, + dueDate, + location, + reminderMinutes, + created, + dismissed, + platformTargetId, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ExtractedActionEntity && + other.id == this.id && + other.memoId == this.memoId && + other.actionType == this.actionType && + other.title == this.title && + other.notes == this.notes && + other.startTime == this.startTime && + other.endTime == this.endTime && + other.dueDate == this.dueDate && + other.location == this.location && + other.reminderMinutes == this.reminderMinutes && + other.created == this.created && + other.dismissed == this.dismissed && + other.platformTargetId == this.platformTargetId && + other.createdAt == this.createdAt); +} + +class ExtractedActionsCompanion extends UpdateCompanion { + final Value id; + final Value memoId; + final Value actionType; + final Value title; + final Value notes; + final Value startTime; + final Value endTime; + final Value dueDate; + final Value location; + final Value reminderMinutes; + final Value created; + final Value dismissed; + final Value platformTargetId; + final Value createdAt; + const ExtractedActionsCompanion({ + this.id = const Value.absent(), + this.memoId = const Value.absent(), + this.actionType = const Value.absent(), + this.title = const Value.absent(), + this.notes = const Value.absent(), + this.startTime = const Value.absent(), + this.endTime = const Value.absent(), + this.dueDate = const Value.absent(), + this.location = const Value.absent(), + this.reminderMinutes = const Value.absent(), + this.created = const Value.absent(), + this.dismissed = const Value.absent(), + this.platformTargetId = const Value.absent(), + this.createdAt = const Value.absent(), + }); + ExtractedActionsCompanion.insert({ + this.id = const Value.absent(), + required int memoId, + required String actionType, + required String title, + this.notes = const Value.absent(), + this.startTime = const Value.absent(), + this.endTime = const Value.absent(), + this.dueDate = const Value.absent(), + this.location = const Value.absent(), + this.reminderMinutes = const Value.absent(), + this.created = const Value.absent(), + this.dismissed = const Value.absent(), + this.platformTargetId = const Value.absent(), + this.createdAt = const Value.absent(), + }) : memoId = Value(memoId), + actionType = Value(actionType), + title = Value(title); + static Insertable custom({ + Expression? id, + Expression? memoId, + Expression? actionType, + Expression? title, + Expression? notes, + Expression? startTime, + Expression? endTime, + Expression? dueDate, + Expression? location, + Expression? reminderMinutes, + Expression? created, + Expression? dismissed, + Expression? platformTargetId, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (memoId != null) 'memo_id': memoId, + if (actionType != null) 'action_type': actionType, + if (title != null) 'title': title, + if (notes != null) 'notes': notes, + if (startTime != null) 'start_time': startTime, + if (endTime != null) 'end_time': endTime, + if (dueDate != null) 'due_date': dueDate, + if (location != null) 'location': location, + if (reminderMinutes != null) 'reminder_minutes': reminderMinutes, + if (created != null) 'created': created, + if (dismissed != null) 'dismissed': dismissed, + if (platformTargetId != null) 'platform_target_id': platformTargetId, + if (createdAt != null) 'created_at': createdAt, + }); + } + + ExtractedActionsCompanion copyWith({ + Value? id, + Value? memoId, + Value? actionType, + Value? title, + Value? notes, + Value? startTime, + Value? endTime, + Value? dueDate, + Value? location, + Value? reminderMinutes, + Value? created, + Value? dismissed, + Value? platformTargetId, + Value? createdAt, + }) { + return ExtractedActionsCompanion( + id: id ?? this.id, + memoId: memoId ?? this.memoId, + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes ?? this.notes, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + dueDate: dueDate ?? this.dueDate, + location: location ?? this.location, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, + created: created ?? this.created, + dismissed: dismissed ?? this.dismissed, + platformTargetId: platformTargetId ?? this.platformTargetId, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (memoId.present) { + map['memo_id'] = Variable(memoId.value); + } + if (actionType.present) { + map['action_type'] = Variable(actionType.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (notes.present) { + map['notes'] = Variable(notes.value); + } + if (startTime.present) { + map['start_time'] = Variable(startTime.value); + } + if (endTime.present) { + map['end_time'] = Variable(endTime.value); + } + if (dueDate.present) { + map['due_date'] = Variable(dueDate.value); + } + if (location.present) { + map['location'] = Variable(location.value); + } + if (reminderMinutes.present) { + map['reminder_minutes'] = Variable(reminderMinutes.value); + } + if (created.present) { + map['created'] = Variable(created.value); + } + if (dismissed.present) { + map['dismissed'] = Variable(dismissed.value); + } + if (platformTargetId.present) { + map['platform_target_id'] = Variable(platformTargetId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ExtractedActionsCompanion(') + ..write('id: $id, ') + ..write('memoId: $memoId, ') + ..write('actionType: $actionType, ') + ..write('title: $title, ') + ..write('notes: $notes, ') + ..write('startTime: $startTime, ') + ..write('endTime: $endTime, ') + ..write('dueDate: $dueDate, ') + ..write('location: $location, ') + ..write('reminderMinutes: $reminderMinutes, ') + ..write('created: $created, ') + ..write('dismissed: $dismissed, ') + ..write('platformTargetId: $platformTargetId, ') + ..write('createdAt: $createdAt') ..write(')')) .toString(); } @@ -3248,6 +4534,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { this, ); late final $VoiceMemosTable voiceMemos = $VoiceMemosTable(this); + late final $ExtractedActionsTable extractedActions = $ExtractedActionsTable( + this, + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3259,6 +4548,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { commLogEntries, connectionEvents, voiceMemos, + extractedActions, ]; } @@ -5180,6 +6470,14 @@ typedef $$VoiceMemosTableCreateCompanionBuilder = Value downloadedAt, Value transcribedAt, Value convertedFilePath, + Value summary, + Value category, + Value processingStatus, + Value aiModel, + Value aiProcessedAt, + Value taskCreated, + Value calendarEventCreated, + Value actionReviewState, }); typedef $$VoiceMemosTableUpdateCompanionBuilder = VoiceMemosCompanion Function({ @@ -5195,6 +6493,14 @@ typedef $$VoiceMemosTableUpdateCompanionBuilder = Value downloadedAt, Value transcribedAt, Value convertedFilePath, + Value summary, + Value category, + Value processingStatus, + Value aiModel, + Value aiProcessedAt, + Value taskCreated, + Value calendarEventCreated, + Value actionReviewState, }); class $$VoiceMemosTableFilterComposer @@ -5265,6 +6571,46 @@ class $$VoiceMemosTableFilterComposer column: $table.convertedFilePath, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get processingStatus => $composableBuilder( + column: $table.processingStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get aiModel => $composableBuilder( + column: $table.aiModel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get aiProcessedAt => $composableBuilder( + column: $table.aiProcessedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskCreated => $composableBuilder( + column: $table.taskCreated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarEventCreated => $composableBuilder( + column: $table.calendarEventCreated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get actionReviewState => $composableBuilder( + column: $table.actionReviewState, + builder: (column) => ColumnFilters(column), + ); } class $$VoiceMemosTableOrderingComposer @@ -5335,6 +6681,46 @@ class $$VoiceMemosTableOrderingComposer column: $table.convertedFilePath, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get processingStatus => $composableBuilder( + column: $table.processingStatus, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get aiModel => $composableBuilder( + column: $table.aiModel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get aiProcessedAt => $composableBuilder( + column: $table.aiProcessedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskCreated => $composableBuilder( + column: $table.taskCreated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get calendarEventCreated => $composableBuilder( + column: $table.calendarEventCreated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get actionReviewState => $composableBuilder( + column: $table.actionReviewState, + builder: (column) => ColumnOrderings(column), + ); } class $$VoiceMemosTableAnnotationComposer @@ -5399,6 +6785,40 @@ class $$VoiceMemosTableAnnotationComposer column: $table.convertedFilePath, builder: (column) => column, ); + + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get processingStatus => $composableBuilder( + column: $table.processingStatus, + builder: (column) => column, + ); + + GeneratedColumn get aiModel => + $composableBuilder(column: $table.aiModel, builder: (column) => column); + + GeneratedColumn get aiProcessedAt => $composableBuilder( + column: $table.aiProcessedAt, + builder: (column) => column, + ); + + GeneratedColumn get taskCreated => $composableBuilder( + column: $table.taskCreated, + builder: (column) => column, + ); + + GeneratedColumn get calendarEventCreated => $composableBuilder( + column: $table.calendarEventCreated, + builder: (column) => column, + ); + + GeneratedColumn get actionReviewState => $composableBuilder( + column: $table.actionReviewState, + builder: (column) => column, + ); } class $$VoiceMemosTableTableManager @@ -5444,6 +6864,14 @@ class $$VoiceMemosTableTableManager Value downloadedAt = const Value.absent(), Value transcribedAt = const Value.absent(), Value convertedFilePath = const Value.absent(), + Value summary = const Value.absent(), + Value category = const Value.absent(), + Value processingStatus = const Value.absent(), + Value aiModel = const Value.absent(), + Value aiProcessedAt = const Value.absent(), + Value taskCreated = const Value.absent(), + Value calendarEventCreated = const Value.absent(), + Value actionReviewState = const Value.absent(), }) => VoiceMemosCompanion( id: id, filename: filename, @@ -5457,6 +6885,14 @@ class $$VoiceMemosTableTableManager downloadedAt: downloadedAt, transcribedAt: transcribedAt, convertedFilePath: convertedFilePath, + summary: summary, + category: category, + processingStatus: processingStatus, + aiModel: aiModel, + aiProcessedAt: aiProcessedAt, + taskCreated: taskCreated, + calendarEventCreated: calendarEventCreated, + actionReviewState: actionReviewState, ), createCompanionCallback: ({ @@ -5472,6 +6908,14 @@ class $$VoiceMemosTableTableManager Value downloadedAt = const Value.absent(), Value transcribedAt = const Value.absent(), Value convertedFilePath = const Value.absent(), + Value summary = const Value.absent(), + Value category = const Value.absent(), + Value processingStatus = const Value.absent(), + Value aiModel = const Value.absent(), + Value aiProcessedAt = const Value.absent(), + Value taskCreated = const Value.absent(), + Value calendarEventCreated = const Value.absent(), + Value actionReviewState = const Value.absent(), }) => VoiceMemosCompanion.insert( id: id, filename: filename, @@ -5485,6 +6929,14 @@ class $$VoiceMemosTableTableManager downloadedAt: downloadedAt, transcribedAt: transcribedAt, convertedFilePath: convertedFilePath, + summary: summary, + category: category, + processingStatus: processingStatus, + aiModel: aiModel, + aiProcessedAt: aiProcessedAt, + taskCreated: taskCreated, + calendarEventCreated: calendarEventCreated, + actionReviewState: actionReviewState, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -5511,6 +6963,387 @@ typedef $$VoiceMemosTableProcessedTableManager = VoiceMemoEntity, PrefetchHooks Function() >; +typedef $$ExtractedActionsTableCreateCompanionBuilder = + ExtractedActionsCompanion Function({ + Value id, + required int memoId, + required String actionType, + required String title, + Value notes, + Value startTime, + Value endTime, + Value dueDate, + Value location, + Value reminderMinutes, + Value created, + Value dismissed, + Value platformTargetId, + Value createdAt, + }); +typedef $$ExtractedActionsTableUpdateCompanionBuilder = + ExtractedActionsCompanion Function({ + Value id, + Value memoId, + Value actionType, + Value title, + Value notes, + Value startTime, + Value endTime, + Value dueDate, + Value location, + Value reminderMinutes, + Value created, + Value dismissed, + Value platformTargetId, + Value createdAt, + }); + +class $$ExtractedActionsTableFilterComposer + extends Composer<_$AppDatabase, $ExtractedActionsTable> { + $$ExtractedActionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get memoId => $composableBuilder( + column: $table.memoId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get notes => $composableBuilder( + column: $table.notes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get startTime => $composableBuilder( + column: $table.startTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get endTime => $composableBuilder( + column: $table.endTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dueDate => $composableBuilder( + column: $table.dueDate, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dismissed => $composableBuilder( + column: $table.dismissed, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get platformTargetId => $composableBuilder( + column: $table.platformTargetId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ExtractedActionsTableOrderingComposer + extends Composer<_$AppDatabase, $ExtractedActionsTable> { + $$ExtractedActionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get memoId => $composableBuilder( + column: $table.memoId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get notes => $composableBuilder( + column: $table.notes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startTime => $composableBuilder( + column: $table.startTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endTime => $composableBuilder( + column: $table.endTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dueDate => $composableBuilder( + column: $table.dueDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dismissed => $composableBuilder( + column: $table.dismissed, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get platformTargetId => $composableBuilder( + column: $table.platformTargetId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ExtractedActionsTableAnnotationComposer + extends Composer<_$AppDatabase, $ExtractedActionsTable> { + $$ExtractedActionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get memoId => + $composableBuilder(column: $table.memoId, builder: (column) => column); + + GeneratedColumn get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => column, + ); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get notes => + $composableBuilder(column: $table.notes, builder: (column) => column); + + GeneratedColumn get startTime => + $composableBuilder(column: $table.startTime, builder: (column) => column); + + GeneratedColumn get endTime => + $composableBuilder(column: $table.endTime, builder: (column) => column); + + GeneratedColumn get dueDate => + $composableBuilder(column: $table.dueDate, builder: (column) => column); + + GeneratedColumn get location => + $composableBuilder(column: $table.location, builder: (column) => column); + + GeneratedColumn get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => column, + ); + + GeneratedColumn get created => + $composableBuilder(column: $table.created, builder: (column) => column); + + GeneratedColumn get dismissed => + $composableBuilder(column: $table.dismissed, builder: (column) => column); + + GeneratedColumn get platformTargetId => $composableBuilder( + column: $table.platformTargetId, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$ExtractedActionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ExtractedActionsTable, + ExtractedActionEntity, + $$ExtractedActionsTableFilterComposer, + $$ExtractedActionsTableOrderingComposer, + $$ExtractedActionsTableAnnotationComposer, + $$ExtractedActionsTableCreateCompanionBuilder, + $$ExtractedActionsTableUpdateCompanionBuilder, + ( + ExtractedActionEntity, + BaseReferences< + _$AppDatabase, + $ExtractedActionsTable, + ExtractedActionEntity + >, + ), + ExtractedActionEntity, + PrefetchHooks Function() + > { + $$ExtractedActionsTableTableManager( + _$AppDatabase db, + $ExtractedActionsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ExtractedActionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ExtractedActionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ExtractedActionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value memoId = const Value.absent(), + Value actionType = const Value.absent(), + Value title = const Value.absent(), + Value notes = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value dueDate = const Value.absent(), + Value location = const Value.absent(), + Value reminderMinutes = const Value.absent(), + Value created = const Value.absent(), + Value dismissed = const Value.absent(), + Value platformTargetId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ExtractedActionsCompanion( + id: id, + memoId: memoId, + actionType: actionType, + title: title, + notes: notes, + startTime: startTime, + endTime: endTime, + dueDate: dueDate, + location: location, + reminderMinutes: reminderMinutes, + created: created, + dismissed: dismissed, + platformTargetId: platformTargetId, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int memoId, + required String actionType, + required String title, + Value notes = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value dueDate = const Value.absent(), + Value location = const Value.absent(), + Value reminderMinutes = const Value.absent(), + Value created = const Value.absent(), + Value dismissed = const Value.absent(), + Value platformTargetId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ExtractedActionsCompanion.insert( + id: id, + memoId: memoId, + actionType: actionType, + title: title, + notes: notes, + startTime: startTime, + endTime: endTime, + dueDate: dueDate, + location: location, + reminderMinutes: reminderMinutes, + created: created, + dismissed: dismissed, + platformTargetId: platformTargetId, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ExtractedActionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ExtractedActionsTable, + ExtractedActionEntity, + $$ExtractedActionsTableFilterComposer, + $$ExtractedActionsTableOrderingComposer, + $$ExtractedActionsTableAnnotationComposer, + $$ExtractedActionsTableCreateCompanionBuilder, + $$ExtractedActionsTableUpdateCompanionBuilder, + ( + ExtractedActionEntity, + BaseReferences< + _$AppDatabase, + $ExtractedActionsTable, + ExtractedActionEntity + >, + ), + ExtractedActionEntity, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -5527,4 +7360,6 @@ class $AppDatabaseManager { $$ConnectionEventsTableTableManager(_db, _db.connectionEvents); $$VoiceMemosTableTableManager get voiceMemos => $$VoiceMemosTableTableManager(_db, _db.voiceMemos); + $$ExtractedActionsTableTableManager get extractedActions => + $$ExtractedActionsTableTableManager(_db, _db.extractedActions); } diff --git a/zswatch_app/lib/data/database/tables/extracted_actions_table.dart b/zswatch_app/lib/data/database/tables/extracted_actions_table.dart new file mode 100644 index 0000000..8b5bb05 --- /dev/null +++ b/zswatch_app/lib/data/database/tables/extracted_actions_table.dart @@ -0,0 +1,54 @@ +import 'package:drift/drift.dart'; + +/// Extracted actions from AI processing of voice memos. +/// +/// Each row represents a single task, reminder, or calendar event +/// suggestion produced by the local LLM from a parent voice memo. +@DataClassName('ExtractedActionEntity') +class ExtractedActions extends Table { + /// Auto-incrementing row identifier + IntColumn get id => integer().autoIncrement()(); + + /// Foreign key to the parent voice memo + IntColumn get memoId => integer().named('memo_id')(); + + /// Action type: 'task', 'calendar_event', 'reminder' + TextColumn get actionType => text().named('action_type')(); + + /// AI-generated title for the action + TextColumn get title => text()(); + + /// Optional notes / body text + TextColumn get notes => text().nullable()(); + + /// Suggested start time (for calendar events) + DateTimeColumn get startTime => dateTime().nullable().named('start_time')(); + + /// Suggested end time (for calendar events) + DateTimeColumn get endTime => dateTime().nullable().named('end_time')(); + + /// Suggested due date (for tasks / reminders) + DateTimeColumn get dueDate => dateTime().nullable().named('due_date')(); + + /// Optional location + TextColumn get location => text().nullable()(); + + /// Reminder offset in minutes before the event + IntColumn get reminderMinutes => + integer().nullable().named('reminder_minutes')(); + + /// Whether this action has been created in the OS (calendar / reminders) + BoolColumn get created => + boolean().withDefault(const Constant(false))(); + + /// Whether the user dismissed this suggestion + BoolColumn get dismissed => + boolean().withDefault(const Constant(false))(); + + /// Platform-specific ID after creation (e.g. calendar event ID) + TextColumn get platformTargetId => + text().nullable().named('platform_target_id')(); + + /// When this action was created in the OS + DateTimeColumn get createdAt => dateTime().nullable().named('created_at')(); +} diff --git a/zswatch_app/lib/data/database/tables/voice_memos_table.dart b/zswatch_app/lib/data/database/tables/voice_memos_table.dart index 2e1296d..f5ddc5f 100644 --- a/zswatch_app/lib/data/database/tables/voice_memos_table.dart +++ b/zswatch_app/lib/data/database/tables/voice_memos_table.dart @@ -48,4 +48,36 @@ class VoiceMemos extends Table { /// Path to converted audio file (WAV/Ogg) for playback/transcription TextColumn get convertedFilePath => text().nullable().named('converted_file_path')(); + + // ==================== AI-Enhanced Fields ==================== + + /// AI-generated summary of the voice note + TextColumn get summary => text().nullable()(); + + /// AI-assigned category: 'idea', 'task', 'reminder', 'meeting', 'note' + TextColumn get category => text().nullable()(); + + /// Current AI processing status: 'pending', 'summarizing', 'categorizing', + /// 'extractingActions', 'ready', 'failed' + TextColumn get processingStatus => + text().nullable().named('processing_status')(); + + /// Which AI model was used for processing + TextColumn get aiModel => text().nullable().named('ai_model')(); + + /// When AI processing completed + DateTimeColumn get aiProcessedAt => + dateTime().nullable().named('ai_processed_at')(); + + /// Whether a task has been created from this memo's suggestions + BoolColumn get taskCreated => + boolean().withDefault(const Constant(false)).named('task_created')(); + + /// Whether a calendar event has been created from this memo's suggestions + BoolColumn get calendarEventCreated => + boolean().withDefault(const Constant(false)).named('calendar_event_created')(); + + /// Review state for extracted actions: 'pending', 'reviewed', 'dismissed' + TextColumn get actionReviewState => + text().nullable().named('action_review_state')(); } diff --git a/zswatch_app/lib/data/models/extracted_action.dart b/zswatch_app/lib/data/models/extracted_action.dart new file mode 100644 index 0000000..ba7aa22 --- /dev/null +++ b/zswatch_app/lib/data/models/extracted_action.dart @@ -0,0 +1,121 @@ +import 'package:equatable/equatable.dart'; + +/// Type of extracted action from AI processing +enum ExtractedActionType { + task, + calendarEvent, + reminder, +} + +/// Domain model for an AI-extracted action from a voice memo +class ExtractedAction extends Equatable { + final int id; + final int memoId; + final ExtractedActionType actionType; + final String title; + final String? notes; + final DateTime? startTime; + final DateTime? endTime; + final DateTime? dueDate; + final String? location; + final int? reminderMinutes; + final bool created; + final bool dismissed; + final String? platformTargetId; + final DateTime? createdAt; + + const ExtractedAction({ + required this.id, + required this.memoId, + required this.actionType, + required this.title, + this.notes, + this.startTime, + this.endTime, + this.dueDate, + this.location, + this.reminderMinutes, + this.created = false, + this.dismissed = false, + this.platformTargetId, + this.createdAt, + }); + + ExtractedAction copyWith({ + int? id, + int? memoId, + ExtractedActionType? actionType, + String? title, + String? notes, + DateTime? startTime, + DateTime? endTime, + DateTime? dueDate, + String? location, + int? reminderMinutes, + bool? created, + bool? dismissed, + String? platformTargetId, + DateTime? createdAt, + }) { + return ExtractedAction( + id: id ?? this.id, + memoId: memoId ?? this.memoId, + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes ?? this.notes, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + dueDate: dueDate ?? this.dueDate, + location: location ?? this.location, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, + created: created ?? this.created, + dismissed: dismissed ?? this.dismissed, + platformTargetId: platformTargetId ?? this.platformTargetId, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// Convert action type string from DB to enum + static ExtractedActionType typeFromString(String value) { + switch (value) { + case 'task': + return ExtractedActionType.task; + case 'calendar_event': + return ExtractedActionType.calendarEvent; + case 'reminder': + return ExtractedActionType.reminder; + default: + return ExtractedActionType.task; + } + } + + /// Convert enum to DB string + static String typeToString(ExtractedActionType type) { + switch (type) { + case ExtractedActionType.task: + return 'task'; + case ExtractedActionType.calendarEvent: + return 'calendar_event'; + case ExtractedActionType.reminder: + return 'reminder'; + } + } + + @override + List get props => [ + id, + memoId, + actionType, + title, + notes, + startTime, + endTime, + dueDate, + location, + reminderMinutes, + created, + dismissed, + platformTargetId, + createdAt, + ]; +} diff --git a/zswatch_app/lib/data/models/voice_memo.dart b/zswatch_app/lib/data/models/voice_memo.dart index d8311bd..3735c70 100644 --- a/zswatch_app/lib/data/models/voice_memo.dart +++ b/zswatch_app/lib/data/models/voice_memo.dart @@ -18,6 +18,36 @@ enum VoiceMemoSyncStatus { transcribed, } +/// AI processing status for a voice memo +enum VoiceNoteProcessingStatus { + /// Not yet processed by AI + pending, + + /// AI is summarizing the transcript + summarizing, + + /// AI is categorizing the note + categorizing, + + /// AI is extracting actions + extractingActions, + + /// AI processing completed successfully + ready, + + /// AI processing failed + failed, +} + +/// Category assigned by AI to a voice note +enum VoiceNoteCategory { + idea, + task, + reminder, + meeting, + note, +} + /// Domain model for a voice memo recording class VoiceMemo extends Equatable { final int id; @@ -33,6 +63,16 @@ class VoiceMemo extends Equatable { final DateTime? transcribedAt; final String? convertedFilePath; + // AI-enhanced fields + final String? summary; + final String? category; + final String? processingStatus; + final String? aiModel; + final DateTime? aiProcessedAt; + final bool taskCreated; + final bool calendarEventCreated; + final String? actionReviewState; + const VoiceMemo({ required this.id, required this.filename, @@ -46,6 +86,14 @@ class VoiceMemo extends Equatable { this.downloadedAt, this.transcribedAt, this.convertedFilePath, + this.summary, + this.category, + this.processingStatus, + this.aiModel, + this.aiProcessedAt, + this.taskCreated = false, + this.calendarEventCreated = false, + this.actionReviewState, }); /// Computed sync status based on field values @@ -57,6 +105,58 @@ class VoiceMemo extends Equatable { return VoiceMemoSyncStatus.onWatchOnly; } + /// Parsed AI processing status + VoiceNoteProcessingStatus get aiProcessingStatus { + if (processingStatus == null) return VoiceNoteProcessingStatus.pending; + switch (processingStatus) { + case 'summarizing': + return VoiceNoteProcessingStatus.summarizing; + case 'categorizing': + return VoiceNoteProcessingStatus.categorizing; + case 'extractingActions': + return VoiceNoteProcessingStatus.extractingActions; + case 'ready': + return VoiceNoteProcessingStatus.ready; + case 'failed': + return VoiceNoteProcessingStatus.failed; + default: + return VoiceNoteProcessingStatus.pending; + } + } + + /// Convenience alias used by UI code + VoiceNoteCategory? get aiCategory => categoryEnum; + + /// Parsed category enum + VoiceNoteCategory? get categoryEnum { + if (category == null) return null; + switch (category) { + case 'idea': + return VoiceNoteCategory.idea; + case 'task': + return VoiceNoteCategory.task; + case 'reminder': + return VoiceNoteCategory.reminder; + case 'meeting': + return VoiceNoteCategory.meeting; + case 'note': + return VoiceNoteCategory.note; + default: + return VoiceNoteCategory.note; + } + } + + /// Whether AI has processed this memo + bool get isAiProcessed => summary != null && processingStatus == 'ready'; + + /// Whether AI is currently processing this memo + bool get isAiProcessing { + final s = processingStatus; + return s == 'summarizing' || + s == 'categorizing' || + s == 'extractingActions'; + } + /// Duration formatted as MM:SS String get formattedDuration { final totalSeconds = durationMs ~/ 1000; @@ -100,6 +200,14 @@ class VoiceMemo extends Equatable { DateTime? downloadedAt, DateTime? transcribedAt, String? convertedFilePath, + String? summary, + String? category, + String? processingStatus, + String? aiModel, + DateTime? aiProcessedAt, + bool? taskCreated, + bool? calendarEventCreated, + String? actionReviewState, }) { return VoiceMemo( id: id ?? this.id, @@ -114,6 +222,14 @@ class VoiceMemo extends Equatable { downloadedAt: downloadedAt ?? this.downloadedAt, transcribedAt: transcribedAt ?? this.transcribedAt, convertedFilePath: convertedFilePath ?? this.convertedFilePath, + summary: summary ?? this.summary, + category: category ?? this.category, + processingStatus: processingStatus ?? this.processingStatus, + aiModel: aiModel ?? this.aiModel, + aiProcessedAt: aiProcessedAt ?? this.aiProcessedAt, + taskCreated: taskCreated ?? this.taskCreated, + calendarEventCreated: calendarEventCreated ?? this.calendarEventCreated, + actionReviewState: actionReviewState ?? this.actionReviewState, ); } @@ -131,5 +247,13 @@ class VoiceMemo extends Equatable { downloadedAt, transcribedAt, convertedFilePath, + summary, + category, + processingStatus, + aiModel, + aiProcessedAt, + taskCreated, + calendarEventCreated, + actionReviewState, ]; } diff --git a/zswatch_app/lib/data/repositories/extracted_action_repository.dart b/zswatch_app/lib/data/repositories/extracted_action_repository.dart new file mode 100644 index 0000000..dafdbb8 --- /dev/null +++ b/zswatch_app/lib/data/repositories/extracted_action_repository.dart @@ -0,0 +1,104 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import '../database/app_database.dart'; +import '../models/extracted_action.dart'; + +/// Repository for AI-extracted action operations +class ExtractedActionRepository { + final AppDatabase _db; + + ExtractedActionRepository(this._db); + + // ==================== Read Operations ==================== + + /// Get all extracted actions for a voice memo + Future> getActionsForMemo(int memoId) async { + final entities = await _db.getActionsForMemo(memoId); + return entities.map(_entityToModel).toList(); + } + + /// Watch extracted actions for a voice memo (reactive stream) + Stream> watchActionsForMemo(int memoId) { + return _db.watchActionsForMemo(memoId).map( + (entities) => entities.map(_entityToModel).toList(), + ); + } + + /// Get all pending (not created, not dismissed) actions + Future> getPendingActions() async { + final entities = await _db.getPendingActions(); + return entities.map(_entityToModel).toList(); + } + + // ==================== Write Operations ==================== + + /// Insert an extracted action + Future insertAction({ + required int memoId, + required ExtractedActionType actionType, + required String title, + String? notes, + DateTime? startTime, + DateTime? endTime, + DateTime? dueDate, + String? location, + int? reminderMinutes, + }) async { + final id = await _db.insertExtractedAction(ExtractedActionsCompanion( + memoId: Value(memoId), + actionType: Value(ExtractedAction.typeToString(actionType)), + title: Value(title), + notes: Value(notes), + startTime: Value(startTime), + endTime: Value(endTime), + dueDate: Value(dueDate), + location: Value(location), + reminderMinutes: Value(reminderMinutes), + )); + debugPrint('[ExtractedActionRepository] Inserted action $id for memo $memoId'); + return id; + } + + /// Mark an action as created in the OS + Future markCreated({ + required int actionId, + String? platformTargetId, + }) async { + await _db.markExtractedActionCreated( + actionId: actionId, + platformTargetId: platformTargetId, + ); + } + + /// Dismiss an action suggestion + Future dismiss(int actionId) async { + await _db.dismissExtractedAction(actionId); + } + + /// Delete all actions for a memo + Future deleteActionsForMemo(int memoId) async { + await _db.deleteActionsForMemo(memoId); + } + + // ==================== Private Helpers ==================== + + ExtractedAction _entityToModel(ExtractedActionEntity entity) { + return ExtractedAction( + id: entity.id, + memoId: entity.memoId, + actionType: ExtractedAction.typeFromString(entity.actionType), + title: entity.title, + notes: entity.notes, + startTime: entity.startTime, + endTime: entity.endTime, + dueDate: entity.dueDate, + location: entity.location, + reminderMinutes: entity.reminderMinutes, + created: entity.created, + dismissed: entity.dismissed, + platformTargetId: entity.platformTargetId, + createdAt: entity.createdAt, + ); + } +} diff --git a/zswatch_app/lib/data/repositories/voice_memo_repository.dart b/zswatch_app/lib/data/repositories/voice_memo_repository.dart index f43d658..05e3a8b 100644 --- a/zswatch_app/lib/data/repositories/voice_memo_repository.dart +++ b/zswatch_app/lib/data/repositories/voice_memo_repository.dart @@ -112,6 +112,38 @@ class VoiceMemoRepository { ); } + /// Update AI processing results + Future updateAiResults({ + required String filename, + required String summary, + required String category, + required String aiModel, + }) async { + await _db.updateVoiceMemoAiResults( + filename: filename, + summary: summary, + category: category, + aiModel: aiModel, + ); + } + + /// Update AI processing status + Future updateProcessingStatus({ + required String filename, + required String status, + }) async { + await _db.updateVoiceMemoProcessingStatus( + filename: filename, + status: status, + ); + } + + /// Get memos that are transcribed but not yet AI-processed + Future> getUnprocessedMemos() async { + final entities = await _db.getUnprocessedVoiceMemos(); + return entities.map(_entityToModel).toList(); + } + /// Delete a voice memo by filename (deletes local files and DB entry) Future deleteMemo(String filename) async { // Get the memo first so we can clean up local files @@ -163,6 +195,14 @@ class VoiceMemoRepository { downloadedAt: entity.downloadedAt, transcribedAt: entity.transcribedAt, convertedFilePath: entity.convertedFilePath, + summary: entity.summary, + category: entity.category, + processingStatus: entity.processingStatus, + aiModel: entity.aiModel, + aiProcessedAt: entity.aiProcessedAt, + taskCreated: entity.taskCreated, + calendarEventCreated: entity.calendarEventCreated, + actionReviewState: entity.actionReviewState, ); } } diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart new file mode 100644 index 0000000..48f22e2 --- /dev/null +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -0,0 +1,169 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/extracted_action.dart'; +import '../data/repositories/extracted_action_repository.dart'; +import '../data/repositories/voice_memo_repository.dart'; +import '../services/ai/ai_startup_test.dart'; +import '../services/ai/llm_service.dart'; +import '../services/ai/voice_note_ai_pipeline.dart'; +import 'settings_providers.dart'; +import 'voice_memo_providers.dart'; +import 'watch_providers.dart'; + +// --------------------------------------------------------------------------- +// Core service providers +// --------------------------------------------------------------------------- + +/// Singleton LLM service backed by fllama. +final llmServiceProvider = Provider((ref) { + final selectedModelId = ref.watch(selectedAiModelIdProvider); + final service = LlmService(); + service.selectModel(selectedModelId); + ref.onDispose(() => service.dispose()); + return service; +}); + +final llmAvailableModelsProvider = FutureProvider>((ref) async { + final service = ref.watch(llmServiceProvider); + return service.availableModels(); +}); + +final selectedLlmModelInfoProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + return service.currentModelInfo(); +}); + +/// Observable service state (status + download progress). +final llmServiceStateProvider = StreamProvider((ref) { + final service = ref.watch(llmServiceProvider); + return service.stateStream; +}); + +/// Whether the GGUF model file exists on disk. +final llmModelDownloadedProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + return service.isModelDownloaded(); +}); + +/// Size of the local model file in bytes (null if not downloaded). +final llmModelSizeProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + return service.modelFileSize(); +}); + +// --------------------------------------------------------------------------- +// Extracted-action repository +// --------------------------------------------------------------------------- + +final _extractedActionRepositoryProvider = + Provider((ref) { + final db = ref.watch(databaseProvider); + return ExtractedActionRepository(db); +}); + +// --------------------------------------------------------------------------- +// AI pipeline +// --------------------------------------------------------------------------- + +/// The voice-note AI pipeline wired with the LLM service + repositories. +final voiceNoteAiPipelineProvider = Provider((ref) { + final llm = ref.watch(llmServiceProvider); + final memoRepo = ref.watch(voiceMemoRepositoryProvider); + final actionRepo = ref.watch(_extractedActionRepositoryProvider); + return VoiceNoteAiPipeline( + llmService: llm, + memoRepository: memoRepo, + actionRepository: actionRepo, + ); +}); + +/// Stream of debug info from the most recent AI processing run. +final aiProcessingDebugInfoProvider = + StreamProvider((ref) { + final pipeline = ref.watch(voiceNoteAiPipelineProvider); + return pipeline.debugInfoStream; +}); + +// --------------------------------------------------------------------------- +// AI actions notifier (used by settings + voice memos screens) +// --------------------------------------------------------------------------- + +class _AiActionsNotifier extends StateNotifier> { + final VoiceNoteAiPipeline _pipeline; + final VoiceMemoRepository _memoRepo; + + _AiActionsNotifier({ + required VoiceNoteAiPipeline pipeline, + required VoiceMemoRepository memoRepo, + }) : _pipeline = pipeline, + _memoRepo = memoRepo, + super(const AsyncData(null)); + + /// Process a single voice memo identified by [filename]. + Future processVoiceMemo(String filename) async { + state = const AsyncLoading(); + try { + final memo = await _memoRepo.getMemoByFilename(filename); + if (memo == null) throw Exception('Memo not found: $filename'); + + await _pipeline.processMemo( + memoId: memo.id, + filename: memo.filename, + transcript: memo.transcription ?? '', + ); + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[AiActions] processVoiceMemo error: $e'); + state = AsyncError(e, st); + } + } + + /// Process all transcribed-but-unprocessed memos. + Future processAllUnprocessed() async { + state = const AsyncLoading(); + try { + final count = await _pipeline.processAllUnprocessed(); + debugPrint('[AiActions] Processed $count memos'); + state = const AsyncData(null); + } catch (e, st) { + debugPrint('[AiActions] processAllUnprocessed error: $e'); + state = AsyncError(e, st); + } + } +} + +final aiActionsProvider = + StateNotifierProvider<_AiActionsNotifier, AsyncValue>((ref) { + final pipeline = ref.watch(voiceNoteAiPipelineProvider); + final memoRepo = ref.watch(voiceMemoRepositoryProvider); + return _AiActionsNotifier(pipeline: pipeline, memoRepo: memoRepo); +}); + +// --------------------------------------------------------------------------- +// Extracted actions per memo (for the detail screen) +// --------------------------------------------------------------------------- + +final extractedActionsForMemoProvider = + StreamProvider.family, int>((ref, memoId) { + final repo = ref.watch(_extractedActionRepositoryProvider); + return repo.watchActionsForMemo(memoId); +}); + +// --------------------------------------------------------------------------- +// Startup test (re-export for app.dart) +// --------------------------------------------------------------------------- + +/// Convenience re-export so app.dart can import just ai_providers.dart. +Future runAiStartupTest(WidgetRef ref) async { + final llm = ref.read(llmServiceProvider); + final downloaded = await llm.isModelDownloaded(); + if (!downloaded) { + debugPrint( + '[AiStartupTest] Model not downloaded, skipping self-test. ' + 'Download via Settings → AI Processing.', + ); + return; + } + await aiStartupSelfTest(llm); +} diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index bd7df6a..4303def 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -16,6 +16,9 @@ abstract final class SettingsKeys { static const String keepScreenOnDuringDfu = 'keep_screen_on_during_dfu'; static const String backgroundConnectionEnabled = 'background_connection_enabled'; static const String transcriptionEngineType = 'transcription_engine_type'; + static const String localAiEnabled = 'local_ai_enabled'; + static const String autoProcessVoiceNotes = 'auto_process_voice_notes'; + static const String selectedAiModelId = 'selected_ai_model_id'; } /// Provider for SharedPreferences instance @@ -316,3 +319,77 @@ class TranscriptionEngineTypeNotifier } } +// --------------------------------------------------------------------------- +// Local AI settings +// --------------------------------------------------------------------------- + +/// Whether local AI processing of voice notes is enabled +final localAiEnabledProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return LocalAiEnabledNotifier(prefs.valueOrNull); +}); + +class LocalAiEnabledNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + LocalAiEnabledNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.localAiEnabled) ?? false); + + void toggle() { + state = !state; + _prefs?.setBool(SettingsKeys.localAiEnabled, state); + } + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.localAiEnabled, enabled); + } +} + +/// Whether voice notes should be automatically AI-processed after transcription +final autoProcessVoiceNotesProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AutoProcessVoiceNotesNotifier(prefs.valueOrNull); +}); + +class AutoProcessVoiceNotesNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + AutoProcessVoiceNotesNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.autoProcessVoiceNotes) ?? true); + + void toggle() { + state = !state; + _prefs?.setBool(SettingsKeys.autoProcessVoiceNotes, state); + } + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.autoProcessVoiceNotes, enabled); + } +} + +/// Currently selected local AI model id. +final selectedAiModelIdProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return SelectedAiModelIdNotifier(prefs.valueOrNull); +}); + +class SelectedAiModelIdNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + SelectedAiModelIdNotifier(this._prefs) + : super( + _prefs?.getString(SettingsKeys.selectedAiModelId) ?? + 'qwen25_1_5b_q4_k_m', + ); + + void setModelId(String modelId) { + state = modelId; + _prefs?.setString(SettingsKeys.selectedAiModelId, modelId); + } +} + diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart index 739115c..324750b 100644 --- a/zswatch_app/lib/providers/voice_memo_providers.dart +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -6,8 +6,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/voice_memo.dart'; import '../data/repositories/voice_memo_repository.dart'; +import '../services/ai/voice_note_ai_pipeline.dart'; import '../services/voice_memo/transcription_engine.dart'; import '../services/voice_memo/voice_memo_sync_service.dart'; +import 'ai_providers.dart'; import 'settings_providers.dart'; import 'watch_providers.dart'; import 'watch_service_provider.dart'; @@ -87,27 +89,41 @@ final voiceMemoSyncServiceProvider = Provider((ref) { repository: repository, ); - // Wire up auto-transcription after sync completes + // Wire up auto-transcription (and optionally AI processing) after sync final engine = ref.watch(transcriptionEngineProvider); + final aiEnabled = ref.read(localAiEnabledProvider); + final autoProcess = ref.read(autoProcessVoiceNotesProvider); + VoiceNoteAiPipeline? pipeline; + if (aiEnabled && autoProcess) { + pipeline = ref.read(voiceNoteAiPipelineProvider); + } service.onSyncCompleted = (downloadedCount) { debugPrint( '[VoiceMemoProviders] Sync completed ($downloadedCount new). ' 'Starting auto-transcription.'); - _autoTranscribe(repository, engine); + _autoTranscribeAndProcess(repository, engine, pipeline); }; ref.onDispose(() => service.dispose()); return service; }); -/// Auto-transcribe all untranscribed memos after sync -Future _autoTranscribe( +/// Auto-transcribe all untranscribed memos after sync, then optionally +/// run the AI pipeline on newly transcribed memos. +Future _autoTranscribeAndProcess( VoiceMemoRepository repository, TranscriptionEngine engine, + VoiceNoteAiPipeline? pipeline, ) async { try { final untranscribed = await repository.getUntranscribedMemos(); - if (untranscribed.isEmpty) return; + if (untranscribed.isEmpty) { + // Even if nothing new to transcribe, there may be unprocessed memos + if (pipeline != null) { + await pipeline.processAllUnprocessed(); + } + return; + } debugPrint( '[VoiceMemoProviders] Auto-transcribing ${untranscribed.length} memos'); @@ -129,8 +145,14 @@ Future _autoTranscribe( '[VoiceMemoProviders] Failed to transcribe ${memo.filename}: $e'); } } + + // After transcription, run AI processing on all unprocessed memos + if (pipeline != null) { + debugPrint('[VoiceMemoProviders] Starting auto AI processing'); + await pipeline.processAllUnprocessed(); + } } catch (e) { - debugPrint('[VoiceMemoProviders] Auto-transcription error: $e'); + debugPrint('[VoiceMemoProviders] Auto-transcription/processing error: $e'); } } diff --git a/zswatch_app/lib/services/ai/ai_startup_test.dart b/zswatch_app/lib/services/ai/ai_startup_test.dart new file mode 100644 index 0000000..5cb8655 --- /dev/null +++ b/zswatch_app/lib/services/ai/ai_startup_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; + +/// Runs a quick self-test of the AI inference pipeline at startup. +/// +/// This is a **development-time smoke test** — remove before release. +/// +/// Call through [runAiStartupTest] in `ai_providers.dart` which resolves +/// [LlmService] from the Riverpod graph. +Future aiStartupSelfTest(LlmService llm) async { + debugPrint('[AiStartupTest] ========== AI STARTUP SELF-TEST START =========='); + final sw = Stopwatch()..start(); + + int passed = 0; + int failed = 0; + + // ----- Test 1: English classify ----- + await _runTest( + name: 'classify_en', + input: + 'Remind me to call the mechanic tomorrow at 3 PM about the brakes ' + 'and also pick up milk on the way home.', + llm: llm, + onPass: () => passed++, + onFail: () => failed++, + ); + + // ----- Test 2: Swedish classify ----- + await _runTest( + name: 'note_sv', + input: + 'Kom ihåg att köpa mjölk och bröd på vägen hem. ' + 'Dessutom behöver jag ringa tandläkaren.', + llm: llm, + onPass: () => passed++, + onFail: () => failed++, + ); + + sw.stop(); + debugPrint('[AiStartupTest] Tests: $passed passed, $failed failed ' + '(total ${sw.elapsedMilliseconds} ms)'); + debugPrint('[AiStartupTest] ========== AI STARTUP SELF-TEST END =========='); +} + +Future _runTest({ + required String name, + required String input, + required LlmService llm, + required VoidCallback onPass, + required VoidCallback onFail, +}) async { + debugPrint('[AiStartupTest] --- Test: $name ---'); + debugPrint('[AiStartupTest] Input: "$input"'); + + try { + final testSw = Stopwatch()..start(); + final result = await llm.processTranscript(input); + testSw.stop(); + + debugPrint('[AiStartupTest] Summary : ${result.summary}'); + debugPrint('[AiStartupTest] Category : ${result.category}'); + debugPrint('[AiStartupTest] Actions : ${result.actions.length}'); + debugPrint('[AiStartupTest] Time : ${testSw.elapsedMilliseconds} ms'); + debugPrint('[AiStartupTest] Result : PASS ✓'); + onPass(); + } catch (e) { + debugPrint('[AiStartupTest] Error : $e'); + debugPrint('[AiStartupTest] Result : FAIL ✗'); + onFail(); + } + debugPrint('[AiStartupTest] '); +} diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart new file mode 100644 index 0000000..7a3e028 --- /dev/null +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -0,0 +1,881 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:fllama/fllama.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +class LlmModelInfo { + final String id; + final String displayName; + final String family; + final String filename; + final String? downloadUrl; + final int? expectedSizeBytes; + final bool userProvided; + + const LlmModelInfo({ + required this.id, + required this.displayName, + required this.family, + required this.filename, + this.downloadUrl, + this.expectedSizeBytes, + this.userProvided = false, + }); + + bool get isDownloadable => downloadUrl != null; + + String get shortSourceLabel => userProvided ? 'Imported' : 'Catalog'; +} + +/// Status of the LLM service. +enum LlmServiceStatus { + idle, + downloading, + processing, + ready, + error, +} + +/// Observable state of the service (for the settings UI). +class LlmServiceState { + final LlmServiceStatus status; + final double downloadProgress; + final String? error; + + const LlmServiceState({ + this.status = LlmServiceStatus.idle, + this.downloadProgress = 0.0, + this.error, + }); + + LlmServiceState copyWith({ + LlmServiceStatus? status, + double? downloadProgress, + String? error, + }) => + LlmServiceState( + status: status ?? this.status, + downloadProgress: downloadProgress ?? this.downloadProgress, + error: error ?? this.error, + ); +} + +/// One extracted action from the LLM output. +class ExtractedActionResult { + final String type; // "task", "calendar_event", "reminder" + final String title; + final String? notes; + final String? dueDate; + final String? startTime; + final String? location; + + const ExtractedActionResult({ + required this.type, + required this.title, + this.notes, + this.dueDate, + this.startTime, + this.location, + }); +} + +/// Performance metrics from a single LLM inference run. +class LlmInferenceMetrics { + final String modelName; + final String rawPrompt; + final String rawResponse; + final String? parsedJson; + final Duration wallTime; + final int promptTokens; + final int completionTokens; + final double tokensPerSecond; + + const LlmInferenceMetrics({ + required this.modelName, + required this.rawPrompt, + required this.rawResponse, + this.parsedJson, + required this.wallTime, + this.promptTokens = 0, + this.completionTokens = 0, + this.tokensPerSecond = 0.0, + }); + + LlmInferenceMetrics copyWithParsedJson(String? json) => + LlmInferenceMetrics( + modelName: modelName, + rawPrompt: rawPrompt, + rawResponse: rawResponse, + parsedJson: json ?? parsedJson, + wallTime: wallTime, + promptTokens: promptTokens, + completionTokens: completionTokens, + tokensPerSecond: tokensPerSecond, + ); +} + +/// Result of processTranscript(). +class TranscriptResult { + final String summary; + final String category; + final List actions; + final String? originalTranscription; + final String? correctedTranscription; + final LlmInferenceMetrics? correctionMetrics; + final LlmInferenceMetrics? classifyMetrics; + + const TranscriptResult({ + required this.summary, + required this.category, + this.actions = const [], + this.originalTranscription, + this.correctedTranscription, + this.correctionMetrics, + this.classifyMetrics, + }); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// Local LLM inference service backed by fllama (llama.cpp). +/// +/// Usage flow: +/// 1. [downloadModel] to fetch the GGUF file (one-time). +/// 2. [processTranscript] to run classification/summarisation. +/// +/// The model loads lazily on first inference and stays cached in-process. +class LlmService { + static const String defaultModelId = 'qwen25_1_5b_q4_k_m'; + static const List catalogModels = [ + LlmModelInfo( + id: defaultModelId, + displayName: 'Qwen2.5 1.5B Instruct · Q4_K_M', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q4_k_m.gguf', + downloadUrl: + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf', + expectedSizeBytes: 1120 * 1024 * 1024, + ), + LlmModelInfo( + id: 'qwen25_1_5b_q5_k_m', + displayName: 'Qwen2.5 1.5B Instruct · Q5_K_M', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q5_k_m.gguf', + downloadUrl: + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q5_k_m.gguf', + expectedSizeBytes: 1290 * 1024 * 1024, + ), + LlmModelInfo( + id: 'qwen25_1_5b_q8_0', + displayName: 'Qwen2.5 1.5B Instruct · Q8_0', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q8_0.gguf', + downloadUrl: + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q8_0.gguf', + expectedSizeBytes: 1890 * 1024 * 1024, + ), + LlmModelInfo( + id: 'llama32_3b_q4_k_m', + displayName: 'Llama 3.2 3B Instruct · Q4_K_M', + family: 'Llama-3.2-3B-Instruct', + filename: 'Llama-3.2-3B-Instruct-Q4_K_M.gguf', + downloadUrl: + 'https://huggingface.co/unsloth/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf', + expectedSizeBytes: 2020 * 1024 * 1024, + ), + ]; + + String _selectedModelId = defaultModelId; + String _selectedModelName = 'Qwen2.5 1.5B Instruct · Q4_K_M'; + + /// Human-readable name shown in the UI / persisted alongside AI results. + String get modelName => _selectedModelName; + String get selectedModelId => _selectedModelId; + + // ---- Tunables ---- + int nCtx = 2048; + int nThreads = 2; + int maxTokens = 512; + double temperature = 0.1; + double topP = 0.9; + double presencePenalty = 1.1; + int numGpuLayers = 99; + + // ---- Internal state ---- + String? _modelPath; + int _runningRequestId = -1; + + final _stateSubject = BehaviorSubject.seeded( + const LlmServiceState(), + ); + + /// Observable service state (for UI bindings). + Stream get stateStream => _stateSubject.stream; + LlmServiceState get currentState => _stateSubject.value; + + // ---- Helpers ---- + + Future _modelDir() async { + final appDir = await getApplicationSupportDirectory(); + final dir = Directory('${appDir.path}/llm_models'); + if (!dir.existsSync()) dir.createSync(recursive: true); + return dir.path; + } + + Future _importedModelDir() async { + final dir = Directory('${await _modelDir()}/imported'); + if (!dir.existsSync()) dir.createSync(recursive: true); + return dir.path; + } + + static String customModelIdForFilename(String filename) => 'custom::$filename'; + + static bool _isCustomModelId(String id) => id.startsWith('custom::'); + + Future> availableModels() async { + final importedDir = Directory(await _importedModelDir()); + final imported = []; + + if (importedDir.existsSync()) { + for (final entity in importedDir.listSync()) { + if (entity is! File || !entity.path.toLowerCase().endsWith('.gguf')) { + continue; + } + + final filename = p.basename(entity.path); + imported.add( + LlmModelInfo( + id: customModelIdForFilename(filename), + displayName: 'Imported · $filename', + family: 'Imported', + filename: filename, + expectedSizeBytes: entity.lengthSync(), + userProvided: true, + ), + ); + } + } + + imported.sort((a, b) => a.displayName.compareTo(b.displayName)); + return [...catalogModels, ...imported]; + } + + void selectModel(String modelId) { + _selectedModelId = modelId; + final builtIn = catalogModels.where((m) => m.id == modelId).firstOrNull; + _selectedModelName = builtIn?.displayName ?? + (_isCustomModelId(modelId) + ? modelId.replaceFirst('custom::', '') + : catalogModels.first.displayName); + _modelPath = null; + } + + Future currentModelInfo() async { + final resolved = await _resolveModelById(_selectedModelId); + return resolved ?? catalogModels.first; + } + + Future _resolveModelById(String modelId) async { + for (final model in catalogModels) { + if (model.id == modelId) { + return model; + } + } + + final allModels = await availableModels(); + for (final model in allModels) { + if (model.id == modelId) { + return model; + } + } + + return null; + } + + Future _modelFilePathFor(LlmModelInfo model) async { + if (model.userProvided) { + return '${await _importedModelDir()}/${model.filename}'; + } + return '${await _modelDir()}/${model.filename}'; + } + + /// Whether the model file is present on disk. + Future isModelDownloaded({String? modelId}) async { + final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + return File(await _modelFilePathFor(model)).existsSync(); + } + + /// Size of the local model file in bytes, or null if not downloaded. + Future modelFileSize({String? modelId}) async { + final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + final f = File(await _modelFilePathFor(model)); + return f.existsSync() ? f.lengthSync() : null; + } + + // ---- Model management ---- + + /// Download the GGUF model from HuggingFace. + Future downloadModel({String? modelId}) async { + final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + + if (!model.isDownloadable) { + throw StateError('Selected model is imported and cannot be downloaded.'); + } + + if (await isModelDownloaded(modelId: model.id)) { + debugPrint('[LlmService] Model already downloaded'); + return; + } + + _stateSubject.add(_stateSubject.value.copyWith( + status: LlmServiceStatus.downloading, + downloadProgress: 0.0, + )); + + try { + final destPath = await _modelFilePathFor(model); + final tmpPath = '$destPath.tmp'; + final client = http.Client(); + final request = http.Request('GET', Uri.parse(model.downloadUrl!)); + final response = await client.send(request); + + if (response.statusCode != 200) { + throw Exception('Download failed: HTTP ${response.statusCode}'); + } + + final contentLength = response.contentLength ?? 0; + int received = 0; + final sink = File(tmpPath).openWrite(); + + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + if (contentLength > 0) { + _stateSubject.add(_stateSubject.value.copyWith( + downloadProgress: received / contentLength, + )); + } + } + + await sink.close(); + client.close(); + + // Atomic rename + File(tmpPath).renameSync(destPath); + + _stateSubject.add(_stateSubject.value.copyWith( + status: LlmServiceStatus.ready, + downloadProgress: 1.0, + )); + + _selectedModelName = model.displayName; + debugPrint('[LlmService] Model downloaded to $destPath'); + } catch (e) { + _stateSubject.add(_stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + )); + rethrow; + } + } + + /// Delete the local model file. + Future deleteModel({String? modelId}) async { + final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + catalogModels.first; + final f = File(await _modelFilePathFor(model)); + if (f.existsSync()) { + f.deleteSync(); + } + if ((modelId ?? _selectedModelId) == _selectedModelId) { + _modelPath = null; + } + _stateSubject.add(const LlmServiceState()); + debugPrint('[LlmService] Model deleted'); + } + + Future importModel(String sourcePath) async { + final source = File(sourcePath); + if (!source.existsSync()) { + throw ArgumentError('Model file not found: $sourcePath'); + } + if (!source.path.toLowerCase().endsWith('.gguf')) { + throw ArgumentError('Only .gguf models can be imported.'); + } + + final importedDir = await _importedModelDir(); + final baseName = p.basename(source.path); + var candidateName = baseName; + var counter = 1; + while (File('$importedDir/$candidateName').existsSync()) { + final stem = p.basenameWithoutExtension(baseName); + final ext = p.extension(baseName); + candidateName = '${stem}_$counter$ext'; + counter++; + } + + final destination = File('$importedDir/$candidateName'); + await source.copy(destination.path); + + final importedModel = LlmModelInfo( + id: customModelIdForFilename(candidateName), + displayName: 'Imported · $candidateName', + family: 'Imported', + filename: candidateName, + expectedSizeBytes: destination.lengthSync(), + userProvided: true, + ); + + selectModel(importedModel.id); + return importedModel; + } + + // ---- Inference ---- + + /// Ensure _modelPath is set (lazy init). + Future _ensureModel() async { + if (_modelPath != null) return; + final model = await currentModelInfo(); + final path = await _modelFilePathFor(model); + if (!File(path).existsSync()) { + throw StateError( + 'Selected model is not available locally. Download or import it first.', + ); + } + _selectedModelName = model.displayName; + _modelPath = path; + } + + static void _logFilter(String log) { + if (log.contains('loaded') || + log.contains('error') || + log.contains('Error') || + log.contains('token') || + log.contains('speed') || + log.contains('FAILED') || + log.contains('Model loaded') || + log.contains('BATCH') || + log.contains('Initialized')) { + debugPrint('[llama.cpp] $log'); + } + } + + /// Low-level chat completion. Returns the raw text output and metrics. + /// + /// If [onPartialResponse] is provided, it is called after every token with + /// the accumulated response so far and the current token count. + Future<({String text, LlmInferenceMetrics metrics})> _generate( + String prompt, { + int? overrideMaxTokens, + void Function(String partial, int tokens)? onPartialResponse, + }) async { + await _ensureModel(); + + final completer = Completer(); + final stopwatch = Stopwatch()..start(); + int tokenCount = 0; + + final request = OpenAiRequest( + messages: [Message(Role.user, prompt)], + modelPath: _modelPath!, + maxTokens: overrideMaxTokens ?? maxTokens, + numGpuLayers: numGpuLayers, + temperature: temperature, + topP: topP, + frequencyPenalty: 0.0, + presencePenalty: presencePenalty, + contextSize: nCtx, + logger: _logFilter, + ); + + _runningRequestId = await fllamaChat( + request, + (String response, String responseJson, bool done) { + tokenCount++; + onPartialResponse?.call(response, tokenCount); + if (done && !completer.isCompleted) { + completer.complete(response); + } + }, + ); + + final text = (await completer.future).trim(); + stopwatch.stop(); + + final wallTime = stopwatch.elapsed; + final tokPerSec = wallTime.inMilliseconds > 0 + ? (tokenCount / (wallTime.inMilliseconds / 1000.0)) + : 0.0; + + final metrics = LlmInferenceMetrics( + modelName: _selectedModelName, + rawPrompt: prompt, + rawResponse: text, + wallTime: wallTime, + completionTokens: tokenCount, + tokensPerSecond: tokPerSec, + ); + + return (text: text, metrics: metrics); + } + + /// Process a voice memo transcript: optionally correct transcription errors, + /// then classify + summarise in a single LLM pass, and parse the structured + /// JSON output. + /// + /// This is the main entry-point used by [VoiceNoteAiPipeline]. + Future processTranscript( + String transcript, { + bool correctTranscription = true, + void Function(String phase, String partialResponse, int tokens)? onProgress, + }) async { + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.processing), + ); + + try { + debugPrint( + '[LlmService] Processing transcript (${transcript.length} chars)', + ); + + String effectiveTranscript = transcript; + LlmInferenceMetrics? correctionMetrics; + String? correctedTranscription; + + // --- Step 1: Correct transcription errors if enabled --- + if (correctTranscription) { + final correctionPrompt = _buildCorrectionPrompt(transcript); + final correctionResult = await _generate( + correctionPrompt, + overrideMaxTokens: 1024, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress('correcting', partial, tokens), + ); + + final corrected = correctionResult.text.trim(); + correctionMetrics = correctionResult.metrics; + + // Only use the correction if it looks like actual text (not JSON/noise) + if (corrected.isNotEmpty && + !corrected.startsWith('{') && + corrected.length > 5) { + correctedTranscription = corrected; + effectiveTranscript = corrected; + debugPrint('[LlmService] Corrected transcription: $corrected'); + } else { + debugPrint( + '[LlmService] Correction output not usable, using original'); + } + } + + // Brief pause between inference calls to let the native (C++) side + // finish any post-done logging from the previous request. Without this, + // the next fllamaChat call triggers cleanup of the previous logger + // NativeCallable while C++ may still be invoking it, causing a fatal + // "Callback invoked after it has been deleted" crash. + await Future.delayed(const Duration(milliseconds: 500)); + + // --- Step 2: Build the extraction prompt --- + final prompt = _buildClassifyPrompt(effectiveTranscript); + final genResult = await _generate( + prompt, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress('classifying', partial, tokens), + ); + final raw = genResult.text; + final classifyMetrics = genResult.metrics.copyWithParsedJson(null); + + debugPrint('[LlmService] Raw AI response: $raw'); + + // --- Parse JSON from output --- + final result = _parseTranscriptResult(raw); + + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.ready), + ); + + // Attach the parsed JSON to classify metrics + final jsonStr = _extractFirstJsonObject(raw); + final finalClassifyMetrics = classifyMetrics.copyWithParsedJson(jsonStr); + + return TranscriptResult( + summary: result.summary, + category: result.category, + actions: result.actions, + originalTranscription: transcript, + correctedTranscription: correctedTranscription, + correctionMetrics: correctionMetrics, + classifyMetrics: finalClassifyMetrics, + ); + } catch (e) { + debugPrint('[LlmService] Failed to process transcript: $e'); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + ), + ); + rethrow; + } + } + + /// Cancel a running inference (best-effort). + void cancelInference() { + if (_runningRequestId >= 0) { + fllamaCancelInference(_runningRequestId); + _runningRequestId = -1; + } + } + + void dispose() { + cancelInference(); + _stateSubject.close(); + } + + // ---- Prompt construction ---- + + String _buildCorrectionPrompt(String transcript) { + return ''' +You are a precise transcription correction assistant. + +The following text was produced by an automatic speech-to-text system and may contain errors such as: +- Wrong words that sound similar (homophones) +- Missing or extra words +- Spelling mistakes in proper nouns +- Grammar errors introduced by the speech recognizer + +Your job: output ONLY the corrected text, preserving the original language. +Do not add explanations, markdown, or any text that was not in the original. +If the transcription is already correct, output it unchanged. + +Original transcription: +"$transcript" + +Corrected transcription:'''; + } + + String _buildClassifyPrompt(String transcript) { + return ''' +You are a precise voice-note extraction assistant. + +Return EXACTLY ONE valid JSON object. +Do not include markdown fences. +Do not include explanations. +Do not include any text before or after the JSON. +Do not return multiple JSON objects. + +Analyze the transcript and produce: +1. a short summary +2. a category +3. structured actions if the transcript contains actionable items + +Preserve the transcript language in summary, title, notes, and location. +Do not invent dates, times, or locations. Use null when unknown. + +Use this exact schema: +{ + "summary": "short summary in the original language", + "category": "idea" | "task" | "reminder" | "meeting" | "note", + "actions": [ + { + "type": "task" | "reminder" | "calendar_event", + "title": "short action title in the original language", + "notes": "optional extra details" | null, + "due_date": "ISO-8601 datetime" | null, + "start_time": "ISO-8601 datetime" | null, + "end_time": "ISO-8601 datetime" | null, + "location": "location text" | null, + "priority": "low" | "medium" | "high" | null, + "reminder_minutes": number | null + } + ] +} + +Rules: +- Use "meeting" for calendar-like content. +- Use "task" or "reminder" for actionable personal follow-ups. +- Use "note" or "idea" when there is no clear action. +- If no actions exist, return an empty array. +- Keep the summary short and useful for a timeline card. + +Transcript: "$transcript" +JSON: '''; + } + + // ---- Output parsing ---- + + String? _extractFirstJsonObject(String raw) { + final start = raw.indexOf('{'); + if (start == -1) { + return null; + } + + var depth = 0; + var inString = false; + var escaping = false; + + for (var i = start; i < raw.length; i++) { + final char = raw[i]; + + if (escaping) { + escaping = false; + continue; + } + + if (char == '\\' && inString) { + escaping = true; + continue; + } + + if (char == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char == '{') { + depth++; + } else if (char == '}') { + depth--; + if (depth == 0) { + return raw.substring(start, i + 1); + } + } + } + + return null; + } + + String _normalizeCategory(String? rawCategory) { + switch ((rawCategory ?? '').trim().toLowerCase()) { + case 'todo': + case 'task': + return 'task'; + case 'reminder': + return 'reminder'; + case 'event': + case 'meeting': + case 'calendar_event': + return 'meeting'; + case 'idea': + return 'idea'; + default: + return 'note'; + } + } + + String _normalizeActionType(String? rawType, String category) { + switch ((rawType ?? '').trim().toLowerCase()) { + case 'calendar_event': + case 'event': + case 'meeting': + case 'schedule': + return 'calendar_event'; + case 'reminder': + return 'reminder'; + case 'task': + case 'todo': + return 'task'; + default: + return category == 'meeting' ? 'calendar_event' : 'task'; + } + } + + TranscriptResult _parseTranscriptResult(String raw) { + final jsonStr = _extractFirstJsonObject(raw); + + if (jsonStr == null) { + debugPrint('[LlmService] Failed to parse AI response: ' + 'FormatException: No JSON object found'); + return TranscriptResult( + summary: raw.trim(), + category: 'note', + ); + } + + try { + final parsed = jsonDecode(jsonStr) as Map; + + final category = _normalizeCategory(parsed['category'] as String?); + final summary = (parsed['summary'] as String?)?.trim(); + final title = (parsed['title'] as String?)?.trim(); + + final actions = []; + + final parsedActions = parsed['actions']; + if (parsedActions is List) { + for (final action in parsedActions.whereType>()) { + final actionTitle = + ((action['title'] ?? action['summary']) as String?)?.trim() ?? ''; + if (actionTitle.isEmpty) { + continue; + } + + actions.add( + ExtractedActionResult( + type: _normalizeActionType(action['type'] as String?, category), + title: actionTitle, + notes: ((action['notes'] ?? action['body']) as String?)?.trim(), + dueDate: (action['due_date'] ?? action['dueDate']) as String?, + startTime: (action['start_time'] ?? action['startTime']) as String?, + location: (action['location'] as String?)?.trim(), + ), + ); + } + } + + if (actions.isEmpty) { + final actionItems = (parsed['action_items'] as List?) + ?.whereType() + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList() ?? + const []; + + for (final item in actionItems) { + actions.add( + ExtractedActionResult( + type: category == 'meeting' ? 'calendar_event' : 'task', + title: item, + ), + ); + } + } + + final resolvedSummary = + (summary != null && summary.isNotEmpty) ? summary : (title ?? '').trim(); + + return TranscriptResult( + summary: resolvedSummary.isEmpty ? raw.trim() : resolvedSummary, + category: category, + actions: actions, + ); + } catch (e) { + debugPrint('[LlmService] Failed to parse AI response: $e'); + return TranscriptResult( + summary: jsonStr, + category: 'note', + ); + } + } +} diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart new file mode 100644 index 0000000..b885c32 --- /dev/null +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -0,0 +1,271 @@ +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data/models/extracted_action.dart'; +import '../../data/repositories/extracted_action_repository.dart'; +import '../../data/repositories/voice_memo_repository.dart'; +import 'llm_service.dart'; + +/// Debug info from the last AI processing run. +class AiProcessingDebugInfo { + final String filename; + final String modelName; + final String? originalTranscription; + final String? correctedTranscription; + final String? rawLlmResponse; + final String? parsedJson; + final String? summary; + final String? category; + final int actionCount; + final Duration? correctionTime; + final double? correctionTokensPerSec; + final Duration? classifyTime; + final double? classifyTokensPerSec; + final int? correctionTokens; + final int? classifyTokens; + final DateTime timestamp; + + /// Current processing phase: 'correcting', 'classifying', 'done', or null + /// when viewing a completed result. + final String? currentPhase; + + /// Partial LLM output that builds up token-by-token during generation. + final String partialResponse; + + /// Current token count for the active generation phase. + final int liveTokenCount; + + /// Whether processing has finished (final snapshot vs live update). + final bool isComplete; + + const AiProcessingDebugInfo({ + required this.filename, + required this.modelName, + this.originalTranscription, + this.correctedTranscription, + this.rawLlmResponse, + this.parsedJson, + this.summary, + this.category, + this.actionCount = 0, + this.correctionTime, + this.correctionTokensPerSec, + this.classifyTime, + this.classifyTokensPerSec, + this.correctionTokens, + this.classifyTokens, + required this.timestamp, + this.currentPhase, + this.partialResponse = '', + this.liveTokenCount = 0, + this.isComplete = true, + }); +} + +/// Orchestrates AI processing of voice memo transcripts. +/// +/// After a transcript is available, this pipeline: +/// 1. Sends the transcript to the LLM for summarization, categorization, +/// and action extraction (single pass) +/// 2. Persists results in the database +/// 3. Creates extracted action records for user review +class VoiceNoteAiPipeline { + final LlmService _llmService; + final VoiceMemoRepository _memoRepository; + final ExtractedActionRepository _actionRepository; + + /// Stream of debug info from the most recent AI processing runs. + final _debugInfoSubject = BehaviorSubject.seeded(null); + Stream get debugInfoStream => _debugInfoSubject.stream; + AiProcessingDebugInfo? get lastDebugInfo => _debugInfoSubject.value; + + /// Completed debug info stored per filename so the UI can retrieve results + /// for a specific voice note rather than only the latest global run. + final Map _debugInfoByFile = {}; + + /// Get the most recent completed debug info for [filename], or null. + AiProcessingDebugInfo? getDebugInfoForFile(String filename) => + _debugInfoByFile[filename]; + + VoiceNoteAiPipeline({ + required LlmService llmService, + required VoiceMemoRepository memoRepository, + required ExtractedActionRepository actionRepository, + }) : _llmService = llmService, + _memoRepository = memoRepository, + _actionRepository = actionRepository; + + /// Process a single voice memo's transcript with the local LLM. + /// + /// Updates the processing status incrementally and persists results. + /// Returns true if processing succeeded. + Future processMemo({ + required int memoId, + required String filename, + required String transcript, + }) async { + if (transcript.trim().isEmpty) { + debugPrint('[VoiceNoteAiPipeline] Skipping empty transcript for $filename'); + return false; + } + + try { + // Update status: summarizing (covers the single-pass processing) + await _memoRepository.updateProcessingStatus( + filename: filename, + status: 'summarizing', + ); + + // Publish initial loading state so the debug sheet shows something + // immediately (before the model finishes loading / first token arrives). + _debugInfoSubject.add(AiProcessingDebugInfo( + filename: filename, + modelName: _llmService.modelName, + originalTranscription: transcript, + currentPhase: 'loading', + partialResponse: '', + liveTokenCount: 0, + isComplete: false, + timestamp: DateTime.now(), + )); + + // Run the LLM processing with live progress updates + final result = await _llmService.processTranscript( + transcript, + onProgress: (phase, partial, tokens) { + _debugInfoSubject.add(AiProcessingDebugInfo( + filename: filename, + modelName: _llmService.modelName, + originalTranscription: transcript, + currentPhase: phase, + partialResponse: partial, + liveTokenCount: tokens, + isComplete: false, + timestamp: DateTime.now(), + )); + }, + ); + + debugPrint( + '[VoiceNoteAiPipeline] Processed $filename: ' + 'summary="${result.summary}", category=${result.category}, ' + '${result.actions.length} actions'); + + // If the LLM corrected the transcription, update the transcript as well + if (result.correctedTranscription != null && + result.correctedTranscription!.isNotEmpty) { + await _memoRepository.updateTranscription( + filename: filename, + transcription: result.correctedTranscription!, + ); + } + + // Persist AI results on the memo + await _memoRepository.updateAiResults( + filename: filename, + summary: result.summary, + category: result.category, + aiModel: _llmService.modelName, + ); + + // Persist extracted actions + for (final action in result.actions) { + final actionType = _mapActionType(action.type); + await _actionRepository.insertAction( + memoId: memoId, + actionType: actionType, + title: action.title, + notes: action.notes, + dueDate: _tryParseDate(action.dueDate), + startTime: _tryParseDate(action.startTime), + location: action.location, + ); + } + + // Publish final debug info and store per-file + final finalDebug = AiProcessingDebugInfo( + filename: filename, + modelName: _llmService.modelName, + originalTranscription: result.originalTranscription, + correctedTranscription: result.correctedTranscription, + rawLlmResponse: result.classifyMetrics?.rawResponse, + parsedJson: result.classifyMetrics?.parsedJson, + summary: result.summary, + category: result.category, + actionCount: result.actions.length, + correctionTime: result.correctionMetrics?.wallTime, + correctionTokensPerSec: result.correctionMetrics?.tokensPerSecond, + classifyTime: result.classifyMetrics?.wallTime, + classifyTokensPerSec: result.classifyMetrics?.tokensPerSecond, + correctionTokens: result.correctionMetrics?.completionTokens, + classifyTokens: result.classifyMetrics?.completionTokens, + currentPhase: 'done', + isComplete: true, + timestamp: DateTime.now(), + ); + _debugInfoByFile[filename] = finalDebug; + _debugInfoSubject.add(finalDebug); + + return true; + } catch (e) { + debugPrint('[VoiceNoteAiPipeline] Failed to process $filename: $e'); + await _memoRepository.updateProcessingStatus( + filename: filename, + status: 'failed', + ); + return false; + } + } + + /// Process all transcribed but unprocessed memos + Future processAllUnprocessed() async { + final unprocessed = await _memoRepository.getUnprocessedMemos(); + if (unprocessed.isEmpty) { + debugPrint('[VoiceNoteAiPipeline] No unprocessed memos'); + return 0; + } + + debugPrint('[VoiceNoteAiPipeline] Processing ${unprocessed.length} memos'); + int processed = 0; + + for (final memo in unprocessed) { + if (memo.transcription == null || memo.transcription!.trim().isEmpty) { + continue; + } + + final success = await processMemo( + memoId: memo.id, + filename: memo.filename, + transcript: memo.transcription!, + ); + + if (success) processed++; + } + + return processed; + } + + ExtractedActionType _mapActionType(String type) { + switch (type.toLowerCase()) { + case 'task': + return ExtractedActionType.task; + case 'calendar_event': + return ExtractedActionType.calendarEvent; + case 'reminder': + return ExtractedActionType.reminder; + default: + return ExtractedActionType.task; + } + } + + DateTime? _tryParseDate(String? value) { + if (value == null || value.trim().isEmpty) return null; + try { + return DateTime.parse(value); + } catch (_) { + // Natural language dates like "tomorrow" need more sophisticated parsing. + // For v1, we just return null and let the user edit. + return null; + } + } +} diff --git a/zswatch_app/lib/services/ble/ble_service_impl.dart b/zswatch_app/lib/services/ble/ble_service_impl.dart index 7371be1..408e3e4 100644 --- a/zswatch_app/lib/services/ble/ble_service_impl.dart +++ b/zswatch_app/lib/services/ble/ble_service_impl.dart @@ -239,8 +239,10 @@ class BleServiceImpl implements BleService { _reconnectionCount < BleConfig.maxReconnectionAttempts) { try { await _connectedDevice!.connect( + license: License.free, timeout: BleConfig.connectionTimeout, autoConnect: true, + mtu: null, ); } catch (_) { // Will be handled by connection state listener diff --git a/zswatch_app/lib/ui/screens/settings/settings_screen.dart b/zswatch_app/lib/ui/screens/settings/settings_screen.dart index cf202d2..06cd734 100644 --- a/zswatch_app/lib/ui/screens/settings/settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/settings_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -10,10 +11,12 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../providers/ai_providers.dart'; import '../../../providers/demo_mode_provider.dart'; import '../../../providers/permission_providers.dart'; import '../../../providers/settings_providers.dart'; import '../../../providers/voice_memo_providers.dart'; +import '../../../services/ai/llm_service.dart'; import '../../../services/voice_memo/transcription_engine.dart'; import '../onboarding/permission_onboarding_screen.dart'; @@ -114,6 +117,12 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 32), + // AI Processing Section + _SectionHeader(title: 'AI Processing'), + _AiProcessingSection(), + + const Divider(height: 32), + // About Section _SectionHeader(title: 'About'), _SettingsTile( @@ -908,4 +917,573 @@ class _QuickPermissionRow extends StatelessWidget { ), ); } +} + +/// AI Processing section +class _AiProcessingSection extends ConsumerWidget { + const _AiProcessingSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAiEnabled = ref.watch(localAiEnabledProvider); + final autoProcessEnabled = ref.watch(autoProcessVoiceNotesProvider); + final aiActionsState = ref.watch(aiActionsProvider); + final isBusy = aiActionsState.isLoading; + + return Column( + children: [ + // Local AI Processing toggle + _SettingsTile( + leading: Icon( + Icons.auto_awesome, + color: localAiEnabled ? AppTheme.primaryColor : AppTheme.textSecondary, + ), + title: 'Local AI Processing', + subtitle: 'Enable AI processing of voice notes', + trailing: Switch( + value: localAiEnabled, + onChanged: (value) { + ref.read(localAiEnabledProvider.notifier).setEnabled(value); + }, + ), + ), + + // Auto-process after transcription toggle + Opacity( + opacity: localAiEnabled ? 1.0 : 0.5, + child: _SettingsTile( + leading: Icon( + Icons.autorenew, + color: autoProcessEnabled && localAiEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: 'Auto-process after transcription', + subtitle: localAiEnabled + ? 'Automatically process voice notes after transcription' + : 'Enable Local AI Processing first', + trailing: Switch( + value: autoProcessEnabled, + onChanged: localAiEnabled + ? (value) { + ref + .read(autoProcessVoiceNotesProvider.notifier) + .setEnabled(value); + } + : null, + ), + ), + ), + + // LLM Model tile + const _LlmModelTile(), + + // Process all unprocessed button + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: isBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome), + label: Text(isBusy + ? 'Processing...' + : 'Process all unprocessed'), + onPressed: isBusy || !localAiEnabled + ? null + : () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Process all unprocessed memos?'), + content: const Text( + 'This will process all voice memos that have not yet been processed with AI.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Process'), + ), + ], + ), + ) ?? + false; + + if (!confirmed || !context.mounted) return; + + try { + await ref + .read(aiActionsProvider.notifier) + .processAllUnprocessed(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Started processing unprocessed memos'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Processing failed: $e'), + ), + ); + } + } + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Text( + 'Process voice memos with AI to extract tasks, summaries, and more.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ); + } +} + +/// LLM Model tile showing download status +class _LlmModelTile extends ConsumerStatefulWidget { + const _LlmModelTile(); + + @override + ConsumerState<_LlmModelTile> createState() => _LlmModelTileState(); +} + +class _LlmModelTileState extends ConsumerState<_LlmModelTile> { + static String _formatBytes(int bytes) { + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + if (bytes >= gb) { + return '${(bytes / gb).toStringAsFixed(2)} GB'; + } + if (bytes >= mb) { + return '${(bytes / mb).toStringAsFixed(1)} MB'; + } + if (bytes >= kb) { + return '${(bytes / kb).toStringAsFixed(1)} KB'; + } + return '$bytes B'; + } + + void _refreshModelProviders() { + ref.invalidate(llmAvailableModelsProvider); + ref.invalidate(selectedLlmModelInfoProvider); + ref.invalidate(llmModelDownloadedProvider); + ref.invalidate(llmModelSizeProvider); + ref.invalidate(llmServiceStateProvider); + } + + Future _downloadModel(BuildContext context) async { + final llmService = ref.read(llmServiceProvider); + + try { + await llmService.downloadModel(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Model downloaded successfully')), + ); + } + + _refreshModelProviders(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $e')), + ); + } + } + } + + Future _deleteModel(BuildContext context) async { + final selectedModel = await ref.read(selectedLlmModelInfoProvider.future); + final shouldDelete = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete model?'), + content: Text( + selectedModel.userProvided + ? 'Delete this imported model from local storage?' + : 'Delete the selected model from local storage?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) return; + + final llmService = ref.read(llmServiceProvider); + + try { + await llmService.deleteModel(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Model deleted')), + ); + } + + _refreshModelProviders(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + } + + Future _importModel(BuildContext context) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + dialogTitle: 'Select a GGUF model file', + ); + + final path = result?.files.single.path; + if (path == null) { + return; + } + + if (!path.toLowerCase().endsWith('.gguf')) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Only .gguf model files can be imported'), + ), + ); + } + return; + } + + final llmService = ref.read(llmServiceProvider); + final imported = await llmService.importModel(path); + ref.read(selectedAiModelIdProvider.notifier).setModelId(imported.id); + _refreshModelProviders(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Imported ${imported.filename}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final selectedModelId = ref.watch(selectedAiModelIdProvider); + final availableModelsAsync = ref.watch(llmAvailableModelsProvider); + final selectedModelAsync = ref.watch(selectedLlmModelInfoProvider); + final isDownloadedAsync = ref.watch(llmModelDownloadedProvider); + final modelSizeAsync = ref.watch(llmModelSizeProvider); + final serviceStateAsync = ref.watch(llmServiceStateProvider); + + return selectedModelAsync.when( + data: (selectedModel) { + return isDownloadedAsync.when( + data: (isDownloaded) { + final localSize = modelSizeAsync.when( + data: (size) => size != null ? _formatBytes(size) : null, + loading: () => null, + error: (_, __) => null, + ); + + final isDownloading = serviceStateAsync.when( + data: (state) => state.status == LlmServiceStatus.downloading, + loading: () => false, + error: (_, __) => false, + ); + + final downloadProgress = serviceStateAsync.when( + data: (state) => state.downloadProgress, + loading: () => 0.0, + error: (_, __) => 0.0, + ); + + final canDownload = selectedModel.isDownloadable && !selectedModel.userProvided; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- Active model status banner --- + Container( + margin: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDownloaded + ? AppTheme.successColor.withValues(alpha: 0.08) + : AppTheme.warningColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all( + color: isDownloaded + ? AppTheme.successColor.withValues(alpha: 0.3) + : AppTheme.warningColor.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + isDownloaded ? Icons.check_circle : Icons.warning_amber, + size: 20, + color: isDownloaded + ? AppTheme.successColor + : AppTheme.warningColor, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isDownloaded ? 'Active model' : 'Model not downloaded', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: isDownloaded + ? AppTheme.successColor + : AppTheme.warningColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + selectedModel.displayName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (localSize != null) + Text( + localSize, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // --- Model selector dropdown --- + availableModelsAsync.when( + data: (models) => Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 12, + AppTheme.spacingMd, + 0, + ), + child: DropdownButtonFormField( + value: models.any((m) => m.id == selectedModelId) + ? selectedModelId + : models.isNotEmpty + ? models.first.id + : null, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Change model', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: models + .map( + (model) => DropdownMenuItem( + value: model.id, + child: Text( + model.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: isDownloading + ? null + : (value) { + if (value == null) return; + ref + .read(selectedAiModelIdProvider.notifier) + .setModelId(value); + _refreshModelProviders(); + }, + ), + ), + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text('Error loading models: $e', + style: const TextStyle(color: AppTheme.errorColor)), + ), + ), + + // --- Download progress --- + if (isDownloading) + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 12, + AppTheme.spacingMd, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: downloadProgress > 0 ? downloadProgress : null, + ), + const SizedBox(height: 4), + Text( + downloadProgress > 0 + ? 'Downloading... ${(downloadProgress * 100).toStringAsFixed(0)}%' + : 'Starting download...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + + // --- Action buttons --- + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 12, + AppTheme.spacingMd, + AppTheme.spacingSm, + ), + child: Row( + children: [ + // Primary action: Download or Delete for the selected model + if (isDownloaded) + OutlinedButton.icon( + onPressed: isDownloading + ? null + : () => _deleteModel(context), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + ) + else if (canDownload) + FilledButton.icon( + onPressed: isDownloading + ? null + : () => _downloadModel(context), + icon: isDownloading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download, size: 18), + label: Text( + isDownloading ? 'Downloading...' : 'Download'), + ), + const SizedBox(width: AppTheme.spacingSm), + // Secondary action: Import a custom GGUF + OutlinedButton.icon( + onPressed: isDownloading + ? null + : () => _importModel(context), + icon: const Icon(Icons.upload_file, size: 18), + label: const Text('Import .gguf'), + ), + ], + ), + ), + ], + ); + }, + loading: () => const ListTile( + leading: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + title: Text('Loading model status...'), + ), + error: (e, _) => ListTile( + leading: const Icon(Icons.error, color: AppTheme.errorColor), + title: Text(selectedModel.displayName), + subtitle: Text('Error loading model status: $e'), + ), + ); + }, + loading: () => const ListTile( + leading: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + title: Text('Loading model info...'), + ), + error: (e, _) => ListTile( + leading: const Icon(Icons.error, color: AppTheme.errorColor), + title: const Text('AI model'), + subtitle: Text('Error loading model status: $e'), + ), + ); + } } \ No newline at end of file diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart index 480b67c..c00e937 100644 --- a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -8,9 +8,13 @@ import 'package:intl/intl.dart'; import 'package:just_audio/just_audio.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../data/models/extracted_action.dart'; import '../../../data/models/voice_memo.dart'; +import '../../../providers/ai_providers.dart'; +import '../../../providers/settings_providers.dart'; import '../../../providers/voice_memo_providers.dart'; import '../../../providers/watch_service_provider.dart'; +import '../../../services/ai/voice_note_ai_pipeline.dart'; import '../../../services/voice_memo/transcription_engine.dart'; import '../../../services/voice_memo/voice_memo_sync_service.dart'; import '../../navigation/app_router.dart'; @@ -299,6 +303,10 @@ class _VoiceMemoDetailScreenState extends ConsumerState { sideBySide: showSideBySide, ), const SizedBox(height: 12), + _AISummarySection(memo: effectiveMemo), + const SizedBox(height: 12), + _ExtractedActionsSection(memo: effectiveMemo), + const SizedBox(height: 12), _SectionCard( title: 'Transcript', trailing: IconButton( @@ -485,6 +493,1044 @@ class _VoiceMemoDetailScreenState extends ConsumerState { } } +class _AISummarySection extends ConsumerWidget { + final VoiceMemo memo; + + const _AISummarySection({required this.memo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final aiEnabled = ref.watch(localAiEnabledProvider); + final modelDownloadedAsync = ref.watch(llmModelDownloadedProvider); + + if (!aiEnabled) { + return const SizedBox.shrink(); + } + + return modelDownloadedAsync.when( + data: (modelDownloaded) { + if (!modelDownloaded) { + return const SizedBox.shrink(); + } + + final hasSummary = memo.summary != null && memo.summary!.isNotEmpty; + final hasCategory = memo.aiCategory != null; + final isProcessing = memo.isAiProcessing; + final hasFailed = memo.aiProcessingStatus == VoiceNoteProcessingStatus.failed; + + if (!hasSummary && !hasCategory && !isProcessing && !hasFailed) { + return _SectionCard( + title: 'AI Analysis', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Get AI-powered insights including summary, category, and extracted actions.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: memo.transcription?.trim().isEmpty == true + ? null + : () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + icon: const Icon(Icons.auto_awesome), + label: const Text('Process with AI'), + ), + if (memo.transcription?.trim().isEmpty == true) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Transcribe the audio first before AI processing.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); + } + + return _SectionCard( + title: 'AI Summary', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isProcessing) + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _showAiDebugDialog(context, ref), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppTheme.spacingSm), + Text( + 'Processing with AI...', + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const Spacer(), + const Icon( + Icons.bug_report_outlined, + size: 16, + color: AppTheme.textSecondary, + ), + ], + ), + ), + ) + else if (hasFailed) + Text( + 'AI processing failed. Please try again.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.errorColor, + ), + ) + else ...[ + if (hasCategory) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CategoryBadge(category: memo.aiCategory!), + ), + if (hasSummary) + SelectableText( + memo.summary!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.45, + ), + ), + ], + if (!isProcessing && (hasSummary || hasCategory)) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Wrap( + spacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + icon: const Icon(Icons.refresh), + label: const Text('Re-process'), + ), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => _showAiDebugDialog(context, ref), + icon: const Icon(Icons.bug_report_outlined), + label: const Text('Debug'), + ), + ], + ), + ), + if (hasSummary && memo.aiModel != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Model: ${memo.aiModel}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } + + void _showAiDebugDialog(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.elevatedSurfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _AiDebugSheet( + memo: memo, + scrollController: scrollController, + ), + ), + ); + } +} + +class _AiDebugSheet extends ConsumerWidget { + final VoiceMemo memo; + final ScrollController scrollController; + + const _AiDebugSheet({ + required this.memo, + required this.scrollController, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final streamValue = ref.watch(aiProcessingDebugInfoProvider).valueOrNull; + // Show live stream data only when it's for THIS memo + final liveInfo = (streamValue != null && + streamValue.filename == memo.filename) + ? streamValue + : null; + // For completed results, look up per-file cache + final storedInfo = ref + .read(voiceNoteAiPipelineProvider) + .getDebugInfoForFile(memo.filename); + // Prefer live (in-progress or just-completed) over stored + final debugInfo = liveInfo ?? storedInfo; + final theme = Theme.of(context); + + return Column( + children: [ + // Handle bar + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.bug_report_outlined, size: 20), + const SizedBox(width: 8), + Text('AI Debug Info', style: theme.textTheme.titleMedium), + const Spacer(), + if (debugInfo != null && !debugInfo.isComplete) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + if (debugInfo == null) ...[ + _debugNote( + context, + 'No debug data available for the latest run. ' + 'Re-process this memo to see debug info.', + ), + const SizedBox(height: 16), + _debugInfoFromMemo(context), + ] else if (!debugInfo.isComplete) ...[ + // --- Live / in-progress view --- + _livePhaseHeader(context, debugInfo), + const SizedBox(height: 12), + if (debugInfo.originalTranscription != null) ...[ + _debugBlock( + context, + title: 'Original Transcription', + content: debugInfo.originalTranscription!, + icon: Icons.mic, + ), + const SizedBox(height: 12), + ], + // Only show the partial-response block once tokens are flowing + if (debugInfo.currentPhase != 'loading') + _debugBlock( + context, + title: '${_phaseLabel(debugInfo.currentPhase)} (live)', + content: debugInfo.partialResponse.isEmpty + ? '...' + : debugInfo.partialResponse, + icon: debugInfo.currentPhase == 'correcting' + ? Icons.auto_fix_high + : Icons.code, + mono: debugInfo.currentPhase == 'classifying', + ), + ] else ...[ + // --- Completed view --- + _metricsRow(context, debugInfo), + const SizedBox(height: 16), + if (debugInfo.originalTranscription != null && + debugInfo.correctedTranscription != null && + debugInfo.correctedTranscription != + debugInfo.originalTranscription) ...[ + _transcriptionDiffBlock( + context, + original: debugInfo.originalTranscription!, + corrected: debugInfo.correctedTranscription!, + ), + const SizedBox(height: 12), + ] else if (debugInfo.originalTranscription != null) ...[ + _debugBlock( + context, + title: 'Transcription', + content: debugInfo.originalTranscription!, + icon: Icons.mic, + ), + const SizedBox(height: 12), + ], + if (debugInfo.rawLlmResponse != null) ...[ + _debugBlock( + context, + title: 'Raw LLM Response', + content: debugInfo.rawLlmResponse!, + icon: Icons.code, + mono: true, + ), + const SizedBox(height: 12), + ], + if (debugInfo.parsedJson != null) ...[ + _debugBlock( + context, + title: 'Parsed JSON', + content: debugInfo.parsedJson!, + icon: Icons.data_object, + mono: true, + ), + const SizedBox(height: 12), + ], + _resultRow(context, debugInfo), + ], + ], + ), + ), + ], + ); + } + + String _phaseLabel(String? phase) { + switch (phase) { + case 'correcting': + return 'Correction Output'; + case 'classifying': + return 'Classify Output'; + default: + return 'Output'; + } + } + + Widget _livePhaseHeader(BuildContext context, AiProcessingDebugInfo info) { + final theme = Theme.of(context); + final phaseText = switch (info.currentPhase) { + 'loading' => 'Loading model...', + 'correcting' => 'Correcting transcription...', + 'classifying' => 'Classifying & summarizing...', + _ => 'Processing...', + }; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 8), + Text( + phaseText, + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _metricChip( + context, + 'Tokens', + '${info.liveTokenCount}', + Icons.token, + ), + ], + ), + ], + ), + ); + } + + Widget _debugNote(BuildContext context, String text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); + } + + Widget _debugInfoFromMemo(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (memo.aiModel != null) + _kvRow(context, 'Model', memo.aiModel!), + if (memo.summary != null) + _kvRow(context, 'Summary', memo.summary!), + if (memo.aiCategory != null) + _kvRow(context, 'Category', memo.aiCategory!.name), + if (memo.transcription != null) ...[ + const SizedBox(height: 12), + Text('Transcription', style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + SelectableText( + memo.transcription!, + style: theme.textTheme.bodySmall, + ), + ], + ], + ); + } + + Widget _metricsRow(BuildContext context, AiProcessingDebugInfo info) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (info.correctionTime != null) + _metricChip( + context, + 'Correction', + '${info.correctionTime!.inMilliseconds}ms', + Icons.timer_outlined, + ), + if (info.correctionTokensPerSec != null) + _metricChip( + context, + 'Correction tok/s', + info.correctionTokensPerSec!.toStringAsFixed(1), + Icons.speed, + ), + if (info.correctionTokens != null) + _metricChip( + context, + 'Correction tokens', + '${info.correctionTokens}', + Icons.token, + ), + if (info.classifyTime != null) + _metricChip( + context, + 'Classify', + '${info.classifyTime!.inMilliseconds}ms', + Icons.timer_outlined, + ), + if (info.classifyTokensPerSec != null) + _metricChip( + context, + 'Classify tok/s', + info.classifyTokensPerSec!.toStringAsFixed(1), + Icons.speed, + ), + if (info.classifyTokens != null) + _metricChip( + context, + 'Classify tokens', + '${info.classifyTokens}', + Icons.token, + ), + ], + ), + ], + ), + ); + } + + Widget _metricChip( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ], + ); + } + + Widget _debugBlock( + BuildContext context, { + required String title, + required String content, + required IconData icon, + bool mono = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + title, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: content)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied $title'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: SelectableText( + content, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), + ), + ), + ], + ); + } + + /// Build a diff view showing words removed (from original) in red and words + /// added (in corrected) in green. Uses a simple longest-common-subsequence + /// approach on whitespace-split word arrays. + Widget _transcriptionDiffBlock( + BuildContext context, { + required String original, + required String corrected, + }) { + final origWords = original.split(RegExp(r'\s+')); + final corrWords = corrected.split(RegExp(r'\s+')); + final spans = _computeWordDiffSpans(origWords, corrWords, context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.compare_arrows, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + 'Transcription Diff', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const Spacer(), + _diffLegendChip(context, 'removed', const Color(0x40EF5350)), + const SizedBox(width: 6), + _diffLegendChip(context, 'added', const Color(0x4066BB6A)), + ], + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: Text.rich( + TextSpan(children: spans), + style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6), + ), + ), + ], + ); + } + + Widget _diffLegendChip(BuildContext context, String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 10), + ), + ); + } + + /// Compute word-level diff spans using LCS (longest common subsequence). + List _computeWordDiffSpans( + List origWords, + List corrWords, + BuildContext context, + ) { + final n = origWords.length; + final m = corrWords.length; + + // Build LCS table + final dp = List.generate(n + 1, (_) => List.filled(m + 1, 0)); + for (var i = 1; i <= n; i++) { + for (var j = 1; j <= m; j++) { + if (origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = dp[i - 1][j] > dp[i][j - 1] + ? dp[i - 1][j] + : dp[i][j - 1]; + } + } + } + + // Backtrack to produce diff operations + final ops = <_DiffOp>[]; + var i = n; + var j = m; + while (i > 0 || j > 0) { + if (i > 0 && + j > 0 && + origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { + ops.add(_DiffOp.equal(corrWords[j - 1])); + i--; + j--; + } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) { + ops.add(_DiffOp.insert(corrWords[j - 1])); + j--; + } else { + ops.add(_DiffOp.delete(origWords[i - 1])); + i--; + } + } + ops.reversed; // reversed is lazy, need toList + final orderedOps = ops.reversed.toList(); + + // Convert to TextSpans + final spans = []; + for (final op in orderedOps) { + if (spans.isNotEmpty) { + spans.add(const TextSpan(text: ' ')); + } + switch (op.type) { + case _DiffType.equal: + spans.add(TextSpan(text: op.word)); + break; + case _DiffType.delete: + spans.add(TextSpan( + text: op.word, + style: const TextStyle( + backgroundColor: Color(0x40EF5350), + decoration: TextDecoration.lineThrough, + decorationColor: Color(0xFFEF5350), + ), + )); + break; + case _DiffType.insert: + spans.add(TextSpan( + text: op.word, + style: const TextStyle( + backgroundColor: Color(0x4066BB6A), + fontWeight: FontWeight.w600, + ), + )); + break; + } + } + return spans; + } + + Widget _resultRow(BuildContext context, AiProcessingDebugInfo info) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.check_circle_outline, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + 'Parsed Result', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 8), + if (info.category != null) _kvRow(context, 'Category', info.category!), + if (info.summary != null) _kvRow(context, 'Summary', info.summary!), + _kvRow(context, 'Actions', '${info.actionCount}'), + _kvRow( + context, + 'Processed', + DateFormat('HH:mm:ss.SSS').format(info.timestamp), + ), + ], + ); + } + + Widget _kvRow(BuildContext context, String key, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + key, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} + +class _ExtractedActionsSection extends ConsumerStatefulWidget { + final VoiceMemo memo; + + const _ExtractedActionsSection({required this.memo}); + + @override + ConsumerState<_ExtractedActionsSection> createState() => + _ExtractedActionsSectionState(); +} + +class _ExtractedActionsSectionState + extends ConsumerState<_ExtractedActionsSection> { + bool _isExpanded = true; + + @override + Widget build(BuildContext context) { + final aiEnabled = ref.watch(localAiEnabledProvider); + + if (!aiEnabled) { + return const SizedBox.shrink(); + } + + final actionsAsync = ref.watch( + extractedActionsForMemoProvider(widget.memo.id), + ); + + return actionsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (actions) { + if (actions.isEmpty) { + return const SizedBox.shrink(); + } + + return _SectionCard( + title: 'Extracted Actions', + trailing: IconButton( + tooltip: _isExpanded ? 'Collapse' : 'Expand', + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 32, + height: 32, + ), + onPressed: () => setState(() => _isExpanded = !_isExpanded), + icon: Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + ), + ), + child: _isExpanded + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + _ActionItem(action: actions[i]), + ], + ], + ) + : Text( + '${actions.length} action${actions.length == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + }, + ); + } +} + +class _ActionItem extends StatelessWidget { + final ExtractedAction action; + + const _ActionItem({required this.action}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _actionTypeColor(action.actionType).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + ), + child: Icon( + _actionTypeIcon(action.actionType), + size: 16, + color: _actionTypeColor(action.actionType), + ), + ), + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + action.title, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (action.dueDate != null) ...[ + const SizedBox(height: 4), + Text( + 'Due: ${DateFormat.yMMMd().format(action.dueDate!)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + if (action.location != null) ...[ + const SizedBox(height: 4), + Text( + action.location!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ), + ), + ], + ); + } + + IconData _actionTypeIcon(ExtractedActionType type) { + return switch (type) { + ExtractedActionType.task => Icons.check_box_outlined, + ExtractedActionType.reminder => Icons.alarm, + ExtractedActionType.calendarEvent => Icons.calendar_today, + }; + } + + Color _actionTypeColor(ExtractedActionType type) { + return switch (type) { + ExtractedActionType.task => AppTheme.primaryColor, + ExtractedActionType.reminder => AppTheme.warningColor, + ExtractedActionType.calendarEvent => AppTheme.infoColor, + }; + } +} + +class _CategoryBadge extends StatelessWidget { + final VoiceNoteCategory category; + + const _CategoryBadge({required this.category}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: _categoryColor(category).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _categoryIcon(category), + size: 16, + color: _categoryColor(category), + ), + const SizedBox(width: 6), + Text( + _categoryLabel(category), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: _categoryColor(category), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + IconData _categoryIcon(VoiceNoteCategory category) { + return switch (category) { + VoiceNoteCategory.idea => Icons.lightbulb_outline, + VoiceNoteCategory.task => Icons.check_box_outlined, + VoiceNoteCategory.reminder => Icons.alarm, + VoiceNoteCategory.meeting => Icons.people_outline, + VoiceNoteCategory.note => Icons.note_outlined, + }; + } + + Color _categoryColor(VoiceNoteCategory category) { + return switch (category) { + VoiceNoteCategory.idea => const Color(0xFFFFA726), + VoiceNoteCategory.task => AppTheme.primaryColor, + VoiceNoteCategory.reminder => AppTheme.warningColor, + VoiceNoteCategory.meeting => const Color(0xFF26A69A), + VoiceNoteCategory.note => AppTheme.textSecondary, + }; + } + + String _categoryLabel(VoiceNoteCategory category) { + return switch (category) { + VoiceNoteCategory.idea => 'Idea', + VoiceNoteCategory.task => 'Task', + VoiceNoteCategory.reminder => 'Reminder', + VoiceNoteCategory.meeting => 'Meeting', + VoiceNoteCategory.note => 'Note', + }; + } +} + class _SyncProgressBar extends StatelessWidget { final VoiceMemoSyncState state; @@ -656,6 +1702,7 @@ class _VoiceNoteCard extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final previewText = _memoPreviewText(memo); final canPlay = _hasLocalAudio(memo); + final isProcessing = memo.isAiProcessing; return Dismissible( key: ValueKey('voice-note-${memo.id}'), @@ -686,6 +1733,15 @@ class _VoiceNoteCard extends ConsumerWidget { children: [ Row( children: [ + if (memo.aiCategory != null) + Padding( + padding: const EdgeInsets.only(right: AppTheme.spacingSm), + child: Icon( + _categoryIcon(memo.aiCategory!), + size: 20, + color: _categoryColor(memo.aiCategory!), + ), + ), Expanded( child: Text( _timelineTimestampLabel(memo.timestampUtc.toLocal()), @@ -707,23 +1763,55 @@ class _VoiceNoteCard extends ConsumerWidget { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith( height: 1.35, - color: memo.transcription?.trim().isNotEmpty == true + color: memo.summary != null || + memo.transcription?.trim().isNotEmpty == true ? AppTheme.textPrimary : AppTheme.textSecondary, ), ), const SizedBox(height: AppTheme.spacingSm), - Text( - 'Tap to view full note', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: AppTheme.spacingMd), Wrap( spacing: AppTheme.spacingSm, runSpacing: AppTheme.spacingSm, children: [ + if (memo.aiCategory != null) + _MetaChip( + icon: _categoryIcon(memo.aiCategory!), + label: _categoryLabel(memo.aiCategory!), + color: _categoryColor(memo.aiCategory!), + ), + if (isProcessing) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.12), + borderRadius: + BorderRadius.circular(AppTheme.radiusXLarge), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + const SizedBox(width: 4), + Text( + 'Processing', + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 10.5, + ), + ), + ], + ), + ), _MetaChip( icon: _syncStatusIcon(memo.syncStatus), label: _syncStatusLabel(memo), @@ -775,6 +1863,36 @@ class _VoiceNoteCard extends ConsumerWidget { ), ); } + + IconData _categoryIcon(VoiceNoteCategory category) { + return switch (category) { + VoiceNoteCategory.idea => Icons.lightbulb_outline, + VoiceNoteCategory.task => Icons.check_box_outlined, + VoiceNoteCategory.reminder => Icons.alarm, + VoiceNoteCategory.meeting => Icons.people_outline, + VoiceNoteCategory.note => Icons.note_outlined, + }; + } + + Color _categoryColor(VoiceNoteCategory category) { + return switch (category) { + VoiceNoteCategory.idea => const Color(0xFFFFA726), + VoiceNoteCategory.task => AppTheme.primaryColor, + VoiceNoteCategory.reminder => AppTheme.warningColor, + VoiceNoteCategory.meeting => const Color(0xFF26A69A), + VoiceNoteCategory.note => AppTheme.textSecondary, + }; + } + + String _categoryLabel(VoiceNoteCategory category) { + return switch (category) { + VoiceNoteCategory.idea => 'Idea', + VoiceNoteCategory.task => 'Task', + VoiceNoteCategory.reminder => 'Reminder', + VoiceNoteCategory.meeting => 'Meeting', + VoiceNoteCategory.note => 'Note', + }; + } } class _MissingNoteState extends StatelessWidget { @@ -1443,6 +2561,7 @@ bool _matchesQuery(VoiceMemo memo, String query) { final haystack = [ memo.filename, memo.transcription ?? '', + memo.summary ?? '', _dayGroupLabel(local), _timelineTimestampLabel(local), DateFormat.yMMMMd().format(local), @@ -1453,9 +2572,15 @@ bool _matchesQuery(VoiceMemo memo, String query) { } String _memoPreviewText(VoiceMemo memo) { + final aiSummary = memo.summary?.trim(); + if (aiSummary != null && aiSummary.isNotEmpty) { + return aiSummary; + } + final transcript = memo.transcription?.trim(); if (transcript != null && transcript.isNotEmpty) { - return transcript; + final lines = transcript.split('\n'); + return lines.first; } if (memo.syncedFromWatch) { @@ -1567,3 +2692,19 @@ ButtonStyle _compactFilledButtonStyle() { textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ); } + +// --------------------------------------------------------------------------- +// Word-level diff helpers +// --------------------------------------------------------------------------- + +enum _DiffType { equal, delete, insert } + +class _DiffOp { + final _DiffType type; + final String word; + + const _DiffOp._(this.type, this.word); + factory _DiffOp.equal(String word) => _DiffOp._(_DiffType.equal, word); + factory _DiffOp.delete(String word) => _DiffOp._(_DiffType.delete, word); + factory _DiffOp.insert(String word) => _DiffOp._(_DiffType.insert, word); +} diff --git a/zswatch_app/linux/flutter/generated_plugins.cmake b/zswatch_app/linux/flutter/generated_plugins.cmake index 92a2e6f..ca0b2d4 100644 --- a/zswatch_app/linux/flutter/generated_plugins.cmake +++ b/zswatch_app/linux/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama whisper_ggml_plus ) diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index e75bd97..f9b991e 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -345,6 +353,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.69.2" + fllama: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "13a73d0666bd5e8b5d1c05decdc8dea6a1179331" + url: "https://github.com/Telosnex/fllama" + source: git + version: "0.0.1" flutter: dependency: "direct main" description: flutter @@ -576,6 +593,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: dependency: "direct main" description: @@ -616,6 +649,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jinja: + dependency: transitive + description: + name: jinja + sha256: "67485c43c8551688669a81b4e01fe94f6126578ba8c194908d00f254f23f9b8b" + url: "https://pub.dev" + source: hosted + version: "0.6.5" js: dependency: transitive description: @@ -743,6 +784,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.6" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -1172,6 +1229,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + textwrap: + dependency: transitive + description: + name: textwrap + sha256: "7e79503c220a9c772d370075e0d4117204546ed4c6479ab1c9ee4d4c27add606" + url: "https://pub.dev" + source: hosted + version: "2.2.0" timing: dependency: transitive description: diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index 13a9167..630606f 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -70,6 +70,12 @@ dependencies: # Location (for GPS requests from watch) geolocator: ^13.0.2 + # Local LLM inference (llama.cpp via fllama) + fllama: + git: + url: https://github.com/Telosnex/fllama + ref: main + dev_dependencies: flutter_test: sdk: flutter diff --git a/zswatch_app/windows/flutter/generated_plugins.cmake b/zswatch_app/windows/flutter/generated_plugins.cmake index e7a7167..5389ffc 100644 --- a/zswatch_app/windows/flutter/generated_plugins.cmake +++ b/zswatch_app/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama whisper_ggml_plus ) From fee8d8f729a197a2b9155c719c21d0201f3e9f9b Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Wed, 11 Mar 2026 21:48:54 +0100 Subject: [PATCH 03/58] Add voice memo AI pipeline, calendar integration, and AI settings screen Voice memo AI processing: - Add time expression resolver for converting relative times ("tomorrow at 3pm") to absolute datetimes using current watch time context - Add model benchmark service for comparing AI model performance - Improve voice note AI pipeline with better action extraction and time resolution - Improve transcription engine with noise handling and better segmentation - Add voice memo sync service improvements - Remove ai_startup_test.dart (debug scaffold) Calendar integration (Android): - Add ExtractedActionCreationService: Flutter service wrapping the productivity MethodChannel (createAction, listWritableCalendars, openCalendarEntry, checkCalendarSyncHealth, openCalendarSyncSettings) - Fix calendar sync on OnePlus/ColorOS: use CALLER_IS_SYNCADAPTER=true URI when inserting events to cloud accounts, bypassing broken OEM SyncAdapter framework - Streamline requestCalendarSync(), trim verbose diagnostic logging, remove debug test handlers from MethodChannel routing - Add calendar permission and calendar picker to iOS (Info.plist, AppDelegate) AI settings screen: - Add ai_models_settings_screen.dart: dedicated screen for transcription model selection, AI processing model selection, calendar integration (Android-only, with proactive permission grant button), and model benchmark - Calendar Integration section uses WidgetsBindingObserver to re-check permission on app resume; warns on non-cloud (local-only) calendars - Wire new screen into app router and settings screen navigation Settings cleanup: - Remove debug calendar tiles (_TestCalendarEventTile, _TestCalendarEventIntentTile, _CalendarSyncStatusTile, _ProductivityCalendarTile) from settings_screen.dart - Move calendar and AI settings into ai_models_settings_screen.dart - Remove unused imports from settings_screen.dart Other: - Add ai_debug_widgets.dart for AI processing debug overlays - Update providers (ai_providers, settings_providers, voice_memo_providers) with new state and service wiring - Update voice_memos_screen with improved UI and AI processing triggers - Add permission_handler and other deps to pubspec.yaml --- specs/ai-tasks/voice-memo-time-extraction.md | 260 +++ .../android/app/src/main/AndroidManifest.xml | 9 + .../kotlin/dev/zswatch/app/MainActivity.kt | 601 ++++++ zswatch_app/ios/Runner/AppDelegate.swift | 200 ++ zswatch_app/ios/Runner/Info.plist | 9 + zswatch_app/lib/app.dart | 7 - .../repositories/voice_memo_repository.dart | 20 + zswatch_app/lib/providers/ai_providers.dart | 85 +- .../lib/providers/settings_providers.dart | 31 +- .../lib/providers/voice_memo_providers.dart | 183 +- .../lib/services/ai/ai_startup_test.dart | 73 - .../ai/extracted_action_creation_service.dart | 454 ++++ zswatch_app/lib/services/ai/llm_service.dart | 513 ++++- .../services/ai/model_benchmark_service.dart | 403 ++++ .../services/ai/time_expression_resolver.dart | 2 + .../services/ai/voice_note_ai_pipeline.dart | 98 +- .../voice_memo/transcription_engine.dart | 105 +- .../voice_memo/voice_memo_sync_service.dart | 46 +- zswatch_app/lib/ui/navigation/app_router.dart | 11 + .../settings/ai_models_settings_screen.dart | 1830 +++++++++++++++++ .../ui/screens/settings/settings_screen.dart | 993 +-------- .../voice_memos/voice_memos_screen.dart | 940 +++++---- .../lib/ui/widgets/ai_debug_widgets.dart | 424 ++++ zswatch_app/pubspec.lock | 23 + zswatch_app/pubspec.yaml | 3 + 25 files changed, 5714 insertions(+), 1609 deletions(-) create mode 100644 specs/ai-tasks/voice-memo-time-extraction.md delete mode 100644 zswatch_app/lib/services/ai/ai_startup_test.dart create mode 100644 zswatch_app/lib/services/ai/extracted_action_creation_service.dart create mode 100644 zswatch_app/lib/services/ai/model_benchmark_service.dart create mode 100644 zswatch_app/lib/services/ai/time_expression_resolver.dart create mode 100644 zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart create mode 100644 zswatch_app/lib/ui/widgets/ai_debug_widgets.dart diff --git a/specs/ai-tasks/voice-memo-time-extraction.md b/specs/ai-tasks/voice-memo-time-extraction.md new file mode 100644 index 0000000..081039a --- /dev/null +++ b/specs/ai-tasks/voice-memo-time-extraction.md @@ -0,0 +1,260 @@ +# Voice Memo → Reminder/Calendar Time Extraction Pipeline + +**Status**: Draft +**Date**: 2026-03-09 +**Depends on**: ai-enhanced-voice-notes.md (existing AI pipeline) + +--- + +## 1. Problem Statement + +The current AI pipeline (`LlmService._buildClassifyPrompt`) asks the LLM to **compute absolute ISO-8601 datetimes** from relative expressions like "tomorrow at 10" or "nästa tisdag kl 14". Small local models (Qwen 2B class) frequently get date math wrong — producing incorrect dates, wrong days-of-week, or hallucinated timestamps. + +Additionally, `VoiceNoteAiPipeline._tryParseDate()` simply calls `DateTime.parse()` and returns `null` for anything that isn't already ISO-8601. Natural language dates are silently lost. + +## 2. Solution: Split Responsibilities + +| Component | Responsibility | +|-----------|---------------| +| **LLM** | Intent detection, title extraction, time phrase extraction, translation to English | +| **chrono_dart** | Deterministic parsing of English natural-language time → `DateTime` | +| **Fallback regex** | Simple patterns (`in X minutes`, `tomorrow`) if chrono fails | + +**Key rule**: The LLM must **never** compute absolute datetimes. It only extracts and translates the natural-language time expression. All date math is performed deterministically by `chrono_dart`. + +## 3. Pipeline Overview + +``` +Transcript (any language) + ↓ +[Existing] Transcription correction (LLM pass 1, optional) + ↓ +[Existing] Classification + summarization (LLM pass 2 — MODIFIED prompt) + ↓ +New fields: datetime_expression_original, datetime_expression_english + ↓ +chrono_dart.parse(english_expression, referenceDate: DateTime.now()) + ↓ +Resolved DateTime (or null) + ↓ +Populate existing ExtractedAction fields (startTime, dueDate, etc.) +``` + +## 4. What Changes vs. Current Pipeline + +### 4.1 LLM Prompt Changes + +The existing `_buildClassifyPrompt()` tells the LLM to: +> "If a relative date/time is mentioned, resolve it to an absolute ISO-8601 datetime" + +**New approach**: Instead of asking the LLM to resolve dates, we ask it to: +1. **Extract** the time phrase exactly as spoken (any language) +2. **Translate** the time phrase to English +3. Output these as two new JSON fields + +The prompt still produces `summary`, `category`, and `actions` — the schema gains two fields per action: +- `datetime_expression_original` — the raw phrase from the transcript +- `datetime_expression_english` — English translation of that phrase + +The existing `due_date`, `start_time`, `end_time` fields become **null** in LLM output (chrono fills them). + +### 4.2 Post-LLM Processing + +After parsing the LLM JSON, a new `TimeExpressionResolver` class: +1. Takes the `datetime_expression_english` string +2. Passes it to `chrono_dart` with `DateTime.now()` as reference +3. If chrono succeeds → populates `startTime`/`dueDate` on the `ExtractedAction` +4. If chrono fails → tries simple regex fallbacks +5. If all fails → leaves time as `null` (user can set manually) + +### 4.3 No Schema Changes Needed + +The existing `ExtractedAction` model and database table already have: +- `startTime`, `endTime`, `dueDate` (nullable `DateTime`) +- `reminderMinutes` (nullable `int`) + +We add no new DB columns. The time expression strings are intermediate — only the resolved `DateTime` gets persisted. + +## 5. Detailed Design + +### 5.1 Modified LLM JSON Schema (per action) + +```json +{ + "type": "reminder", + "title": "köpa mjölk", + "notes": null, + "datetime_expression_original": "imorgon klockan 10", + "datetime_expression_english": "tomorrow at 10 am", + "location": null, + "priority": null, + "reminder_minutes": null +} +``` + +Removed from LLM output: `due_date`, `start_time`, `end_time` (chrono fills these). + +### 5.2 Modified LLM Prompt (extraction section) + +``` +For each action: +- Extract the time/date phrase EXACTLY as spoken in the original transcript. +- Translate that phrase to English, preserving relative meaning. +- Do NOT compute or resolve dates. Do NOT output ISO timestamps. +- If no time is mentioned, use null for both datetime fields. +``` + +### 5.3 TimeExpressionResolver + +```dart +class TimeExpressionResolver { + /// Parse an English time expression into a DateTime. + /// Returns null if unparseable. + DateTime? resolve(String englishExpression, {DateTime? referenceDate}); + + /// Regex fallback for common patterns chrono might miss. + DateTime? _regexFallback(String expression, DateTime reference); +} +``` + +Chrono usage: +```dart +final results = Chrono.parse(englishExpression, referenceDate ?? DateTime.now()); +if (results.isNotEmpty) { + return results.first.start.date(); +} +``` + +### 5.4 Text Normalization (Pre-LLM, Optional) + +Convert number words to digits in the transcript before sending to LLM. This helps both the LLM and chrono: +- "tomorrow at ten" → "tomorrow at 10" +- "in five minutes" → "in 5 minutes" + +Scope: English number words only (the LLM handles other languages via translation). + +### 5.5 Integration Point + +In `LlmService._parseTranscriptResult()`, after extracting actions from JSON: +1. Read `datetime_expression_english` from each action +2. Call `TimeExpressionResolver.resolve()` +3. Map result to the action's `startTime` or `dueDate` based on action type: + - `reminder` → `dueDate` + - `calendar_event` → `startTime` + - `task` → `dueDate` + +## 6. CLI Testbench Spec + +### Purpose +Iterate on LLM prompt + chrono parsing on desktop without needing the full Flutter app, BLE, or a phone. Run test cases, evaluate accuracy, tune prompts. + +### Location +`ai_testbench/bin/test_time_extraction.dart` — a new CLI script in the existing testbench project. + +### Architecture +Reuses: +- `ai_testbench/native_libs/libllama.so` — existing native library +- `ai_testbench/models/` — existing downloaded models (especially `Qwen3.5-2B-Q4_K_M.gguf`) +- `llama_cpp_dart` package — for direct CLI inference (no Flutter dependency) + +New: +- `ai_testbench/lib/services/time_extraction_service.dart` — `TimeExpressionResolver` implementation +- `ai_testbench/lib/prompts/time_extraction_prompts.dart` — the new LLM prompt +- `ai_testbench/bin/test_time_extraction.dart` — CLI test runner +- `chrono_dart` dependency in `ai_testbench/pubspec.yaml` + +### Test Cases + +| # | Input (transcript) | Language | Expected intent | Expected title | Expected time phrase (EN) | Expected resolved DateTime | +|---|-------------------|----------|----------------|---------------|--------------------------|---------------------------| +| 1 | "Remind me tomorrow at 10 am to buy milk" | EN | reminder | buy milk | tomorrow at 10 am | 2026-03-10T10:00 | +| 2 | "påminn mig imorgon klockan 10 att köpa mjölk" | SV | reminder | köpa mjölk | tomorrow at 10 | 2026-03-10T10:00 | +| 3 | "erinnere mich morgen um 10 milch zu kaufen" | DE | reminder | milch kaufen | tomorrow at 10 | 2026-03-10T10:00 | +| 4 | "meeting with John next Tuesday at 2 pm" | EN | event | meeting with John | next Tuesday at 2 pm | 2026-03-10T14:00 (or 2026-03-17) | +| 5 | "remember to buy milk" | EN | note | buy milk | null | null | +| 6 | "ring tandläkaren om 30 minuter" | SV | reminder | ring tandläkaren | in 30 minutes | ~now+30m | +| 7 | "rappelle-moi vendredi à 15h d'appeler le médecin" | FR | reminder | appeler le médecin | Friday at 3 pm | next Friday 15:00 | +| 8 | "dentist appointment on March 15th at 9:30" | EN | event | dentist appointment | March 15th at 9:30 | 2026-03-15T09:30 | +| 9 | "köp bröd på vägen hem" | SV | task | köp bröd | null | null | +| 10 | "team standup every weekday at 9 AM" | EN | event | team standup | every weekday at 9 AM | (recurring — chrono may only get next occurrence) | + +### CLI Output Format + +``` +╔══════════════════════════════════════════════════════════╗ +║ ZSWatch Time Extraction Testbench — CLI ║ +╚══════════════════════════════════════════════════════════╝ + +[1/3] Loading model: Qwen3.5-2B-Q4_K_M.gguf + Model loaded in 1234ms ✓ + +─── Test 1: English reminder ───────────────────────────── + Input: "Remind me tomorrow at 10 am to buy milk" + LLM time: 2.3s (45.2 tok/s) + + LLM output: + intent: reminder + title: buy milk + time (orig): tomorrow at 10 am + time (EN): tomorrow at 10 am + + Chrono parse: 2026-03-10T10:00:00 ✓ + Expected: 2026-03-10T10:00:00 + Status: ✅ PASS + +─── Test 2: Swedish reminder ───────────────────────────── + ... + +╔══════════════════════════════════════════════════════════╗ +║ Results: 8 passed, 2 failed out of 10 tests ║ +║ Total LLM time: 23.4s ║ +╚══════════════════════════════════════════════════════════╝ +``` + +### Evaluation Criteria per Test Case + +1. **Intent correct** — does `intent` match expected? +2. **Title reasonable** — does `title` capture the task? (fuzzy, logged but not auto-scored) +3. **Time phrase extracted** — is `datetime_expression_english` non-null when expected? +4. **Chrono parse succeeds** — does `chrono_dart` produce a valid DateTime? +5. **DateTime correct** — does the resolved DateTime match expected (within ±1 minute tolerance for relative times)? + +A test PASSES if criteria 1, 3, 4, and 5 all succeed (or 3-5 are all null when no time expected). + +## 7. Implementation Plan + +### Phase 1: CLI Testbench (this task) +1. Add `chrono_dart` to `ai_testbench/pubspec.yaml` +2. Create `TimeExpressionResolver` in `ai_testbench/lib/services/` +3. Create time extraction prompt in `ai_testbench/lib/prompts/` +4. Create CLI test runner in `ai_testbench/bin/test_time_extraction.dart` +5. Run and iterate until ≥80% pass rate on test cases + +### Phase 2: Integrate into companion app (future task) +1. Add `chrono_dart` to `zswatch_app/pubspec.yaml` +2. Copy finalized `TimeExpressionResolver` to `zswatch_app/lib/services/ai/` +3. Modify `LlmService._buildClassifyPrompt()` with new prompt +4. Modify `LlmService._parseTranscriptResult()` to use chrono resolution +5. Update `VoiceNoteAiPipeline` to handle new fields + +## 8. Model Selection + +The testbench will default to `Qwen3.5-2B-Q4_K_M.gguf` (already present in `ai_testbench/models/`). The prompt is designed to work with any model in the models directory — the CLI can accept a `--model` flag to test different models. + +## 9. Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| LLM fails to extract time phrase in non-English | Prompt includes explicit examples in multiple languages | +| LLM translates time phrase incorrectly | Test with multilingual corpus; fallback to original phrase | +| chrono_dart doesn't parse some English expressions | Regex fallback for common patterns; log failures for prompt tuning | +| Small model hallucinates time phrases not in transcript | Prompt instructs to copy exact phrase; validation step compares to input | +| chrono_dart doesn't support all relative expressions | Acceptable — leave as null, user can set manually | + +## 10. Out of Scope + +- Recurring events (chrono may parse "every Tuesday" as next Tuesday only — acceptable for v1) +- Duration extraction (e.g., "30 minute meeting") — future enhancement +- Timezone conversion — we use device local time throughout +- Calendar/reminder OS integration — already handled by existing `ExtractedAction` flow +- UI changes — the existing action review UI works with resolved DateTimes diff --git a/zswatch_app/android/app/src/main/AndroidManifest.xml b/zswatch_app/android/app/src/main/AndroidManifest.xml index 078044d..7120955 100644 --- a/zswatch_app/android/app/src/main/AndroidManifest.xml +++ b/zswatch_app/android/app/src/main/AndroidManifest.xml @@ -20,6 +20,15 @@ + + + + + + + + + diff --git a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt index 2db85d6..cc99f24 100644 --- a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt +++ b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt @@ -1,19 +1,30 @@ package dev.zswatch.app +import android.Manifest +import android.accounts.Account +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.ActivityNotFoundException import android.content.ServiceConnection +import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.os.IBinder import android.os.PowerManager +import android.provider.CalendarContract +import android.util.Log import android.provider.Settings +import androidx.core.content.ContextCompat import androidx.core.app.NotificationManagerCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel +import java.util.TimeZone /** * Main activity for ZSWatch companion app. @@ -26,12 +37,23 @@ import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { companion object { + private const val TAG = "ZSWProductivity" private const val NOTIFICATION_CHANNEL = "dev.zswatch.app/notifications" private const val NOTIFICATION_EVENTS_CHANNEL = "dev.zswatch.app/notification_events" private const val MEDIA_CHANNEL = "dev.zswatch.app/media" private const val MEDIA_EVENTS_CHANNEL = "dev.zswatch.app/media_events" private const val FOREGROUND_SERVICE_CHANNEL = "dev.zswatch.app/foreground_service" + private const val PRODUCTIVITY_CHANNEL = "dev.zswatch.app/productivity" } + + private data class WritableCalendarInfo( + val id: Long, + val displayName: String?, + val accountName: String?, + val accountType: String?, + val ownerAccount: String?, + val isPrimary: Boolean, + ) private var mediaBridge: MediaSessionBridge? = null private var notificationEventSink: EventChannel.EventSink? = null @@ -73,6 +95,7 @@ class MainActivity : FlutterActivity() { setupNotificationChannel(flutterEngine) setupMediaChannel(flutterEngine) setupForegroundServiceChannel(flutterEngine) + setupProductivityChannel(flutterEngine) } private fun setupNotificationChannel(flutterEngine: FlutterEngine) { @@ -372,6 +395,584 @@ class MainActivity : FlutterActivity() { .invokeMethod("onDisconnectRequested", null) } } + + private fun setupProductivityChannel(flutterEngine: FlutterEngine) { + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PRODUCTIVITY_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "createAction" -> handleCreateAction(call, result) + "listWritableCalendars" -> handleListWritableCalendars(result) + "openCalendarEntry" -> handleOpenCalendarEntry(call, result) + "checkCalendarSyncHealth" -> handleCheckCalendarSyncHealth(call, result) + "openCalendarSyncSettings" -> handleOpenCalendarSyncSettings(call, result) + else -> result.notImplemented() + } + } + } + + private fun handleOpenCalendarEntry(call: io.flutter.plugin.common.MethodCall, result: MethodChannel.Result) { + val eventId = call.argument("eventId")?.toLongOrNull() + ?: call.argument("eventId")?.toLong() + + if (eventId == null) { + result.error("INVALID_ARGUMENT", "eventId is required", null) + return + } + + val scheduledAtMillis = call.argument("scheduledAtMillis")?.toLong() + + try { + // First verify the event still exists + val eventProjection = arrayOf( + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.TITLE, + ) + val eventUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + var eventStartMillis: Long? = null + var eventEndMillis: Long? = null + var eventTitle: String? = null + + contentResolver.query(eventUri, eventProjection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + eventStartMillis = cursor.getLong(0) + eventEndMillis = cursor.getLong(1) + eventTitle = cursor.getString(2) + } + } + + if (eventStartMillis == null) { + Log.e(TAG, "Calendar event not found eventId=$eventId uri=$eventUri") + result.error( + "EVENT_NOT_FOUND", + "The calendar event could not be found.", + null, + ) + return + } + + val beginTime = scheduledAtMillis ?: eventStartMillis!! + val endTime = eventEndMillis ?: (beginTime + 30 * 60 * 1000L) + + // Use ACTION_VIEW with the direct event URI + begin/end extras. + // This opens the event detail view in Google Calendar. + val intent = Intent(Intent.ACTION_VIEW).apply { + data = eventUri + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginTime) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endTime) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + startActivity(intent) + Log.d(TAG, "Opened calendar event view eventId=$eventId title=$eventTitle uri=$eventUri") + result.success(true) + } catch (e: ActivityNotFoundException) { + // Fallback: open calendar at the event's time + val timeUri = CalendarContract.CONTENT_URI.buildUpon() + .appendPath("time") + .appendPath(beginTime.toString()) + .build() + val fallback = Intent(Intent.ACTION_VIEW).apply { + data = timeUri + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginTime) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endTime) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(fallback) + Log.d(TAG, "Opened calendar time view (fallback) eventId=$eventId timeUri=$timeUri") + result.success(true) + } + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "No activity available to open calendar entry eventId=$eventId", e) + result.error("NO_CALENDAR_APP", "No calendar app is available to open the created event.", null) + } + } + + private fun handleListWritableCalendars(result: MethodChannel.Result) { + if (!hasCalendarPermission()) { + result.error( + "PERMISSION_DENIED", + "Calendar permission is required to list writable calendars.", + null, + ) + return + } + + val calendars = getWritableCalendars().map { calendar -> + mapOf( + "id" to calendar.id, + "displayName" to calendar.displayName, + "accountName" to calendar.accountName, + "accountType" to calendar.accountType, + "ownerAccount" to calendar.ownerAccount, + "isPrimary" to calendar.isPrimary, + ) + } + + result.success(calendars) + } + + private fun handleCreateAction(call: io.flutter.plugin.common.MethodCall, result: MethodChannel.Result) { + Log.d(TAG, "handleCreateAction called with args=${call.arguments}") + + if (!hasCalendarPermission()) { + Log.w(TAG, "Calendar permission missing when attempting to create productivity action") + result.error( + "PERMISSION_DENIED", + "Calendar permission is required to create events or reminders.", + null + ) + return + } + + val actionType = call.argument("actionType") ?: "task" + val title = call.argument("title")?.trim().orEmpty() + val notes = call.argument("notes")?.trim()?.takeIf { it.isNotEmpty() } + val location = call.argument("location")?.trim()?.takeIf { it.isNotEmpty() } + val scheduledAtMillis = call.argument("scheduledAtMillis")?.toLong() + val endAtMillis = call.argument("endAtMillis")?.toLong() + val reminderMinutes = call.argument("reminderMinutes")?.toInt() + val requestedCalendarId = call.argument("calendarId")?.toLong() + + if (title.isEmpty()) { + result.error("INVALID_ARGUMENT", "title is required", null) + return + } + + val calendars = getWritableCalendars() + val calendar = if (requestedCalendarId != null) { + val requestedCalendar = findCalendarById(calendars, requestedCalendarId) + if (requestedCalendar == null) { + Log.e( + TAG, + "Requested calendarId=$requestedCalendarId is unavailable. Available=${calendars.map { it.id to it.displayName }}", + ) + result.error( + "SELECTED_CALENDAR_UNAVAILABLE", + "The selected default calendar is no longer available. Please choose another calendar in Settings.", + null, + ) + return + } + requestedCalendar + } else { + chooseBestCalendar(calendars) + } + + if (calendar == null) { + Log.e(TAG, "No writable calendar available on device") + result.error("NO_CALENDAR", "No writable calendar was found on this device.", null) + return + } + + Log.d( + TAG, + "Using calendar id=${calendar.id} name=${calendar.displayName} account=${calendar.accountName} owner=${calendar.ownerAccount} primary=${calendar.isPrimary}", + ) + + try { + val response = when (actionType) { + "calendar_event" -> createCalendarEntry( + calendarId = calendar.id, + title = title, + notes = notes, + location = location, + startMillis = scheduledAtMillis, + endMillis = endAtMillis, + reminderMinutes = reminderMinutes, + targetType = "calendar_event" + ) + "reminder", "task" -> createReminderEntry( + calendarId = calendar.id, + title = title, + notes = notes, + scheduledAtMillis = scheduledAtMillis, + reminderMinutes = reminderMinutes, + targetType = "calendar_reminder" + ) + else -> { + result.error("INVALID_ARGUMENT", "Unsupported action type: $actionType", null) + return + } + } + + Log.d(TAG, "createAction succeeded with response=$response") + result.success(response) + } catch (e: Exception) { + Log.e(TAG, "createAction failed", e) + result.error("CREATE_ACTION_FAILED", e.localizedMessage, null) + } + } + + private fun hasCalendarPermission(): Boolean { + return ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED + } + + private fun getWritableCalendars(): List { + val preferredCalendars = queryWritableCalendars(requireVisibleAndSynced = true) + if (preferredCalendars.isNotEmpty()) { + return preferredCalendars.sortedByDescending { scoreCalendar(it) } + } + + return queryWritableCalendars(requireVisibleAndSynced = false) + .sortedByDescending { scoreCalendar(it) } + } + + private fun queryWritableCalendars(requireVisibleAndSynced: Boolean): List { + val projection = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.Calendars.OWNER_ACCOUNT, + CalendarContract.Calendars.IS_PRIMARY, + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, + ) + + val selectionParts = mutableListOf() + if (requireVisibleAndSynced) { + selectionParts += "${CalendarContract.Calendars.VISIBLE}=1" + selectionParts += "${CalendarContract.Calendars.SYNC_EVENTS}=1" + } + selectionParts += "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL}>=${CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR}" + val selection = selectionParts.joinToString(" AND ") + val sortOrder = "${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC" + val calendars = mutableListOf() + + contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + null, + sortOrder, + )?.use { cursor -> + while (cursor.moveToNext()) { + val calendar = WritableCalendarInfo( + id = cursor.getLong(0), + displayName = cursor.getString(1), + accountName = cursor.getString(2), + accountType = cursor.getString(3), + ownerAccount = cursor.getString(4), + isPrimary = cursor.getInt(5) == 1, + ) + Log.d( + TAG, + "Writable calendar candidate id=${calendar.id} name=${calendar.displayName} account=${calendar.accountName} type=${calendar.accountType} owner=${calendar.ownerAccount} primary=${calendar.isPrimary}", + ) + calendars.add(calendar) + } + } + + return calendars + } + + private fun findCalendarById( + calendars: List, + calendarId: Long, + ): WritableCalendarInfo? { + return calendars.firstOrNull { it.id == calendarId } + } + + private fun chooseBestCalendar(calendars: List): WritableCalendarInfo? { + return calendars.maxByOrNull { scoreCalendar(it) } + } + + private fun scoreCalendar(calendar: WritableCalendarInfo): Int { + var score = 0 + + if (calendar.isPrimary) { + score += 100 + } + + val accountType = calendar.accountType.orEmpty().lowercase() + val accountName = calendar.accountName.orEmpty().lowercase() + val ownerAccount = calendar.ownerAccount.orEmpty().lowercase() + val displayName = calendar.displayName.orEmpty().lowercase() + + if (accountType.contains("google")) { + score += 80 + } + if (accountType.contains("exchange") || accountType.contains("outlook")) { + score += 60 + } + if (accountType.contains("local") || + accountName.contains("local") || + ownerAccount.contains("local") || + displayName.contains("local") + ) { + score -= 40 + } + + return score + } + + /** + * Check if the sync adapter for a Google account's calendar authority is working. + * Checks both isSyncable (sync adapter registered) and getSyncAutomatically (user toggle). + * On some OEMs (OnePlus/ColorOS), isSyncable can be 0 even when the user has + * Calendar sync enabled in Settings — so we also check getSyncAutomatically(). + */ + private fun isCalendarSyncWorking(calendar: WritableCalendarInfo?): Boolean { + if (calendar == null) return false + val accountName = calendar.accountName ?: return false + val accountType = calendar.accountType ?: return false + // Only relevant for cloud-backed calendar accounts (Google, Exchange, etc.) + if (accountType.equals("LOCAL", ignoreCase = true)) return true + val account = Account(accountName, accountType) + return try { + val isSyncable = ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY) + val autoSync = ContentResolver.getSyncAutomatically(account, CalendarContract.AUTHORITY) + val masterSync = ContentResolver.getMasterSyncAutomatically() + Log.d(TAG, "isCalendarSyncWorking: account=$accountName isSyncable=$isSyncable autoSync=$autoSync masterSync=$masterSync") + // Consider sync working if EITHER the sync adapter is registered (isSyncable>=1) + // OR the user has auto-sync enabled (which means the Settings toggle is ON). + // On some OEMs, isSyncable stays 0 even when the toggle is ON. + (isSyncable >= 1) || (autoSync && masterSync) + } catch (_: Exception) { + false + } + } + + private fun createCalendarEntry( + calendarId: Long, + title: String, + notes: String?, + location: String?, + startMillis: Long?, + endMillis: Long?, + reminderMinutes: Int?, + targetType: String, + ): Map { + val start = startMillis + ?: throw IllegalArgumentException("A start time is required for calendar events.") + val end = endMillis ?: (start + 30 * 60 * 1000L) + + Log.d(TAG, "createCalendarEntry calendarId=$calendarId title=$title reminderMinutes=$reminderMinutes") + + // Look up calendar info so we can set ORGANIZER (required for Google Calendar sync) + val calendar = findCalendarById(getWritableCalendars(), calendarId) + val organizerEmail = calendar?.ownerAccount ?: calendar?.accountName + + val syncWorking = isCalendarSyncWorking(calendar) + + val values = ContentValues().apply { + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.TITLE, title) + put(CalendarContract.Events.DESCRIPTION, notes) + put(CalendarContract.Events.EVENT_LOCATION, location) + put(CalendarContract.Events.DTSTART, start) + put(CalendarContract.Events.DTEND, end) + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CONFIRMED) + put(CalendarContract.Events.ACCESS_LEVEL, CalendarContract.Events.ACCESS_DEFAULT) + put(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_BUSY) + put(CalendarContract.Events.HAS_ALARM, if (reminderMinutes != null) 1 else 0) + if (!organizerEmail.isNullOrEmpty()) { + put(CalendarContract.Events.ORGANIZER, organizerEmail) + } + } + + // Insert as sync adapter for cloud accounts (required on OnePlus/ColorOS) + val accountName = calendar?.accountName ?: "" + val accountType = calendar?.accountType ?: "" + val isCloudAccount = !accountType.equals("LOCAL", ignoreCase = true) && accountName.isNotEmpty() + + val insertUri = if (isCloudAccount) { + CalendarContract.Events.CONTENT_URI.buildUpon() + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, accountType) + .build() + } else { + CalendarContract.Events.CONTENT_URI + } + + val uri = contentResolver.insert(insertUri, values) + ?: throw IllegalStateException("Failed to insert calendar event.") + val eventId = ContentUris.parseId(uri) + Log.d(TAG, "Calendar event inserted eventId=$eventId syncAdapter=$isCloudAccount") + + if (reminderMinutes != null) { + insertReminder(eventId, reminderMinutes, accountName = calendar?.accountName, accountType = calendar?.accountType) + } + + requestCalendarSync(calendar) + + return mapOf( + "platformId" to eventId.toString(), + "targetType" to targetType, + "calendarDisplayName" to calendar?.displayName, + "calendarAccountName" to calendar?.accountName, + "syncDisabled" to !syncWorking, + ) + } + + private fun createReminderEntry( + calendarId: Long, + title: String, + notes: String?, + scheduledAtMillis: Long?, + reminderMinutes: Int?, + targetType: String, + ): Map { + val scheduledAt = scheduledAtMillis + ?: throw IllegalArgumentException("A date/time is required to create reminders on Android.") + + Log.d( + TAG, + "createReminderEntry calendarId=$calendarId title=$title scheduledAt=$scheduledAt reminderMinutes=$reminderMinutes", + ) + + val event = createCalendarEntry( + calendarId = calendarId, + title = title, + notes = notes, + location = null, + startMillis = scheduledAt, + endMillis = scheduledAt + 30 * 60 * 1000L, + reminderMinutes = reminderMinutes ?: 0, + targetType = targetType, + ) + + return event + } + + private fun requestCalendarSync(calendar: WritableCalendarInfo?) { + if (calendar == null) return + val accountName = calendar.accountName + val accountType = calendar.accountType + if (accountName.isNullOrEmpty() || accountType.isNullOrEmpty()) return + val account = Account(accountName, accountType) + + try { + // Ensure the sync adapter is marked as syncable + if (ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY) <= 0) { + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + } + if (!ContentResolver.getSyncAutomatically(account, CalendarContract.AUTHORITY)) { + ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true) + } + + val extras = Bundle().apply { + putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) + putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) + putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) + putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true) + } + ContentResolver.requestSync(account, CalendarContract.AUTHORITY, extras) + Log.d(TAG, "requestCalendarSync: requested sync for account=$accountName") + } catch (e: Exception) { + Log.e(TAG, "requestCalendarSync failed", e) + } + } + + private fun insertReminder(eventId: Long, minutes: Int, accountName: String? = null, accountType: String? = null) { + Log.d(TAG, "insertReminder eventId=$eventId minutes=$minutes account=$accountName/$accountType") + + val values = ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.MINUTES, minutes) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + } + + // Use CALLER_IS_SYNCADAPTER URI for cloud accounts to avoid re-dirtying the event + val isCloudAccount = !accountType.isNullOrEmpty() && !accountType.equals("LOCAL", ignoreCase = true) && !accountName.isNullOrEmpty() + val insertUri = if (isCloudAccount) { + CalendarContract.Reminders.CONTENT_URI.buildUpon() + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, accountType) + .build() + } else { + CalendarContract.Reminders.CONTENT_URI + } + + val uri = contentResolver.insert(insertUri, values) + Log.d(TAG, "Reminder inserted for eventId=$eventId") + } + + /** + * Check sync health for a specific calendar or the best available calendar. + * Returns a map with sync diagnostics that Flutter can use to show warnings. + */ + private fun handleCheckCalendarSyncHealth(call: io.flutter.plugin.common.MethodCall, result: MethodChannel.Result) { + if (!hasCalendarPermission()) { + result.error("PERMISSION_DENIED", "Calendar permission required", null) + return + } + val requestedCalendarId = call.argument("calendarId")?.toLong() + val calendars = getWritableCalendars() + val calendar = if (requestedCalendarId != null) { + findCalendarById(calendars, requestedCalendarId) ?: chooseBestCalendar(calendars) + } else { + chooseBestCalendar(calendars) + } + if (calendar == null) { + result.success(mapOf("hasCalendar" to false, "syncWorking" to false)) + return + } + val accountName = calendar.accountName ?: "" + val accountType = calendar.accountType ?: "" + val isLocal = accountType.equals("LOCAL", ignoreCase = true) + var isSyncable = -1 + var autoSync = false + var masterSync = false + if (!isLocal && accountName.isNotEmpty() && accountType.isNotEmpty()) { + try { + val account = Account(accountName, accountType) + isSyncable = ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY) + autoSync = ContentResolver.getSyncAutomatically(account, CalendarContract.AUTHORITY) + masterSync = ContentResolver.getMasterSyncAutomatically() + } catch (_: Exception) { /* ignore */ } + } + // On some OEMs (OnePlus/ColorOS), isSyncable can be 0 even when the user has + // Calendar sync enabled in Settings. Check both conditions. + val syncWorking = isLocal || (isSyncable >= 1) || (autoSync && masterSync) + Log.d(TAG, "checkCalendarSyncHealth: calendar=${calendar.displayName} isSyncable=$isSyncable autoSync=$autoSync masterSync=$masterSync syncWorking=$syncWorking") + result.success(mapOf( + "hasCalendar" to true, + "syncWorking" to syncWorking, + "isSyncable" to isSyncable, + "autoSync" to autoSync, + "masterSync" to masterSync, + "calendarId" to calendar.id, + "calendarDisplayName" to calendar.displayName, + "accountName" to accountName, + "accountType" to accountType, + "isLocal" to isLocal, + )) + } + + /** + * Open the Android system Account Sync Settings for a specific account. + * This allows the user to enable Calendar sync for their Google account. + */ + private fun handleOpenCalendarSyncSettings(call: io.flutter.plugin.common.MethodCall, result: MethodChannel.Result) { + // On OnePlus/ColorOS, ACCOUNT_SYNC_SETTINGS opens and immediately closes. + // Skip it entirely and use intents that reliably stay open. + + // 1. Open "Passwords & accounts" / account management page + try { + val intent = Intent(android.provider.Settings.ACTION_SYNC_SETTINGS) + startActivity(intent) + Log.d(TAG, "Opened ACTION_SYNC_SETTINGS (Passwords & accounts)") + result.success("sync_settings") + return + } catch (e: Exception) { + Log.w(TAG, "ACTION_SYNC_SETTINGS failed: ${e.message}") + } + + // 2. Fallback: general settings + try { + val intent = Intent(android.provider.Settings.ACTION_SETTINGS) + startActivity(intent) + Log.d(TAG, "Opened general settings (fallback)") + result.success("general_settings") + } catch (e: Exception) { + Log.e(TAG, "Failed to open any settings", e) + result.error("SETTINGS_FAILED", "Could not open settings: ${e.message}", null) + } + } override fun onDestroy() { if (foregroundServiceBound) { diff --git a/zswatch_app/ios/Runner/AppDelegate.swift b/zswatch_app/ios/Runner/AppDelegate.swift index 6266644..87b3fbb 100644 --- a/zswatch_app/ios/Runner/AppDelegate.swift +++ b/zswatch_app/ios/Runner/AppDelegate.swift @@ -1,13 +1,213 @@ +import EventKit import Flutter import UIKit @main @objc class AppDelegate: FlutterAppDelegate { + private let productivityChannelName = "dev.zswatch.app/productivity" + private let eventStore = EKEventStore() + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + + if let controller = window?.rootViewController as? FlutterViewController { + let channel = FlutterMethodChannel( + name: productivityChannelName, + binaryMessenger: controller.binaryMessenger + ) + + channel.setMethodCallHandler { [weak self] call, result in + self?.handleProductivityCall(call, result: result) + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + private func handleProductivityCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "createAction" else { + result(FlutterMethodNotImplemented) + return + } + + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing action arguments.", details: nil)) + return + } + + let actionType = (args["actionType"] as? String) ?? "task" + let title = (args["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let notes = (args["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let location = (args["location"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let scheduledAtMillis = (args["scheduledAtMillis"] as? NSNumber)?.doubleValue + let endAtMillis = (args["endAtMillis"] as? NSNumber)?.doubleValue + let reminderMinutes = (args["reminderMinutes"] as? NSNumber)?.intValue + + guard !title.isEmpty else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "title is required", details: nil)) + return + } + + switch actionType { + case "calendar_event": + createCalendarEvent( + title: title, + notes: notes, + location: location, + scheduledAtMillis: scheduledAtMillis, + endAtMillis: endAtMillis, + reminderMinutes: reminderMinutes, + result: result + ) + case "reminder", "task": + createReminder( + title: title, + notes: notes, + scheduledAtMillis: scheduledAtMillis, + reminderMinutes: reminderMinutes, + result: result + ) + default: + result(FlutterError(code: "INVALID_ARGUMENT", message: "Unsupported action type: \(actionType)", details: nil)) + } + } + + private func createCalendarEvent( + title: String, + notes: String?, + location: String?, + scheduledAtMillis: Double?, + endAtMillis: Double?, + reminderMinutes: Int?, + result: @escaping FlutterResult + ) { + guard let scheduledAtMillis else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "A start time is required for calendar events.", details: nil)) + return + } + + requestEventAccess { [weak self] granted, error in + guard let self else { return } + + if let error { + result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) + return + } + + guard granted else { + result(FlutterError(code: "PERMISSION_DENIED", message: "Calendar access was denied.", details: nil)) + return + } + + guard let calendar = self.eventStore.defaultCalendarForNewEvents else { + result(FlutterError(code: "NO_CALENDAR", message: "No writable calendar was found.", details: nil)) + return + } + + let startDate = Date(timeIntervalSince1970: scheduledAtMillis / 1000) + let endDate = Date( + timeIntervalSince1970: (endAtMillis ?? (scheduledAtMillis + 30 * 60 * 1000)) / 1000 + ) + + let event = EKEvent(eventStore: self.eventStore) + event.calendar = calendar + event.title = title + event.notes = notes + event.location = location + event.startDate = startDate + event.endDate = endDate + + if let reminderMinutes { + let alarmDate = startDate.addingTimeInterval(TimeInterval(-reminderMinutes * 60)) + event.addAlarm(EKAlarm(absoluteDate: alarmDate)) + } + + do { + try self.eventStore.save(event, span: .thisEvent, commit: true) + result([ + "platformId": event.calendarItemIdentifier, + "targetType": "calendar_event", + ]) + } catch { + result(FlutterError(code: "CREATE_ACTION_FAILED", message: error.localizedDescription, details: nil)) + } + } + } + + private func createReminder( + title: String, + notes: String?, + scheduledAtMillis: Double?, + reminderMinutes: Int?, + result: @escaping FlutterResult + ) { + requestReminderAccess { [weak self] granted, error in + guard let self else { return } + + if let error { + result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) + return + } + + guard granted else { + result(FlutterError(code: "PERMISSION_DENIED", message: "Reminder access was denied.", details: nil)) + return + } + + guard let calendar = self.eventStore.defaultCalendarForNewReminders() else { + result(FlutterError(code: "NO_CALENDAR", message: "No reminders list was found.", details: nil)) + return + } + + let reminder = EKReminder(eventStore: self.eventStore) + reminder.calendar = calendar + reminder.title = title + reminder.notes = notes + + if let scheduledAtMillis { + let dueDate = Date(timeIntervalSince1970: scheduledAtMillis / 1000) + reminder.dueDateComponents = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], + from: dueDate + ) + + let offset = reminderMinutes ?? 0 + let alarmDate = dueDate.addingTimeInterval(TimeInterval(-offset * 60)) + reminder.addAlarm(EKAlarm(absoluteDate: alarmDate)) + } + + do { + try self.eventStore.save(reminder, commit: true) + result([ + "platformId": reminder.calendarItemIdentifier, + "targetType": "reminder", + ]) + } catch { + result(FlutterError(code: "CREATE_ACTION_FAILED", message: error.localizedDescription, details: nil)) + } + } + } + + private func requestEventAccess( + completion: @escaping (Bool, Error?) -> Void + ) { + if #available(iOS 17.0, *) { + eventStore.requestFullAccessToEvents(completion: completion) + } else { + eventStore.requestAccess(to: .event, completion: completion) + } + } + + private func requestReminderAccess( + completion: @escaping (Bool, Error?) -> Void + ) { + if #available(iOS 17.0, *) { + eventStore.requestFullAccessToReminders(completion: completion) + } else { + eventStore.requestAccess(to: .reminder, completion: completion) + } + } } diff --git a/zswatch_app/ios/Runner/Info.plist b/zswatch_app/ios/Runner/Info.plist index 2e9346a..41f2775 100644 --- a/zswatch_app/ios/Runner/Info.plist +++ b/zswatch_app/ios/Runner/Info.plist @@ -43,6 +43,15 @@ ZSWatch needs access to select firmware files from your photo library for watch updates. NSPhotoLibraryAddUsageDescription ZSWatch needs access to save images to your photo library. + + NSCalendarsUsageDescription + ZSWatch needs calendar access to create events from AI-detected voice note actions. + NSCalendarsFullAccessUsageDescription + ZSWatch needs calendar access to create events from AI-detected voice note actions. + NSRemindersUsageDescription + ZSWatch needs reminders access to create reminder actions from voice notes. + NSRemindersFullAccessUsageDescription + ZSWatch needs reminders access to create reminder actions from voice notes. UIApplicationSupportsIndirectInputEvents diff --git a/zswatch_app/lib/app.dart b/zswatch_app/lib/app.dart index cc73d12..cfd09b0 100644 --- a/zswatch_app/lib/app.dart +++ b/zswatch_app/lib/app.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,7 +9,6 @@ import 'providers/gps_providers.dart'; import 'providers/http_providers.dart'; import 'providers/notification_providers.dart'; import 'providers/permission_providers.dart'; -import 'providers/ai_providers.dart'; import 'providers/voice_memo_providers.dart'; import 'providers/watch_service_provider.dart'; import 'ui/navigation/app_router.dart'; @@ -66,10 +63,6 @@ class _ZSWatchAppState extends ConsumerState { // Initialize voice memo sync service to handle recording sync from watch // This subscribes to watch messages for new recording notifications ref.read(voiceMemoSyncServiceProvider); - - // [DEV] Run AI pipeline self-test on startup to validate model / inference - // TODO: Remove before release — this is a development-time smoke test - unawaited(runAiStartupTest(ref)); } catch (e) { debugPrint('BLE initialization error: $e'); } diff --git a/zswatch_app/lib/data/repositories/voice_memo_repository.dart b/zswatch_app/lib/data/repositories/voice_memo_repository.dart index 05e3a8b..da85a05 100644 --- a/zswatch_app/lib/data/repositories/voice_memo_repository.dart +++ b/zswatch_app/lib/data/repositories/voice_memo_repository.dart @@ -127,6 +127,26 @@ class VoiceMemoRepository { ); } + /// Clear AI results (undo) — resets summary, category, and processing status + /// while keeping raw audio and transcription intact. + Future clearAiResults(String filename) async { + final memo = await _db.getVoiceMemoByFilename(filename); + if (memo != null) { + await _db.deleteActionsForMemo(memo.id); + } + + await _db.updateVoiceMemoAiResults( + filename: filename, + summary: '', + category: '', + aiModel: '', + ); + await _db.updateVoiceMemoProcessingStatus( + filename: filename, + status: 'transcribed', + ); + } + /// Update AI processing status Future updateProcessingStatus({ required String filename, diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart index 48f22e2..2896ee5 100644 --- a/zswatch_app/lib/providers/ai_providers.dart +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/extracted_action.dart'; import '../data/repositories/extracted_action_repository.dart'; import '../data/repositories/voice_memo_repository.dart'; -import '../services/ai/ai_startup_test.dart'; +import '../services/ai/extracted_action_creation_service.dart'; import '../services/ai/llm_service.dart'; import '../services/ai/voice_note_ai_pipeline.dart'; import 'settings_providers.dart'; @@ -56,12 +56,57 @@ final llmModelSizeProvider = FutureProvider((ref) async { // Extracted-action repository // --------------------------------------------------------------------------- -final _extractedActionRepositoryProvider = +final extractedActionRepositoryProvider = Provider((ref) { final db = ref.watch(databaseProvider); return ExtractedActionRepository(db); }); +final extractedActionCreationServiceProvider = + Provider((ref) { + return const ExtractedActionCreationService(); +}); + +final writableCalendarsProvider = FutureProvider>((ref) async { + final service = ref.watch(extractedActionCreationServiceProvider); + return service.listWritableCalendars(); +}); + +class ExtractedActionOperations { + final ExtractedActionRepository _actionRepository; + final ExtractedActionCreationService _creationService; + + const ExtractedActionOperations({ + required ExtractedActionRepository actionRepository, + required ExtractedActionCreationService creationService, + }) : _actionRepository = actionRepository, + _creationService = creationService; + + Future createAction({ + required ExtractedAction action, + required ActionCreationDraft draft, + }) async { + final created = await _creationService.createDraft(draft); + await _actionRepository.markCreated( + actionId: action.id, + platformTargetId: created.platformId, + ); + return created.successMessage; + } + + Future dismissAction(int actionId) { + return _actionRepository.dismiss(actionId); + } +} + +final extractedActionOperationsProvider = + Provider((ref) { + return ExtractedActionOperations( + actionRepository: ref.watch(extractedActionRepositoryProvider), + creationService: ref.watch(extractedActionCreationServiceProvider), + ); +}); + // --------------------------------------------------------------------------- // AI pipeline // --------------------------------------------------------------------------- @@ -70,7 +115,7 @@ final _extractedActionRepositoryProvider = final voiceNoteAiPipelineProvider = Provider((ref) { final llm = ref.watch(llmServiceProvider); final memoRepo = ref.watch(voiceMemoRepositoryProvider); - final actionRepo = ref.watch(_extractedActionRepositoryProvider); + final actionRepo = ref.watch(extractedActionRepositoryProvider); return VoiceNoteAiPipeline( llmService: llm, memoRepository: memoRepo, @@ -107,11 +152,21 @@ class _AiActionsNotifier extends StateNotifier> { final memo = await _memoRepo.getMemoByFilename(filename); if (memo == null) throw Exception('Memo not found: $filename'); - await _pipeline.processMemo( + final transcript = memo.transcription?.trim(); + if (transcript == null || transcript.isEmpty) { + throw StateError('Transcribe the voice note before AI processing.'); + } + + final success = await _pipeline.processMemo( memoId: memo.id, filename: memo.filename, - transcript: memo.transcription ?? '', + transcript: transcript, ); + + if (!success) { + throw StateError('AI processing did not complete for $filename.'); + } + state = const AsyncData(null); } catch (e, st) { debugPrint('[AiActions] processVoiceMemo error: $e'); @@ -146,24 +201,6 @@ final aiActionsProvider = final extractedActionsForMemoProvider = StreamProvider.family, int>((ref, memoId) { - final repo = ref.watch(_extractedActionRepositoryProvider); + final repo = ref.watch(extractedActionRepositoryProvider); return repo.watchActionsForMemo(memoId); }); - -// --------------------------------------------------------------------------- -// Startup test (re-export for app.dart) -// --------------------------------------------------------------------------- - -/// Convenience re-export so app.dart can import just ai_providers.dart. -Future runAiStartupTest(WidgetRef ref) async { - final llm = ref.read(llmServiceProvider); - final downloaded = await llm.isModelDownloaded(); - if (!downloaded) { - debugPrint( - '[AiStartupTest] Model not downloaded, skipping self-test. ' - 'Download via Settings → AI Processing.', - ); - return; - } - await aiStartupSelfTest(llm); -} diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index 4303def..7d01c9c 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -19,6 +19,8 @@ abstract final class SettingsKeys { static const String localAiEnabled = 'local_ai_enabled'; static const String autoProcessVoiceNotes = 'auto_process_voice_notes'; static const String selectedAiModelId = 'selected_ai_model_id'; + static const String selectedProductivityCalendarId = + 'selected_productivity_calendar_id'; } /// Provider for SharedPreferences instance @@ -307,8 +309,8 @@ class TranscriptionEngineTypeNotifier : super(_parseType(_prefs?.getString(SettingsKeys.transcriptionEngineType))); static TranscriptionEngineType _parseType(String? value) { - if (value == TranscriptionEngineType.kbWhisperBase.name) { - return TranscriptionEngineType.kbWhisperBase; + for (final type in TranscriptionEngineType.values) { + if (value == type.name) return type; } return TranscriptionEngineType.whisperTinyEn; } @@ -393,3 +395,28 @@ class SelectedAiModelIdNotifier extends StateNotifier { } } +/// Currently selected Android calendar id for created reminders/events. +final selectedProductivityCalendarIdProvider = + StateNotifierProvider( + (ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return SelectedProductivityCalendarIdNotifier(prefs.valueOrNull); + }, +); + +class SelectedProductivityCalendarIdNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + SelectedProductivityCalendarIdNotifier(this._prefs) + : super(_prefs?.getInt(SettingsKeys.selectedProductivityCalendarId)); + + void setCalendarId(int? calendarId) { + state = calendarId; + if (calendarId == null) { + _prefs?.remove(SettingsKeys.selectedProductivityCalendarId); + return; + } + _prefs?.setInt(SettingsKeys.selectedProductivityCalendarId, calendarId); + } +} + diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart index 324750b..1a29cfc 100644 --- a/zswatch_app/lib/providers/voice_memo_providers.dart +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -89,32 +89,62 @@ final voiceMemoSyncServiceProvider = Provider((ref) { repository: repository, ); - // Wire up auto-transcription (and optionally AI processing) after sync - final engine = ref.watch(transcriptionEngineProvider); - final aiEnabled = ref.read(localAiEnabledProvider); - final autoProcess = ref.read(autoProcessVoiceNotesProvider); - VoiceNoteAiPipeline? pipeline; - if (aiEnabled && autoProcess) { - pipeline = ref.read(voiceNoteAiPipelineProvider); - } - service.onSyncCompleted = (downloadedCount) { + // Wire up auto-transcription (and optionally AI processing) after sync. + // Settings and pipeline are read lazily at call time so changes after + // provider creation are always picked up. + service.onSyncCompleted = (downloadedCount) async { debugPrint( '[VoiceMemoProviders] Sync completed ($downloadedCount new). ' 'Starting auto-transcription.'); - _autoTranscribeAndProcess(repository, engine, pipeline); + final engine = ref.read(transcriptionEngineProvider); + final aiEnabled = ref.read(localAiEnabledProvider); + final autoProcess = ref.read(autoProcessVoiceNotesProvider); + VoiceNoteAiPipeline? pipeline; + if (aiEnabled && autoProcess) { + pipeline = ref.read(voiceNoteAiPipelineProvider); + // Wire round-trip confirmation: send result back to watch after AI processing + pipeline!.onProcessingComplete = (filename, title) { + service.sendResultToWatch(filename, title); + }; + } + await _autoTranscribeAndProcess( + repository: repository, + engine: engine, + pipeline: pipeline, + // Report per-memo progress so the UI shows "Transcribing..." state + onTranscribingMemo: (filename) { + ref.read(autoTranscribeStateProvider.notifier).state = + VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + activeFilename: filename, + ); + }, + onDone: () { + ref.read(autoTranscribeStateProvider.notifier).state = + const VoiceMemoActionState.idle(); + }, + ); }; ref.onDispose(() => service.dispose()); return service; }); +/// State for background auto-transcription triggered after sync. +/// The UI watches this alongside [voiceMemoActionsProvider] so buttons +/// reflect in-progress state even when transcription was auto-started. +final autoTranscribeStateProvider = + StateProvider((ref) => const VoiceMemoActionState.idle()); + /// Auto-transcribe all untranscribed memos after sync, then optionally /// run the AI pipeline on newly transcribed memos. -Future _autoTranscribeAndProcess( - VoiceMemoRepository repository, - TranscriptionEngine engine, - VoiceNoteAiPipeline? pipeline, -) async { +Future _autoTranscribeAndProcess({ + required VoiceMemoRepository repository, + required TranscriptionEngine engine, + required VoiceNoteAiPipeline? pipeline, + void Function(String filename)? onTranscribingMemo, + void Function()? onDone, +}) async { try { final untranscribed = await repository.getUntranscribedMemos(); if (untranscribed.isEmpty) { @@ -122,6 +152,7 @@ Future _autoTranscribeAndProcess( if (pipeline != null) { await pipeline.processAllUnprocessed(); } + onDone?.call(); return; } @@ -133,6 +164,7 @@ Future _autoTranscribeAndProcess( final audioPath = memo.convertedFilePath ?? memo.localFilePath; if (audioPath == null) continue; + onTranscribingMemo?.call(memo.filename); final text = await engine.transcribe(audioPath); await repository.updateTranscription( filename: memo.filename, @@ -153,6 +185,8 @@ Future _autoTranscribeAndProcess( } } catch (e) { debugPrint('[VoiceMemoProviders] Auto-transcription/processing error: $e'); + } finally { + onDone?.call(); } } @@ -187,8 +221,57 @@ final voiceMemoSyncStateProvider = StreamProvider((ref) { // ==================== Voice Memo Actions ==================== +enum VoiceMemoActionType { + sync, + delete, + transcribe, +} + +class VoiceMemoActionState { + final bool isLoading; + final VoiceMemoActionType? actionType; + final String? activeFilename; + final Object? error; + + const VoiceMemoActionState({ + required this.isLoading, + this.actionType, + this.activeFilename, + this.error, + }); + + const VoiceMemoActionState.idle() + : isLoading = false, + actionType = null, + activeFilename = null, + error = null; + + const VoiceMemoActionState.loading({ + required VoiceMemoActionType actionType, + String? activeFilename, + }) : isLoading = true, + actionType = actionType, + activeFilename = activeFilename, + error = null; + + const VoiceMemoActionState.error({ + required VoiceMemoActionType actionType, + String? activeFilename, + required Object error, + }) : isLoading = false, + actionType = actionType, + activeFilename = activeFilename, + error = error; + + bool isTranscribingMemo(String filename) { + return isLoading && + actionType == VoiceMemoActionType.transcribe && + activeFilename == filename; + } +} + /// Notifier for voice memo actions (sync, delete, transcribe) -class VoiceMemoActionsNotifier extends StateNotifier> { +class VoiceMemoActionsNotifier extends StateNotifier { final VoiceMemoSyncService _syncService; final VoiceMemoRepository _repository; final TranscriptionEngine _transcriptionEngine; @@ -200,29 +283,43 @@ class VoiceMemoActionsNotifier extends StateNotifier> { }) : _syncService = syncService, _repository = repository, _transcriptionEngine = transcriptionEngine, - super(const AsyncData(null)); + super(const VoiceMemoActionState.idle()); /// Trigger a sync of voice memos from the watch Future sync() async { - state = const AsyncLoading(); + state = const VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.sync, + ); try { await _syncService.syncRecordings(); - state = const AsyncData(null); + state = const VoiceMemoActionState.idle(); } catch (e, st) { debugPrint('[VoiceMemoActions] Sync error: $e'); - state = AsyncError(e, st); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.sync, + error: e, + ); } } /// Delete a voice memo locally Future delete(String filename) async { - state = const AsyncLoading(); + state = VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.delete, + activeFilename: filename, + ); try { await _repository.deleteMemo(filename); - state = const AsyncData(null); + state = const VoiceMemoActionState.idle(); } catch (e, st) { debugPrint('[VoiceMemoActions] Delete error: $e'); - state = AsyncError(e, st); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.delete, + activeFilename: filename, + error: e, + ); } } @@ -232,15 +329,23 @@ class VoiceMemoActionsNotifier extends StateNotifier> { /// The FFmpeg converter registered at startup handles Ogg → WAV conversion /// for Whisper automatically. Future transcribe(VoiceMemo memo) async { - state = const AsyncLoading(); + state = VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + activeFilename: memo.filename, + ); try { await _transcribeMemo(memo); debugPrint('[VoiceMemoActions] Transcription saved for ${memo.filename}'); - state = const AsyncData(null); + state = const VoiceMemoActionState.idle(); } catch (e, st) { debugPrint('[VoiceMemoActions] Transcription error: $e'); - state = AsyncError(e, st); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.transcribe, + activeFilename: memo.filename, + error: e, + ); } } @@ -251,7 +356,9 @@ class VoiceMemoActionsNotifier extends StateNotifier> { /// Transcribe all synced but untranscribed memos Future transcribeAll() async { - state = const AsyncLoading(); + state = const VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + ); try { final untranscribed = await _repository.getUntranscribedMemos(); debugPrint( @@ -266,10 +373,14 @@ class VoiceMemoActionsNotifier extends StateNotifier> { // Continue with next memo } } - state = const AsyncData(null); + state = const VoiceMemoActionState.idle(); } catch (e, st) { debugPrint('[VoiceMemoActions] TranscribeAll error: $e'); - state = AsyncError(e, st); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.transcribe, + error: e, + ); } } @@ -277,7 +388,9 @@ class VoiceMemoActionsNotifier extends StateNotifier> { /// /// Returns the number of memos attempted. Future retranscribeAll() async { - state = const AsyncLoading(); + state = const VoiceMemoActionState.loading( + actionType: VoiceMemoActionType.transcribe, + ); try { final memos = await _repository.getTranscribableMemos(); debugPrint( @@ -292,11 +405,15 @@ class VoiceMemoActionsNotifier extends StateNotifier> { } } - state = const AsyncData(null); + state = const VoiceMemoActionState.idle(); return memos.length; } catch (e, st) { debugPrint('[VoiceMemoActions] RetranscribeAll error: $e'); - state = AsyncError(e, st); + debugPrint('$st'); + state = VoiceMemoActionState.error( + actionType: VoiceMemoActionType.transcribe, + error: e, + ); rethrow; } } @@ -319,7 +436,7 @@ class VoiceMemoActionsNotifier extends StateNotifier> { /// Provider for voice memo actions final voiceMemoActionsProvider = - StateNotifierProvider>((ref) { + StateNotifierProvider((ref) { final syncService = ref.watch(voiceMemoSyncServiceProvider); final repository = ref.watch(voiceMemoRepositoryProvider); final transcriptionEngine = ref.watch(transcriptionEngineProvider); diff --git a/zswatch_app/lib/services/ai/ai_startup_test.dart b/zswatch_app/lib/services/ai/ai_startup_test.dart deleted file mode 100644 index 5cb8655..0000000 --- a/zswatch_app/lib/services/ai/ai_startup_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'llm_service.dart'; - -/// Runs a quick self-test of the AI inference pipeline at startup. -/// -/// This is a **development-time smoke test** — remove before release. -/// -/// Call through [runAiStartupTest] in `ai_providers.dart` which resolves -/// [LlmService] from the Riverpod graph. -Future aiStartupSelfTest(LlmService llm) async { - debugPrint('[AiStartupTest] ========== AI STARTUP SELF-TEST START =========='); - final sw = Stopwatch()..start(); - - int passed = 0; - int failed = 0; - - // ----- Test 1: English classify ----- - await _runTest( - name: 'classify_en', - input: - 'Remind me to call the mechanic tomorrow at 3 PM about the brakes ' - 'and also pick up milk on the way home.', - llm: llm, - onPass: () => passed++, - onFail: () => failed++, - ); - - // ----- Test 2: Swedish classify ----- - await _runTest( - name: 'note_sv', - input: - 'Kom ihåg att köpa mjölk och bröd på vägen hem. ' - 'Dessutom behöver jag ringa tandläkaren.', - llm: llm, - onPass: () => passed++, - onFail: () => failed++, - ); - - sw.stop(); - debugPrint('[AiStartupTest] Tests: $passed passed, $failed failed ' - '(total ${sw.elapsedMilliseconds} ms)'); - debugPrint('[AiStartupTest] ========== AI STARTUP SELF-TEST END =========='); -} - -Future _runTest({ - required String name, - required String input, - required LlmService llm, - required VoidCallback onPass, - required VoidCallback onFail, -}) async { - debugPrint('[AiStartupTest] --- Test: $name ---'); - debugPrint('[AiStartupTest] Input: "$input"'); - - try { - final testSw = Stopwatch()..start(); - final result = await llm.processTranscript(input); - testSw.stop(); - - debugPrint('[AiStartupTest] Summary : ${result.summary}'); - debugPrint('[AiStartupTest] Category : ${result.category}'); - debugPrint('[AiStartupTest] Actions : ${result.actions.length}'); - debugPrint('[AiStartupTest] Time : ${testSw.elapsedMilliseconds} ms'); - debugPrint('[AiStartupTest] Result : PASS ✓'); - onPass(); - } catch (e) { - debugPrint('[AiStartupTest] Error : $e'); - debugPrint('[AiStartupTest] Result : FAIL ✗'); - onFail(); - } - debugPrint('[AiStartupTest] '); -} diff --git a/zswatch_app/lib/services/ai/extracted_action_creation_service.dart b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart new file mode 100644 index 0000000..f3d3f27 --- /dev/null +++ b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart @@ -0,0 +1,454 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../data/models/extracted_action.dart'; + +class PlatformCalendar { + final int id; + final String? displayName; + final String? accountName; + final String? accountType; + final String? ownerAccount; + final bool isPrimary; + + const PlatformCalendar({ + required this.id, + this.displayName, + this.accountName, + this.accountType, + this.ownerAccount, + required this.isPrimary, + }); + + factory PlatformCalendar.fromMap(Map map) { + return PlatformCalendar( + id: (map['id'] as num).toInt(), + displayName: map['displayName'] as String?, + accountName: map['accountName'] as String?, + accountType: map['accountType'] as String?, + ownerAccount: map['ownerAccount'] as String?, + isPrimary: map['isPrimary'] as bool? ?? false, + ); + } + + String get label { + final name = displayName?.trim(); + final account = accountName?.trim(); + if (name != null && name.isNotEmpty && account != null && account.isNotEmpty) { + return '$name — $account'; + } + return name?.isNotEmpty == true + ? name! + : (account?.isNotEmpty == true ? account! : 'Calendar $id'); + } + + bool get looksLocal { + final combined = [displayName, accountName, accountType, ownerAccount] + .whereType() + .join(' ') + .toLowerCase(); + return combined.contains('local'); + } +} + +class ActionCreationDraft { + final ExtractedActionType actionType; + final String title; + final String? notes; + final DateTime? scheduledAt; + final DateTime? endAt; + final String? location; + final int? reminderMinutes; + final int? platformCalendarId; + + const ActionCreationDraft({ + required this.actionType, + required this.title, + this.notes, + this.scheduledAt, + this.endAt, + this.location, + this.reminderMinutes, + this.platformCalendarId, + }); + + factory ActionCreationDraft.fromAction(ExtractedAction action) { + final scheduledAt = action.startTime ?? action.dueDate; + final defaultEndAt = action.actionType == ExtractedActionType.calendarEvent && + scheduledAt != null + ? (action.endTime ?? scheduledAt.add(const Duration(minutes: 30))) + : action.endTime; + + return ActionCreationDraft( + actionType: action.actionType, + title: action.title, + notes: action.notes, + scheduledAt: scheduledAt, + endAt: defaultEndAt, + location: action.location, + reminderMinutes: action.reminderMinutes ?? + (action.actionType == ExtractedActionType.reminder ? 0 : null), + platformCalendarId: null, + ); + } + + ActionCreationDraft copyWith({ + ExtractedActionType? actionType, + String? title, + String? notes, + DateTime? scheduledAt, + DateTime? endAt, + String? location, + int? reminderMinutes, + int? platformCalendarId, + }) { + return ActionCreationDraft( + actionType: actionType ?? this.actionType, + title: title ?? this.title, + notes: notes ?? this.notes, + scheduledAt: scheduledAt ?? this.scheduledAt, + endAt: endAt ?? this.endAt, + location: location ?? this.location, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, + platformCalendarId: platformCalendarId ?? this.platformCalendarId, + ); + } + + Map toPlatformMap() { + return { + 'actionType': ExtractedAction.typeToString(actionType), + 'title': title, + 'notes': notes, + 'scheduledAtMillis': scheduledAt?.millisecondsSinceEpoch, + 'endAtMillis': endAt?.millisecondsSinceEpoch, + 'location': location, + 'reminderMinutes': reminderMinutes, + 'calendarId': platformCalendarId, + }; + } +} + +class CreatedPlatformAction { + final String? platformId; + final String targetType; + final String? calendarDisplayName; + final String? calendarAccountName; + /// True when the calendar sync adapter is disabled (isSyncable=0). + /// The event was inserted locally but won't appear in Google Calendar + /// until the user enables Calendar sync in Android Settings. + final bool syncDisabled; + + const CreatedPlatformAction({ + required this.platformId, + required this.targetType, + this.calendarDisplayName, + this.calendarAccountName, + this.syncDisabled = false, + }); + + String get successMessage { + final calendarSuffix = calendarDisplayName != null && calendarDisplayName!.isNotEmpty + ? ' in ${calendarDisplayName!}' + : ''; + + switch (targetType) { + case 'calendar_event': + return 'Calendar event created$calendarSuffix'; + case 'reminder': + return 'Reminder created$calendarSuffix'; + case 'calendar_reminder': + return 'Calendar reminder created$calendarSuffix'; + default: + return 'Action created$calendarSuffix'; + } + } + + String? get syncWarningMessage { + if (!syncDisabled) return null; + return 'Calendar sync is disabled for this account. ' + 'Events are saved locally but won\u2019t appear in Google Calendar ' + 'until you enable Calendar sync in Android Settings.'; + } +} + +/// Sync health diagnostics returned by [ExtractedActionCreationService.checkCalendarSyncHealth]. +class CalendarSyncHealth { + final bool hasCalendar; + final bool syncWorking; + final int isSyncable; + final bool autoSync; + final bool masterSync; + final int? calendarId; + final String? calendarDisplayName; + final String? accountName; + final String? accountType; + final bool isLocal; + + const CalendarSyncHealth({ + required this.hasCalendar, + required this.syncWorking, + this.isSyncable = -1, + this.autoSync = false, + this.masterSync = false, + this.calendarId, + this.calendarDisplayName, + this.accountName, + this.accountType, + this.isLocal = false, + }); + + factory CalendarSyncHealth.fromMap(Map map) { + return CalendarSyncHealth( + hasCalendar: map['hasCalendar'] as bool? ?? false, + syncWorking: map['syncWorking'] as bool? ?? false, + isSyncable: (map['isSyncable'] as num?)?.toInt() ?? -1, + autoSync: map['autoSync'] as bool? ?? false, + masterSync: map['masterSync'] as bool? ?? false, + calendarId: (map['calendarId'] as num?)?.toInt(), + calendarDisplayName: map['calendarDisplayName'] as String?, + accountName: map['accountName'] as String?, + accountType: map['accountType'] as String?, + isLocal: map['isLocal'] as bool? ?? false, + ); + } +} + +class ExtractedActionCreationService { + static const MethodChannel _channel = + MethodChannel('dev.zswatch.app/productivity'); + + const ExtractedActionCreationService(); + + Future> listWritableCalendars() async { + if (!Platform.isAndroid) { + return const []; + } + + await _requestPermission( + Permission.calendarFullAccess, + 'Calendar permission is required to load calendars.', + ); + + final result = await _channel.invokeListMethod('listWritableCalendars'); + if (result == null) { + return const []; + } + + return result + .whereType>() + .map(PlatformCalendar.fromMap) + .toList(growable: false); + } + + Future createDraft(ActionCreationDraft draft) async { + await _ensurePermissions(draft.actionType); + + debugPrint( + '[ExtractedActionCreation] Creating ${ExtractedAction.typeToString(draft.actionType)} ' + 'title="${draft.title}" scheduledAt=${draft.scheduledAt?.toIso8601String()}', + ); + + final result = await _invokeCreateAction(draft); + + if (result == null) { + throw StateError('Native action creation returned no result.'); + } + + debugPrint('[ExtractedActionCreation] Native result: $result'); + + // Don't auto-open Google Calendar after creation — locally-inserted events + // may not appear until Google Calendar syncs. The user can tap "Open" later. + + final syncDisabled = result['syncDisabled'] as bool? ?? false; + if (syncDisabled) { + debugPrint( + '[ExtractedActionCreation] WARNING: Calendar sync is disabled! ' + 'Event saved locally but won\u2019t sync to Google.', + ); + } + + return CreatedPlatformAction( + platformId: result['platformId'] as String?, + targetType: (result['targetType'] as String?) ?? 'action', + calendarDisplayName: result['calendarDisplayName'] as String?, + calendarAccountName: result['calendarAccountName'] as String?, + syncDisabled: syncDisabled, + ); + } + + Future openCreatedAction(ExtractedAction action) async { + if (!Platform.isAndroid || action.platformTargetId == null) { + return; + } + + final targetType = switch (action.actionType) { + ExtractedActionType.calendarEvent => 'calendar_event', + ExtractedActionType.task || ExtractedActionType.reminder => + 'calendar_reminder', + }; + + await _openCreatedCalendarEntryIfSupported( + platformId: action.platformTargetId, + targetType: targetType, + scheduledAtMillis: + (action.startTime ?? action.dueDate)?.millisecondsSinceEpoch, + ); + } + + Future?> _invokeCreateAction( + ActionCreationDraft draft, + ) async { + try { + return await _channel.invokeMapMethod( + 'createAction', + draft.toPlatformMap(), + ); + } on PlatformException catch (error) { + debugPrint( + '[ExtractedActionCreation] Native createAction failed ' + 'code=${error.code} message=${error.message} details=${error.details}', + ); + rethrow; + } + } + + Future _openCreatedCalendarEntryIfSupported({ + required String? platformId, + required String targetType, + required int? scheduledAtMillis, + }) async { + if (platformId == null) { + return; + } + + if (targetType != 'calendar_event' && targetType != 'calendar_reminder') { + return; + } + + try { + await _channel.invokeMethod('openCalendarEntry', { + 'eventId': platformId, + 'scheduledAtMillis': scheduledAtMillis, + }); + } on PlatformException catch (error) { + debugPrint( + '[ExtractedActionCreation] Failed to open created calendar entry ' + 'code=${error.code} message=${error.message}', + ); + } + } + + Future _ensurePermissions(ExtractedActionType actionType) async { + if (!(Platform.isAndroid || Platform.isIOS)) { + throw UnsupportedError( + 'Action creation is only supported on Android and iOS.', + ); + } + + final permission = _permissionForActionType(actionType); + final failureMessage = _failureMessageForActionType(actionType); + + await _requestPermission(permission, failureMessage); + } + + Permission _permissionForActionType(ExtractedActionType actionType) { + if (Platform.isAndroid) { + return Permission.calendarFullAccess; + } + + switch (actionType) { + case ExtractedActionType.calendarEvent: + return Permission.calendarFullAccess; + case ExtractedActionType.task: + case ExtractedActionType.reminder: + return Permission.reminders; + } + } + + String _failureMessageForActionType(ExtractedActionType actionType) { + if (Platform.isAndroid) { + return actionType == ExtractedActionType.calendarEvent + ? 'Calendar permission is required to create events.' + : 'Calendar permission is required to create reminders on Android.'; + } + + return switch (actionType) { + ExtractedActionType.calendarEvent => + 'Calendar access is required to create events.', + ExtractedActionType.task || ExtractedActionType.reminder => + 'Reminders access is required to create reminders.', + }; + } + + Future _requestPermission( + Permission permission, + String failureMessage, + ) async { + var status = await permission.status; + debugPrint( + '[ExtractedActionCreation] Permission $permission status before request: $status', + ); + + if (!status.isGranted) { + status = await permission.request(); + debugPrint( + '[ExtractedActionCreation] Permission $permission status after request: $status', + ); + } + + if (!status.isGranted) { + throw StateError(failureMessage); + } + } + + /// Check whether the CalendarProvider sync adapter is working for a specific + /// calendar (or the best available one). + /// + /// Returns [CalendarSyncHealth] with diagnostics. Use [syncWorking] to + /// decide whether to show a warning banner. + Future checkCalendarSyncHealth({int? calendarId}) async { + if (!Platform.isAndroid) { + return const CalendarSyncHealth(hasCalendar: true, syncWorking: true); + } + try { + final result = await _channel.invokeMapMethod( + 'checkCalendarSyncHealth', + {'calendarId': calendarId}, + ); + if (result == null) { + return const CalendarSyncHealth(hasCalendar: false, syncWorking: false); + } + return CalendarSyncHealth.fromMap(result); + } on PlatformException catch (e) { + debugPrint('[ExtractedActionCreation] checkCalendarSyncHealth failed: ${e.message}'); + return const CalendarSyncHealth(hasCalendar: false, syncWorking: false); + } + } + + /// Open Android Settings → Sync Settings so the user can enable Calendar sync + /// for their Google account. This is a one-time action that fixes the + /// "isSyncable=0" issue permanently. + Future openCalendarSyncSettings({ + String? accountName, + String? accountType, + }) async { + if (!Platform.isAndroid) return false; + try { + final result = await _channel.invokeMethod( + 'openCalendarSyncSettings', + { + 'accountName': accountName, + 'accountType': accountType, + }, + ); + // Kotlin returns a String describing which settings page was opened + return result != null; + } on PlatformException catch (e) { + debugPrint('[ExtractedActionCreation] openCalendarSyncSettings failed: ${e.message}'); + return false; + } + } +} diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 7a3e028..b53c458 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; import 'package:fllama/fllama.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -99,6 +100,9 @@ class LlmInferenceMetrics { final int promptTokens; final int completionTokens; final double tokensPerSecond; + final int attempts; + final String? promptStrategy; + final bool retryEnabled; const LlmInferenceMetrics({ required this.modelName, @@ -109,6 +113,9 @@ class LlmInferenceMetrics { this.promptTokens = 0, this.completionTokens = 0, this.tokensPerSecond = 0.0, + this.attempts = 1, + this.promptStrategy, + this.retryEnabled = false, }); LlmInferenceMetrics copyWithParsedJson(String? json) => @@ -121,6 +128,9 @@ class LlmInferenceMetrics { promptTokens: promptTokens, completionTokens: completionTokens, tokensPerSecond: tokensPerSecond, + attempts: attempts, + promptStrategy: promptStrategy, + retryEnabled: retryEnabled, ); } @@ -129,6 +139,12 @@ class TranscriptResult { final String summary; final String category; final List actions; + final String? extractedIntent; + final String? extractedTitle; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; final String? originalTranscription; final String? correctedTranscription; final LlmInferenceMetrics? correctionMetrics; @@ -138,6 +154,12 @@ class TranscriptResult { required this.summary, required this.category, this.actions = const [], + this.extractedIntent, + this.extractedTitle, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, this.originalTranscription, this.correctedTranscription, this.correctionMetrics, @@ -157,6 +179,29 @@ class TranscriptResult { /// /// The model loads lazily on first inference and stays cached in-process. class LlmService { + static const int _maxStructuredOutputAttempts = 2; + static const String promptPlaceholderCurrentLocalDateTime = + ChronoPromptTemplate.promptPlaceholderCurrentLocalDateTime; + static const String promptPlaceholderCurrentLocalDateTimeCompact = + ChronoPromptTemplate.promptPlaceholderCurrentLocalDateTimeCompact; + static const String promptPlaceholderWeekday = + ChronoPromptTemplate.promptPlaceholderWeekday; + static const String promptPlaceholderTimezoneOffset = + ChronoPromptTemplate.promptPlaceholderTimezoneOffset; + static const String promptPlaceholderTranscript = + ChronoPromptTemplate.promptPlaceholderTranscript; + + static String get defaultBenchmarkPromptTemplate => + ChronoPromptTemplate.defaultTemplate; + + static String get defaultClassifyPromptTemplate => + defaultBenchmarkPromptTemplate; + + final TimeExpressionResolver _timeExpressionResolver = + TimeExpressionResolver(); + final ChronoLlmParser _chronoLlmParser = const ChronoLlmParser(); + + static const String defaultModelId = 'qwen25_1_5b_q4_k_m'; static const List catalogModels = [ LlmModelInfo( @@ -186,6 +231,33 @@ class LlmService { 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q8_0.gguf', expectedSizeBytes: 1890 * 1024 * 1024, ), + LlmModelInfo( + id: 'qwen3_1_7b_q4_k_m', + displayName: 'Qwen3 1.7B Instruct · Q4_K_M', + family: 'Qwen3-1.7B', + filename: 'Qwen3-1.7B-Q4_K_M.gguf', + downloadUrl: + 'https://huggingface.co/ggml-org/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf', + expectedSizeBytes: 1220 * 1024 * 1024, + ), + LlmModelInfo( + id: 'smollm3_3b_q4_k_m', + displayName: 'SmolLM3 3B Instruct · Q4_K_M', + family: 'SmolLM3-3B', + filename: 'SmolLM3-Q4_K_M.gguf', + downloadUrl: + 'https://huggingface.co/ggml-org/SmolLM3-3B-GGUF/resolve/main/SmolLM3-Q4_K_M.gguf', + expectedSizeBytes: 1840 * 1024 * 1024, + ), + LlmModelInfo( + id: 'qwen35_2b_q4_k_m', + displayName: 'Qwen3.5 2B Instruct · Q4_K_M (Experimental)', + family: 'Qwen3.5-2B', + filename: 'Qwen3.5-2B-Q4_K_M.gguf', + downloadUrl: + 'https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4_K_M.gguf', + expectedSizeBytes: 1222 * 1024 * 1024, + ), LlmModelInfo( id: 'llama32_3b_q4_k_m', displayName: 'Llama 3.2 3B Instruct · Q4_K_M', @@ -542,6 +614,8 @@ class LlmService { Future processTranscript( String transcript, { bool correctTranscription = true, + String? classifyPromptOverride, + String? promptStrategyOverride, void Function(String phase, String partialResponse, int tokens)? onProgress, }) async { _stateSubject.add( @@ -592,37 +666,47 @@ class LlmService { await Future.delayed(const Duration(milliseconds: 500)); // --- Step 2: Build the extraction prompt --- - final prompt = _buildClassifyPrompt(effectiveTranscript); - final genResult = await _generate( + final promptTemplate = classifyPromptOverride?.trim(); + final prompt = (promptTemplate != null && promptTemplate.isNotEmpty) + ? _renderClassifyPromptTemplate( + promptTemplate, + transcript: effectiveTranscript, + ) + : _buildClassifyPrompt(effectiveTranscript); + final structuredResult = await _generateStructuredJsonWithRetry( prompt, - onPartialResponse: onProgress == null - ? null - : (partial, tokens) => onProgress('classifying', partial, tokens), + promptStrategy: (promptTemplate != null && promptTemplate.isNotEmpty) + ? (promptStrategyOverride ?? 'custom-template') + : 'full+/no_think', + phase: 'classifying', + onProgress: onProgress, ); - final raw = genResult.text; - final classifyMetrics = genResult.metrics.copyWithParsedJson(null); + final raw = structuredResult.raw; + final classifyMetrics = structuredResult.metrics; debugPrint('[LlmService] Raw AI response: $raw'); // --- Parse JSON from output --- - final result = _parseTranscriptResult(raw); + final result = structuredResult.result; _stateSubject.add( _stateSubject.value.copyWith(status: LlmServiceStatus.ready), ); - // Attach the parsed JSON to classify metrics - final jsonStr = _extractFirstJsonObject(raw); - final finalClassifyMetrics = classifyMetrics.copyWithParsedJson(jsonStr); - return TranscriptResult( summary: result.summary, category: result.category, actions: result.actions, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, originalTranscription: transcript, correctedTranscription: correctedTranscription, correctionMetrics: correctionMetrics, - classifyMetrics: finalClassifyMetrics, + classifyMetrics: classifyMetrics, ); } catch (e) { debugPrint('[LlmService] Failed to process transcript: $e'); @@ -672,27 +756,71 @@ Corrected transcription:'''; } String _buildClassifyPrompt(String transcript) { + return _renderClassifyPromptTemplate( + defaultClassifyPromptTemplate, + transcript: transcript, + ); + } + + String _renderClassifyPromptTemplate( + String template, { + required String transcript, + }) { + return ChronoPromptTemplate.render( + template, + transcript: transcript, + ); + } + + /// Word-count threshold for brain dump mode. Transcripts with more + /// words than this use the brain dump prompt instead of the standard + /// classify prompt. + static const int brainDumpWordThreshold = 50; + + String _buildBrainDumpPrompt(String transcript) { + final localNow = DateTime.now(); + final weekday = const [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday', + ][localNow.weekday - 1]; + final iso = localNow.toIso8601String(); + final tzOffset = localNow.timeZoneOffset; + final tzSign = tzOffset.isNegative ? '-' : '+'; + final tzHours = tzOffset.inHours.abs().toString().padLeft(2, '0'); + final tzMinutes = (tzOffset.inMinutes.abs() % 60).toString().padLeft(2, '0'); + final tz = '$tzSign$tzHours:$tzMinutes'; + return ''' -You are a precise voice-note extraction assistant. +You are a voice-note summarization assistant specializing in long, unstructured recordings. -Return EXACTLY ONE valid JSON object. -Do not include markdown fences. -Do not include explanations. -Do not include any text before or after the JSON. -Do not return multiple JSON objects. +Current local date/time: $iso ($weekday), timezone UTC$tz. +Use this to resolve relative references like "tomorrow", "next Tuesday", "in 30 minutes", etc. + +The following transcript is from a "brain dump" — a long, stream-of-consciousness voice recording. It may contain: +- Multiple unrelated topics +- Rambling or repeated ideas +- Filler words and false starts +- Mixed actionable items and general thoughts -Analyze the transcript and produce: -1. a short summary -2. a category -3. structured actions if the transcript contains actionable items +Return EXACTLY ONE valid JSON object. +Do not include markdown fences, explanations, or any text before/after the JSON. -Preserve the transcript language in summary, title, notes, and location. -Do not invent dates, times, or locations. Use null when unknown. +Your job: +1. Produce a concise executive summary (2-3 sentences max) +2. Group the content into logical sections with headers +3. Extract any actionable items mentioned anywhere in the transcript +4. Assign the category "brain_dump" Use this exact schema: { - "summary": "short summary in the original language", - "category": "idea" | "task" | "reminder" | "meeting" | "note", + "summary": "concise 2-3 sentence executive summary in the original language", + "category": "brain_dump", + "sections": [ + { + "header": "Topic or theme heading", + "bullets": ["key point 1", "key point 2"] + } + ], "actions": [ { "type": "task" | "reminder" | "calendar_event", @@ -709,61 +837,249 @@ Use this exact schema: } Rules: -- Use "meeting" for calendar-like content. -- Use "task" or "reminder" for actionable personal follow-ups. -- Use "note" or "idea" when there is no clear action. +- Keep sections to 4 or fewer. +- Keep bullets concise (one line each). +- Extract ALL actionable items regardless of where they appear. - If no actions exist, return an empty array. -- Keep the summary short and useful for a timeline card. +- Preserve the transcript language. +- Do not invent dates, times, or locations. Use null when unknown. Transcript: "$transcript" -JSON: '''; +JSON: + +/no_think'''; } - // ---- Output parsing ---- + /// Determine whether a transcript should use brain dump mode. + bool isBrainDump(String transcript) { + final wordCount = transcript.trim().split(RegExp(r'\s+')).length; + return wordCount >= brainDumpWordThreshold; + } - String? _extractFirstJsonObject(String raw) { - final start = raw.indexOf('{'); - if (start == -1) { - return null; - } + /// Process a transcript using the brain dump prompt for long recordings. + Future processTranscriptBrainDump( + String transcript, { + bool correctTranscription = true, + void Function(String phase, String partialResponse, int tokens)? onProgress, + }) async { + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.processing), + ); - var depth = 0; - var inString = false; - var escaping = false; + try { + debugPrint( + '[LlmService] Processing brain dump transcript (${transcript.length} chars)', + ); - for (var i = start; i < raw.length; i++) { - final char = raw[i]; + String effectiveTranscript = transcript; + LlmInferenceMetrics? correctionMetrics; + String? correctedTranscription; - if (escaping) { - escaping = false; - continue; - } + // --- Step 1: Correct transcription errors if enabled --- + if (correctTranscription) { + final correctionPrompt = _buildCorrectionPrompt(transcript); + final correctionResult = await _generate( + correctionPrompt, + overrideMaxTokens: 1024, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress('correcting', partial, tokens), + ); - if (char == '\\' && inString) { - escaping = true; - continue; - } + final corrected = correctionResult.text.trim(); + correctionMetrics = correctionResult.metrics; - if (char == '"') { - inString = !inString; - continue; + if (corrected.isNotEmpty && + !corrected.startsWith('{') && + corrected.length > 5) { + correctedTranscription = corrected; + effectiveTranscript = corrected; + } } - if (inString) { - continue; - } + await Future.delayed(const Duration(milliseconds: 500)); + + // --- Step 2: Brain dump extraction prompt --- + final prompt = _buildBrainDumpPrompt(effectiveTranscript); + final structuredResult = await _generateStructuredJsonWithRetry( + prompt, + overrideMaxTokens: 768, + promptStrategy: 'brain_dump+/no_think', + phase: 'summarizing', + onProgress: onProgress, + ); + final raw = structuredResult.raw; + final classifyMetrics = structuredResult.metrics; + + debugPrint('[LlmService] Raw brain dump response: $raw'); + + // Parse using the same JSON extraction logic + final result = structuredResult.result; + final jsonStr = classifyMetrics.parsedJson; + + _stateSubject.add( + _stateSubject.value.copyWith(status: LlmServiceStatus.ready), + ); - if (char == '{') { - depth++; - } else if (char == '}') { - depth--; - if (depth == 0) { - return raw.substring(start, i + 1); + // Build a rich summary including sections if present + String richSummary = result.summary; + if (jsonStr != null) { + try { + final parsed = jsonDecode(jsonStr) as Map; + final sections = parsed['sections'] as List?; + if (sections != null && sections.isNotEmpty) { + final buf = StringBuffer(result.summary); + buf.writeln(); + for (final section in sections.whereType>()) { + final header = section['header'] as String?; + final bullets = (section['bullets'] as List?) + ?.whereType() + .toList() ?? + []; + if (header != null) { + buf.writeln('\n## $header'); + for (final bullet in bullets) { + buf.writeln('• $bullet'); + } + } + } + richSummary = buf.toString().trim(); + } + } catch (_) { + // Fall back to plain summary } } + + return TranscriptResult( + summary: richSummary, + category: 'brain_dump', + actions: result.actions, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + originalTranscription: transcript, + correctedTranscription: correctedTranscription, + correctionMetrics: correctionMetrics, + classifyMetrics: classifyMetrics, + ); + } catch (e) { + debugPrint('[LlmService] Failed to process brain dump: $e'); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + ), + ); + rethrow; } + } - return null; + // ---- Output parsing ---- + + Future<({ + String raw, + TranscriptResult result, + LlmInferenceMetrics metrics, + int attempts, + })> _generateStructuredJsonWithRetry( + String prompt, { + int? overrideMaxTokens, + required String promptStrategy, + String phase = 'classifying', + void Function(String phase, String partialResponse, int tokens)? onProgress, + }) async { + String raw = ''; + TranscriptResult parsed = const TranscriptResult(summary: '', category: 'note'); + LlmInferenceMetrics? lastMetrics; + Duration totalWallTime = Duration.zero; + var totalCompletionTokens = 0; + var attempts = 0; + + while (attempts < _maxStructuredOutputAttempts) { + attempts++; + + final genResult = await _generate( + prompt, + overrideMaxTokens: overrideMaxTokens, + onPartialResponse: onProgress == null + ? null + : (partial, tokens) => onProgress(phase, partial, tokens), + ); + + raw = genResult.text; + parsed = _parseTranscriptResult(raw); + lastMetrics = genResult.metrics; + totalWallTime += genResult.metrics.wallTime; + totalCompletionTokens += genResult.metrics.completionTokens; + + if (!_shouldRetryStructuredOutput(raw, parsed) || + attempts >= _maxStructuredOutputAttempts) { + break; + } + + debugPrint( + '[LlmService] Retrying invalid structured output ' + '(attempt ${attempts + 1}/$_maxStructuredOutputAttempts)', + ); + await Future.delayed(const Duration(milliseconds: 300)); + } + + final parsedJson = _extractFirstJsonObject(raw); + final metrics = LlmInferenceMetrics( + modelName: _selectedModelName, + rawPrompt: prompt, + rawResponse: raw, + parsedJson: parsedJson, + wallTime: totalWallTime, + completionTokens: totalCompletionTokens, + tokensPerSecond: lastMetrics?.tokensPerSecond ?? 0.0, + attempts: attempts, + promptStrategy: promptStrategy, + retryEnabled: _maxStructuredOutputAttempts > 1, + ); + + return ( + raw: raw, + result: parsed, + metrics: metrics, + attempts: attempts, + ); + } + + String _sanitizeModelOutput(String raw) { + return _chronoLlmParser.sanitizeModelOutput(raw); + } + + bool _shouldRetryStructuredOutput(String raw, TranscriptResult result) { + final cleaned = _sanitizeModelOutput(raw); + final jsonStr = _extractFirstJsonObject(cleaned); + + if (jsonStr == null) { + return true; + } + + if (result.summary.trim().isEmpty) { + return true; + } + + if (result.summary.trim() == cleaned && result.actions.isEmpty) { + return true; + } + + if (result.category == 'note' && + result.actions.isEmpty && + (result.summary.trim() == cleaned || result.summary.trim() == jsonStr.trim())) { + return true; + } + + return false; + } + + String? _extractFirstJsonObject(String raw) { + return _chronoLlmParser.extractFirstJsonObject(raw); } String _normalizeCategory(String? rawCategory) { @@ -801,14 +1117,71 @@ JSON: '''; } } + ChronoLlmExtraction? _parseChronoExtractionResult( + Map parsed, + ) { + return _chronoLlmParser.parse(jsonEncode(parsed)).extraction; + } + + TranscriptResult _buildTranscriptResultFromChronoExtraction( + ChronoLlmExtraction extraction, + String raw, + ) { + final summary = extraction.title.isNotEmpty + ? extraction.title + : raw.trim(); + final category = switch (extraction.intent) { + 'event' => 'meeting', + 'reminder' => 'reminder', + _ => 'note', + }; + + if (extraction.intent == 'note') { + return TranscriptResult( + summary: summary, + category: 'note', + extractedIntent: extraction.intent, + extractedTitle: extraction.title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + ); + } + + final englishExpression = extraction.datetimeExpressionEnglish; + final resolved = englishExpression == null + ? null + : _timeExpressionResolver.resolve(englishExpression); + + final action = ExtractedActionResult( + type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', + title: summary, + notes: extraction.datetimeExpressionOriginal, + dueDate: extraction.intent == 'reminder' ? resolved?.dateTime.toIso8601String() : null, + startTime: extraction.intent == 'event' ? resolved?.dateTime.toIso8601String() : null, + ); + + return TranscriptResult( + summary: summary, + category: category, + actions: [action], + extractedIntent: extraction.intent, + extractedTitle: extraction.title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + resolvedDateTime: resolved?.dateTime.toIso8601String(), + resolverMethod: resolved?.method, + ); + } + TranscriptResult _parseTranscriptResult(String raw) { - final jsonStr = _extractFirstJsonObject(raw); + final cleaned = _sanitizeModelOutput(raw); + final jsonStr = _extractFirstJsonObject(cleaned); if (jsonStr == null) { debugPrint('[LlmService] Failed to parse AI response: ' 'FormatException: No JSON object found'); return TranscriptResult( - summary: raw.trim(), + summary: cleaned.trim(), category: 'note', ); } @@ -816,6 +1189,14 @@ JSON: '''; try { final parsed = jsonDecode(jsonStr) as Map; + final chronoExtraction = _parseChronoExtractionResult(parsed); + if (chronoExtraction != null) { + return _buildTranscriptResultFromChronoExtraction( + chronoExtraction, + raw, + ); + } + final category = _normalizeCategory(parsed['category'] as String?); final summary = (parsed['summary'] as String?)?.trim(); final title = (parsed['title'] as String?)?.trim(); diff --git a/zswatch_app/lib/services/ai/model_benchmark_service.dart b/zswatch_app/lib/services/ai/model_benchmark_service.dart new file mode 100644 index 0000000..357d0d3 --- /dev/null +++ b/zswatch_app/lib/services/ai/model_benchmark_service.dart @@ -0,0 +1,403 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; +import '../voice_memo/transcription_engine.dart'; +import 'llm_service.dart'; + +// --------------------------------------------------------------------------- +// State model +// --------------------------------------------------------------------------- + +/// Live-updating benchmark run (mirrors the AiProcessingDebugInfo pattern from +/// the voice-memo debug sheet so the UI can reuse the same visual style). +class BenchmarkProgress { + final String testType; // 'transcription' or 'ai' + final String modelName; + final String? promptStrategy; + final String? rawPrompt; + final String? parsedJson; + final String? extractedIntent; + final String? extractedTitle; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; + final int attempts; + final bool retryEnabled; + + /// Current phase: 'loading', 'running', 'done', 'error'. + final String phase; + final String partialOutput; + final int tokens; + final Duration elapsed; + final double? tokensPerSecond; + final String? error; + + /// Full raw LLM output preserved across completion. During live streaming + /// this mirrors [partialOutput]; on completion [partialOutput] is set to a + /// human-readable summary while [rawOutput] keeps the full model response. + final String? rawOutput; + + const BenchmarkProgress({ + required this.testType, + required this.modelName, + this.promptStrategy, + this.rawPrompt, + this.parsedJson, + this.extractedIntent, + this.extractedTitle, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, + this.attempts = 1, + this.retryEnabled = false, + this.phase = 'loading', + this.partialOutput = '', + this.tokens = 0, + this.elapsed = Duration.zero, + this.tokensPerSecond, + this.error, + this.rawOutput, + }); + + bool get isComplete => phase == 'done' || phase == 'error'; + bool get isError => phase == 'error'; +} + +/// Top-level state for the benchmark section. +class BenchmarkState { + final bool isRunning; + + /// Which test is currently running ('transcription' or 'ai'), null if idle. + final String? runningTestType; + final BenchmarkProgress? current; + + const BenchmarkState({ + this.isRunning = false, + this.runningTestType, + this.current, + }); + + BenchmarkState copyWith({ + bool? isRunning, + String? runningTestType, + BenchmarkProgress? current, + }) => + BenchmarkState( + isRunning: isRunning ?? this.isRunning, + runningTestType: runningTestType ?? this.runningTestType, + current: current ?? this.current, + ); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// Benchmarks transcription and AI models so users can gauge performance on +/// their hardware. Streams [BenchmarkState] with live progress that the UI +/// renders in the same style as the voice-memo debug sheet. +class ModelBenchmarkService { + final _stateSubject = BehaviorSubject.seeded( + const BenchmarkState(), + ); + + /// Set to `true` when the user requests an abort. Checked between phases + /// and after async work completes. Note: Whisper and fllama FFI calls are + /// blocking and cannot be interrupted mid-inference, so the abort takes + /// effect as soon as the current native call returns. + bool _abortRequested = false; + + Stream get stateStream => _stateSubject.stream; + BenchmarkState get currentState => _stateSubject.value; + + /// Request the current benchmark run to abort. + void abort() { + if (!currentState.isRunning) return; + _abortRequested = true; + final current = currentState.current; + if (current != null) { + _emit(BenchmarkProgress( + testType: current.testType, + modelName: current.modelName, + phase: 'running', + partialOutput: 'Aborting after current operation…', + tokens: current.tokens, + elapsed: current.elapsed, + tokensPerSecond: current.tokensPerSecond, + )); + } + } + + // ---- Transcription benchmark ---- + + /// Benchmark the selected transcription engine using a real voice recording. + /// + /// [audioFilePath] must point to an existing audio file (typically an .ogg + /// from the voice memos directory). + Future benchmarkTranscription( + TranscriptionEngineType type, + String audioFilePath, + ) async { + _abortRequested = false; + final info = TranscriptionModelCatalog.info(type); + final engine = createTranscriptionEngine(type); + StreamSubscription? engineSub; + + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'loading', + partialOutput: 'Checking model availability…', + )); + + try { + // Verify the audio file exists + if (!File(audioFilePath).existsSync()) { + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: 'Audio file not found: $audioFilePath', + )); + return; + } + + final available = await engine.isAvailable(); + if (!available) { + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: 'Model not downloaded – download it first.', + )); + return; + } + + // Listen to engine state for status updates (loading model, transcribing) + engineSub = engine.stateStream.listen((engineState) { + final statusText = switch (engineState.status) { + TranscriptionEngineStatus.downloading => + 'Downloading model (${(engineState.downloadProgress * 100).toInt()}%)…', + TranscriptionEngineStatus.transcribing => 'Transcribing audio…', + TranscriptionEngineStatus.ready => 'Model ready', + TranscriptionEngineStatus.error => + 'Engine error: ${engineState.errorMessage ?? "unknown"}', + _ => 'Initializing…', + }; + // Only emit running-phase status updates while we're still running + if (!currentState.current!.isComplete) { + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'running', + partialOutput: statusText, + )); + } + }); + + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'running', + partialOutput: 'Starting transcription…', + )); + + final sw = Stopwatch()..start(); + final output = await engine.transcribe(audioFilePath); + sw.stop(); + + if (_abortRequested) { + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'done', + partialOutput: '(aborted)\n${output.isEmpty ? '' : output}', + elapsed: sw.elapsed, + )); + return; + } + + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'done', + partialOutput: output.isEmpty ? '(no speech detected)' : output, + elapsed: sw.elapsed, + )); + } catch (e) { + _emit(BenchmarkProgress( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: e.toString(), + )); + } finally { + await engineSub?.cancel(); + engine.dispose(); + _stateSubject.add(BenchmarkState( + isRunning: false, + runningTestType: null, + current: currentState.current, + )); + } + } + + // ---- AI benchmark ---- + + /// Run a single short transcript through the normal app AI flow to test + /// speed and behavior. The prompt stays fixed to the app's shared chrono + /// extraction prompt; only the sample input text is variable. + Future benchmarkAiModel( + LlmService llmService, { + String? testInput, + }) async { + _abortRequested = false; + final modelName = llmService.modelName; + + _emit(BenchmarkProgress( + testType: 'ai', + modelName: modelName, + phase: 'loading', + partialOutput: 'Loading model…', + )); + + try { + final isDownloaded = await llmService.isModelDownloaded(); + if (!isDownloaded) { + _emit(BenchmarkProgress( + testType: 'ai', + modelName: modelName, + phase: 'error', + error: 'Model not downloaded – download it first.', + )); + return; + } + + final benchmarkInput = (testInput != null && testInput.trim().isNotEmpty) + ? testInput.trim() + : 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; + + debugPrint('[ModelBenchmark] Running AI benchmark with: $modelName'); + final sw = Stopwatch()..start(); + + String lastRawOutput = ''; + final result = await llmService.processTranscript( + benchmarkInput, + correctTranscription: false, // skip correction – test classify speed + onProgress: (phase, partial, tokens) { + lastRawOutput = partial; + final tps = sw.elapsedMilliseconds > 0 + ? tokens / (sw.elapsedMilliseconds / 1000.0) + : 0.0; + _emit(BenchmarkProgress( + testType: 'ai', + modelName: modelName, + promptStrategy: 'shared-chrono-flow', + retryEnabled: true, + phase: 'running', + partialOutput: partial, + rawOutput: partial, + tokens: tokens, + elapsed: sw.elapsed, + tokensPerSecond: tps, + )); + }, + ); + sw.stop(); + + // Use the raw classify response when available + final rawResponse = + result.classifyMetrics?.rawResponse ?? lastRawOutput; + + if (_abortRequested) { + _emit(BenchmarkProgress( + testType: 'ai', + modelName: modelName, + promptStrategy: result.classifyMetrics?.promptStrategy, + rawPrompt: result.classifyMetrics?.rawPrompt, + parsedJson: result.classifyMetrics?.parsedJson, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + attempts: result.classifyMetrics?.attempts ?? 1, + retryEnabled: result.classifyMetrics?.retryEnabled ?? false, + phase: 'done', + partialOutput: '(aborted)', + rawOutput: rawResponse, + tokens: result.classifyMetrics?.completionTokens ?? 0, + elapsed: sw.elapsed, + tokensPerSecond: + result.classifyMetrics?.tokensPerSecond ?? 0.0, + )); + return; + } + + final tps = result.classifyMetrics?.tokensPerSecond ?? 0.0; + + _emit(BenchmarkProgress( + testType: 'ai', + modelName: modelName, + promptStrategy: result.classifyMetrics?.promptStrategy, + rawPrompt: result.classifyMetrics?.rawPrompt, + parsedJson: result.classifyMetrics?.parsedJson, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, + attempts: result.classifyMetrics?.attempts ?? 1, + retryEnabled: result.classifyMetrics?.retryEnabled ?? false, + phase: 'done', + partialOutput: + 'Category: ${result.category}\n' + 'Summary: ${result.summary}\n' + 'Actions: ${result.actions.length}', + rawOutput: rawResponse, + tokens: result.classifyMetrics?.completionTokens ?? 0, + elapsed: sw.elapsed, + tokensPerSecond: tps, + )); + } catch (e) { + debugPrint('[ModelBenchmark] AI benchmark error: $e'); + _emit(BenchmarkProgress( + testType: 'ai', + modelName: modelName, + phase: 'error', + error: e.toString(), + )); + } finally { + _stateSubject.add(BenchmarkState( + isRunning: false, + runningTestType: null, + current: currentState.current, + )); + } + } + + /// Reset state to initial (no results). + void clear() { + _stateSubject.add(const BenchmarkState()); + } + + void dispose() { + _stateSubject.close(); + } + + // ---- Helpers ---- + + void _emit(BenchmarkProgress progress) { + _stateSubject.add(BenchmarkState( + isRunning: !progress.isComplete, + runningTestType: progress.isComplete ? null : progress.testType, + current: progress, + )); + } +} diff --git a/zswatch_app/lib/services/ai/time_expression_resolver.dart b/zswatch_app/lib/services/ai/time_expression_resolver.dart new file mode 100644 index 0000000..99afb42 --- /dev/null +++ b/zswatch_app/lib/services/ai/time_expression_resolver.dart @@ -0,0 +1,2 @@ +export 'package:chrono_ai_flow/chrono_ai_flow.dart' + show ResolvedTime, TimeExpressionResolver; diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart index b885c32..1c6a16b 100644 --- a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -10,10 +10,20 @@ import 'llm_service.dart'; class AiProcessingDebugInfo { final String filename; final String modelName; + final String? classifyPrompt; + final String? classifyPromptStrategy; + final int? classifyAttempts; + final bool retryEnabled; final String? originalTranscription; final String? correctedTranscription; final String? rawLlmResponse; final String? parsedJson; + final String? extractedIntent; + final String? extractedTitle; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; final String? summary; final String? category; final int actionCount; @@ -35,16 +45,32 @@ class AiProcessingDebugInfo { /// Current token count for the active generation phase. final int liveTokenCount; + /// Elapsed wall-clock time since inference started (live updates). + final Duration? liveElapsed; + + /// Live tokens-per-second during the current generation phase. + final double? liveTokensPerSecond; + /// Whether processing has finished (final snapshot vs live update). final bool isComplete; const AiProcessingDebugInfo({ required this.filename, required this.modelName, + this.classifyPrompt, + this.classifyPromptStrategy, + this.classifyAttempts, + this.retryEnabled = false, this.originalTranscription, this.correctedTranscription, this.rawLlmResponse, this.parsedJson, + this.extractedIntent, + this.extractedTitle, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, this.summary, this.category, this.actionCount = 0, @@ -58,6 +84,8 @@ class AiProcessingDebugInfo { this.currentPhase, this.partialResponse = '', this.liveTokenCount = 0, + this.liveElapsed, + this.liveTokensPerSecond, this.isComplete = true, }); } @@ -74,6 +102,10 @@ class VoiceNoteAiPipeline { final VoiceMemoRepository _memoRepository; final ExtractedActionRepository _actionRepository; + /// Called after successful AI processing with (filename, summary). + /// Used to send the result toast back to the watch. + void Function(String filename, String title)? onProcessingComplete; + /// Stream of debug info from the most recent AI processing runs. final _debugInfoSubject = BehaviorSubject.seeded(null); Stream get debugInfoStream => _debugInfoSubject.stream; @@ -129,22 +161,49 @@ class VoiceNoteAiPipeline { timestamp: DateTime.now(), )); + // Route to brain dump prompt for long transcripts (Feature 6) + final useBrainDump = _llmService.isBrainDump(transcript); + debugPrint( + '[VoiceNoteAiPipeline] Brain dump routing: ' + '${useBrainDump ? "YES" : "NO"} for $filename', + ); + + // Stopwatch to compute live elapsed time & tokens-per-second + final sw = Stopwatch()..start(); + + // Helper that emits a live progress update with timing metrics. + void emitLive(String phase, String partial, int tokens) { + final elapsedMs = sw.elapsedMilliseconds; + final tps = elapsedMs > 0 ? tokens / (elapsedMs / 1000.0) : 0.0; + _debugInfoSubject.add(AiProcessingDebugInfo( + filename: filename, + modelName: _llmService.modelName, + originalTranscription: transcript, + currentPhase: phase, + partialResponse: partial, + liveTokenCount: tokens, + liveElapsed: sw.elapsed, + liveTokensPerSecond: tps, + isComplete: false, + timestamp: DateTime.now(), + )); + } + // Run the LLM processing with live progress updates - final result = await _llmService.processTranscript( + final result = useBrainDump + ? await _llmService.processTranscriptBrainDump( + transcript, + onProgress: (phase, partial, tokens) { + emitLive(phase, partial, tokens); + }, + ) + : await _llmService.processTranscript( transcript, onProgress: (phase, partial, tokens) { - _debugInfoSubject.add(AiProcessingDebugInfo( - filename: filename, - modelName: _llmService.modelName, - originalTranscription: transcript, - currentPhase: phase, - partialResponse: partial, - liveTokenCount: tokens, - isComplete: false, - timestamp: DateTime.now(), - )); + emitLive(phase, partial, tokens); }, ); + sw.stop(); debugPrint( '[VoiceNoteAiPipeline] Processed $filename: ' @@ -168,6 +227,10 @@ class VoiceNoteAiPipeline { aiModel: _llmService.modelName, ); + // Replace any previous extracted actions for this memo before inserting + // the latest set, so re-processing never duplicates suggestions. + await _actionRepository.deleteActionsForMemo(memoId); + // Persist extracted actions for (final action in result.actions) { final actionType = _mapActionType(action.type); @@ -182,14 +245,27 @@ class VoiceNoteAiPipeline { ); } + // Notify watch with round-trip confirmation toast + onProcessingComplete?.call(filename, result.summary); + // Publish final debug info and store per-file final finalDebug = AiProcessingDebugInfo( filename: filename, modelName: _llmService.modelName, + classifyPrompt: result.classifyMetrics?.rawPrompt, + classifyPromptStrategy: result.classifyMetrics?.promptStrategy, + classifyAttempts: result.classifyMetrics?.attempts, + retryEnabled: result.classifyMetrics?.retryEnabled ?? false, originalTranscription: result.originalTranscription, correctedTranscription: result.correctedTranscription, rawLlmResponse: result.classifyMetrics?.rawResponse, parsedJson: result.classifyMetrics?.parsedJson, + extractedIntent: result.extractedIntent, + extractedTitle: result.extractedTitle, + datetimeExpressionOriginal: result.datetimeExpressionOriginal, + datetimeExpressionEnglish: result.datetimeExpressionEnglish, + resolvedDateTime: result.resolvedDateTime, + resolverMethod: result.resolverMethod, summary: result.summary, category: result.category, actionCount: result.actions.length, diff --git a/zswatch_app/lib/services/voice_memo/transcription_engine.dart b/zswatch_app/lib/services/voice_memo/transcription_engine.dart index 44788c2..18d53ed 100644 --- a/zswatch_app/lib/services/voice_memo/transcription_engine.dart +++ b/zswatch_app/lib/services/voice_memo/transcription_engine.dart @@ -19,6 +19,18 @@ enum TranscriptionEngineType { /// KB-Whisper Base fine-tuned on 50 k+ hours of Swedish speech (~147 MB, /// q5_0 quantised). Downloaded from HuggingFace on first use. kbWhisperBase, + + /// KB-Whisper Small q5_0 quantised (~175 MB). Massive accuracy improvement + /// over Base for Swedish at nearly the same size. ~500 MB RAM needed. + kbWhisperSmallQ5, + + /// KB-Whisper Small full GGML checkpoint (~488 MB). Highest fidelity + /// available from the upstream GGML release. ~1 GB RAM needed. + kbWhisperSmallQ8, + + /// Whisper Large-v3-Turbo q5_0 quantised (~547 MB). Near cloud-level + /// accuracy, optimised for speed. Needs ~1 GB RAM — modern flagships only. + whisperLargeV3TurboQ5, } /// Static metadata for a selectable transcription model. @@ -62,27 +74,72 @@ abstract final class TranscriptionModelCatalog { expectedSizeBytes: 147 * 1024 * 1024, ); + static const _kbWhisperSmallQ5 = TranscriptionModelInfo( + type: TranscriptionEngineType.kbWhisperSmallQ5, + name: 'KB-Whisper Small · Q5_0 (Swedish)', + language: 'sv', + sourceUrl: + 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main/ggml-model-q5_0.bin', + fileName: 'ggml-kb-whisper-small-q5_0.bin', + expectedSizeBytes: 175 * 1024 * 1024, + ); + + static const _kbWhisperSmallQ8 = TranscriptionModelInfo( + type: TranscriptionEngineType.kbWhisperSmallQ8, + name: 'KB-Whisper Small · Full GGML (Swedish)', + language: 'sv', + sourceUrl: + 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main/ggml-model.bin', + fileName: 'ggml-kb-whisper-small.bin', + expectedSizeBytes: 488 * 1024 * 1024, + ); + + static const _whisperLargeV3TurboQ5 = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperLargeV3TurboQ5, + name: 'Whisper Large-v3-Turbo · Q5_0', + language: 'auto', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin', + fileName: 'ggml-large-v3-turbo-q5_0.bin', + expectedSizeBytes: 547 * 1024 * 1024, + ); + static const List all = [ _tinyEn, _kbWhisperBase, + _kbWhisperSmallQ5, + _kbWhisperSmallQ8, + _whisperLargeV3TurboQ5, ]; static TranscriptionModelInfo info(TranscriptionEngineType type) { switch (type) { - case TranscriptionEngineType.kbWhisperBase: - return _kbWhisperBase; case TranscriptionEngineType.whisperTinyEn: return _tinyEn; + case TranscriptionEngineType.kbWhisperBase: + return _kbWhisperBase; + case TranscriptionEngineType.kbWhisperSmallQ5: + return _kbWhisperSmallQ5; + case TranscriptionEngineType.kbWhisperSmallQ8: + return _kbWhisperSmallQ8; + case TranscriptionEngineType.whisperLargeV3TurboQ5: + return _whisperLargeV3TurboQ5; } } } TranscriptionEngine createTranscriptionEngine(TranscriptionEngineType type) { switch (type) { - case TranscriptionEngineType.kbWhisperBase: - return KbWhisperEngines.base(); case TranscriptionEngineType.whisperTinyEn: return WhisperEngine(); + case TranscriptionEngineType.kbWhisperBase: + return KbWhisperEngines.base(); + case TranscriptionEngineType.kbWhisperSmallQ5: + return KbWhisperEngines.smallQ5(); + case TranscriptionEngineType.kbWhisperSmallQ8: + return KbWhisperEngines.smallQ8(); + case TranscriptionEngineType.whisperLargeV3TurboQ5: + return KbWhisperEngines.largeV3TurboQ5(); } } @@ -244,6 +301,7 @@ class WhisperEngine implements TranscriptionEngine { status: TranscriptionEngineStatus.error, errorMessage: e.toString(), )); + rethrow; } } @@ -415,10 +473,11 @@ class CustomGgmlWhisperEngine implements TranscriptionEngine { @override int get expectedModelSizeBytes { - if (_modelFileName == 'ggml-kb-whisper-base-q5_0.bin') { - return TranscriptionModelCatalog - .info(TranscriptionEngineType.kbWhisperBase) - .expectedSizeBytes; + // Try to look up from the catalog by filename + for (final info in TranscriptionModelCatalog.all) { + if (info.fileName == _modelFileName) { + return info.expectedSizeBytes; + } } return 0; } @@ -466,6 +525,7 @@ class CustomGgmlWhisperEngine implements TranscriptionEngine { status: TranscriptionEngineStatus.error, errorMessage: e.toString(), )); + rethrow; } } @@ -676,6 +736,8 @@ class CustomGgmlWhisperEngine implements TranscriptionEngine { /// Apache-2.0 licensed. Models are hosted on HuggingFace. abstract final class KbWhisperEngines { static const String _hfBase = 'https://huggingface.co/KBLab/kb-whisper-base/resolve/main'; + static const String _hfSmall = 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main'; + static const String _hfWhisperCpp = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main'; /// Base model, q5_0 quantised (~147 MB). /// Recommended: good accuracy, reasonable size for mobile. @@ -685,4 +747,31 @@ abstract final class KbWhisperEngines { languageCode: 'sv', displayName: 'KB-Whisper Base (Swedish)', ); + + /// Small model, q5_0 quantised (~175 MB). + /// Big accuracy leap over Base at nearly the same size. ~500 MB RAM. + static CustomGgmlWhisperEngine smallQ5() => CustomGgmlWhisperEngine( + modelUrl: '$_hfSmall/ggml-model-q5_0.bin', + modelFileName: 'ggml-kb-whisper-small-q5_0.bin', + languageCode: 'sv', + displayName: 'KB-Whisper Small · Q5_0 (Swedish)', + ); + + /// Small model, full GGML checkpoint (~488 MB). + /// Highest fidelity Small variant currently published for whisper.cpp. + static CustomGgmlWhisperEngine smallQ8() => CustomGgmlWhisperEngine( + modelUrl: '$_hfSmall/ggml-model.bin', + modelFileName: 'ggml-kb-whisper-small.bin', + languageCode: 'sv', + displayName: 'KB-Whisper Small · Full GGML (Swedish)', + ); + + /// Whisper Large-v3-Turbo, q5_0 quantised (~547 MB). + /// Near-cloud accuracy, heavily optimised. ~1 GB RAM — flagship devices. + static CustomGgmlWhisperEngine largeV3TurboQ5() => CustomGgmlWhisperEngine( + modelUrl: '$_hfWhisperCpp/ggml-large-v3-turbo-q5_0.bin', + modelFileName: 'ggml-large-v3-turbo-q5_0.bin', + languageCode: 'auto', + displayName: 'Whisper Large-v3-Turbo · Q5_0', + ); } diff --git a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart index 749f99d..0b2edf2 100644 --- a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart +++ b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart @@ -110,7 +110,7 @@ class VoiceMemoSyncService { /// Called after sync completes with the number of newly downloaded memos. /// Used to trigger auto-transcription from the provider layer. - void Function(int downloadedCount)? onSyncCompleted; + Future Function(int downloadedCount)? onSyncCompleted; final _syncState = BehaviorSubject.seeded( const VoiceMemoSyncState(), @@ -166,7 +166,7 @@ class VoiceMemoSyncService { // Auto-download the new recording if (_watchService.isConnected && !currentState.isSyncing) { _log('Auto-downloading new recording: ${info.filename}'); - syncRecordings(); + unawaited(syncRecordings()); } } @@ -296,7 +296,7 @@ class VoiceMemoSyncService { // Notify listeners that new memos were downloaded (triggers auto-transcribe) if (completed > 0) { - onSyncCompleted?.call(completed); + await onSyncCompleted?.call(completed); } } catch (e) { _log('Sync failed: $e'); @@ -462,6 +462,8 @@ class VoiceMemoSyncService { handleNewRecording(message); case 'list_result': handleListResult(message); + case 'undo_last': + unawaited(_handleUndoLast(message)); } } @@ -477,6 +479,44 @@ class VoiceMemoSyncService { }); } + /// Send AI processing result back to the watch for toast confirmation. + /// + /// The watch displays the parsed title with an Undo button for 3 seconds. + Future sendResultToWatch(String filename, String title) async { + if (!_watchService.isConnected) { + _log('Cannot send result to watch — not connected'); + return; + } + try { + await _watchService.sendVoiceMemoCommand( + 'result', + extraData: {'text': title, 'filename': filename}, + ); + _log('Sent AI result to watch: $filename → "$title"'); + } catch (e) { + _log('Failed to send result to watch: $e'); + } + } + + /// Handle the undo_last command from the watch. + /// + /// Deletes AI-parsed results (summary, category, actions) but keeps the + /// raw audio and transcription intact. + Future _handleUndoLast(Map message) async { + final filename = message['filename'] as String?; + if (filename == null || filename.isEmpty) { + _log('undo_last: missing filename'); + return; + } + _log('Undo requested for: $filename'); + try { + await _repository.clearAiResults(filename); + _log('Cleared AI results for: $filename'); + } catch (e) { + _log('Failed to undo AI results for $filename: $e'); + } + } + void _log(String message) { debugPrint('[VoiceMemoSync] $message'); } diff --git a/zswatch_app/lib/ui/navigation/app_router.dart b/zswatch_app/lib/ui/navigation/app_router.dart index 4bf1c19..4340ada 100644 --- a/zswatch_app/lib/ui/navigation/app_router.dart +++ b/zswatch_app/lib/ui/navigation/app_router.dart @@ -22,6 +22,7 @@ import '../screens/health/health_screen.dart'; import '../screens/health/heart_rate_screen.dart'; import '../screens/notifications/notification_settings_screen.dart'; import '../screens/onboarding/permission_onboarding_screen.dart'; +import '../screens/settings/ai_models_settings_screen.dart'; import '../screens/settings/settings_screen.dart'; import '../screens/start/start_page_screen.dart'; import '../screens/voice_memos/voice_memos_screen.dart'; @@ -52,6 +53,9 @@ abstract final class AppRoutes { static const String sensors = '/developer/sensors'; static const String commLog = '/developer/comm-log'; + // Settings sub-routes + static const String aiModels = '/settings/ai-models'; + // Voice routes (placeholder) static const String voiceMemos = '/voice-memos'; @@ -105,6 +109,13 @@ class AppRouter { path: AppRoutes.settings, name: 'settings', builder: (context, state) => const SettingsScreen(), + routes: [ + GoRoute( + path: 'ai-models', + name: 'ai-models', + builder: (context, state) => const AiModelsSettingsScreen(), + ), + ], ), // Firmware update diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart new file mode 100644 index 0000000..cbb4399 --- /dev/null +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -0,0 +1,1830 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../providers/ai_providers.dart'; +import '../../../providers/settings_providers.dart'; +import '../../../providers/voice_memo_providers.dart'; +import '../../../services/ai/extracted_action_creation_service.dart'; +import '../../../services/ai/llm_service.dart'; +import '../../../services/ai/model_benchmark_service.dart'; +import '../../../services/voice_memo/transcription_engine.dart'; +import '../../widgets/ai_debug_widgets.dart'; + +// --------------------------------------------------------------------------- +// Benchmark provider (screen-scoped singleton) +// --------------------------------------------------------------------------- + +final _benchmarkServiceProvider = Provider.autoDispose((ref) { + final service = ModelBenchmarkService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +final _benchmarkStateProvider = StreamProvider.autoDispose((ref) { + return ref.watch(_benchmarkServiceProvider).stateStream; +}); + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +/// Unified settings page for both Transcription and AI Processing models. +/// +/// Replaces the separate Voice Memos / AI Processing sections that were +/// previously inline in the main Settings screen. +class AiModelsSettingsScreen extends ConsumerWidget { + const AiModelsSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('AI & Transcription')), + body: ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + // ---- Transcription section ---- + const _SectionHeader( + title: 'Transcription Model', + subtitle: 'Speech-to-text engine used for voice memos', + ), + const _TranscriptionModelSelector(), + const _RetranscribeButton(), + + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- AI Processing section ---- + const _SectionHeader( + title: 'AI Processing Model', + subtitle: 'Local LLM for summarisation & classification', + ), + const _AiTogglesTile(), + const _AiModelSelector(), + + if (Platform.isAndroid) ...[ + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- Calendar section (Android only) ---- + const _SectionHeader( + title: 'Calendar Integration', + subtitle: 'Permission and calendar for AI-created events', + ), + const _CalendarPermissionTile(), + const _CalendarPickerTile(), + ], + + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- Benchmark section ---- + const _SectionHeader( + title: 'Model Benchmark', + subtitle: 'Test model performance on your device', + ), + const _BenchmarkSection(), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Common helpers +// --------------------------------------------------------------------------- + +class _SectionHeader extends StatelessWidget { + final String title; + final String subtitle; + + const _SectionHeader({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingMd, + AppTheme.spacingMd, + 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } +} + +String _formatBytes(int bytes) { + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + if (bytes >= gb) return '${(bytes / gb).toStringAsFixed(2)} GB'; + if (bytes >= mb) return '${(bytes / mb).toStringAsFixed(0)} MB'; + if (bytes >= kb) return '${(bytes / kb).toStringAsFixed(0)} KB'; + return '$bytes B'; +} + +// --------------------------------------------------------------------------- +// Transcription model selector (dropdown + download/delete) +// --------------------------------------------------------------------------- + +class _TranscriptionModelSelector extends ConsumerStatefulWidget { + const _TranscriptionModelSelector(); + + @override + ConsumerState<_TranscriptionModelSelector> createState() => + _TranscriptionModelSelectorState(); +} + +class _TranscriptionModelSelectorState + extends ConsumerState<_TranscriptionModelSelector> { + bool _isDownloading = false; + double _downloadProgress = 0; + + Future _downloadModel(TranscriptionEngineType type) async { + final info = TranscriptionModelCatalog.info(type); + final engine = createTranscriptionEngine(type); + StreamSubscription? sub; + + try { + setState(() { + _isDownloading = true; + _downloadProgress = 0; + }); + + sub = engine.stateStream.listen((state) { + if (!mounted) return; + if (state.status == TranscriptionEngineStatus.downloading) { + setState(() => _downloadProgress = state.downloadProgress); + } + }); + + await engine.initialize(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloaded ${info.name}')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $e')), + ); + } + } finally { + await sub?.cancel(); + if (mounted) setState(() => _isDownloading = false); + engine.dispose(); + _invalidateTranscription(); + } + } + + Future _deleteModel(TranscriptionEngineType type) async { + final info = TranscriptionModelCatalog.info(type); + final shouldDelete = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete model?'), + content: Text('Delete ${info.name} from local storage?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) return; + + final engine = createTranscriptionEngine(type); + try { + await engine.deleteModel(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted ${info.name}')), + ); + } + } finally { + engine.dispose(); + _invalidateTranscription(); + } + } + + void _invalidateTranscription() { + for (final type in TranscriptionEngineType.values) { + ref.invalidate(transcriptionModelStatusProvider(type)); + } + ref.invalidate(transcriptionConfiguredProvider); + ref.invalidate(transcriptionEngineProvider); + ref.invalidate(transcriptionEngineStateProvider); + } + + @override + Widget build(BuildContext context) { + final selectedType = ref.watch(transcriptionEngineTypeProvider); + + return Column( + children: [ + // Dropdown selector + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: DropdownButtonFormField( + value: selectedType, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Select model', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + items: TranscriptionModelCatalog.all.map((info) { + return DropdownMenuItem( + value: info.type, + child: Text(info.name, overflow: TextOverflow.ellipsis), + ); + }).toList(), + onChanged: _isDownloading + ? null + : (value) { + if (value == null) return; + ref + .read(transcriptionEngineTypeProvider.notifier) + .setType(value); + _invalidateTranscription(); + }, + ), + ), + + // Selected model details card + _TranscriptionModelCard( + type: selectedType, + isDownloading: _isDownloading, + downloadProgress: _downloadProgress, + onDownload: () => _downloadModel(selectedType), + onDelete: () => _deleteModel(selectedType), + ), + ], + ); + } +} + +class _TranscriptionModelCard extends ConsumerWidget { + final TranscriptionEngineType type; + final bool isDownloading; + final double downloadProgress; + final VoidCallback onDownload; + final VoidCallback onDelete; + + const _TranscriptionModelCard({ + required this.type, + required this.isDownloading, + required this.downloadProgress, + required this.onDownload, + required this.onDelete, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final info = TranscriptionModelCatalog.info(type); + final statusAsync = ref.watch(transcriptionModelStatusProvider(type)); + + return statusAsync.when( + data: (status) { + return Container( + margin: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: status.downloaded + ? AppTheme.successColor.withValues(alpha: 0.06) + : Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all( + color: status.downloaded + ? AppTheme.successColor.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status row + Row( + children: [ + Icon( + status.downloaded + ? Icons.check_circle + : Icons.cloud_download_outlined, + size: 18, + color: status.downloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + status.downloaded ? 'Downloaded' : 'Not downloaded', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: status.downloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // Info rows + _DetailRow( + label: 'Language', + value: info.language == 'auto' + ? 'Auto-detect' + : info.language.toUpperCase(), + ), + _DetailRow( + label: 'Size', + value: _formatBytes(info.expectedSizeBytes), + ), + if (status.localSizeBytes != null) + _DetailRow( + label: 'Local', + value: _formatBytes(status.localSizeBytes!), + ), + _DetailRow( + label: 'RAM needed', + value: _ramEstimate(info.expectedSizeBytes), + ), + + // Download progress + if (isDownloading) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: downloadProgress > 0 ? downloadProgress : null, + ), + const SizedBox(height: 4), + Text( + downloadProgress > 0 + ? 'Downloading... ${(downloadProgress * 100).toStringAsFixed(0)}%' + : 'Downloading...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + + const SizedBox(height: 8), + + // Action buttons + Row( + children: [ + if (!status.downloaded) + _CompactButton( + icon: isDownloading ? null : Icons.download, + label: isDownloading ? 'Downloading...' : 'Download', + onPressed: isDownloading ? null : onDownload, + showSpinner: isDownloading, + ) + else + _CompactButton( + icon: Icons.delete_outline, + label: 'Delete', + onPressed: isDownloading ? null : onDelete, + ), + const SizedBox(width: 8), + _CompactButton( + icon: Icons.open_in_new, + label: 'Source', + onPressed: () => launchUrl( + Uri.parse(info.sourceUrl), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text('Error: $e', style: const TextStyle(color: AppTheme.errorColor)), + ), + ); + } + + static String _ramEstimate(int modelSizeBytes) { + final mb = modelSizeBytes / (1024 * 1024); + if (mb < 100) return '~200 MB'; + if (mb < 200) return '~500 MB'; + if (mb < 300) return '~500 MB'; + return '~1 GB'; + } +} + +// --------------------------------------------------------------------------- +// Re-transcribe button +// --------------------------------------------------------------------------- + +class _RetranscribeButton extends ConsumerWidget { + const _RetranscribeButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final actionsState = ref.watch(voiceMemoActionsProvider); + final isBusy = actionsState.isLoading; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: isBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 18), + label: Text( + isBusy ? 'Re-transcribing...' : 'Re-transcribe all with selected model', + ), + onPressed: isBusy + ? null + : () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Re-transcribe all memos?'), + content: const Text( + 'This will overwrite existing transcriptions ' + 'using the currently selected model.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Re-transcribe'), + ), + ], + ), + ) ?? + false; + + if (!confirmed || !context.mounted) return; + + try { + final count = await ref + .read(voiceMemoActionsProvider.notifier) + .retranscribeAll(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + count == 0 + ? 'No downloaded memos to re-transcribe' + : 'Started re-transcribing $count memos', + ), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Re-transcription failed: $e')), + ); + } + } + }, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// AI Processing toggles +// --------------------------------------------------------------------------- + +class _AiTogglesTile extends ConsumerWidget { + const _AiTogglesTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAiEnabled = ref.watch(localAiEnabledProvider); + final autoProcess = ref.watch(autoProcessVoiceNotesProvider); + + return Column( + children: [ + SwitchListTile( + secondary: Icon( + Icons.auto_awesome, + color: localAiEnabled ? AppTheme.primaryColor : AppTheme.textSecondary, + ), + title: const Text('Enable Local AI'), + subtitle: const Text('Process voice notes with on-device LLM'), + value: localAiEnabled, + onChanged: (value) { + ref.read(localAiEnabledProvider.notifier).setEnabled(value); + }, + ), + Opacity( + opacity: localAiEnabled ? 1.0 : 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.autorenew, + color: autoProcess && localAiEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('Auto-process after transcription'), + subtitle: Text( + localAiEnabled + ? 'Automatically run AI after each transcription' + : 'Enable Local AI first', + ), + value: autoProcess, + onChanged: localAiEnabled + ? (value) { + ref.read(autoProcessVoiceNotesProvider.notifier).setEnabled(value); + } + : null, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// AI Model selector (dropdown + status + download/delete/import) +// --------------------------------------------------------------------------- + +class _AiModelSelector extends ConsumerStatefulWidget { + const _AiModelSelector(); + + @override + ConsumerState<_AiModelSelector> createState() => _AiModelSelectorState(); +} + +class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { + void _refreshProviders() { + ref.invalidate(llmAvailableModelsProvider); + ref.invalidate(selectedLlmModelInfoProvider); + ref.invalidate(llmModelDownloadedProvider); + ref.invalidate(llmModelSizeProvider); + ref.invalidate(llmServiceStateProvider); + } + + Future _downloadModel() async { + final llm = ref.read(llmServiceProvider); + try { + await llm.downloadModel(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Model downloaded')), + ); + } + _refreshProviders(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $e')), + ); + } + } + } + + Future _deleteModel() async { + final shouldDelete = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete model?'), + content: const Text('Delete the selected model from local storage?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) return; + + try { + await ref.read(llmServiceProvider).deleteModel(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Model deleted')), + ); + } + _refreshProviders(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + } + + Future _importModel() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + dialogTitle: 'Select a GGUF model file', + ); + final path = result?.files.single.path; + if (path == null) return; + + if (!path.toLowerCase().endsWith('.gguf')) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Only .gguf model files can be imported')), + ); + } + return; + } + + final llm = ref.read(llmServiceProvider); + final imported = await llm.importModel(path); + ref.read(selectedAiModelIdProvider.notifier).setModelId(imported.id); + _refreshProviders(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Imported ${imported.filename}')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final selectedModelId = ref.watch(selectedAiModelIdProvider); + final availableAsync = ref.watch(llmAvailableModelsProvider); + final selectedAsync = ref.watch(selectedLlmModelInfoProvider); + final downloadedAsync = ref.watch(llmModelDownloadedProvider); + final sizeAsync = ref.watch(llmModelSizeProvider); + final serviceAsync = ref.watch(llmServiceStateProvider); + + return selectedAsync.when( + data: (selectedModel) { + return downloadedAsync.when( + data: (isDownloaded) { + final localSize = sizeAsync.whenOrNull( + data: (s) => s != null ? _formatBytes(s) : null, + ); + final isDownloading = serviceAsync.whenOrNull( + data: (s) => s.status == LlmServiceStatus.downloading, + ) ?? + false; + final downloadProgress = serviceAsync.whenOrNull( + data: (s) => s.downloadProgress, + ) ?? + 0.0; + + return Column( + children: [ + // Dropdown + availableAsync.when( + data: (models) => Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: DropdownButtonFormField( + value: models.any((m) => m.id == selectedModelId) + ? selectedModelId + : models.isNotEmpty + ? models.first.id + : null, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Select model', + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + items: models + .map((m) => DropdownMenuItem( + value: m.id, + child: Text( + m.displayName, + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: isDownloading + ? null + : (value) { + if (value == null) return; + ref + .read(selectedAiModelIdProvider.notifier) + .setModelId(value); + _refreshProviders(); + }, + ), + ), + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text('Error: $e', + style: const TextStyle(color: AppTheme.errorColor)), + ), + ), + + // Status card + Container( + margin: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDownloaded + ? AppTheme.successColor.withValues(alpha: 0.06) + : Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all( + color: isDownloaded + ? AppTheme.successColor.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isDownloaded + ? Icons.check_circle + : Icons.cloud_download_outlined, + size: 18, + color: isDownloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + isDownloaded ? 'Downloaded' : 'Not downloaded', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: isDownloaded + ? AppTheme.successColor + : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + if (selectedModel.expectedSizeBytes != null) + _DetailRow( + label: 'Size', + value: _formatBytes(selectedModel.expectedSizeBytes!), + ), + if (localSize != null) + _DetailRow(label: 'Local', value: localSize), + _DetailRow( + label: 'Source', + value: selectedModel.userProvided + ? 'Imported' + : 'Catalog', + ), + + // Download progress + if (isDownloading) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: downloadProgress > 0 ? downloadProgress : null, + ), + const SizedBox(height: 4), + Text( + downloadProgress > 0 + ? 'Downloading... ${(downloadProgress * 100).toStringAsFixed(0)}%' + : 'Starting download...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + + const SizedBox(height: 8), + + // Action buttons + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (isDownloaded) + _CompactButton( + icon: Icons.delete_outline, + label: 'Delete', + onPressed: isDownloading ? null : _deleteModel, + ) + else if (selectedModel.isDownloadable) + _CompactButton( + icon: isDownloading ? null : Icons.download, + label: isDownloading ? 'Downloading...' : 'Download', + onPressed: isDownloading ? null : _downloadModel, + showSpinner: isDownloading, + ), + _CompactButton( + icon: Icons.upload_file, + label: 'Import .gguf', + onPressed: isDownloading ? null : _importModel, + ), + ], + ), + ], + ), + ), + + // Process all button + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: _ProcessAllButton(), + ), + ], + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text('Error: $e', + style: const TextStyle(color: AppTheme.errorColor)), + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppTheme.spacingMd), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text('Error: $e', + style: const TextStyle(color: AppTheme.errorColor)), + ), + ); + } +} + +class _ProcessAllButton extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAiEnabled = ref.watch(localAiEnabledProvider); + final aiActionsState = ref.watch(aiActionsProvider); + final isBusy = aiActionsState.isLoading; + + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: isBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome, size: 18), + label: Text(isBusy ? 'Processing...' : 'Process all unprocessed'), + onPressed: isBusy || !localAiEnabled + ? null + : () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Process all unprocessed?'), + content: const Text( + 'All voice memos not yet AI-processed will be processed now.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Process'), + ), + ], + ), + ) ?? + false; + + if (!confirmed || !context.mounted) return; + + try { + await ref + .read(aiActionsProvider.notifier) + .processAllUnprocessed(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Started processing unprocessed memos'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Processing failed: $e')), + ); + } + } + }, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Benchmark section (debug-sheet-style live progress) +// --------------------------------------------------------------------------- + +class _BenchmarkSection extends ConsumerStatefulWidget { + const _BenchmarkSection(); + + @override + ConsumerState<_BenchmarkSection> createState() => + _BenchmarkSectionState(); +} + +class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { + late final TextEditingController _aiInputController; + + @override + void initState() { + super.initState(); + _aiInputController = TextEditingController( + text: 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.', + ); + } + + @override + void dispose() { + _aiInputController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final benchState = ref.watch(_benchmarkStateProvider); + final isRunning = + benchState.whenOrNull(data: (s) => s.isRunning) ?? false; + final runningType = + benchState.whenOrNull(data: (s) => s.runningTestType); + + return Column( + children: [ + _AiBenchmarkInputEditor( + controller: _aiInputController, + onReset: () { + _aiInputController.text = + 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; + }, + ), + + // ---------- Run buttons ---------- + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + icon: runningType == 'transcription' + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ) + : const Icon(Icons.mic, size: 18), + label: Text(runningType == 'transcription' + ? 'Running…' + : 'Test Transcription'), + onPressed: isRunning + ? null + : () => _runTranscriptionBenchmark(context), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + icon: runningType == 'ai' + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ) + : const Icon(Icons.psychology, size: 18), + label: Text( + runningType == 'ai' ? 'Running…' : 'Test AI'), + onPressed: isRunning + ? null + : () => _runAiBenchmark(context), + ), + ), + ], + ), + ), + + // ---------- Last result summary (shown after completion) ---------- + benchState.when( + data: (state) { + final progress = state.current; + if (progress == null || !progress.isComplete) { + return Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text( + 'Run a quick test to see how fast the selected models ' + 'perform on your device.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + } + // Show a compact last-result row with a "View" button to re-open + return _LastResultTile(progress: progress); + }, + loading: () => const SizedBox.shrink(), + error: (e, _) => Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Text('Error: $e', + style: const TextStyle(color: AppTheme.errorColor)), + ), + ), + ], + ); + } + + Future _runTranscriptionBenchmark(BuildContext context) async { + // Find a real voice recording to use + final repo = ref.read(voiceMemoRepositoryProvider); + final memos = await repo.getAllMemos(); + final usable = memos.where((m) { + final path = m.convertedFilePath ?? m.localFilePath; + return path != null && File(path).existsSync(); + }).toList(); + + if (usable.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No voice recordings found — record one on the watch first.', + ), + ), + ); + } + return; + } + + final memo = usable.first; + final audioPath = memo.convertedFilePath ?? memo.localFilePath!; + + // Open debug sheet, then start the benchmark + if (context.mounted) { + _showBenchmarkSheet(context); + } + + final selectedType = ref.read(transcriptionEngineTypeProvider); + unawaited( + ref + .read(_benchmarkServiceProvider) + .benchmarkTranscription(selectedType, audioPath), + ); + } + + void _runAiBenchmark(BuildContext context) { + final benchmarkInput = _aiInputController.text.trim(); + if (benchmarkInput.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Benchmark input cannot be empty.')), + ); + return; + } + + _showBenchmarkSheet(context); + final llm = ref.read(llmServiceProvider); + unawaited( + ref.read(_benchmarkServiceProvider).benchmarkAiModel( + llm, + testInput: benchmarkInput, + ), + ); + } + + void _showBenchmarkSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.elevatedSurfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.3, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _BenchmarkDebugSheet( + scrollController: scrollController, + ), + ), + ); + } +} + +class _AiBenchmarkInputEditor extends StatelessWidget { + final TextEditingController controller; + final VoidCallback onReset; + + const _AiBenchmarkInputEditor({ + required this.controller, + required this.onReset, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'AI benchmark input', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + TextButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt, size: 16), + label: const Text('Reset'), + ), + ], + ), + Text( + 'Edit only the sample transcript here. The benchmark uses the same fixed AI prompt and chrono flow as the main app.', + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + labelText: 'Test input text', + alignLabelWithHint: true, + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ); + } +} + +/// Compact tile shown in the benchmark section after a completed run. +class _LastResultTile extends StatelessWidget { + final BenchmarkProgress progress; + const _LastResultTile({required this.progress}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isError = progress.isError; + final icon = progress.testType == 'transcription' + ? Icons.mic + : Icons.psychology; + final statusColor = + isError ? AppTheme.errorColor : AppTheme.successColor; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: statusColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + progress.modelName, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + if (progress.elapsed > Duration.zero) + Text( + isError + ? 'Failed' + : '${(progress.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s' + '${progress.tokensPerSecond != null ? ' • ${progress.tokensPerSecond!.toStringAsFixed(1)} t/s' : ''}', + style: theme.textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Bottom sheet showing live benchmark progress — uses shared debug widgets +/// from [ai_debug_widgets.dart] for visual parity with the voice-memo debug +/// sheet. +class _BenchmarkDebugSheet extends ConsumerWidget { + final ScrollController scrollController; + + const _BenchmarkDebugSheet({required this.scrollController}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final benchState = ref.watch(_benchmarkStateProvider); + final progress = + benchState.whenOrNull(data: (s) => s.current); + final isRunning = + benchState.whenOrNull(data: (s) => s.isRunning) ?? false; + + return Column( + children: [ + aiDebugHandleBar(), + aiDebugSheetHeader( + context, + title: 'Benchmark Debug', + showSpinner: progress != null && !progress.isComplete, + onStop: isRunning + ? () => ref.read(_benchmarkServiceProvider).abort() + : null, + onClose: () => Navigator.of(context).pop(), + ), + const Divider(), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: _buildBody(context, progress), + ), + ), + ], + ); + } + + List _buildBody(BuildContext context, BenchmarkProgress? progress) { + if (progress == null) { + return [aiDebugNote(context, 'Waiting for benchmark to start…')]; + } + + if (!progress.isComplete) { + // ---- Live / in-progress view ---- + final phaseText = switch (progress.phase) { + 'loading' => 'Loading model…', + 'running' => progress.testType == 'transcription' + ? 'Transcribing…' + : 'Generating…', + _ => 'Processing…', + }; + return [ + aiLivePhaseHeader( + context, + modelName: progress.modelName, + phaseText: phaseText, + tokens: progress.tokens, + tokensPerSecond: progress.tokensPerSecond, + elapsed: progress.elapsed, + ), + if (progress.partialOutput.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: progress.testType == 'transcription' + ? 'Transcription Status' + : 'LLM Output (live)', + content: progress.partialOutput, + icon: progress.testType == 'transcription' + ? Icons.mic + : Icons.code, + mono: progress.testType == 'ai', + showCopyButton: true, + ), + ], + ]; + } + + // ---- Completed view ---- + return [ + aiCompletedHeader( + context, + modelName: progress.modelName, + isError: progress.isError, + tokens: progress.tokens, + tokensPerSecond: progress.tokensPerSecond, + elapsed: progress.elapsed, + ), + if (progress.testType == 'ai') ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Prompt / Flow', + content: aiFormatPromptFlow( + strategy: progress.promptStrategy, + retryEnabled: progress.retryEnabled, + attempts: progress.attempts, + ), + icon: Icons.tune, + showCopyButton: true, + ), + ], + if (progress.testType == 'ai' && + aiHasChronoDetails( + extractedIntent: progress.extractedIntent, + extractedTitle: progress.extractedTitle, + datetimeExpressionOriginal: progress.datetimeExpressionOriginal, + datetimeExpressionEnglish: progress.datetimeExpressionEnglish, + resolvedDateTime: progress.resolvedDateTime, + resolverMethod: progress.resolverMethod, + )) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Chrono Extraction / Resolution', + content: aiFormatChronoDetails( + extractedIntent: progress.extractedIntent, + extractedTitle: progress.extractedTitle, + datetimeExpressionOriginal: progress.datetimeExpressionOriginal, + datetimeExpressionEnglish: progress.datetimeExpressionEnglish, + resolvedDateTime: progress.resolvedDateTime, + resolverMethod: progress.resolverMethod, + ), + icon: Icons.schedule, + showCopyButton: true, + ), + ], + // Show parsed summary for AI tests + if (progress.partialOutput.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: progress.testType == 'transcription' + ? 'Transcription Result' + : 'Parsed Result', + content: progress.partialOutput, + icon: progress.testType == 'transcription' + ? Icons.mic + : Icons.check_circle_outline, + showCopyButton: true, + ), + ], + if (progress.parsedJson != null && progress.parsedJson!.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Parsed JSON', + content: progress.parsedJson!, + icon: Icons.data_object, + mono: true, + showCopyButton: true, + ), + ], + // Show full raw LLM output for AI tests (preserved after completion) + if (progress.rawOutput != null && + progress.rawOutput!.isNotEmpty && + progress.rawOutput != progress.partialOutput) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Raw LLM Output', + content: progress.rawOutput!, + icon: Icons.code, + mono: true, + showCopyButton: true, + ), + ], + if (progress.isError && progress.error != null) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Error', + content: progress.error!, + icon: Icons.error_outline, + showCopyButton: true, + ), + ], + ]; + } +} + +// --------------------------------------------------------------------------- +// Shared small widgets +// --------------------------------------------------------------------------- + +class _DetailRow extends StatelessWidget { + final String label; + final String value; + + const _DetailRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + children: [ + SizedBox( + width: 90, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} + +class _CompactButton extends StatelessWidget { + final IconData? icon; + final String label; + final VoidCallback? onPressed; + final bool showSpinner; + + const _CompactButton({ + this.icon, + required this.label, + this.onPressed, + this.showSpinner = false, + }); + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onPressed, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + icon: showSpinner + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : icon != null + ? Icon(icon, size: 16) + : const SizedBox.shrink(), + label: Text(label), + ); + } +} + +// --------------------------------------------------------------------------- +// Calendar Integration (Android only) +// --------------------------------------------------------------------------- + +/// Shows calendar permission status and a button to grant it. +class _CalendarPermissionTile extends ConsumerStatefulWidget { + const _CalendarPermissionTile(); + + @override + ConsumerState<_CalendarPermissionTile> createState() => + _CalendarPermissionTileState(); +} + +class _CalendarPermissionTileState + extends ConsumerState<_CalendarPermissionTile> + with WidgetsBindingObserver { + bool _granted = false; + bool _checking = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _checkPermission(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkPermission(); + } + } + + Future _checkPermission() async { + final status = await Permission.calendarFullAccess.status; + if (mounted) { + setState(() { + _granted = status.isGranted; + _checking = false; + }); + } + } + + Future _requestPermission() async { + final status = await Permission.calendarFullAccess.request(); + if (status.isPermanentlyDenied && mounted) { + await openAppSettings(); + } + await _checkPermission(); + if (_granted) { + // Refresh calendar list now that permission is granted + ref.invalidate(writableCalendarsProvider); + } + } + + @override + Widget build(BuildContext context) { + if (_checking) { + return const ListTile( + leading: Icon(Icons.hourglass_empty, color: AppTheme.textSecondary), + title: Text('Calendar Permission'), + subtitle: Text('Checking...'), + ); + } + + return ListTile( + leading: Icon( + _granted ? Icons.check_circle : Icons.calendar_month, + color: _granted ? AppTheme.successColor : AppTheme.warningColor, + ), + title: const Text('Calendar Permission'), + subtitle: Text( + _granted + ? 'Granted — AI can create calendar events and reminders' + : 'Required for creating events from voice memos', + ), + trailing: _granted + ? null + : FilledButton( + onPressed: _requestPermission, + child: const Text('Grant'), + ), + ); + } +} + +/// Shows the selected calendar and allows picking a different one. +/// Only visible when calendar permission is granted. +class _CalendarPickerTile extends ConsumerWidget { + const _CalendarPickerTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final calendarsAsync = ref.watch(writableCalendarsProvider); + final selectedCalendarId = ref.watch(selectedProductivityCalendarIdProvider); + + return calendarsAsync.when( + data: (calendars) { + if (calendars.isEmpty) { + return const ListTile( + leading: Icon(Icons.calendar_today, color: AppTheme.textSecondary), + title: Text('Default Calendar'), + subtitle: Text( + 'No writable calendars found. Grant calendar permission above.', + ), + ); + } + + final selectedCalendar = calendars + .where((c) => c.id == selectedCalendarId) + .cast() + .firstWhere((c) => c != null, orElse: () => calendars.first); + + final cal = selectedCalendar ?? calendars.first; + + return ListTile( + leading: Icon( + cal.looksLocal ? Icons.event_busy : Icons.calendar_today, + color: + cal.looksLocal ? AppTheme.warningColor : AppTheme.primaryColor, + ), + title: const Text('Default Calendar'), + subtitle: Text( + cal.looksLocal + ? '${cal.label}\nLocal calendars may not sync to cloud.' + : cal.label, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showPicker(context, ref, calendars), + ); + }, + loading: () => const ListTile( + leading: Icon(Icons.calendar_today, color: AppTheme.textSecondary), + title: Text('Default Calendar'), + subtitle: Text('Loading calendars...'), + ), + error: (error, _) => ListTile( + leading: + const Icon(Icons.calendar_today, color: AppTheme.warningColor), + title: const Text('Default Calendar'), + subtitle: Text('Error: $error'), + trailing: IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(writableCalendarsProvider), + ), + ), + ); + } + + Future _showPicker( + BuildContext context, + WidgetRef ref, + List calendars, + ) async { + final selectedId = ref.read(selectedProductivityCalendarIdProvider); + final picked = await showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text('Choose Calendar'), + subtitle: + Text('Used for events and reminders created by AI.'), + ), + for (final calendar in calendars) + ListTile( + leading: Icon( + calendar.id == selectedId + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: calendar.id == selectedId + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: Text(calendar.label), + subtitle: calendar.looksLocal + ? const Text('Local — may not sync to cloud') + : null, + onTap: () => Navigator.of(context).pop(calendar.id), + ), + ], + ), + ); + }, + ); + + if (picked != null) { + ref + .read(selectedProductivityCalendarIdProvider.notifier) + .setCalendarId(picked); + } + } +} diff --git a/zswatch_app/lib/ui/screens/settings/settings_screen.dart b/zswatch_app/lib/ui/screens/settings/settings_screen.dart index 06cd734..475627e 100644 --- a/zswatch_app/lib/ui/screens/settings/settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/settings_screen.dart @@ -1,7 +1,5 @@ -import 'dart:async'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -15,9 +13,8 @@ import '../../../providers/ai_providers.dart'; import '../../../providers/demo_mode_provider.dart'; import '../../../providers/permission_providers.dart'; import '../../../providers/settings_providers.dart'; -import '../../../providers/voice_memo_providers.dart'; -import '../../../services/ai/llm_service.dart'; import '../../../services/voice_memo/transcription_engine.dart'; +import '../../navigation/app_router.dart'; import '../onboarding/permission_onboarding_screen.dart'; /// Settings screen for app configuration @@ -111,15 +108,11 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 32), - // Voice Memos / Transcription Settings - _SectionHeader(title: 'Voice Memos'), - _TranscriptionModelsSection(), - const Divider(height: 32), - // AI Processing Section - _SectionHeader(title: 'AI Processing'), - _AiProcessingSection(), + // AI & Transcription Models (sub-page) + _SectionHeader(title: 'AI & Transcription'), + _AiTranscriptionNavTile(), const Divider(height: 32), @@ -373,403 +366,26 @@ class _InfoRow extends StatelessWidget { } } -class _TranscriptionModelsSection extends ConsumerWidget { - const _TranscriptionModelsSection(); - +/// Compact summary tile that navigates to the consolidated AI & Transcription +/// models sub-page. +class _AiTranscriptionNavTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedType = ref.watch(transcriptionEngineTypeProvider); - final actionsState = ref.watch(voiceMemoActionsProvider); - final isBusy = actionsState.isLoading; - - return Column( - children: [ - for (final info in TranscriptionModelCatalog.all) - _TranscriptionModelTile( - info: info, - isSelected: selectedType == info.type, - ), - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - icon: isBusy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - label: Text(isBusy - ? 'Re-transcribing...' - : 'Re-transcribe all with selected model'), - onPressed: isBusy - ? null - : () async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Re-transcribe all memos?'), - content: const Text( - 'This will overwrite existing transcriptions ' - 'using the currently selected language/model.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Re-transcribe'), - ), - ], - ), - ) ?? - false; - - if (!confirmed || !context.mounted) return; - - try { - final count = await ref - .read(voiceMemoActionsProvider.notifier) - .retranscribeAll(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - count == 0 - ? 'No downloaded memos to re-transcribe' - : 'Started re-transcribing $count memos', - ), - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Re-transcription failed: $e'), - ), - ); - } - } - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: Text( - 'Use this after changing the language/model to regenerate old transcriptions.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - ], - ); - } -} - -class _TranscriptionModelTile extends ConsumerStatefulWidget { - final TranscriptionModelInfo info; - final bool isSelected; - - const _TranscriptionModelTile({ - required this.info, - required this.isSelected, - }); - - @override - ConsumerState<_TranscriptionModelTile> createState() => - _TranscriptionModelTileState(); -} - -class _TranscriptionModelTileState extends ConsumerState<_TranscriptionModelTile> { - bool _isDownloading = false; - double _downloadProgress = 0; - - void _selectModel(WidgetRef ref) { - ref - .read(transcriptionEngineTypeProvider.notifier) - .setType(widget.info.type); - ref.invalidate(transcriptionConfiguredProvider); - } - - static String _formatBytes(int bytes) { - const kb = 1024; - const mb = kb * 1024; - if (bytes >= mb) { - return '${(bytes / mb).toStringAsFixed(1)} MB'; - } - if (bytes >= kb) { - return '${(bytes / kb).toStringAsFixed(1)} KB'; - } - return '$bytes B'; - } - - Future _downloadModel(BuildContext context, WidgetRef ref) async { - final engine = createTranscriptionEngine(widget.info.type); - StreamSubscription? sub; - - try { - if (mounted) { - setState(() { - _isDownloading = true; - _downloadProgress = 0; - }); - } - - sub = engine.stateStream.listen((state) { - if (!mounted) return; - if (state.status == TranscriptionEngineStatus.downloading) { - setState(() { - _isDownloading = true; - _downloadProgress = state.downloadProgress; - }); - } - }); - - await engine.initialize(); - - final downloaded = await engine.isAvailable(); - if (!downloaded) { - throw Exception('Model file not found after download'); - } - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Downloaded ${widget.info.name}')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Download failed: $e')), - ); - } - } finally { - await sub?.cancel(); - if (mounted) { - setState(() { - _isDownloading = false; - _downloadProgress = 0; - }); - } - - engine.dispose(); - ref.invalidate(transcriptionModelStatusProvider(widget.info.type)); - ref.invalidate(transcriptionConfiguredProvider); - ref.invalidate(transcriptionEngineProvider); - ref.invalidate(transcriptionEngineStateProvider); - } - } - - Future _deleteModel(BuildContext context, WidgetRef ref) async { - final shouldDelete = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete model?'), - content: Text('Delete ${widget.info.name} from local storage?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Delete'), - ), - ], - ), - ) ?? - false; - - if (!shouldDelete) return; - - final engine = createTranscriptionEngine(widget.info.type); - try { - await engine.deleteModel(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Deleted ${widget.info.name}')), + final transcriptionType = ref.watch(transcriptionEngineTypeProvider); + final transcriptionInfo = TranscriptionModelCatalog.info(transcriptionType); + final localAiEnabled = ref.watch(localAiEnabledProvider); + final aiModelName = ref.watch(selectedLlmModelInfoProvider).whenOrNull( + data: (m) => m.displayName, ); - } - } finally { - engine.dispose(); - ref.invalidate(transcriptionModelStatusProvider(widget.info.type)); - ref.invalidate(transcriptionConfiguredProvider); - ref.invalidate(transcriptionEngineProvider); - ref.invalidate(transcriptionEngineStateProvider); - } - } - - @override - Widget build(BuildContext context) { - final statusAsync = ref.watch(transcriptionModelStatusProvider(widget.info.type)); - - return statusAsync.when( - data: (status) { - final downloadedSize = status.localSizeBytes != null - ? _formatBytes(status.localSizeBytes!) - : 'Not downloaded'; - return Column( - children: [ - ListTile( - onTap: () => _selectModel(ref), - leading: Icon( - Icons.memory, - color: widget.isSelected ? AppTheme.primaryColor : AppTheme.textSecondary, - ), - title: Text(widget.info.name), - subtitle: Text( - 'Language: ${widget.info.language.toUpperCase()}\n' - 'Size: ${_formatBytes(widget.info.expectedSizeBytes)}\n' - 'Local: $downloadedSize', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - isThreeLine: true, - trailing: Checkbox( - value: widget.isSelected, - onChanged: (_) => _selectModel(ref), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(AppTheme.spacingSm), - decoration: BoxDecoration( - color: Colors.black12, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Source URL', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 4), - SelectableText( - widget.info.sourceUrl, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ), - if (_isDownloading) - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: AppTheme.spacingSm), - LinearProgressIndicator( - value: _downloadProgress > 0 ? _downloadProgress : null, - ), - const SizedBox(height: 4), - Text( - _downloadProgress > 0 - ? 'Downloading... ${(_downloadProgress * 100).toStringAsFixed(0)}%' - : 'Downloading...', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(height: AppTheme.spacingSm), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (!status.downloaded) - TextButton.icon( - onPressed: _isDownloading - ? null - : () => _downloadModel(context, ref), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - minimumSize: const Size(48, 32), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - icon: _isDownloading - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download, size: 16), - label: Text(_isDownloading ? 'Downloading' : 'Download'), - ) - else - TextButton.icon( - onPressed: _isDownloading - ? null - : () => _deleteModel(context, ref), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - minimumSize: const Size(48, 32), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - icon: const Icon(Icons.delete_outline, size: 16), - label: const Text('Delete'), - ), - TextButton.icon( - onPressed: () => launchUrl( - Uri.parse(widget.info.sourceUrl), - mode: LaunchMode.externalApplication, - ), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - minimumSize: const Size(48, 32), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - icon: const Icon(Icons.open_in_new, size: 16), - label: const Text('Open source'), - ), - ], - ), - ), - const SizedBox(height: AppTheme.spacingSm), - ], - ); - }, - loading: () => const ListTile( - leading: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - title: Text('Loading model info...'), - ), - error: (e, _) => ListTile( - leading: const Icon(Icons.error, color: AppTheme.errorColor), - title: Text(widget.info.name), - subtitle: Text('Error loading model status: $e'), - ), + return _SettingsTile( + leading: const Icon(Icons.psychology, color: AppTheme.primaryColor), + title: 'Model Configuration', + subtitle: + 'Transcription: ${transcriptionInfo.name}\n' + 'AI: ${localAiEnabled ? (aiModelName ?? 'Loading...') : 'Disabled'}', + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(AppRoutes.aiModels), ); } } @@ -918,572 +534,3 @@ class _QuickPermissionRow extends StatelessWidget { ); } } - -/// AI Processing section -class _AiProcessingSection extends ConsumerWidget { - const _AiProcessingSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final localAiEnabled = ref.watch(localAiEnabledProvider); - final autoProcessEnabled = ref.watch(autoProcessVoiceNotesProvider); - final aiActionsState = ref.watch(aiActionsProvider); - final isBusy = aiActionsState.isLoading; - - return Column( - children: [ - // Local AI Processing toggle - _SettingsTile( - leading: Icon( - Icons.auto_awesome, - color: localAiEnabled ? AppTheme.primaryColor : AppTheme.textSecondary, - ), - title: 'Local AI Processing', - subtitle: 'Enable AI processing of voice notes', - trailing: Switch( - value: localAiEnabled, - onChanged: (value) { - ref.read(localAiEnabledProvider.notifier).setEnabled(value); - }, - ), - ), - - // Auto-process after transcription toggle - Opacity( - opacity: localAiEnabled ? 1.0 : 0.5, - child: _SettingsTile( - leading: Icon( - Icons.autorenew, - color: autoProcessEnabled && localAiEnabled - ? AppTheme.primaryColor - : AppTheme.textSecondary, - ), - title: 'Auto-process after transcription', - subtitle: localAiEnabled - ? 'Automatically process voice notes after transcription' - : 'Enable Local AI Processing first', - trailing: Switch( - value: autoProcessEnabled, - onChanged: localAiEnabled - ? (value) { - ref - .read(autoProcessVoiceNotesProvider.notifier) - .setEnabled(value); - } - : null, - ), - ), - ), - - // LLM Model tile - const _LlmModelTile(), - - // Process all unprocessed button - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - icon: isBusy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.auto_awesome), - label: Text(isBusy - ? 'Processing...' - : 'Process all unprocessed'), - onPressed: isBusy || !localAiEnabled - ? null - : () async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Process all unprocessed memos?'), - content: const Text( - 'This will process all voice memos that have not yet been processed with AI.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Process'), - ), - ], - ), - ) ?? - false; - - if (!confirmed || !context.mounted) return; - - try { - await ref - .read(aiActionsProvider.notifier) - .processAllUnprocessed(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Started processing unprocessed memos'), - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Processing failed: $e'), - ), - ); - } - } - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: Text( - 'Process voice memos with AI to extract tasks, summaries, and more.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - ], - ); - } -} - -/// LLM Model tile showing download status -class _LlmModelTile extends ConsumerStatefulWidget { - const _LlmModelTile(); - - @override - ConsumerState<_LlmModelTile> createState() => _LlmModelTileState(); -} - -class _LlmModelTileState extends ConsumerState<_LlmModelTile> { - static String _formatBytes(int bytes) { - const kb = 1024; - const mb = kb * 1024; - const gb = mb * 1024; - if (bytes >= gb) { - return '${(bytes / gb).toStringAsFixed(2)} GB'; - } - if (bytes >= mb) { - return '${(bytes / mb).toStringAsFixed(1)} MB'; - } - if (bytes >= kb) { - return '${(bytes / kb).toStringAsFixed(1)} KB'; - } - return '$bytes B'; - } - - void _refreshModelProviders() { - ref.invalidate(llmAvailableModelsProvider); - ref.invalidate(selectedLlmModelInfoProvider); - ref.invalidate(llmModelDownloadedProvider); - ref.invalidate(llmModelSizeProvider); - ref.invalidate(llmServiceStateProvider); - } - - Future _downloadModel(BuildContext context) async { - final llmService = ref.read(llmServiceProvider); - - try { - await llmService.downloadModel(); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Model downloaded successfully')), - ); - } - - _refreshModelProviders(); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Download failed: $e')), - ); - } - } - } - - Future _deleteModel(BuildContext context) async { - final selectedModel = await ref.read(selectedLlmModelInfoProvider.future); - final shouldDelete = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete model?'), - content: Text( - selectedModel.userProvided - ? 'Delete this imported model from local storage?' - : 'Delete the selected model from local storage?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Delete'), - ), - ], - ), - ) ?? - false; - - if (!shouldDelete) return; - - final llmService = ref.read(llmServiceProvider); - - try { - await llmService.deleteModel(); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Model deleted')), - ); - } - - _refreshModelProviders(); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Delete failed: $e')), - ); - } - } - } - - Future _importModel(BuildContext context) async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.any, - dialogTitle: 'Select a GGUF model file', - ); - - final path = result?.files.single.path; - if (path == null) { - return; - } - - if (!path.toLowerCase().endsWith('.gguf')) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Only .gguf model files can be imported'), - ), - ); - } - return; - } - - final llmService = ref.read(llmServiceProvider); - final imported = await llmService.importModel(path); - ref.read(selectedAiModelIdProvider.notifier).setModelId(imported.id); - _refreshModelProviders(); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Imported ${imported.filename}')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import failed: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final selectedModelId = ref.watch(selectedAiModelIdProvider); - final availableModelsAsync = ref.watch(llmAvailableModelsProvider); - final selectedModelAsync = ref.watch(selectedLlmModelInfoProvider); - final isDownloadedAsync = ref.watch(llmModelDownloadedProvider); - final modelSizeAsync = ref.watch(llmModelSizeProvider); - final serviceStateAsync = ref.watch(llmServiceStateProvider); - - return selectedModelAsync.when( - data: (selectedModel) { - return isDownloadedAsync.when( - data: (isDownloaded) { - final localSize = modelSizeAsync.when( - data: (size) => size != null ? _formatBytes(size) : null, - loading: () => null, - error: (_, __) => null, - ); - - final isDownloading = serviceStateAsync.when( - data: (state) => state.status == LlmServiceStatus.downloading, - loading: () => false, - error: (_, __) => false, - ); - - final downloadProgress = serviceStateAsync.when( - data: (state) => state.downloadProgress, - loading: () => 0.0, - error: (_, __) => 0.0, - ); - - final canDownload = selectedModel.isDownloadable && !selectedModel.userProvided; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // --- Active model status banner --- - Container( - margin: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDownloaded - ? AppTheme.successColor.withValues(alpha: 0.08) - : AppTheme.warningColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - border: Border.all( - color: isDownloaded - ? AppTheme.successColor.withValues(alpha: 0.3) - : AppTheme.warningColor.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - isDownloaded ? Icons.check_circle : Icons.warning_amber, - size: 20, - color: isDownloaded - ? AppTheme.successColor - : AppTheme.warningColor, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isDownloaded ? 'Active model' : 'Model not downloaded', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: isDownloaded - ? AppTheme.successColor - : AppTheme.warningColor, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - selectedModel.displayName, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - if (localSize != null) - Text( - localSize, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ), - - // --- Model selector dropdown --- - availableModelsAsync.when( - data: (models) => Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - 12, - AppTheme.spacingMd, - 0, - ), - child: DropdownButtonFormField( - value: models.any((m) => m.id == selectedModelId) - ? selectedModelId - : models.isNotEmpty - ? models.first.id - : null, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Change model', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - items: models - .map( - (model) => DropdownMenuItem( - value: model.id, - child: Text( - model.displayName, - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - onChanged: isDownloading - ? null - : (value) { - if (value == null) return; - ref - .read(selectedAiModelIdProvider.notifier) - .setModelId(value); - _refreshModelProviders(); - }, - ), - ), - loading: () => const Padding( - padding: EdgeInsets.all(AppTheme.spacingMd), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Text('Error loading models: $e', - style: const TextStyle(color: AppTheme.errorColor)), - ), - ), - - // --- Download progress --- - if (isDownloading) - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - 12, - AppTheme.spacingMd, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LinearProgressIndicator( - value: downloadProgress > 0 ? downloadProgress : null, - ), - const SizedBox(height: 4), - Text( - downloadProgress > 0 - ? 'Downloading... ${(downloadProgress * 100).toStringAsFixed(0)}%' - : 'Starting download...', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - - // --- Action buttons --- - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - 12, - AppTheme.spacingMd, - AppTheme.spacingSm, - ), - child: Row( - children: [ - // Primary action: Download or Delete for the selected model - if (isDownloaded) - OutlinedButton.icon( - onPressed: isDownloading - ? null - : () => _deleteModel(context), - icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Delete'), - ) - else if (canDownload) - FilledButton.icon( - onPressed: isDownloading - ? null - : () => _downloadModel(context), - icon: isDownloading - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.download, size: 18), - label: Text( - isDownloading ? 'Downloading...' : 'Download'), - ), - const SizedBox(width: AppTheme.spacingSm), - // Secondary action: Import a custom GGUF - OutlinedButton.icon( - onPressed: isDownloading - ? null - : () => _importModel(context), - icon: const Icon(Icons.upload_file, size: 18), - label: const Text('Import .gguf'), - ), - ], - ), - ), - ], - ); - }, - loading: () => const ListTile( - leading: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - title: Text('Loading model status...'), - ), - error: (e, _) => ListTile( - leading: const Icon(Icons.error, color: AppTheme.errorColor), - title: Text(selectedModel.displayName), - subtitle: Text('Error loading model status: $e'), - ), - ); - }, - loading: () => const ListTile( - leading: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - title: Text('Loading model info...'), - ), - error: (e, _) => ListTile( - leading: const Icon(Icons.error, color: AppTheme.errorColor), - title: const Text('AI model'), - subtitle: Text('Error loading model status: $e'), - ), - ); - } -} \ No newline at end of file diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart index c00e937..a883dfb 100644 --- a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -14,10 +14,12 @@ import '../../../providers/ai_providers.dart'; import '../../../providers/settings_providers.dart'; import '../../../providers/voice_memo_providers.dart'; import '../../../providers/watch_service_provider.dart'; +import '../../../services/ai/extracted_action_creation_service.dart'; import '../../../services/ai/voice_note_ai_pipeline.dart'; import '../../../services/voice_memo/transcription_engine.dart'; import '../../../services/voice_memo/voice_memo_sync_service.dart'; import '../../navigation/app_router.dart'; +import '../../widgets/ai_debug_widgets.dart'; /// Transcript-first timeline view for synced voice notes. class VoiceMemosScreen extends ConsumerStatefulWidget { @@ -500,12 +502,21 @@ class _AISummarySection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final aiEnabled = ref.watch(localAiEnabledProvider); final modelDownloadedAsync = ref.watch(llmModelDownloadedProvider); - if (!aiEnabled) { - return const SizedBox.shrink(); - } + ref.listen>(aiActionsProvider, (previous, next) { + next.whenOrNull( + error: (error, _) { + final message = error is StateError + ? error.message.toString() + : error.toString(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + }, + ); + }); return modelDownloadedAsync.when( data: (modelDownloaded) { @@ -514,6 +525,7 @@ class _AISummarySection extends ConsumerWidget { } final hasSummary = memo.summary != null && memo.summary!.isNotEmpty; + final hasTranscript = memo.transcription?.trim().isNotEmpty == true; final hasCategory = memo.aiCategory != null; final isProcessing = memo.isAiProcessing; final hasFailed = memo.aiProcessingStatus == VoiceNoteProcessingStatus.failed; @@ -533,7 +545,7 @@ class _AISummarySection extends ConsumerWidget { const SizedBox(height: 12), OutlinedButton.icon( style: _compactOutlinedButtonStyle(), - onPressed: memo.transcription?.trim().isEmpty == true + onPressed: !hasTranscript ? null : () => ref .read(aiActionsProvider.notifier) @@ -541,7 +553,7 @@ class _AISummarySection extends ConsumerWidget { icon: const Icon(Icons.auto_awesome), label: const Text('Process with AI'), ), - if (memo.transcription?.trim().isEmpty == true) + if (!hasTranscript) Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -593,11 +605,39 @@ class _AISummarySection extends ConsumerWidget { ), ) else if (hasFailed) - Text( - 'AI processing failed. Please try again.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.errorColor, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI processing failed. Please try again.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.errorColor, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: !hasTranscript + ? null + : () => ref + .read(aiActionsProvider.notifier) + .processVoiceMemo(memo.filename), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: () => _showAiDebugDialog(context, ref), + icon: const Icon(Icons.bug_report_outlined), + label: const Text('Debug'), + ), + ], + ), + ], ) else ...[ if (hasCategory) @@ -613,7 +653,7 @@ class _AISummarySection extends ConsumerWidget { ), ), ], - if (!isProcessing && (hasSummary || hasCategory)) + if (!isProcessing && (hasSummary || hasCategory || hasFailed)) Padding( padding: const EdgeInsets.only(top: 12), child: Wrap( @@ -701,141 +741,185 @@ class _AiDebugSheet extends ConsumerWidget { .getDebugInfoForFile(memo.filename); // Prefer live (in-progress or just-completed) over stored final debugInfo = liveInfo ?? storedInfo; - final theme = Theme.of(context); return Column( children: [ - // Handle bar - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textSecondary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - const Icon(Icons.bug_report_outlined, size: 20), - const SizedBox(width: 8), - Text('AI Debug Info', style: theme.textTheme.titleMedium), - const Spacer(), - if (debugInfo != null && !debugInfo.isComplete) - Padding( - padding: const EdgeInsets.only(right: 8), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppTheme.primaryColor, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), + aiDebugHandleBar(), + aiDebugSheetHeader( + context, + title: 'AI Debug Info', + showSpinner: debugInfo != null && !debugInfo.isComplete, + onClose: () => Navigator.of(context).pop(), ), const Divider(), Expanded( child: ListView( controller: scrollController, padding: const EdgeInsets.all(16), - children: [ - if (debugInfo == null) ...[ - _debugNote( - context, - 'No debug data available for the latest run. ' - 'Re-process this memo to see debug info.', - ), - const SizedBox(height: 16), - _debugInfoFromMemo(context), - ] else if (!debugInfo.isComplete) ...[ - // --- Live / in-progress view --- - _livePhaseHeader(context, debugInfo), - const SizedBox(height: 12), - if (debugInfo.originalTranscription != null) ...[ - _debugBlock( - context, - title: 'Original Transcription', - content: debugInfo.originalTranscription!, - icon: Icons.mic, - ), - const SizedBox(height: 12), - ], - // Only show the partial-response block once tokens are flowing - if (debugInfo.currentPhase != 'loading') - _debugBlock( - context, - title: '${_phaseLabel(debugInfo.currentPhase)} (live)', - content: debugInfo.partialResponse.isEmpty - ? '...' - : debugInfo.partialResponse, - icon: debugInfo.currentPhase == 'correcting' - ? Icons.auto_fix_high - : Icons.code, - mono: debugInfo.currentPhase == 'classifying', - ), - ] else ...[ - // --- Completed view --- - _metricsRow(context, debugInfo), - const SizedBox(height: 16), - if (debugInfo.originalTranscription != null && - debugInfo.correctedTranscription != null && - debugInfo.correctedTranscription != - debugInfo.originalTranscription) ...[ - _transcriptionDiffBlock( - context, - original: debugInfo.originalTranscription!, - corrected: debugInfo.correctedTranscription!, - ), - const SizedBox(height: 12), - ] else if (debugInfo.originalTranscription != null) ...[ - _debugBlock( - context, - title: 'Transcription', - content: debugInfo.originalTranscription!, - icon: Icons.mic, - ), - const SizedBox(height: 12), - ], - if (debugInfo.rawLlmResponse != null) ...[ - _debugBlock( - context, - title: 'Raw LLM Response', - content: debugInfo.rawLlmResponse!, - icon: Icons.code, - mono: true, - ), - const SizedBox(height: 12), - ], - if (debugInfo.parsedJson != null) ...[ - _debugBlock( - context, - title: 'Parsed JSON', - content: debugInfo.parsedJson!, - icon: Icons.data_object, - mono: true, - ), - const SizedBox(height: 12), - ], - _resultRow(context, debugInfo), - ], - ], + children: _buildBody(context, debugInfo), ), ), ], ); } + List _buildBody( + BuildContext context, AiProcessingDebugInfo? debugInfo) { + if (debugInfo == null) { + return [ + aiDebugNote( + context, + 'No debug data available for the latest run. ' + 'Re-process this memo to see debug info.', + ), + const SizedBox(height: 16), + _debugInfoFromMemo(context), + ]; + } + + if (!debugInfo.isComplete) { + // --- Live / in-progress view --- + final phaseText = switch (debugInfo.currentPhase) { + 'loading' => 'Loading model...', + 'correcting' => 'Correcting transcription...', + 'classifying' => 'Classifying & summarizing...', + _ => 'Processing...', + }; + return [ + aiLivePhaseHeader( + context, + modelName: debugInfo.modelName, + phaseText: phaseText, + tokens: debugInfo.liveTokenCount, + tokensPerSecond: debugInfo.liveTokensPerSecond, + elapsed: debugInfo.liveElapsed, + ), + if (debugInfo.originalTranscription != null) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Original Transcription', + content: debugInfo.originalTranscription!, + icon: Icons.mic, + showCopyButton: true, + ), + ], + // Only show the partial-response block once tokens are flowing + if (debugInfo.currentPhase != 'loading') ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: '${_phaseLabel(debugInfo.currentPhase)} (live)', + content: debugInfo.partialResponse.isEmpty + ? '...' + : debugInfo.partialResponse, + icon: debugInfo.currentPhase == 'correcting' + ? Icons.auto_fix_high + : Icons.code, + mono: debugInfo.currentPhase == 'classifying', + showCopyButton: true, + ), + ], + ]; + } + + // --- Completed view --- + return [ + _metricsRow(context, debugInfo), + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Prompt / Flow', + content: aiFormatPromptFlow( + strategy: debugInfo.classifyPromptStrategy, + retryEnabled: debugInfo.retryEnabled, + attempts: debugInfo.classifyAttempts ?? 1, + ), + icon: Icons.tune, + showCopyButton: true, + ), + if (aiHasChronoDetails( + extractedIntent: debugInfo.extractedIntent, + extractedTitle: debugInfo.extractedTitle, + datetimeExpressionOriginal: debugInfo.datetimeExpressionOriginal, + datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, + resolvedDateTime: debugInfo.resolvedDateTime, + resolverMethod: debugInfo.resolverMethod, + )) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Chrono Extraction / Resolution', + content: aiFormatChronoDetails( + extractedIntent: debugInfo.extractedIntent, + extractedTitle: debugInfo.extractedTitle, + datetimeExpressionOriginal: debugInfo.datetimeExpressionOriginal, + datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, + resolvedDateTime: debugInfo.resolvedDateTime, + resolverMethod: debugInfo.resolverMethod, + ), + icon: Icons.schedule, + showCopyButton: true, + ), + ], + const SizedBox(height: 16), + if (debugInfo.originalTranscription != null && + debugInfo.correctedTranscription != null && + debugInfo.correctedTranscription != + debugInfo.originalTranscription) ...[ + _transcriptionDiffBlock( + context, + original: debugInfo.originalTranscription!, + corrected: debugInfo.correctedTranscription!, + ), + const SizedBox(height: 12), + ] else if (debugInfo.originalTranscription != null) ...[ + aiDebugBlock( + context, + title: 'Transcription', + content: debugInfo.originalTranscription!, + icon: Icons.mic, + showCopyButton: true, + ), + const SizedBox(height: 12), + ], + if (debugInfo.rawLlmResponse != null) ...[ + aiDebugBlock( + context, + title: 'Raw LLM Response', + content: debugInfo.rawLlmResponse!, + icon: Icons.code, + mono: true, + showCopyButton: true, + ), + const SizedBox(height: 12), + ], + if (debugInfo.parsedJson != null) ...[ + aiDebugBlock( + context, + title: 'Parsed JSON', + content: debugInfo.parsedJson!, + icon: Icons.data_object, + mono: true, + showCopyButton: true, + ), + const SizedBox(height: 12), + ], + if (debugInfo.classifyPrompt != null) ...[ + aiDebugBlock( + context, + title: 'Prompt Sent', + content: debugInfo.classifyPrompt!, + icon: Icons.text_snippet_outlined, + mono: true, + showCopyButton: true, + ), + const SizedBox(height: 12), + ], + _resultRow(context, debugInfo), + ]; + } + String _phaseLabel(String? phase) { switch (phase) { case 'correcting': @@ -847,92 +931,6 @@ class _AiDebugSheet extends ConsumerWidget { } } - Widget _livePhaseHeader(BuildContext context, AiProcessingDebugInfo info) { - final theme = Theme.of(context); - final phaseText = switch (info.currentPhase) { - 'loading' => 'Loading model...', - 'correcting' => 'Correcting transcription...', - 'classifying' => 'Classifying & summarizing...', - _ => 'Processing...', - }; - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - info.modelName, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 8), - Text( - phaseText, - style: theme.textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 16, - runSpacing: 8, - children: [ - _metricChip( - context, - 'Tokens', - '${info.liveTokenCount}', - Icons.token, - ), - ], - ), - ], - ), - ); - } - - Widget _debugNote(BuildContext context, String text) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.textSecondary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 16, color: AppTheme.textSecondary), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - ], - ), - ); - } - Widget _debugInfoFromMemo(BuildContext context) { final theme = Theme.of(context); return Column( @@ -958,164 +956,55 @@ class _AiDebugSheet extends ConsumerWidget { } Widget _metricsRow(BuildContext context, AiProcessingDebugInfo info) { - final theme = Theme.of(context); - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - info.modelName, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 16, - runSpacing: 8, - children: [ - if (info.correctionTime != null) - _metricChip( - context, - 'Correction', - '${info.correctionTime!.inMilliseconds}ms', - Icons.timer_outlined, - ), - if (info.correctionTokensPerSec != null) - _metricChip( - context, - 'Correction tok/s', - info.correctionTokensPerSec!.toStringAsFixed(1), - Icons.speed, - ), - if (info.correctionTokens != null) - _metricChip( - context, - 'Correction tokens', - '${info.correctionTokens}', - Icons.token, - ), - if (info.classifyTime != null) - _metricChip( - context, - 'Classify', - '${info.classifyTime!.inMilliseconds}ms', - Icons.timer_outlined, - ), - if (info.classifyTokensPerSec != null) - _metricChip( - context, - 'Classify tok/s', - info.classifyTokensPerSec!.toStringAsFixed(1), - Icons.speed, - ), - if (info.classifyTokens != null) - _metricChip( - context, - 'Classify tokens', - '${info.classifyTokens}', - Icons.token, - ), - ], - ), - ], - ), - ); - } - - Widget _metricChip( - BuildContext context, - String label, - String value, - IconData icon, - ) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text( - '$label: ', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - fontSize: 11, - ), + final chips = [ + if (info.correctionTime != null) + aiMetricChip( + context, + 'Correction', + '${info.correctionTime!.inMilliseconds}ms', + Icons.timer_outlined, ), - Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 11, - ), + if (info.correctionTokensPerSec != null) + aiMetricChip( + context, + 'Correction tok/s', + info.correctionTokensPerSec!.toStringAsFixed(1), + Icons.speed, ), - ], - ); - } - - Widget _debugBlock( - BuildContext context, { - required String title, - required String content, - required IconData icon, - bool mono = false, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 16, color: AppTheme.textSecondary), - const SizedBox(width: 6), - Text( - title, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.copy, size: 16), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () { - Clipboard.setData(ClipboardData(text: content)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Copied $title'), - duration: const Duration(seconds: 1), - ), - ); - }, - ), - ], + if (info.correctionTokens != null) + aiMetricChip( + context, + 'Correction tokens', + '${info.correctionTokens}', + Icons.token, ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.textSecondary.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppTheme.textSecondary.withValues(alpha: 0.12), - ), - ), - child: SelectableText( - content, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: mono ? 'monospace' : null, - fontSize: mono ? 11 : null, - height: 1.5, - ), - ), + if (info.classifyTime != null) + aiMetricChip( + context, + 'Classify', + '${info.classifyTime!.inMilliseconds}ms', + Icons.timer_outlined, ), - ], + if (info.classifyTokensPerSec != null) + aiMetricChip( + context, + 'Classify tok/s', + info.classifyTokensPerSec!.toStringAsFixed(1), + Icons.speed, + ), + if (info.classifyTokens != null) + aiMetricChip( + context, + 'Classify tokens', + '${info.classifyTokens}', + Icons.token, + ), + ]; + + return aiCompletedMetricsHeader( + context, + modelName: info.modelName, + extraChips: chips, ); } @@ -1136,7 +1025,8 @@ class _AiDebugSheet extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.compare_arrows, size: 16, color: AppTheme.textSecondary), + const Icon(Icons.compare_arrows, + size: 16, color: AppTheme.textSecondary), const SizedBox(width: 6), Text( 'Transcription Diff', @@ -1163,7 +1053,8 @@ class _AiDebugSheet extends ConsumerWidget { ), child: Text.rich( TextSpan(children: spans), - style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6), + style: + Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6), ), ), ], @@ -1197,7 +1088,8 @@ class _AiDebugSheet extends ConsumerWidget { final dp = List.generate(n + 1, (_) => List.filled(m + 1, 0)); for (var i = 1; i <= n; i++) { for (var j = 1; j <= m; j++) { - if (origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { + if (origWords[i - 1].toLowerCase() == + corrWords[j - 1].toLowerCase()) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = dp[i - 1][j] > dp[i][j - 1] @@ -1214,7 +1106,8 @@ class _AiDebugSheet extends ConsumerWidget { while (i > 0 || j > 0) { if (i > 0 && j > 0 && - origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { + origWords[i - 1].toLowerCase() == + corrWords[j - 1].toLowerCase()) { ops.add(_DiffOp.equal(corrWords[j - 1])); i--; j--; @@ -1226,7 +1119,6 @@ class _AiDebugSheet extends ConsumerWidget { i--; } } - ops.reversed; // reversed is lazy, need toList final orderedOps = ops.reversed.toList(); // Convert to TextSpans @@ -1269,7 +1161,8 @@ class _AiDebugSheet extends ConsumerWidget { children: [ Row( children: [ - Icon(Icons.check_circle_outline, size: 16, color: AppTheme.textSecondary), + const Icon(Icons.check_circle_outline, + size: 16, color: AppTheme.textSecondary), const SizedBox(width: 6), Text( 'Parsed Result', @@ -1335,12 +1228,6 @@ class _ExtractedActionsSectionState @override Widget build(BuildContext context) { - final aiEnabled = ref.watch(localAiEnabledProvider); - - if (!aiEnabled) { - return const SizedBox.shrink(); - } - final actionsAsync = ref.watch( extractedActionsForMemoProvider(widget.memo.id), ); @@ -1389,11 +1276,22 @@ class _ExtractedActionsSectionState } } -class _ActionItem extends StatelessWidget { +class _ActionItem extends ConsumerStatefulWidget { final ExtractedAction action; const _ActionItem({required this.action}); + @override + ConsumerState<_ActionItem> createState() => _ActionItemState(); +} + +class _ActionItemState extends ConsumerState<_ActionItem> { + bool _isCreating = false; + bool _isDismissing = false; + bool _isOpening = false; + + ExtractedAction get action => widget.action; + @override Widget build(BuildContext context) { return Row( @@ -1417,14 +1315,32 @@ class _ActionItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - action.title, - style: Theme.of(context).textTheme.bodyMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + action.title, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: AppTheme.spacingSm), + _ActionStatusBadge(action: action), + ], ), - if (action.dueDate != null) ...[ + if (_timingLabel(action) case final timingLabel?) ...[ const SizedBox(height: 4), Text( - 'Due: ${DateFormat.yMMMd().format(action.dueDate!)}', + timingLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + if (action.notes != null && action.notes!.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + action.notes!.trim(), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), @@ -1439,6 +1355,52 @@ class _ActionItem extends StatelessWidget { ), ), ], + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + if (!action.created && !action.dismissed) + FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: _isCreating ? null : _createAction, + icon: _isCreating + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_task_outlined), + label: Text(_isCreating ? 'Creating…' : 'Create'), + ), + if (!action.created && !action.dismissed) + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: _isDismissing ? null : _dismissAction, + icon: _isDismissing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + if (action.created && action.platformTargetId != null) + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: _isOpening ? null : _openCreatedAction, + icon: _isOpening + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.open_in_new_rounded), + label: const Text('Open'), + ), + ], + ), ], ), ), @@ -1446,6 +1408,81 @@ class _ActionItem extends StatelessWidget { ); } + Future _createAction() async { + setState(() => _isCreating = true); + try { + final selectedCalendarId = ref.read(selectedProductivityCalendarIdProvider); + final draft = ActionCreationDraft.fromAction(action).copyWith( + platformCalendarId: Platform.isAndroid ? selectedCalendarId : null, + ); + + final message = await ref.read(extractedActionOperationsProvider).createAction( + action: action, + draft: draft, + ); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + } + + Future _dismissAction() async { + setState(() => _isDismissing = true); + try { + await ref.read(extractedActionOperationsProvider).dismissAction(action.id); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to dismiss action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isDismissing = false); + } + } + } + + Future _openCreatedAction() async { + setState(() => _isOpening = true); + try { + await ref + .read(extractedActionCreationServiceProvider) + .openCreatedAction(action); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to open created action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isOpening = false); + } + } + } + IconData _actionTypeIcon(ExtractedActionType type) { return switch (type) { ExtractedActionType.task => Icons.check_box_outlined, @@ -1461,6 +1498,56 @@ class _ActionItem extends StatelessWidget { ExtractedActionType.calendarEvent => AppTheme.infoColor, }; } + + String? _timingLabel(ExtractedAction action) { + final dateFormat = DateFormat.yMMMd(); + final dateTimeFormat = DateFormat.yMMMd().add_jm(); + + if (action.startTime != null) { + final start = action.startTime!.toLocal(); + if (action.endTime != null) { + final end = action.endTime!.toLocal(); + return 'When: ${dateTimeFormat.format(start)} → ${dateTimeFormat.format(end)}'; + } + return 'When: ${dateTimeFormat.format(start)}'; + } + + if (action.dueDate != null) { + return 'Due: ${dateFormat.format(action.dueDate!.toLocal())}'; + } + + return null; + } +} + +class _ActionStatusBadge extends StatelessWidget { + final ExtractedAction action; + + const _ActionStatusBadge({required this.action}); + + @override + Widget build(BuildContext context) { + final (label, color) = switch ((action.created, action.dismissed)) { + (true, _) => ('Created', AppTheme.successColor), + (_, true) => ('Dismissed', AppTheme.textSecondary), + _ => ('Pending', AppTheme.warningColor), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } } class _CategoryBadge extends StatelessWidget { @@ -2260,6 +2347,9 @@ class _TranscribeButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final engineStateAsync = ref.watch(transcriptionEngineStateProvider); final configuredAsync = ref.watch(transcriptionConfiguredProvider); + final actionState = ref.watch(voiceMemoActionsProvider); + // Also watch background auto-transcription so buttons update immediately + final autoTranscribeState = ref.watch(autoTranscribeStateProvider); return configuredAsync.when( data: (configured) { @@ -2277,28 +2367,61 @@ class _TranscribeButton extends ConsumerWidget { return engineStateAsync.when( data: (engineState) { - final isTranscribing = - engineState.status == TranscriptionEngineStatus.transcribing; + final isTranscribing = actionState.isTranscribingMemo(memo.filename) || + autoTranscribeState.isTranscribingMemo(memo.filename); final buttonLabel = memo.transcription == null ? 'Transcribe' : 'Re-transcribe'; + // Build a status label from the engine state while transcribing + String? statusLabel; + if (isTranscribing) { + statusLabel = switch (engineState.status) { + TranscriptionEngineStatus.downloading => + 'Downloading model (${(engineState.downloadProgress * 100).toInt()}%)…', + TranscriptionEngineStatus.transcribing => 'Transcribing audio…', + TranscriptionEngineStatus.ready => 'Preparing…', + TranscriptionEngineStatus.error => + engineState.errorMessage ?? 'Error', + _ => 'Initializing…', + }; + } + return _ButtonBox( expand: expand, - child: OutlinedButton.icon( - style: _compactOutlinedButtonStyle(), - icon: isTranscribing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.transcribe), - label: Text(isTranscribing ? 'Transcribing...' : buttonLabel), - onPressed: isTranscribing - ? null - : () => ref - .read(voiceMemoActionsProvider.notifier) - .retranscribe(memo), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: expand + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.transcribe), + label: Text(isTranscribing ? 'Transcribing...' : buttonLabel), + onPressed: isTranscribing + ? null + : () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), + ), + if (statusLabel != null) + Padding( + padding: const EdgeInsets.only(top: 4, left: 4), + child: Text( + statusLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ), + ], ), ); }, @@ -2319,8 +2442,7 @@ class _TranscribeButton extends ConsumerWidget { loading: () => const SizedBox.shrink(), error: (_, _) => engineStateAsync.when( data: (engineState) { - final isTranscribing = - engineState.status == TranscriptionEngineStatus.transcribing; + final isTranscribing = actionState.isTranscribingMemo(memo.filename); return _ButtonBox( expand: expand, diff --git a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart new file mode 100644 index 0000000..ef2e020 --- /dev/null +++ b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/theme/app_theme.dart'; + +// --------------------------------------------------------------------------- +// Shared UI primitives for AI / benchmark debug bottom sheets. +// +// Used by both: +// • _BenchmarkDebugSheet (settings → AI models page) +// • _AiDebugSheet (voice memos page) +// --------------------------------------------------------------------------- + +/// Drag handle bar shown at the top of a modal bottom sheet. +Widget aiDebugHandleBar() { + return Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ); +} + +/// Header row: bug icon · title · optional spinner · optional Stop · Close. +Widget aiDebugSheetHeader( + BuildContext context, { + required String title, + bool showSpinner = false, + VoidCallback? onStop, + required VoidCallback onClose, +}) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.bug_report_outlined, size: 20), + const SizedBox(width: 8), + Text(title, style: theme.textTheme.titleMedium), + const Spacer(), + if (showSpinner) + const Padding( + padding: EdgeInsets.only(right: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + ), + if (onStop != null) + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.errorColor, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + icon: const Icon(Icons.stop, size: 18), + label: const Text('Stop'), + onPressed: onStop, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: onClose, + ), + ], + ), + ); +} + +/// Informational note / empty-state box. +Widget aiDebugNote(BuildContext context, String text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); +} + +/// Content block with a title row, optional copy button, and body text. +Widget aiDebugBlock( + BuildContext context, { + required String title, + required String content, + required IconData icon, + bool mono = false, + bool showCopyButton = false, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + title, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + if (showCopyButton) ...[ + const Spacer(), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: content)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied $title'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ], + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: SelectableText( + content, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), + ), + ), + ], + ); +} + +String aiFormatPromptFlow({ + required String? strategy, + required bool retryEnabled, + required int attempts, +}) { + return 'Strategy: ${strategy ?? 'unknown'}\n' + 'Retry invalid output: ${retryEnabled ? 'enabled' : 'disabled'}\n' + 'Attempts used: $attempts'; +} + +bool aiHasChronoDetails({ + String? extractedIntent, + String? extractedTitle, + String? datetimeExpressionOriginal, + String? datetimeExpressionEnglish, + String? resolvedDateTime, + String? resolverMethod, +}) { + return (extractedIntent?.isNotEmpty ?? false) || + (extractedTitle?.isNotEmpty ?? false) || + (datetimeExpressionOriginal?.isNotEmpty ?? false) || + (datetimeExpressionEnglish?.isNotEmpty ?? false) || + (resolvedDateTime?.isNotEmpty ?? false) || + (resolverMethod?.isNotEmpty ?? false); +} + +String aiFormatChronoDetails({ + String? extractedIntent, + String? extractedTitle, + String? datetimeExpressionOriginal, + String? datetimeExpressionEnglish, + String? resolvedDateTime, + String? resolverMethod, +}) { + String show(String? value) => + (value != null && value.trim().isNotEmpty) ? value.trim() : 'null'; + + return 'Intent: ${show(extractedIntent)}\n' + 'Title: ${show(extractedTitle)}\n' + 'Original time phrase: ${show(datetimeExpressionOriginal)}\n' + 'English time phrase: ${show(datetimeExpressionEnglish)}\n' + 'Resolved datetime: ${show(resolvedDateTime)}\n' + 'Resolver: ${show(resolverMethod)}'; +} + +/// Small label + value chip used inside metric rows. +Widget aiMetricChip( + BuildContext context, + String label, + String value, + IconData icon, +) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ], + ); +} + +/// Build the standard metric-chip [Wrap] from optional token / speed / time +/// values. Returns an empty list when nothing should be shown. +List aiMetricChips( + BuildContext context, { + int? tokens, + double? tokensPerSecond, + Duration? elapsed, +}) { + return [ + if (tokens != null && tokens > 0) + aiMetricChip(context, 'Tokens', '$tokens', Icons.token), + if (tokensPerSecond != null && tokensPerSecond > 0) + aiMetricChip( + context, + 'Speed', + '${tokensPerSecond.toStringAsFixed(1)} t/s', + Icons.speed, + ), + if (elapsed != null && elapsed > Duration.zero) + aiMetricChip( + context, + 'Time', + '${(elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s', + Icons.timer_outlined, + ), + ]; +} + +/// Live phase header: model name, animated spinner + phase label, metric chips. +Widget aiLivePhaseHeader( + BuildContext context, { + required String modelName, + required String phaseText, + int? tokens, + double? tokensPerSecond, + Duration? elapsed, +}) { + final theme = Theme.of(context); + final chips = aiMetricChips( + context, + tokens: tokens, + tokensPerSecond: tokensPerSecond, + elapsed: elapsed, + ); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 8), + Text( + phaseText, + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 16, runSpacing: 8, children: chips), + ], + ], + ), + ); +} + +/// Completed status header (benchmark-style: success / error colouring). +Widget aiCompletedHeader( + BuildContext context, { + required String modelName, + required bool isError, + int? tokens, + double? tokensPerSecond, + Duration? elapsed, +}) { + final theme = Theme.of(context); + final statusColor = + isError ? AppTheme.errorColor : AppTheme.successColor; + final chips = aiMetricChips( + context, + tokens: tokens, + tokensPerSecond: tokensPerSecond, + elapsed: elapsed, + ); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isError ? Icons.error_outline : Icons.check_circle_outline, + size: 18, + color: statusColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + isError ? 'Failed' : 'Complete', + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 16, runSpacing: 8, children: chips), + ], + ], + ), + ); +} + +/// Completed metrics banner for the voice-memo debug sheet. +/// Shows per-phase timing & throughput in a single box. +Widget aiCompletedMetricsHeader( + BuildContext context, { + required String modelName, + List extraChips = const [], +}) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (extraChips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 16, runSpacing: 8, children: extraChips), + ], + ], + ), + ); +} diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index f9b991e..1325a45 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -161,6 +161,21 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + chrono_ai_flow: + dependency: "direct main" + description: + path: "../packages/chrono_ai_flow" + relative: true + source: path + version: "0.1.0" + chrono_dart: + dependency: "direct main" + description: + name: chrono_dart + sha256: ac121aeec8c8ea22765d6eff5bf5bc8caae3fda1473d996bb5ee915e1b4b8a9d + url: "https://pub.dev" + source: hosted + version: "2.0.2" cli_util: dependency: transitive description: @@ -257,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + day: + dependency: transitive + description: + name: day + sha256: "1e7068deb2f825a8b705d01d1116cc485ddc32531b43dc8c4bf58c5a1b87cd48" + url: "https://pub.dev" + source: hosted + version: "0.8.0" dbus: dependency: transitive description: diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index 630606f..3f4e287 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: equatable: ^2.0.7 uuid: ^4.5.1 rxdart: ^0.28.0 + chrono_dart: ^2.0.2 url_launcher: ^6.3.1 file_picker: ^8.1.6 wakelock_plus: ^1.2.8 @@ -75,6 +76,8 @@ dependencies: git: url: https://github.com/Telosnex/fllama ref: main + chrono_ai_flow: + path: ../packages/chrono_ai_flow dev_dependencies: flutter_test: From 9ac8943a16c271f30fa56786dc2f1daa64c30d58 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Wed, 11 Mar 2026 22:09:26 +0100 Subject: [PATCH 04/58] Add missing shared code between app and desktop benchmarking. --- .../chrono_ai_flow/lib/chrono_ai_flow.dart | 4 + packages/chrono_ai_flow/lib/src/models.dart | 32 +++ packages/chrono_ai_flow/lib/src/parser.dart | 131 ++++++++++++ .../lib/src/prompt_template.dart | 115 +++++++++++ .../lib/src/time_expression_resolver.dart | 193 ++++++++++++++++++ packages/chrono_ai_flow/pubspec.yaml | 10 + 6 files changed, 485 insertions(+) create mode 100644 packages/chrono_ai_flow/lib/chrono_ai_flow.dart create mode 100644 packages/chrono_ai_flow/lib/src/models.dart create mode 100644 packages/chrono_ai_flow/lib/src/parser.dart create mode 100644 packages/chrono_ai_flow/lib/src/prompt_template.dart create mode 100644 packages/chrono_ai_flow/lib/src/time_expression_resolver.dart create mode 100644 packages/chrono_ai_flow/pubspec.yaml diff --git a/packages/chrono_ai_flow/lib/chrono_ai_flow.dart b/packages/chrono_ai_flow/lib/chrono_ai_flow.dart new file mode 100644 index 0000000..526f593 --- /dev/null +++ b/packages/chrono_ai_flow/lib/chrono_ai_flow.dart @@ -0,0 +1,4 @@ +export 'src/models.dart'; +export 'src/prompt_template.dart'; +export 'src/parser.dart'; +export 'src/time_expression_resolver.dart'; diff --git a/packages/chrono_ai_flow/lib/src/models.dart b/packages/chrono_ai_flow/lib/src/models.dart new file mode 100644 index 0000000..21c71c4 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/models.dart @@ -0,0 +1,32 @@ +class ChronoLlmExtraction { + final String intent; + final String title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + + const ChronoLlmExtraction({ + required this.intent, + required this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + }); +} + +class ChronoLlmParseResult { + final String rawOutput; + final String? parsedJson; + final ChronoLlmExtraction? extraction; + + const ChronoLlmParseResult({ + required this.rawOutput, + this.parsedJson, + this.extraction, + }); +} + +class ResolvedTime { + final DateTime dateTime; + final String method; + + const ResolvedTime({required this.dateTime, required this.method}); +} diff --git a/packages/chrono_ai_flow/lib/src/parser.dart b/packages/chrono_ai_flow/lib/src/parser.dart new file mode 100644 index 0000000..f2723d8 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/parser.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; + +import 'models.dart'; + +class ChronoLlmParser { + const ChronoLlmParser(); + + ChronoLlmParseResult parse(String raw) { + final cleaned = sanitizeModelOutput(raw); + final jsonStr = extractFirstJsonObject(cleaned); + if (jsonStr == null) { + return ChronoLlmParseResult(rawOutput: cleaned); + } + + try { + final parsed = jsonDecode(jsonStr) as Map; + if (!parsed.containsKey('intent')) { + return ChronoLlmParseResult(rawOutput: cleaned, parsedJson: jsonStr); + } + + final intent = _normalizeIntent(parsed['intent'] as String?); + final title = ((parsed['title'] ?? parsed['summary']) as String?)?.trim() ?? + ''; + final datetimeOriginal = + (parsed['datetime_expression_original'] as String?)?.trim(); + final datetimeEnglish = + (parsed['datetime_expression_english'] as String?)?.trim(); + + return ChronoLlmParseResult( + rawOutput: cleaned, + parsedJson: jsonStr, + extraction: ChronoLlmExtraction( + intent: intent, + title: title, + datetimeExpressionOriginal: + (datetimeOriginal?.isNotEmpty ?? false) ? datetimeOriginal : null, + datetimeExpressionEnglish: + (datetimeEnglish?.isNotEmpty ?? false) ? datetimeEnglish : null, + ), + ); + } catch (_) { + return ChronoLlmParseResult(rawOutput: cleaned, parsedJson: jsonStr); + } + } + + String sanitizeModelOutput(String raw) { + return raw + .replaceAll('<|im_end|>', '') + .replaceAll(RegExp(r'.*?', dotAll: true), '') + .replaceAll(RegExp(r'.*', dotAll: true), '') + .trim(); + } + + String? extractFirstJsonObject(String raw) { + final cleaned = sanitizeModelOutput(raw); + final start = cleaned.indexOf('{'); + if (start == -1) { + return null; + } + + var depth = 0; + var inString = false; + var escaping = false; + + for (var i = start; i < cleaned.length; i++) { + final char = cleaned[i]; + + if (escaping) { + escaping = false; + continue; + } + + if (char == '\\' && inString) { + escaping = true; + continue; + } + + if (char == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char == '{') { + depth++; + } else if (char == '}') { + depth--; + if (depth == 0) { + return cleaned.substring(start, i + 1); + } + } + } + + return null; + } + + String normalizeIntent(String? rawIntent) => _normalizeIntent(rawIntent); + + bool shouldRetryInvalidChronoOutput(String raw) { + final parsed = parse(raw); + final extraction = parsed.extraction; + if (parsed.parsedJson == null) { + return true; + } + if (extraction == null) { + return true; + } + if (extraction.intent.trim().isEmpty) { + return true; + } + return false; + } + + String _normalizeIntent(String? rawIntent) { + switch ((rawIntent ?? '').trim().toLowerCase()) { + case 'event': + case 'meeting': + case 'calendar_event': + return 'event'; + case 'reminder': + case 'task': + case 'todo': + return 'reminder'; + default: + return 'note'; + } + } +} diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart new file mode 100644 index 0000000..724fc09 --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -0,0 +1,115 @@ +class ChronoPromptTemplate { + ChronoPromptTemplate._(); + + static const String promptPlaceholderCurrentLocalDateTime = + '{{current_local_datetime}}'; + static const String promptPlaceholderCurrentLocalDateTimeCompact = + '{{current_local_datetime_compact}}'; + static const String promptPlaceholderWeekday = '{{weekday}}'; + static const String promptPlaceholderTimezoneOffset = + '{{timezone_offset}}'; + static const String promptPlaceholderTranscript = '{{transcript}}'; + + static const String defaultTemplate = ''' +You extract structured information from a voice memo. + +The memo may be in ANY language. + +Return JSON only. No explanation. + +Your tasks: +1. Detect intent: "reminder", "event", or "note". +2. Extract the time/date phrase exactly as it appears in the memo. +3. Translate that time/date phrase into natural English. If already English, copy it. +4. Extract a short title (the task or event, NOT the time part). + +Rules: +- NEVER compute or resolve dates. NEVER output ISO timestamps. +- Keep time expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". +- Copy the original time phrase exactly from the memo. +- You MUST fill "datetime_expression_english" whenever "datetime_expression_original" is not null. +- If the memo is in English, copy the same English time phrase to both fields. +- If no time/date is mentioned, set both datetime fields to null and intent to "note". +- The title must be short (2-5 words) and in the ORIGINAL language. +- Translate time expressions accurately to natural English. Convert 24-hour to 12-hour format. Translate idioms correctly (e.g. the Swedish "halv 10" means 9:30, not 10:30). +- Intent rules: + - "event" = scheduled meetings, appointments, bookings (dentist, conference, meeting with someone) + - "reminder" = personal tasks/actions with a specific time (call someone at 3 pm, buy milk tomorrow) + - "note" = no time/date mentioned, or just a task without any when (buy bread, remember to call) +- NOT time expressions (never extract these as datetime): + - Locations: "on the way home", "at work", "at the store" + - Vague conditions: "when I get home", "after lunch", "later" + - These make the intent "note", not "reminder" + +Examples: + +Memo: "Remind me tomorrow at 10 am to buy milk" +{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"} + +Memo: "påminn mig imorgon klockan 10 att köpa mjölk" +{"intent":"reminder","title":"köpa mjölk","datetime_expression_original":"imorgon klockan 10","datetime_expression_english":"tomorrow at 10 am"} + +Memo: "tandläkare den 15 mars klockan halv 10" +{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"} + +Memo: "remember to buy milk" +{"intent":"note","title":"buy milk","datetime_expression_original":null,"datetime_expression_english":null} + +Memo: "köp bröd på vägen hem" +{"intent":"note","title":"köp bröd","datetime_expression_original":null,"datetime_expression_english":null} + +Memo: "call the plumber this afternoon at 3" +{"intent":"reminder","title":"call the plumber","datetime_expression_original":"this afternoon at 3","datetime_expression_english":"this afternoon at 3 pm"} + +Output JSON schema: +{ + "intent": "reminder" | "event" | "note", + "title": "short task description in original language", + "datetime_expression_original": "original time phrase" | null, + "datetime_expression_english": "english translation of time phrase" | null +} + +Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact +Timezone: UTC$promptPlaceholderTimezoneOffset + +Voice memo: + +$promptPlaceholderTranscript + +/no_think +JSON:'''; + + static String render( + String template, { + required String transcript, + DateTime? now, + }) { + final localNow = now ?? DateTime.now(); + final weekday = const [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ][localNow.weekday - 1]; + final iso = localNow.toIso8601String(); + final tzOffset = localNow.timeZoneOffset; + final tzSign = tzOffset.isNegative ? '-' : '+'; + final tzHours = tzOffset.inHours.abs().toString().padLeft(2, '0'); + final tzMinutes = + (tzOffset.inMinutes.abs() % 60).toString().padLeft(2, '0'); + final tz = '$tzSign$tzHours:$tzMinutes'; + final compactDateTime = + '${localNow.year}-${localNow.month.toString().padLeft(2, '0')}-${localNow.day.toString().padLeft(2, '0')} ' + '${localNow.hour.toString().padLeft(2, '0')}:${localNow.minute.toString().padLeft(2, '0')}'; + + return template + .replaceAll(promptPlaceholderCurrentLocalDateTime, iso) + .replaceAll(promptPlaceholderCurrentLocalDateTimeCompact, compactDateTime) + .replaceAll(promptPlaceholderWeekday, weekday) + .replaceAll(promptPlaceholderTimezoneOffset, tz) + .replaceAll(promptPlaceholderTranscript, transcript); + } +} diff --git a/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart new file mode 100644 index 0000000..ed234af --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart @@ -0,0 +1,193 @@ +import 'package:chrono_dart/chrono_dart.dart'; + +import 'models.dart'; + +class TimeExpressionResolver { + static final _weekdayNames = RegExp( + r'\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b', + ); + + static const _weekdayMap = { + 'monday': 1, + 'tuesday': 2, + 'wednesday': 3, + 'thursday': 4, + 'friday': 5, + 'saturday': 6, + 'sunday': 7, + }; + + ResolvedTime? resolve(String expression, {DateTime? referenceDate}) { + if (expression.trim().isEmpty) return null; + + final ref = referenceDate ?? DateTime.now(); + final normalized = _normalize(expression); + + final specific = _specificPatterns(normalized, ref); + if (specific != null) { + return ResolvedTime(dateTime: specific, method: 'regex'); + } + + try { + final results = Chrono.parse( + normalized, + ref: ref, + option: ParsingOption(forwardDate: true), + ); + if (results.isNotEmpty) { + var resolved = results.first.date(); + if (resolved.isUtc) resolved = resolved.toLocal(); + + final weekdayMatch = _weekdayNames.firstMatch(normalized); + if (weekdayMatch != null) { + final mentionedDay = _weekdayMap[weekdayMatch.group(1)!]!; + + if (resolved.weekday != mentionedDay) { + var daysUntil = mentionedDay - ref.weekday; + if (daysUntil <= 0) daysUntil += 7; + resolved = DateTime( + ref.year, + ref.month, + ref.day + daysUntil, + resolved.hour, + resolved.minute, + resolved.second, + ); + return ResolvedTime( + dateTime: resolved, + method: 'chrono+adjusted', + ); + } + + if (resolved.isBefore(ref)) { + resolved = resolved.add(const Duration(days: 7)); + return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); + } + + if (normalized.contains('next') && + resolved.difference(ref).inDays > 7) { + resolved = resolved.subtract(const Duration(days: 7)); + return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); + } + } + + return ResolvedTime(dateTime: resolved, method: 'chrono'); + } + } catch (_) { + // Ignore and continue to regex fallbacks. + } + + final regexResult = _generalRegex(normalized, ref); + if (regexResult != null) { + return ResolvedTime(dateTime: regexResult, method: 'regex'); + } + + return null; + } + + String _normalize(String expression) { + var s = expression.trim().toLowerCase(); + + const numberWords = { + 'one': '1', + 'two': '2', + 'three': '3', + 'four': '4', + 'five': '5', + 'six': '6', + 'seven': '7', + 'eight': '8', + 'nine': '9', + 'ten': '10', + 'eleven': '11', + 'twelve': '12', + 'thirteen': '13', + 'fourteen': '14', + 'fifteen': '15', + 'twenty': '20', + 'thirty': '30', + 'forty': '40', + 'fifty': '50', + }; + + for (final entry in numberWords.entries) { + s = s.replaceAllMapped(RegExp('\\b${entry.key}\\b'), (_) => entry.value); + } + + return s; + } + + DateTime? _specificPatterns(String expr, DateTime ref) { + final dayAfterTomorrow = RegExp( + r'\bday\s+after\s+tomorrow\b' + r'(?:\s+(?:morning|afternoon|evening))?' + r'(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', + ).firstMatch(expr); + if (dayAfterTomorrow != null) { + var hour = int.tryParse(dayAfterTomorrow.group(1) ?? '') ?? 9; + final minute = int.tryParse(dayAfterTomorrow.group(2) ?? '') ?? 0; + final ampm = dayAfterTomorrow.group(3); + if (ampm == 'pm' && hour < 12) hour += 12; + if (ampm == 'am' && hour == 12) hour = 0; + if (expr.contains('morning') && dayAfterTomorrow.group(1) == null) { + hour = 8; + } + return DateTime(ref.year, ref.month, ref.day + 2, hour, minute); + } + + final tonight = RegExp( + r'\btonight\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', + ).firstMatch(expr); + if (tonight != null) { + var hour = int.tryParse(tonight.group(1) ?? '') ?? 20; + final minute = int.tryParse(tonight.group(2) ?? '') ?? 0; + final ampm = tonight.group(3); + if (ampm == 'pm' && hour < 12) hour += 12; + if (ampm == null && hour < 12) hour += 12; + return DateTime(ref.year, ref.month, ref.day, hour, minute); + } + + final thisAfternoon = RegExp( + r'\bthis\s+afternoon\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(pm)?)?', + ).firstMatch(expr); + if (thisAfternoon != null) { + var hour = int.tryParse(thisAfternoon.group(1) ?? '') ?? 14; + final minute = int.tryParse(thisAfternoon.group(2) ?? '') ?? 0; + if (hour < 12) hour += 12; + return DateTime(ref.year, ref.month, ref.day, hour, minute); + } + + return null; + } + + DateTime? _generalRegex(String expr, DateTime ref) { + final inMinutes = RegExp(r'\bin\s+(\d+)\s+minutes?\b').firstMatch(expr); + if (inMinutes != null) { + final minutes = int.tryParse(inMinutes.group(1)!); + if (minutes != null) return ref.add(Duration(minutes: minutes)); + } + + final inHours = RegExp(r'\bin\s+(\d+)\s+hours?\b').firstMatch(expr); + if (inHours != null) { + final hours = int.tryParse(inHours.group(1)!); + if (hours != null) return ref.add(Duration(hours: hours)); + } + + final inDays = RegExp(r'\bin\s+(\d+)\s+days?\b').firstMatch(expr); + if (inDays != null) { + final days = int.tryParse(inDays.group(1)!); + if (days != null) return ref.add(Duration(days: days)); + } + + if (RegExp(r'\bin\s+half\s+an?\s+hour\b').hasMatch(expr)) { + return ref.add(const Duration(minutes: 30)); + } + + if (RegExp(r'\btomorrow\b').hasMatch(expr) && + !RegExp(r'\bat\b|\d+\s*(am|pm|:\d)').hasMatch(expr)) { + return DateTime(ref.year, ref.month, ref.day + 1, 9, 0); + } + + return null; + } +} diff --git a/packages/chrono_ai_flow/pubspec.yaml b/packages/chrono_ai_flow/pubspec.yaml new file mode 100644 index 0000000..fd72d46 --- /dev/null +++ b/packages/chrono_ai_flow/pubspec.yaml @@ -0,0 +1,10 @@ +name: chrono_ai_flow +description: Shared chrono-based voice memo extraction flow for the app and benchmark. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.10.1 + +dependencies: + chrono_dart: ^2.0.2 From 47493c93dd1712dee06eaac8f5db2b9d511199b3 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sun, 8 Mar 2026 09:40:23 +0100 Subject: [PATCH 05/58] Update project files. --- .../macos/Runner.xcodeproj/project.pbxproj | 98 ++++++++++++++++++- .../contents.xcworkspacedata | 3 + zswatch_app/pubspec.yaml | 4 +- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/zswatch_app/macos/Runner.xcodeproj/project.pbxproj b/zswatch_app/macos/Runner.xcodeproj/project.pbxproj index 79af6d2..ec42db1 100644 --- a/zswatch_app/macos/Runner.xcodeproj/project.pbxproj +++ b/zswatch_app/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 73605F5E48BC31DCFDF3187B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B3BF77EBBF6BA2054AE7EEBE /* Pods_RunnerTests.framework */; }; + A74ABE6B0CFF0B4CE71D63DC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D7A23D4AE09087BFBE7A0F7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 27C015300132C67602B34583 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 2D7A23D4AE09087BFBE7A0F7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* zswatch_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "zswatch_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* zswatch_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = zswatch_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +80,14 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 36C3A23C56EB05B1F5A55552 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 4F7B90951BC1BBBAACCD6558 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 828F3D83EE1B8802F4257328 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 877C13AC571A1EC8808A3308 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A53B852986222779194C1FD4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B3BF77EBBF6BA2054AE7EEBE /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 73605F5E48BC31DCFDF3187B /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A74ABE6B0CFF0B4CE71D63DC /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + DAA0D77AEB775359935A1CD0 /* Pods */, ); sourceTree = ""; }; @@ -175,10 +188,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 2D7A23D4AE09087BFBE7A0F7 /* Pods_Runner.framework */, + B3BF77EBBF6BA2054AE7EEBE /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + DAA0D77AEB775359935A1CD0 /* Pods */ = { + isa = PBXGroup; + children = ( + A53B852986222779194C1FD4 /* Pods-Runner.debug.xcconfig */, + 828F3D83EE1B8802F4257328 /* Pods-Runner.release.xcconfig */, + 4F7B90951BC1BBBAACCD6558 /* Pods-Runner.profile.xcconfig */, + 877C13AC571A1EC8808A3308 /* Pods-RunnerTests.debug.xcconfig */, + 36C3A23C56EB05B1F5A55552 /* Pods-RunnerTests.release.xcconfig */, + 27C015300132C67602B34583 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + DFEBB83803065A91D337FCE3 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + FE274AFC8F797F6075A838DF /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 3CD6BB3E961BBBBDB6D2442B /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 3CD6BB3E961BBBBDB6D2442B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + DFEBB83803065A91D337FCE3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FE274AFC8F797F6075A838DF /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 877C13AC571A1EC8808A3308 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 36C3A23C56EB05B1F5A55552 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 27C015300132C67602B34583 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/zswatch_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index 3f4e287..121962c 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -1,7 +1,7 @@ name: zswatch_app -description: "ZSWatch Companion App - BLE communication, firmware updates, health data, and developer tools for ZSWatch smartwatch." +description: "Connect your Open Source ZSWatch smartwatch to set it up, update firmware, track health data and more." publish_to: 'none' -version: 1.0.0+2 +version: 1.1.0+1 environment: sdk: ^3.10.1 From f6791bbc1dc7d56546262ac0be998e0b521d0840 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Mon, 9 Mar 2026 16:39:11 +0100 Subject: [PATCH 06/58] Replace fllama dependency with git submodule fork (ZSWatch/fllama fork) Vendor fllama as a git submodule pointing to git@github.com:ZSWatch/fllama.git to fix iOS ggml header conflicts via CocoaPods configuration. The fork contains iOS header-map fixes: - podspec: USE_HEADERMAP=NO to prevent ggml header cross-resolution - podspec: iOS minimum bumped to 14.0 - Explicit relative includes in clip.cpp, llava.h, llama.h, log.h Changes: - third_party/fllama: git submodule (ZSWatch/fllama fork) - zswatch_app/pubspec.yaml: path dependency ../third_party/fllama - zswatch_app/ios/Podfile: set deployment target - zswatch_app/ios/Runner.xcodeproj/project.pbxproj: update deployment target - zswatch_app/lib/data/database/app_database.dart: guard schema v5 migration - zswatch_app/lib/providers/ai_providers.dart: use ref.listen to avoid recreating LlmService on model-id change --- .gitmodules | 3 + CHANGELOG.md | 15 +++ third_party/fllama | 1 + zswatch_app/ios/Podfile | 11 +- zswatch_app/ios/Podfile.lock | 51 +++++++-- .../ios/Runner.xcodeproj/project.pbxproj | 6 +- zswatch_app/ios/Runner/Info.plist | 2 +- .../lib/data/database/app_database.dart | 18 +-- zswatch_app/lib/providers/ai_providers.dart | 10 +- zswatch_app/macos/Podfile.lock | 108 ++++++++++++++++++ zswatch_app/pubspec.lock | 8 +- zswatch_app/pubspec.yaml | 5 +- 12 files changed, 207 insertions(+), 31 deletions(-) create mode 100644 CHANGELOG.md create mode 160000 third_party/fllama create mode 100644 zswatch_app/macos/Podfile.lock diff --git a/.gitmodules b/.gitmodules index c1b1963..cf07bc7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "Flutter-nRF-Connect-Device-Manager"] path = Flutter-nRF-Connect-Device-Manager url = https://github.com/ZSWatch/Flutter-nRF-Connect-Device-Manager.git +[submodule "third_party/fllama"] + path = third_party/fllama + url = git@github.com:ZSWatch/fllama.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9f965f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## [1.1.0] - 2026-02-17 + +### What's New +- The firmware update screen now shows only the firmware compatible with your watch, making updates simpler and safer. A new option for watches with a rotated display is also available. + +### Fixes & Improvements +- Fixed an issue on iOS where Bluetooth permission was not requested at the right time, which could prevent the app from connecting to your watch. +- Improved reliability when loading firmware update files from local storage. +- Fixed app icon, splash screen, and launcher icon display on Android and iOS. + +## [1.0.0] - 2026-02-12 + +Initial release. diff --git a/third_party/fllama b/third_party/fllama new file mode 160000 index 0000000..20d78d3 --- /dev/null +++ b/third_party/fllama @@ -0,0 +1 @@ +Subproject commit 20d78d3a1bf4e8165265442dec2831a47b4ae3ee diff --git a/zswatch_app/ios/Podfile b/zswatch_app/ios/Podfile index 620e46e..5edcf0d 100644 --- a/zswatch_app/ios/Podfile +++ b/zswatch_app/ios/Podfile @@ -1,5 +1,5 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +# Required: iOS 14.0+ for ffmpeg_kit_flutter_new_min (audio conversion for Whisper) +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -39,5 +39,12 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + + # Ensure all pods respect the minimum deployment target. + # ffmpeg_kit_flutter_new_min requires 14.0; fllama/whisper_ggml_plus + # require Metal (available since iOS 8) but benefit from 14.0+ APIs. + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + end end end diff --git a/zswatch_app/ios/Podfile.lock b/zswatch_app/ios/Podfile.lock index dc82470..9ef7999 100644 --- a/zswatch_app/ios/Podfile.lock +++ b/zswatch_app/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audio_session (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -30,9 +32,16 @@ PODS: - DKPhotoGallery/Resource (0.0.19): - SDWebImage - SwiftyGif + - ffmpeg_kit_flutter_new_min (7.1.1): + - ffmpeg_kit_flutter_new_min/min (= 7.1.1) + - Flutter + - ffmpeg_kit_flutter_new_min/min (7.1.1): + - Flutter - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - fllama (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_blue_plus_darwin (0.0.2): - Flutter @@ -45,6 +54,9 @@ PODS: - iOSMcuManagerLibrary (1.10.1): - SwiftCBOR (= 0.4.7) - ZIPFoundation (= 0.9.19) + - just_audio (0.0.1): + - Flutter + - FlutterMacOS - mcumgr_flutter (0.6.1): - Flutter - iOSMcuManagerLibrary (= 1.10.1) @@ -56,9 +68,9 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) + - SDWebImage (5.21.6): + - SDWebImage/Core (= 5.21.6) + - SDWebImage/Core (5.21.6) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -88,20 +100,29 @@ PODS: - sqlite3/rtree - sqlite3/session - SwiftCBOR (0.4.7) - - SwiftProtobuf (1.33.3) + - SwiftProtobuf (1.34.1) - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): - Flutter + - whisper_ggml_plus (1.3.1): + - Flutter + - whisper_ggml_plus/no-arc (= 1.3.1) + - whisper_ggml_plus/no-arc (1.3.1): + - Flutter - ZIPFoundation (0.9.19) DEPENDENCIES: + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - ffmpeg_kit_flutter_new_min (from `.symlinks/plugins/ffmpeg_kit_flutter_new_min/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) + - fllama (from `.symlinks/plugins/fllama/ios`) - Flutter (from `Flutter`) - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -110,6 +131,7 @@ DEPENDENCIES: - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + - whisper_ggml_plus (from `.symlinks/plugins/whisper_ggml_plus/ios`) SPEC REPOS: trunk: @@ -124,8 +146,14 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + ffmpeg_kit_flutter_new_min: + :path: ".symlinks/plugins/ffmpeg_kit_flutter_new_min/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" + fllama: + :path: ".symlinks/plugins/fllama/ios" Flutter: :path: Flutter flutter_blue_plus_darwin: @@ -134,6 +162,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" mcumgr_flutter: :path: ".symlinks/plugins/mcumgr_flutter/ios" package_info_plus: @@ -150,31 +180,38 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" wakelock_plus: :path: ".symlinks/plugins/wakelock_plus/ios" + whisper_ggml_plus: + :path: ".symlinks/plugins/whisper_ggml_plus/ios" SPEC CHECKSUMS: + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + ffmpeg_kit_flutter_new_min: 85493896be79f45e846b5c9b23a3bf73eb284c4a file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + fllama: 9e717d7e6b8b5b00ad33500d900f8f036c85f76a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 + SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 SwiftCBOR: 465775bed0e8bac7bfb8160bcf7b95d7f75971e4 - SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 + SwiftProtobuf: c901f00a3e125dc33cac9b16824da85682ee47da SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + whisper_ggml_plus: a01d7b6bf1208c76137032b9d6c91b1d202038e4 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: b4d94b25c2131f58eba197ab3f5cee04a5e8332a COCOAPODS: 1.16.2 diff --git a/zswatch_app/ios/Runner.xcodeproj/project.pbxproj b/zswatch_app/ios/Runner.xcodeproj/project.pbxproj index 0974282..12f4e50 100644 --- a/zswatch_app/ios/Runner.xcodeproj/project.pbxproj +++ b/zswatch_app/ios/Runner.xcodeproj/project.pbxproj @@ -473,7 +473,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -605,7 +605,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -658,7 +658,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/zswatch_app/ios/Runner/Info.plist b/zswatch_app/ios/Runner/Info.plist index 41f2775..61770ff 100644 --- a/zswatch_app/ios/Runner/Info.plist +++ b/zswatch_app/ios/Runner/Info.plist @@ -27,7 +27,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 13.0 + 14.0 NSBluetoothAlwaysUsageDescription ZSWatch needs Bluetooth to communicate with your smartwatch for syncing data, notifications, and firmware updates. diff --git a/zswatch_app/lib/data/database/app_database.dart b/zswatch_app/lib/data/database/app_database.dart index 85c56f6..cf46d66 100644 --- a/zswatch_app/lib/data/database/app_database.dart +++ b/zswatch_app/lib/data/database/app_database.dart @@ -56,14 +56,16 @@ class AppDatabase extends _$AppDatabase { if (from < 5) { // Add AI-enhanced voice notes fields and extracted actions table await m.createTable(extractedActions); - await m.addColumn(voiceMemos, voiceMemos.summary); - await m.addColumn(voiceMemos, voiceMemos.category); - await m.addColumn(voiceMemos, voiceMemos.processingStatus); - await m.addColumn(voiceMemos, voiceMemos.aiModel); - await m.addColumn(voiceMemos, voiceMemos.aiProcessedAt); - await m.addColumn(voiceMemos, voiceMemos.taskCreated); - await m.addColumn(voiceMemos, voiceMemos.calendarEventCreated); - await m.addColumn(voiceMemos, voiceMemos.actionReviewState); + if (from == 4) { + await m.addColumn(voiceMemos, voiceMemos.summary); + await m.addColumn(voiceMemos, voiceMemos.category); + await m.addColumn(voiceMemos, voiceMemos.processingStatus); + await m.addColumn(voiceMemos, voiceMemos.aiModel); + await m.addColumn(voiceMemos, voiceMemos.aiProcessedAt); + await m.addColumn(voiceMemos, voiceMemos.taskCreated); + await m.addColumn(voiceMemos, voiceMemos.calendarEventCreated); + await m.addColumn(voiceMemos, voiceMemos.actionReviewState); + } } }, ); diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart index 2896ee5..fe652d1 100644 --- a/zswatch_app/lib/providers/ai_providers.dart +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -17,9 +17,15 @@ import 'watch_providers.dart'; /// Singleton LLM service backed by fllama. final llmServiceProvider = Provider((ref) { - final selectedModelId = ref.watch(selectedAiModelIdProvider); final service = LlmService(); - service.selectModel(selectedModelId); + + // Update model gracefully without completely destroying the LlmService + ref.listen(selectedAiModelIdProvider, (previous, next) { + service.selectModel(next); + }); + + service.selectModel(ref.read(selectedAiModelIdProvider)); + ref.onDispose(() => service.dispose()); return service; }); diff --git a/zswatch_app/macos/Podfile.lock b/zswatch_app/macos/Podfile.lock new file mode 100644 index 0000000..8355d71 --- /dev/null +++ b/zswatch_app/macos/Podfile.lock @@ -0,0 +1,108 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): + - sqlite3/common + - sqlite3/fts5 (3.50.4): + - sqlite3/common + - sqlite3/math (3.50.4): + - sqlite3/common + - sqlite3/perf-threadsafe (3.50.4): + - sqlite3/common + - sqlite3/rtree (3.50.4): + - sqlite3/common + - sqlite3/session (3.50.4): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.50.4) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session + - url_launcher_macos (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + +SPEC REPOS: + trunk: + - sqlite3 + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + flutter_blue_plus_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + geolocator_apple: + :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + +SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index 1325a45..debc0a8 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -379,11 +379,9 @@ packages: fllama: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: "13a73d0666bd5e8b5d1c05decdc8dea6a1179331" - url: "https://github.com/Telosnex/fllama" - source: git + path: "../third_party/fllama" + relative: true + source: path version: "0.0.1" flutter: dependency: "direct main" diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index 121962c..d197acd 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -72,10 +72,9 @@ dependencies: geolocator: ^13.0.2 # Local LLM inference (llama.cpp via fllama) + # Git submodule (ZSWatch/fllama fork) with iOS ggml header-map fixes. fllama: - git: - url: https://github.com/Telosnex/fllama - ref: main + path: ../third_party/fllama chrono_ai_flow: path: ../packages/chrono_ai_flow From 94affab6ef2f5440d5df406f52e142db5d15ce67 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 09:12:56 +0100 Subject: [PATCH 07/58] feat: iOS calendar/reminders permissions, dynamic RAM inference, model fit UI - iOS: Calendar Integration section shown on all platforms; add _RemindersPermissionTile - Podfile: PERMISSION_EVENTS, PERMISSION_EVENTS_FULL_ACCESS, PERMISSION_REMINDERS macros - AppDelegate: getDeviceMemoryMB via ProcessInfo.physicalMemory, switch-based routing - MainActivity: getDeviceMemoryMB via ActivityManager.MemoryInfo.totalMem - LlmService: ModelMemoryFit enum, ModelFitResult, _computeInferenceParams() RAM heuristic, CPU fallback <100 MB headroom, checkModelFit() public API - ai_providers: llmModelFitProvider (FutureProvider) - Settings screen: _ModelMemoryFitBanner with green/amber/red fit indicator --- .../kotlin/dev/zswatch/app/MainActivity.kt | 6 + zswatch_app/ios/Podfile | 13 ++ zswatch_app/ios/Podfile.lock | 2 +- zswatch_app/ios/Runner/AppDelegate.swift | 11 +- zswatch_app/lib/providers/ai_providers.dart | 7 + zswatch_app/lib/services/ai/llm_service.dart | 181 +++++++++++++++++- .../settings/ai_models_settings_screen.dart | 175 +++++++++++++++-- 7 files changed, 375 insertions(+), 20 deletions(-) diff --git a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt index cc99f24..e6b43dc 100644 --- a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt +++ b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt @@ -404,6 +404,12 @@ class MainActivity : FlutterActivity() { "openCalendarEntry" -> handleOpenCalendarEntry(call, result) "checkCalendarSyncHealth" -> handleCheckCalendarSyncHealth(call, result) "openCalendarSyncSettings" -> handleOpenCalendarSyncSettings(call, result) + "getDeviceMemoryMB" -> { + val am = getSystemService(android.content.Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val memInfo = android.app.ActivityManager.MemoryInfo() + am.getMemoryInfo(memInfo) + result.success((memInfo.totalMem / (1024 * 1024)).toInt()) + } else -> result.notImplemented() } } diff --git a/zswatch_app/ios/Podfile b/zswatch_app/ios/Podfile index 5edcf0d..c3edff4 100644 --- a/zswatch_app/ios/Podfile +++ b/zswatch_app/ios/Podfile @@ -46,5 +46,18 @@ post_install do |installer| target.build_configurations.each do |config| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' end + + # Enable calendar & reminders permissions for permission_handler on iOS. + # Without these macros the plugin compiles out the permission code and the + # system dialog never appears. + # Macro names: https://pub.dev/packages/permission_handler (iOS setup) + if target.name == 'permission_handler_apple' + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)'] + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'PERMISSION_EVENTS=1' + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'PERMISSION_EVENTS_FULL_ACCESS=1' + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'PERMISSION_REMINDERS=1' + end + end end end diff --git a/zswatch_app/ios/Podfile.lock b/zswatch_app/ios/Podfile.lock index 9ef7999..5c3c704 100644 --- a/zswatch_app/ios/Podfile.lock +++ b/zswatch_app/ios/Podfile.lock @@ -212,6 +212,6 @@ SPEC CHECKSUMS: whisper_ggml_plus: a01d7b6bf1208c76137032b9d6c91b1d202038e4 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: b4d94b25c2131f58eba197ab3f5cee04a5e8332a +PODFILE CHECKSUM: 1dea06515fc3f63e8ba85cdb5cefc845478b247e COCOAPODS: 1.16.2 diff --git a/zswatch_app/ios/Runner/AppDelegate.swift b/zswatch_app/ios/Runner/AppDelegate.swift index 87b3fbb..d4e26ed 100644 --- a/zswatch_app/ios/Runner/AppDelegate.swift +++ b/zswatch_app/ios/Runner/AppDelegate.swift @@ -28,11 +28,18 @@ import UIKit } private func handleProductivityCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard call.method == "createAction" else { + switch call.method { + case "createAction": + handleCreateAction(call, result: result) + case "getDeviceMemoryMB": + let bytes = ProcessInfo.processInfo.physicalMemory + result(Int(bytes / (1024 * 1024))) + default: result(FlutterMethodNotImplemented) - return } + } + private func handleCreateAction(_ call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any] else { result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing action arguments.", details: nil)) return diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart index fe652d1..663da18 100644 --- a/zswatch_app/lib/providers/ai_providers.dart +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -58,6 +58,13 @@ final llmModelSizeProvider = FutureProvider((ref) async { return service.modelFileSize(); }); +/// Memory fit check for the currently selected model on this device. +final llmModelFitProvider = FutureProvider((ref) async { + final service = ref.watch(llmServiceProvider); + final model = await ref.watch(selectedLlmModelInfoProvider.future); + return service.checkModelFit(model); +}); + // --------------------------------------------------------------------------- // Extracted-action repository // --------------------------------------------------------------------------- diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index b53c458..7c2702c 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:chrono_ai_flow/chrono_ai_flow.dart'; import 'package:fllama/fllama.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -23,6 +24,16 @@ class LlmModelInfo { final int? expectedSizeBytes; final bool userProvided; + /// Per-model context size override. When non-null the inference engine will + /// use this instead of the global [LlmService.nCtx]. Useful for memory- + /// hungry architectures (e.g. Qwen3.5 with gated attention). + final int? contextSize; + + /// Per-model GPU layer limit. When non-null, overrides + /// [LlmService.numGpuLayers]. Set to a low value (or 0) for models that + /// exceed available Metal VRAM on smaller devices. + final int? maxGpuLayers; + const LlmModelInfo({ required this.id, required this.displayName, @@ -31,6 +42,8 @@ class LlmModelInfo { this.downloadUrl, this.expectedSizeBytes, this.userProvided = false, + this.contextSize, + this.maxGpuLayers, }); bool get isDownloadable => downloadUrl != null; @@ -47,6 +60,49 @@ enum LlmServiceStatus { error, } +/// How well a model fits into available device memory. +enum ModelMemoryFit { + /// Plenty of headroom — full context, full GPU. + comfortable, + + /// Tight — context will be reduced but GPU is still used. + reduced, + + /// Very tight — minimal context and CPU-only fallback. + cpuFallback, +} + +/// Result of [LlmService.checkModelFit] for a specific model on this device. +class ModelFitResult { + final ModelMemoryFit fit; + final int contextSize; + final int gpuLayers; + final int deviceMemoryMB; + final int modelSizeMB; + final int headroomMB; + + const ModelFitResult({ + required this.fit, + required this.contextSize, + required this.gpuLayers, + required this.deviceMemoryMB, + required this.modelSizeMB, + required this.headroomMB, + }); + + /// Human-readable summary for the UI. + String get summary { + switch (fit) { + case ModelMemoryFit.comfortable: + return 'Fits well — full performance'; + case ModelMemoryFit.reduced: + return 'Tight fit — context reduced to $contextSize tokens'; + case ModelMemoryFit.cpuFallback: + return 'Low memory — CPU-only mode (slower)'; + } + } +} + /// Observable state of the service (for the settings UI). class LlmServiceState { final LlmServiceStatus status; @@ -283,11 +339,14 @@ class LlmService { double temperature = 0.1; double topP = 0.9; double presencePenalty = 1.1; + + /// GPU layer offloading. 99 = all layers on Metal/GPU, 0 = CPU-only. int numGpuLayers = 99; // ---- Internal state ---- String? _modelPath; int _runningRequestId = -1; + int? _deviceMemoryMB; final _stateSubject = BehaviorSubject.seeded( const LlmServiceState(), @@ -297,6 +356,34 @@ class LlmService { Stream get stateStream => _stateSubject.stream; LlmServiceState get currentState => _stateSubject.value; + /// Check how well [modelInfo] fits on this device. Call from the UI when the + /// user selects a model to show a warning banner if limits will be applied. + Future checkModelFit(LlmModelInfo modelInfo) async { + final params = await _computeInferenceParams(modelInfo); + final deviceMB = _deviceMemoryMB ?? 4096; + final modelMB = (modelInfo.expectedSizeBytes ?? 0) ~/ (1024 * 1024); + final usableMB = (deviceMB * 0.55).round(); + final headroomMB = usableMB - modelMB; + + ModelMemoryFit fit; + if (params.gpuLayers == 0) { + fit = ModelMemoryFit.cpuFallback; + } else if (params.contextSize < nCtx) { + fit = ModelMemoryFit.reduced; + } else { + fit = ModelMemoryFit.comfortable; + } + + return ModelFitResult( + fit: fit, + contextSize: params.contextSize, + gpuLayers: params.gpuLayers, + deviceMemoryMB: deviceMB, + modelSizeMB: modelMB, + headroomMB: headroomMB, + ); + } + // ---- Helpers ---- Future _modelDir() async { @@ -533,6 +620,88 @@ class LlmService { _modelPath = path; } + // ---- Memory-aware tunables ---- + + static const MethodChannel _deviceChannel = + MethodChannel('dev.zswatch.app/productivity'); + + /// Query device physical RAM (MB), cached after first call. + Future _queryDeviceMemoryMB() async { + if (_deviceMemoryMB != null) return _deviceMemoryMB!; + try { + final mb = await _deviceChannel.invokeMethod('getDeviceMemoryMB'); + _deviceMemoryMB = mb ?? 4096; // conservative fallback + } on MissingPluginException { + _deviceMemoryMB = 4096; + } catch (e) { + debugPrint('[LlmService] Failed to query device memory: $e'); + _deviceMemoryMB = 4096; + } + debugPrint('[LlmService] Device physical RAM: ${_deviceMemoryMB}MB'); + return _deviceMemoryMB!; + } + + /// Compute context size dynamically based on available device RAM and model + /// size. Uses [LlmModelInfo.contextSize] if explicitly set, otherwise scales + /// down when the model weight file would leave too little headroom for the + /// KV cache + compute buffers on the GPU. + /// + /// Heuristic budget (conservative): + /// usableGPU ≈ deviceRAM × 0.55 (OS + app + Flutter overhead) + /// headroom = usableGPU − modelFileSize + /// if headroom ≥ 600 MB → nCtx 2048, full GPU + /// if headroom ≥ 300 MB → nCtx 1024, full GPU + /// if headroom ≥ 100 MB → nCtx 512, full GPU + /// else → nCtx 512, CPU-only (avoid Metal OOM crash) + Future<({int contextSize, int gpuLayers})> _computeInferenceParams( + LlmModelInfo modelInfo, + ) async { + // Explicit per-model overrides win. + final explicitCtx = modelInfo.contextSize; + final explicitGpu = modelInfo.maxGpuLayers; + if (explicitCtx != null && explicitGpu != null) { + return (contextSize: explicitCtx, gpuLayers: explicitGpu); + } + + final deviceMB = await _queryDeviceMemoryMB(); + final modelMB = (modelInfo.expectedSizeBytes ?? 0) ~/ (1024 * 1024); + + // On iOS, Metal shares unified memory with the system. After OS + app + + // Flutter + BLE overhead, roughly 55% is available for the GPU working set. + // On Android the GPU typically has even less available headroom. + final usableMB = (deviceMB * 0.55).round(); + final headroomMB = usableMB - modelMB; + + int ctx; + int gpu; + + if (headroomMB >= 600) { + ctx = nCtx; // full context (default 2048) + gpu = numGpuLayers; + } else if (headroomMB >= 300) { + ctx = 1024; + gpu = numGpuLayers; + } else if (headroomMB >= 100) { + ctx = 512; + gpu = numGpuLayers; + } else { + // Very tight — fall back to CPU-only to avoid Metal page-fault crash. + ctx = 512; + gpu = 0; + debugPrint( + '[LlmService] WARNING: Low memory headroom (${headroomMB}MB). ' + 'Falling back to CPU-only inference to prevent GPU crash. ' + 'Model ${modelInfo.id} ($modelMB MB) on device with $deviceMB MB RAM.', + ); + } + + // Let explicit per-model values override the computed ones. + return ( + contextSize: explicitCtx ?? ctx, + gpuLayers: explicitGpu ?? gpu, + ); + } + static void _logFilter(String log) { if (log.contains('loaded') || log.contains('error') || @@ -562,16 +731,24 @@ class LlmService { final stopwatch = Stopwatch()..start(); int tokenCount = 0; + final modelInfo = await currentModelInfo(); + final params = await _computeInferenceParams(modelInfo); + + debugPrint( + '[LlmService] Inference: model=${modelInfo.id} nCtx=${params.contextSize} ' + 'gpuLayers=${params.gpuLayers} deviceRAM=${_deviceMemoryMB ?? "?"}MB', + ); + final request = OpenAiRequest( messages: [Message(Role.user, prompt)], modelPath: _modelPath!, maxTokens: overrideMaxTokens ?? maxTokens, - numGpuLayers: numGpuLayers, + numGpuLayers: params.gpuLayers, temperature: temperature, topP: topP, frequencyPenalty: 0.0, presencePenalty: presencePenalty, - contextSize: nCtx, + contextSize: params.contextSize, logger: _logFilter, ); diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index cbb4399..7587815 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -69,19 +69,18 @@ class AiModelsSettingsScreen extends ConsumerWidget { const _AiTogglesTile(), const _AiModelSelector(), - if (Platform.isAndroid) ...[ - const SizedBox(height: 24), - const Divider(height: 1), - const SizedBox(height: 8), - - // ---- Calendar section (Android only) ---- - const _SectionHeader( - title: 'Calendar Integration', - subtitle: 'Permission and calendar for AI-created events', - ), - const _CalendarPermissionTile(), - const _CalendarPickerTile(), - ], + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + + // ---- Calendar / Reminders section ---- + const _SectionHeader( + title: 'Calendar Integration', + subtitle: 'Permissions for AI-created events & reminders', + ), + const _CalendarPermissionTile(), + if (Platform.isIOS) const _RemindersPermissionTile(), + if (Platform.isAndroid) const _CalendarPickerTile(), const SizedBox(height: 24), const Divider(height: 1), @@ -872,6 +871,9 @@ class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { : 'Catalog', ), + // Memory fit indicator + _ModelMemoryFitBanner(model: selectedModel), + // Download progress if (isDownloading) ...[ const SizedBox(height: 8), @@ -1627,7 +1629,66 @@ class _CompactButton extends StatelessWidget { } // --------------------------------------------------------------------------- -// Calendar Integration (Android only) +// Memory fit banner for the selected AI model +// --------------------------------------------------------------------------- + +class _ModelMemoryFitBanner extends ConsumerWidget { + const _ModelMemoryFitBanner({required this.model}); + + final LlmModelInfo model; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fitAsync = ref.watch(llmModelFitProvider); + + return fitAsync.when( + data: (fit) { + final IconData icon; + final Color color; + final String label; + + switch (fit.fit) { + case ModelMemoryFit.comfortable: + icon = Icons.check_circle_outline; + color = AppTheme.successColor; + label = fit.summary; + case ModelMemoryFit.reduced: + icon = Icons.warning_amber_rounded; + color = AppTheme.warningColor; + label = fit.summary; + case ModelMemoryFit.cpuFallback: + icon = Icons.memory; + color = AppTheme.errorColor; + label = fit.summary; + } + + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} + +// --------------------------------------------------------------------------- +// Calendar Integration // --------------------------------------------------------------------------- /// Shows calendar permission status and a button to grant it. @@ -1718,8 +1779,92 @@ class _CalendarPermissionTileState } } +/// Shows iOS Reminders permission status (separate from calendar on iOS). +class _RemindersPermissionTile extends ConsumerStatefulWidget { + const _RemindersPermissionTile(); + + @override + ConsumerState<_RemindersPermissionTile> createState() => + _RemindersPermissionTileState(); +} + +class _RemindersPermissionTileState + extends ConsumerState<_RemindersPermissionTile> + with WidgetsBindingObserver { + bool _granted = false; + bool _checking = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _checkPermission(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkPermission(); + } + } + + Future _checkPermission() async { + final status = await Permission.reminders.status; + if (mounted) { + setState(() { + _granted = status.isGranted; + _checking = false; + }); + } + } + + Future _requestPermission() async { + final status = await Permission.reminders.request(); + if (status.isPermanentlyDenied && mounted) { + await openAppSettings(); + } + await _checkPermission(); + } + + @override + Widget build(BuildContext context) { + if (_checking) { + return const ListTile( + leading: Icon(Icons.hourglass_empty, color: AppTheme.textSecondary), + title: Text('Reminders Permission'), + subtitle: Text('Checking...'), + ); + } + + return ListTile( + leading: Icon( + _granted ? Icons.check_circle : Icons.checklist, + color: _granted ? AppTheme.successColor : AppTheme.warningColor, + ), + title: const Text('Reminders Permission'), + subtitle: Text( + _granted + ? 'Granted — AI can create reminders' + : 'Required for creating reminders from voice memos', + ), + trailing: _granted + ? null + : FilledButton( + onPressed: _requestPermission, + child: const Text('Grant'), + ), + ); + } +} + /// Shows the selected calendar and allows picking a different one. -/// Only visible when calendar permission is granted. +/// Only visible when calendar permission is granted (Android only). class _CalendarPickerTile extends ConsumerWidget { const _CalendarPickerTile(); From f7163287c55929d550b7af86d6476ba3d6ed805c Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 13:21:56 +0100 Subject: [PATCH 08/58] Add CorrectionPromptTemplate to chrono_ai_flow, use dynamic maxTokens in llm_service --- .../chrono_ai_flow/lib/chrono_ai_flow.dart | 1 + .../lib/src/correction_prompt_template.dart | 58 +++++++++++++++++++ .../lib/src/prompt_template.dart | 15 ++++- zswatch_app/lib/services/ai/llm_service.dart | 29 ++++------ 4 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 packages/chrono_ai_flow/lib/src/correction_prompt_template.dart diff --git a/packages/chrono_ai_flow/lib/chrono_ai_flow.dart b/packages/chrono_ai_flow/lib/chrono_ai_flow.dart index 526f593..f507411 100644 --- a/packages/chrono_ai_flow/lib/chrono_ai_flow.dart +++ b/packages/chrono_ai_flow/lib/chrono_ai_flow.dart @@ -1,3 +1,4 @@ +export 'src/correction_prompt_template.dart'; export 'src/models.dart'; export 'src/prompt_template.dart'; export 'src/parser.dart'; diff --git a/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart b/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart new file mode 100644 index 0000000..ab6ea1f --- /dev/null +++ b/packages/chrono_ai_flow/lib/src/correction_prompt_template.dart @@ -0,0 +1,58 @@ +/// Shared prompt template for speech-to-text correction. +/// +/// Used by both the main app (`llm_service.dart`) and the AI testbench +/// (`correction_benchmark_service.dart`). Edit this file to tune the +/// correction prompt — changes apply everywhere automatically. +class CorrectionPromptTemplate { + CorrectionPromptTemplate._(); + + static const String promptPlaceholderTranscript = '{{transcript}}'; + + static const String defaultTemplate = ''' +Fix speech-to-text errors. Output ONLY the corrected text. + +IMPORTANT: NEVER translate. The output language MUST be the same as the input language. Swedish input → Swedish output. German input → German output. + +Fix these common STT errors: +- Homophones: "there"/"their", "weak"/"week", "by"/"buy" +- Stuttering/repeats: "I I need" → "I need", "och och" → "och" +- Filler words: remove um, uh, eh, like, you know, alltså, liksom, also +- Missing punctuation: add periods, commas, capitalize first word +- Missing diacritics: "mote" → "möte", "fur" → "für", "pa" → "på" +- Split/joined words: "i morgan" → "imorgon", "can not" → "cannot" + +If already correct, repeat the input verbatim. Do NOT add explanations. Output only the corrected text. + +Input: "remind me to uh call the the dentist and dont forget to by milk" +Output: "Remind me to call the dentist and don't forget to buy milk." + +Input: "vi har mote med kunden pa torsdag klockan tva" +Output: "Vi har möte med kunden på torsdag klockan två." + +Input: "also ich äh muss muss den Bericht fertig machen" +Output: "Ich muss den Bericht fertig machen." + +Input: "$promptPlaceholderTranscript" +/no_think +Output:'''; + + /// Render the correction prompt with the given [transcript]. + static String render( + String template, { + required String transcript, + }) { + return template.replaceAll(promptPlaceholderTranscript, transcript); + } + + /// Estimate a reasonable maxTokens for the correction output. + /// + /// The corrected text is always roughly the same length as (or shorter than) + /// the input transcript. We use ~3.5 chars/token (conservative for + /// mixed-language text with diacritics) and add a fixed margin for + /// punctuation/capitalization changes. + static int estimateMaxTokens(String transcript, {int margin = 32}) { + final estimated = (transcript.length / 3.5).ceil() + margin; + // Clamp to a sane range: at least 64, at most 512. + return estimated.clamp(64, 512); + } +} diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index 724fc09..afc17ef 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -24,13 +24,14 @@ Your tasks: 4. Extract a short title (the task or event, NOT the time part). Rules: +- The title MUST stay in the SAME language as the voice memo. DO NOT translate the title to English. - NEVER compute or resolve dates. NEVER output ISO timestamps. - Keep time expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". - Copy the original time phrase exactly from the memo. - You MUST fill "datetime_expression_english" whenever "datetime_expression_original" is not null. - If the memo is in English, copy the same English time phrase to both fields. - If no time/date is mentioned, set both datetime fields to null and intent to "note". -- The title must be short (2-5 words) and in the ORIGINAL language. +- Title must be short (2-5 words). Only translate datetime fields to English, NEVER the title. - Translate time expressions accurately to natural English. Convert 24-hour to 12-hour format. Translate idioms correctly (e.g. the Swedish "halv 10" means 9:30, not 10:30). - Intent rules: - "event" = scheduled meetings, appointments, bookings (dentist, conference, meeting with someone) @@ -61,6 +62,18 @@ Memo: "köp bröd på vägen hem" Memo: "call the plumber this afternoon at 3" {"intent":"reminder","title":"call the plumber","datetime_expression_original":"this afternoon at 3","datetime_expression_english":"this afternoon at 3 pm"} +Memo: "Arzttermin am Donnerstag um 9 Uhr" +{"intent":"event","title":"Arzttermin","datetime_expression_original":"am Donnerstag um 9 Uhr","datetime_expression_english":"Thursday at 9 am"} + +WRONG — never translate the title, not even for notes: +Memo: "möte med projektgruppen på torsdag klockan 14" +WRONG: {"intent":"event","title":"meeting with project group",...} +RIGHT: {"intent":"event","title":"möte projektgruppen","datetime_expression_original":"på torsdag klockan 14","datetime_expression_english":"Thursday at 2 pm"} + +Memo: "köp mjölk och bröd på vägen hem" +WRONG: {"intent":"note","title":"buy milk and bread",...} +RIGHT: {"intent":"note","title":"köp mjölk och bröd","datetime_expression_original":null,"datetime_expression_english":null} + Output JSON schema: { "intent": "reminder" | "event" | "note", diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 7c2702c..fe7eb76 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -811,9 +811,11 @@ class LlmService { // --- Step 1: Correct transcription errors if enabled --- if (correctTranscription) { final correctionPrompt = _buildCorrectionPrompt(transcript); + final correctionMaxTokens = + CorrectionPromptTemplate.estimateMaxTokens(transcript); final correctionResult = await _generate( correctionPrompt, - overrideMaxTokens: 1024, + overrideMaxTokens: correctionMaxTokens, onPartialResponse: onProgress == null ? null : (partial, tokens) => onProgress('correcting', partial, tokens), @@ -913,23 +915,10 @@ class LlmService { // ---- Prompt construction ---- String _buildCorrectionPrompt(String transcript) { - return ''' -You are a precise transcription correction assistant. - -The following text was produced by an automatic speech-to-text system and may contain errors such as: -- Wrong words that sound similar (homophones) -- Missing or extra words -- Spelling mistakes in proper nouns -- Grammar errors introduced by the speech recognizer - -Your job: output ONLY the corrected text, preserving the original language. -Do not add explanations, markdown, or any text that was not in the original. -If the transcription is already correct, output it unchanged. - -Original transcription: -"$transcript" - -Corrected transcription:'''; + return CorrectionPromptTemplate.render( + CorrectionPromptTemplate.defaultTemplate, + transcript: transcript, + ); } String _buildClassifyPrompt(String transcript) { @@ -1055,9 +1044,11 @@ JSON: // --- Step 1: Correct transcription errors if enabled --- if (correctTranscription) { final correctionPrompt = _buildCorrectionPrompt(transcript); + final correctionMaxTokens = + CorrectionPromptTemplate.estimateMaxTokens(transcript); final correctionResult = await _generate( correctionPrompt, - overrideMaxTokens: 1024, + overrideMaxTokens: correctionMaxTokens, onPartialResponse: onProgress == null ? null : (partial, tokens) => onProgress('correcting', partial, tokens), From e835a9ee3b36c77016b9a79dc32b0f492e00e03b Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 14:15:19 +0100 Subject: [PATCH 09/58] Fix iOS background Metal crashes and add CPU/GPU mode toggle --- zswatch_app/ios/Podfile | 9 ++ zswatch_app/ios/Podfile.lock | 2 +- zswatch_app/ios/patch_whisper_gpu.rb | 101 ++++++++++++++ zswatch_app/lib/app.dart | 5 + .../lib/providers/settings_providers.dart | 48 +++++++ .../lib/providers/voice_memo_providers.dart | 13 ++ zswatch_app/lib/services/ai/llm_service.dart | 16 ++- .../voice_memo/whisper_lifecycle_manager.dart | 128 ++++++++++++++++++ .../voice_memo/whisper_native_bridge.dart | 83 ++++++++++++ .../settings/ai_models_settings_screen.dart | 89 ++++++++++++ zswatch_app/pubspec.lock | 2 +- zswatch_app/pubspec.yaml | 1 + 12 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 zswatch_app/ios/patch_whisper_gpu.rb create mode 100644 zswatch_app/lib/services/voice_memo/whisper_lifecycle_manager.dart create mode 100644 zswatch_app/lib/services/voice_memo/whisper_native_bridge.dart diff --git a/zswatch_app/ios/Podfile b/zswatch_app/ios/Podfile index c3edff4..4357eb8 100644 --- a/zswatch_app/ios/Podfile +++ b/zswatch_app/ios/Podfile @@ -4,6 +4,10 @@ platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' +# Build-time patch: adds dynamic GPU/CPU switching to whisper_ggml_plus +# to prevent Metal crashes when the app is backgrounded on iOS. +require_relative 'patch_whisper_gpu' + project 'Runner', { 'Debug' => :debug, 'Profile' => :release, @@ -37,6 +41,11 @@ target 'Runner' do end post_install do |installer| + # Patch whisper_ggml_plus C++ source to support dynamic GPU/CPU switching. + # Must run before build — modifies the hardcoded use_gpu=true to be + # controllable via a "forceCpu" FFI command from Dart. + patch_whisper_gpu_control(installer) + installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) diff --git a/zswatch_app/ios/Podfile.lock b/zswatch_app/ios/Podfile.lock index 5c3c704..a2592f6 100644 --- a/zswatch_app/ios/Podfile.lock +++ b/zswatch_app/ios/Podfile.lock @@ -212,6 +212,6 @@ SPEC CHECKSUMS: whisper_ggml_plus: a01d7b6bf1208c76137032b9d6c91b1d202038e4 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 1dea06515fc3f63e8ba85cdb5cefc845478b247e +PODFILE CHECKSUM: e79c5324de5d9a295fcc0f8df4c3031ea6ae2710 COCOAPODS: 1.16.2 diff --git a/zswatch_app/ios/patch_whisper_gpu.rb b/zswatch_app/ios/patch_whisper_gpu.rb new file mode 100644 index 0000000..b4b580a --- /dev/null +++ b/zswatch_app/ios/patch_whisper_gpu.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Build-time patch for whisper_ggml_plus: adds dynamic GPU/CPU switching. +# +# iOS kills Metal GPU command buffers when the app is backgrounded +# (kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted), which +# crashes the entire process. This patch adds a `forceCpu` command so the +# Dart side can switch to CPU-only before running transcription in background +# and back to GPU when the app is foregrounded. +# +# Called from the Podfile post_install hook. + +def patch_whisper_gpu_control(installer) + # Find the whisper_flutter_plus.cpp source. + # Flutter plugin sources live under ios/.symlinks/plugins/ (symlinked to + # pub cache), not in the Pods root itself. Search both locations. + pods_root = installer.sandbox.root + podfile_dir = File.dirname(pods_root) # ios/ directory + + search_paths = [ + File.join(pods_root, '**', 'whisper_flutter_plus.cpp'), + File.join(podfile_dir, '.symlinks', 'plugins', 'whisper_ggml_plus', '**', 'whisper_flutter_plus.cpp'), + ] + + matches = search_paths.flat_map { |p| Dir.glob(p) }.uniq + + if matches.empty? + Pod::UI.warn '[ZSWatch] whisper_flutter_plus.cpp not found — GPU patch skipped' + return + end + + matches.each do |cpp_path| + src = File.read(cpp_path) + + # Guard: don't patch twice + if src.include?('g_force_cpu') + Pod::UI.message '[ZSWatch] whisper_flutter_plus.cpp already patched — skipping' + next + end + + # 1. Add g_force_cpu global + g_ctx_gpu_mode tracker after existing globals + src.sub!( + 'static std::atomic g_should_abort(false);', + <<~CPP.chomp + static std::atomic g_should_abort(false); + + // --- ZSWatch GPU/CPU toggle for background safety --- + // When true, Metal GPU is disabled and whisper runs on CPU only. + // Controlled via {"@type": "forceCpu", "value": true/false} from Dart. + static std::atomic g_force_cpu(false); + // Tracks whether the cached context was created with GPU enabled. + static bool g_ctx_gpu_mode = true; + CPP + ) + + # 2. Replace hardcoded use_gpu=true with dynamic check, and recreate + # context when GPU mode changes. + src.sub!( + /if \(g_ctx == nullptr \|\| g_model_path != params\.model\)\s*\{[^}]*?cparams\.use_gpu = true;[^}]*?cparams\.flash_attn = true;[^}]*?g_ctx = whisper_init_from_file_with_params\(params\.model\.c_str\(\), cparams\);/m, + <<~CPP.chomp + const bool want_gpu = !g_force_cpu.load(); + if (g_ctx == nullptr || g_model_path != params.model || g_ctx_gpu_mode != want_gpu) { + if (g_ctx_gpu_mode != want_gpu) { + fprintf(stderr, "[ZSWatch] GPU mode changed (%s -> %s), recreating whisper context\\n", + g_ctx_gpu_mode ? "GPU" : "CPU", want_gpu ? "GPU" : "CPU"); + } + dispose_context_locked(); + + whisper_context_params cparams = whisper_context_default_params(); + cparams.use_gpu = want_gpu; + cparams.flash_attn = want_gpu; // flash_attn requires Metal + g_ctx_gpu_mode = want_gpu; + + g_ctx = whisper_init_from_file_with_params(params.model.c_str(), cparams); + CPP + ) + + # 3. Add handler for the forceCpu command in the request() function, + # right before the existing "abort" handler. + src.sub!( + 'if (jsonBody["@type"] == "abort")', + <<~CPP.chomp + if (jsonBody["@type"] == "forceCpu") { + bool value = jsonBody.value("value", false); + g_force_cpu.store(value); + fprintf(stderr, "[ZSWatch] Whisper force_cpu set to %s\\n", value ? "true" : "false"); + // Dispose context so next transcription recreates with correct mode + { + std::lock_guard lock(g_mutex); + dispose_context_locked(); + } + return jsonToChar({{"@type", "forceCpu"}, {"value", value}}); + } + if (jsonBody["@type"] == "abort") + CPP + ) + + File.write(cpp_path, src) + Pod::UI.message "[ZSWatch] Patched whisper_flutter_plus.cpp: added dynamic GPU/CPU control" + end +end diff --git a/zswatch_app/lib/app.dart b/zswatch_app/lib/app.dart index cfd09b0..bbf85cf 100644 --- a/zswatch_app/lib/app.dart +++ b/zswatch_app/lib/app.dart @@ -11,6 +11,7 @@ import 'providers/notification_providers.dart'; import 'providers/permission_providers.dart'; import 'providers/voice_memo_providers.dart'; import 'providers/watch_service_provider.dart'; +import 'services/voice_memo/whisper_lifecycle_manager.dart'; import 'ui/navigation/app_router.dart'; /// Main application widget @@ -63,6 +64,10 @@ class _ZSWatchAppState extends ConsumerState { // Initialize voice memo sync service to handle recording sync from watch // This subscribes to watch messages for new recording notifications ref.read(voiceMemoSyncServiceProvider); + // Initialize GPU lifecycle manager to switch between Metal GPU + // (foreground) and CPU-only (background) — prevents iOS Metal crashes + // for both whisper (transcription) and fllama (LLM inference). + GpuLifecycleManager.instance.initialize(); } catch (e) { debugPrint('BLE initialization error: $e'); } diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index 7d01c9c..ad1f4fb 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../services/voice_memo/transcription_engine.dart'; +import '../services/voice_memo/whisper_lifecycle_manager.dart'; /// Keys for SharedPreferences abstract final class SettingsKeys { @@ -21,6 +22,7 @@ abstract final class SettingsKeys { static const String selectedAiModelId = 'selected_ai_model_id'; static const String selectedProductivityCalendarId = 'selected_productivity_calendar_id'; + static const String gpuInferenceMode = 'gpu_inference_mode'; } /// Provider for SharedPreferences instance @@ -420,3 +422,49 @@ class SelectedProductivityCalendarIdNotifier extends StateNotifier { } } +// --------------------------------------------------------------------------- +// GPU inference mode (iOS Metal) +// --------------------------------------------------------------------------- + +/// Controls GPU usage for whisper (transcription) and fllama (LLM) on iOS. +/// +/// - [GpuInferenceMode.auto]: GPU in foreground, CPU in background (default, +/// prevents Metal crashes when iOS suspends GPU access). +/// - [GpuInferenceMode.alwaysGpu]: Force Metal GPU always (faster, but risky +/// if inference runs while backgrounded). +/// - [GpuInferenceMode.alwaysCpu]: Force CPU always (safe, slower — useful +/// for benchmarking). +final gpuInferenceModeProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return GpuInferenceModeNotifier(prefs.valueOrNull); +}); + +class GpuInferenceModeNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + GpuInferenceModeNotifier(this._prefs) + : super(_fromString( + _prefs?.getString(SettingsKeys.gpuInferenceMode))) { + // Sync the initial value to the lifecycle manager. + GpuLifecycleManager.instance.setGpuMode(state); + } + + static GpuInferenceMode _fromString(String? value) { + switch (value) { + case 'alwaysGpu': + return GpuInferenceMode.alwaysGpu; + case 'alwaysCpu': + return GpuInferenceMode.alwaysCpu; + default: + return GpuInferenceMode.auto; + } + } + + void setMode(GpuInferenceMode mode) { + state = mode; + _prefs?.setString(SettingsKeys.gpuInferenceMode, mode.name); + GpuLifecycleManager.instance.setGpuMode(mode); + } +} + diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart index 1a29cfc..93d7b38 100644 --- a/zswatch_app/lib/providers/voice_memo_providers.dart +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -9,6 +9,8 @@ import '../data/repositories/voice_memo_repository.dart'; import '../services/ai/voice_note_ai_pipeline.dart'; import '../services/voice_memo/transcription_engine.dart'; import '../services/voice_memo/voice_memo_sync_service.dart'; +import '../services/voice_memo/whisper_lifecycle_manager.dart'; // GpuLifecycleManager +import '../services/voice_memo/whisper_native_bridge.dart'; import 'ai_providers.dart'; import 'settings_providers.dart'; import 'watch_providers.dart'; @@ -159,6 +161,17 @@ Future _autoTranscribeAndProcess({ debugPrint( '[VoiceMemoProviders] Auto-transcribing ${untranscribed.length} memos'); + // Safety: ensure whisper uses CPU-only if we're in the background. + // The GpuLifecycleManager handles this proactively, but if the + // lifecycle callback hasn't fired yet (e.g. rapid background entry), + // this explicit check prevents the Metal GPU crash on iOS. + final isBackground = GpuLifecycleManager.instance.isBackground; + if (isBackground) { + debugPrint( + '[VoiceMemoProviders] App is backgrounded — ensuring CPU-only whisper'); + await WhisperNativeBridge.setForceCpu(forceCpu: true); + } + for (final memo in untranscribed) { try { final audioPath = memo.convertedFilePath ?? memo.localFilePath; diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index fe7eb76..b805b16 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -11,6 +11,8 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; +import '../voice_memo/whisper_lifecycle_manager.dart'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -696,9 +698,21 @@ class LlmService { } // Let explicit per-model values override the computed ones. + // On iOS, also check if the GPU lifecycle manager says GPU is currently + // unsafe (app backgrounded in auto mode, or user forced CPU). + final gpuAllowed = GpuLifecycleManager.instance.shouldUseGpu; + int resolvedGpu = explicitGpu ?? gpu; + if (!gpuAllowed && resolvedGpu > 0) { + debugPrint( + '[LlmService] GPU not allowed (background or user preference) — ' + 'forcing CPU-only inference (was gpuLayers=$resolvedGpu).', + ); + resolvedGpu = 0; + } + return ( contextSize: explicitCtx ?? ctx, - gpuLayers: explicitGpu ?? gpu, + gpuLayers: resolvedGpu, ); } diff --git a/zswatch_app/lib/services/voice_memo/whisper_lifecycle_manager.dart b/zswatch_app/lib/services/voice_memo/whisper_lifecycle_manager.dart new file mode 100644 index 0000000..89fbc6c --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/whisper_lifecycle_manager.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'whisper_native_bridge.dart'; + +/// User-facing GPU inference mode for iOS Metal. +/// +/// Persisted in SharedPreferences so the choice survives restarts. +enum GpuInferenceMode { + /// Automatic: GPU in foreground, CPU in background (recommended). + auto, + + /// Always use Metal GPU — faster but will crash if inference runs while + /// the app is backgrounded. + alwaysGpu, + + /// Always use CPU — slower but completely safe in any lifecycle state. + alwaysCpu, +} + +/// Tracks the app's lifecycle state and automatically switches whisper and +/// fllama between Metal GPU (foreground) and CPU-only (background) on iOS. +/// +/// iOS kills Metal GPU command buffers when the app is backgrounded +/// (`kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted`), +/// which crashes the process. This class prevents that by proactively +/// switching whisper to CPU mode before any background transcription runs. +/// +/// The user can override via [gpuMode]: +/// - [GpuInferenceMode.auto]: GPU when foregrounded, CPU when backgrounded +/// - [GpuInferenceMode.alwaysGpu]: Always GPU (risky in background) +/// - [GpuInferenceMode.alwaysCpu]: Always CPU (safe, slower) +/// +/// fllama (Qwen LLM) reads [shouldUseGpu] at inference time to decide +/// `numGpuLayers`. Whisper is controlled via the native FFI bridge. +class GpuLifecycleManager with WidgetsBindingObserver { + static GpuLifecycleManager? _instance; + + /// Singleton access. Created lazily on first call. + static GpuLifecycleManager get instance { + _instance ??= GpuLifecycleManager._(); + return _instance!; + } + + GpuLifecycleManager._(); + + final _isBackground = BehaviorSubject.seeded(false); + final _gpuMode = BehaviorSubject.seeded(GpuInferenceMode.auto); + + /// Whether the app is currently in a background/inactive/hidden state + /// where Metal GPU access is not permitted by iOS. + bool get isBackground => _isBackground.value; + + /// Stream of background state changes. + Stream get backgroundStream => _isBackground.stream; + + /// Current user-chosen GPU mode. + GpuInferenceMode get gpuMode => _gpuMode.value; + + /// Stream of GPU mode changes (for provider/UI binding). + Stream get gpuModeStream => _gpuMode.stream; + + /// Whether GPU should be used *right now* — combines user preference with + /// lifecycle state. Both whisper and fllama should read this. + bool get shouldUseGpu { + if (!Platform.isIOS) return false; // Android: no Metal + switch (_gpuMode.value) { + case GpuInferenceMode.alwaysGpu: + return true; + case GpuInferenceMode.alwaysCpu: + return false; + case GpuInferenceMode.auto: + return !_isBackground.value; + } + } + + /// Set the user-chosen GPU inference mode. Call from the settings UI. + void setGpuMode(GpuInferenceMode mode) { + if (_gpuMode.value == mode) return; + _gpuMode.add(mode); + debugPrint('[GpuLifecycleManager] GPU mode set to: ${mode.name}'); + _syncWhisperGpuState(); + } + + bool _initialized = false; + + /// Start observing lifecycle changes. Call once after Flutter binding is + /// initialized (e.g. in your app's init or a Riverpod provider). + void initialize() { + if (_initialized) return; + _initialized = true; + WidgetsBinding.instance.addObserver(this); + debugPrint('[GpuLifecycleManager] Initialized — monitoring app lifecycle for GPU safety'); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final wasBackground = _isBackground.value; + final nowBackground = state != AppLifecycleState.resumed; + + if (wasBackground != nowBackground) { + _isBackground.add(nowBackground); + debugPrint( + '[GpuLifecycleManager] App lifecycle: $state → ' + '${nowBackground ? "BACKGROUND" : "FOREGROUND"}' + ' (shouldUseGpu=$shouldUseGpu, mode=${_gpuMode.value.name})'); + _syncWhisperGpuState(); + } + } + + /// Push the current GPU decision to whisper's native layer. + void _syncWhisperGpuState() { + if (Platform.isIOS) { + WhisperNativeBridge.setForceCpu(forceCpu: !shouldUseGpu); + } + } + + void dispose() { + if (_initialized) { + WidgetsBinding.instance.removeObserver(this); + _initialized = false; + } + _isBackground.close(); + _gpuMode.close(); + } +} diff --git a/zswatch_app/lib/services/voice_memo/whisper_native_bridge.dart b/zswatch_app/lib/services/voice_memo/whisper_native_bridge.dart new file mode 100644 index 0000000..2f4311b --- /dev/null +++ b/zswatch_app/lib/services/voice_memo/whisper_native_bridge.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; + +/// Low-level FFI bridge to the whisper_ggml_plus native `request()` function. +/// +/// This bypasses the plugin's frozen Dart API so we can send custom commands +/// (e.g. `forceCpu`) that the patched C++ code handles. Only used on iOS where +/// Metal GPU is available; on Android this is a no-op. +abstract final class WhisperNativeBridge { + /// Native function signature: `char* request(char* body)` + static final _request = _openLib()?.lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer)>('request'); + + static DynamicLibrary? _openLib() { + try { + if (Platform.isIOS) { + return DynamicLibrary.process(); + } + if (Platform.isAndroid) { + return DynamicLibrary.open('libwhisper.so'); + } + } catch (e) { + debugPrint('[WhisperNativeBridge] Failed to open native library: $e'); + } + return null; + } + + /// Tell the whisper native code to force CPU-only mode (no Metal GPU). + /// + /// When [forceCpu] is `true`, the cached Metal whisper context is disposed + /// and the next transcription will create a CPU-only context. When `false`, + /// the next transcription will recreate with Metal GPU enabled. + /// + /// This is a no-op on Android (GPU is already disabled at build time) and + /// on platforms where the native library isn't available. + static Future setForceCpu({required bool forceCpu}) async { + if (!Platform.isIOS) return; + + final requestFn = _request; + if (requestFn == null) { + debugPrint('[WhisperNativeBridge] Native request function not available'); + return; + } + + try { + // Run on an isolate to avoid blocking the UI thread — the native side + // acquires a mutex and frees the cached whisper context. + await Isolate.run(() { + final lib = Platform.isIOS + ? DynamicLibrary.process() + : DynamicLibrary.open('libwhisper.so'); + + final nativeRequest = lib.lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer)>('request'); + + final body = json.encode({ + '@type': 'forceCpu', + 'value': forceCpu, + }); + + final Pointer data = body.toNativeUtf8(); + final Pointer res = nativeRequest(data); + + final result = res.toDartString(); + malloc.free(data); + + return result; + }); + + debugPrint( + '[WhisperNativeBridge] forceCpu=$forceCpu — context will be recreated on next transcription'); + } catch (e) { + debugPrint('[WhisperNativeBridge] Failed to set forceCpu: $e'); + } + } +} diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index 7587815..a79d71f 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -15,6 +15,7 @@ import '../../../services/ai/extracted_action_creation_service.dart'; import '../../../services/ai/llm_service.dart'; import '../../../services/ai/model_benchmark_service.dart'; import '../../../services/voice_memo/transcription_engine.dart'; +import '../../../services/voice_memo/whisper_lifecycle_manager.dart'; import '../../widgets/ai_debug_widgets.dart'; // --------------------------------------------------------------------------- @@ -69,6 +70,18 @@ class AiModelsSettingsScreen extends ConsumerWidget { const _AiTogglesTile(), const _AiModelSelector(), + // ---- GPU / Metal section (iOS only) ---- + if (Platform.isIOS) ...[ + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), + const _SectionHeader( + title: 'GPU Acceleration (Metal)', + subtitle: 'Controls Metal GPU for transcription & LLM inference', + ), + const _GpuModeTile(), + ], + const SizedBox(height: 24), const Divider(height: 1), const SizedBox(height: 8), @@ -610,6 +623,82 @@ class _AiTogglesTile extends ConsumerWidget { } } +// --------------------------------------------------------------------------- +// GPU inference mode selector (iOS Metal) +// --------------------------------------------------------------------------- + +class _GpuModeTile extends ConsumerWidget { + const _GpuModeTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(gpuInferenceModeProvider); + final isBackground = GpuLifecycleManager.instance.isBackground; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingMd, + vertical: AppTheme.spacingSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: GpuInferenceMode.auto, + label: Text('Auto'), + icon: Icon(Icons.auto_mode, size: 18), + ), + ButtonSegment( + value: GpuInferenceMode.alwaysGpu, + label: Text('GPU'), + icon: Icon(Icons.speed, size: 18), + ), + ButtonSegment( + value: GpuInferenceMode.alwaysCpu, + label: Text('CPU'), + icon: Icon(Icons.memory, size: 18), + ), + ], + selected: {mode}, + onSelectionChanged: (selected) { + ref + .read(gpuInferenceModeProvider.notifier) + .setMode(selected.first); + }, + ), + const SizedBox(height: 8), + Text( + _modeDescription(mode, isBackground), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + String _modeDescription(GpuInferenceMode mode, bool isBackground) { + switch (mode) { + case GpuInferenceMode.auto: + return isBackground + ? 'Auto: Currently using CPU (app backgrounded)' + : 'Auto: Currently using Metal GPU (app in foreground). ' + 'Switches to CPU automatically when backgrounded to ' + 'prevent iOS Metal crashes.'; + case GpuInferenceMode.alwaysGpu: + return 'Always use Metal GPU for maximum speed. ' + 'Warning: may crash if inference runs while the app is ' + 'backgrounded (e.g. auto-transcription after BLE sync).'; + case GpuInferenceMode.alwaysCpu: + return 'Always use CPU. Slower but safe in all lifecycle states. ' + 'Useful for benchmarking CPU vs GPU performance.'; + } + } +} + // --------------------------------------------------------------------------- // AI Model selector (dropdown + status + download/delete/import) // --------------------------------------------------------------------------- diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index debc0a8..4d0bea1 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -321,7 +321,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index d197acd..ecd4f78 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: equatable: ^2.0.7 uuid: ^4.5.1 rxdart: ^0.28.0 + ffi: ^2.1.3 chrono_dart: ^2.0.2 url_launcher: ^6.3.1 file_picker: ^8.1.6 From 8c98cd585b39475a4eca8f7801b4bb061e9febb7 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 14:17:27 +0100 Subject: [PATCH 10/58] fix: update fllama submodule and gate calendar permission behind AI toggle - Update fllama submodule to fix cpp-httplib unconditional linkage that broke Android builds after the qwen 3.5 llama.cpp drop - Hide calendar/reminders permission tiles and calendar picker until AI processing is enabled in settings - listWritableCalendars() no longer auto-requests permission; it checks status only, returning empty list if not granted --- third_party/fllama | 2 +- .../ai/extracted_action_creation_service.dart | 11 +++++---- .../settings/ai_models_settings_screen.dart | 24 ++++++++++--------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/third_party/fllama b/third_party/fllama index 20d78d3..79f77b8 160000 --- a/third_party/fllama +++ b/third_party/fllama @@ -1 +1 @@ -Subproject commit 20d78d3a1bf4e8165265442dec2831a47b4ae3ee +Subproject commit 79f77b83a8df990693c57dade4f9975ebfb434aa diff --git a/zswatch_app/lib/services/ai/extracted_action_creation_service.dart b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart index f3d3f27..a21e1ac 100644 --- a/zswatch_app/lib/services/ai/extracted_action_creation_service.dart +++ b/zswatch_app/lib/services/ai/extracted_action_creation_service.dart @@ -227,10 +227,13 @@ class ExtractedActionCreationService { return const []; } - await _requestPermission( - Permission.calendarFullAccess, - 'Calendar permission is required to load calendars.', - ); + // Only check permission status — don't request it here. + // The user grants calendar permission via the explicit "Grant" button + // in the AI settings screen. + final status = await Permission.calendarFullAccess.status; + if (!status.isGranted) { + return const []; + } final result = await _channel.invokeListMethod('listWritableCalendars'); if (result == null) { diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index a79d71f..1630732 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -82,18 +82,20 @@ class AiModelsSettingsScreen extends ConsumerWidget { const _GpuModeTile(), ], - const SizedBox(height: 24), - const Divider(height: 1), - const SizedBox(height: 8), + if (ref.watch(localAiEnabledProvider)) ...[ + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 8), - // ---- Calendar / Reminders section ---- - const _SectionHeader( - title: 'Calendar Integration', - subtitle: 'Permissions for AI-created events & reminders', - ), - const _CalendarPermissionTile(), - if (Platform.isIOS) const _RemindersPermissionTile(), - if (Platform.isAndroid) const _CalendarPickerTile(), + // ---- Calendar / Reminders section ---- + const _SectionHeader( + title: 'Calendar Integration', + subtitle: 'Permissions for AI-created events & reminders', + ), + const _CalendarPermissionTile(), + if (Platform.isIOS) const _RemindersPermissionTile(), + if (Platform.isAndroid) const _CalendarPickerTile(), + ], const SizedBox(height: 24), const Divider(height: 1), From 233515b720dbe542221b3aa9a64f59f3a23f8b33 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 20:59:52 +0100 Subject: [PATCH 11/58] settings: Voice Memo AI section improvements - Add dedicated Voice Memo AI section header in settings - Rename AI & Transcription to Voice Memo AI throughout - Show model ranking badges (#1-#4) in model dropdown - Remove 3 worst-performing models from catalog - Remove Experimental label from Qwen3.5 - Move Import .gguf to standalone tile - Shrink Re-transcribe button to secondary TextButton - Add descriptive subtitle to Calendar Integration section - Fix Voice Memo AI grouped under Firmware Update section --- zswatch_app/lib/services/ai/llm_service.dart | 86 ++++---- .../settings/ai_models_settings_screen.dart | 203 ++++++++++++------ .../ui/screens/settings/settings_screen.dart | 17 +- 3 files changed, 190 insertions(+), 116 deletions(-) diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index b805b16..733442f 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -36,6 +36,11 @@ class LlmModelInfo { /// exceed available Metal VRAM on smaller devices. final int? maxGpuLayers; + /// Benchmark score from ai_testbench (passed cases out of [benchmarkTotal]). + /// Shown in the model picker so users can compare accuracy. + final int? benchmarkScore; + final int? benchmarkTotal; + const LlmModelInfo({ required this.id, required this.displayName, @@ -46,6 +51,8 @@ class LlmModelInfo { this.userProvided = false, this.contextSize, this.maxGpuLayers, + this.benchmarkScore, + this.benchmarkTotal, }); bool get isDownloadable => downloadUrl != null; @@ -261,24 +268,19 @@ class LlmService { static const String defaultModelId = 'qwen25_1_5b_q4_k_m'; + // Models ordered by benchmark score (best first). + // Worst 3 removed: SmolLM3 (1/40), Llama-3.2-3B (25/40), Qwen3-1.7B (26/40). static const List catalogModels = [ LlmModelInfo( - id: defaultModelId, - displayName: 'Qwen2.5 1.5B Instruct · Q4_K_M', - family: 'Qwen2.5-1.5B-Instruct', - filename: 'qwen2.5-1.5b-instruct-q4_k_m.gguf', - downloadUrl: - 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf', - expectedSizeBytes: 1120 * 1024 * 1024, - ), - LlmModelInfo( - id: 'qwen25_1_5b_q5_k_m', - displayName: 'Qwen2.5 1.5B Instruct · Q5_K_M', - family: 'Qwen2.5-1.5B-Instruct', - filename: 'qwen2.5-1.5b-instruct-q5_k_m.gguf', + id: 'qwen35_2b_q4_k_m', + displayName: 'Qwen3.5 2B Instruct · Q4_K_M', + family: 'Qwen3.5-2B', + filename: 'Qwen3.5-2B-Q4_K_M.gguf', downloadUrl: - 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q5_k_m.gguf', - expectedSizeBytes: 1290 * 1024 * 1024, + 'https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4_K_M.gguf', + expectedSizeBytes: 1222 * 1024 * 1024, + benchmarkScore: 35, + benchmarkTotal: 40, ), LlmModelInfo( id: 'qwen25_1_5b_q8_0', @@ -288,42 +290,30 @@ class LlmService { downloadUrl: 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q8_0.gguf', expectedSizeBytes: 1890 * 1024 * 1024, + benchmarkScore: 35, + benchmarkTotal: 40, ), LlmModelInfo( - id: 'qwen3_1_7b_q4_k_m', - displayName: 'Qwen3 1.7B Instruct · Q4_K_M', - family: 'Qwen3-1.7B', - filename: 'Qwen3-1.7B-Q4_K_M.gguf', - downloadUrl: - 'https://huggingface.co/ggml-org/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf', - expectedSizeBytes: 1220 * 1024 * 1024, - ), - LlmModelInfo( - id: 'smollm3_3b_q4_k_m', - displayName: 'SmolLM3 3B Instruct · Q4_K_M', - family: 'SmolLM3-3B', - filename: 'SmolLM3-Q4_K_M.gguf', - downloadUrl: - 'https://huggingface.co/ggml-org/SmolLM3-3B-GGUF/resolve/main/SmolLM3-Q4_K_M.gguf', - expectedSizeBytes: 1840 * 1024 * 1024, - ), - LlmModelInfo( - id: 'qwen35_2b_q4_k_m', - displayName: 'Qwen3.5 2B Instruct · Q4_K_M (Experimental)', - family: 'Qwen3.5-2B', - filename: 'Qwen3.5-2B-Q4_K_M.gguf', + id: 'qwen25_1_5b_q5_k_m', + displayName: 'Qwen2.5 1.5B Instruct · Q5_K_M', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q5_k_m.gguf', downloadUrl: - 'https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4_K_M.gguf', - expectedSizeBytes: 1222 * 1024 * 1024, + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q5_k_m.gguf', + expectedSizeBytes: 1290 * 1024 * 1024, + benchmarkScore: 34, + benchmarkTotal: 40, ), LlmModelInfo( - id: 'llama32_3b_q4_k_m', - displayName: 'Llama 3.2 3B Instruct · Q4_K_M', - family: 'Llama-3.2-3B-Instruct', - filename: 'Llama-3.2-3B-Instruct-Q4_K_M.gguf', + id: defaultModelId, + displayName: 'Qwen2.5 1.5B Instruct · Q4_K_M', + family: 'Qwen2.5-1.5B-Instruct', + filename: 'qwen2.5-1.5b-instruct-q4_k_m.gguf', downloadUrl: - 'https://huggingface.co/unsloth/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf', - expectedSizeBytes: 2020 * 1024 * 1024, + 'https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf', + expectedSizeBytes: 1120 * 1024 * 1024, + benchmarkScore: 31, + benchmarkTotal: 40, ), ]; @@ -367,8 +357,11 @@ class LlmService { final usableMB = (deviceMB * 0.55).round(); final headroomMB = usableMB - modelMB; + // Base fit on actual memory headroom, not on gpuLayers. + // gpuLayers is always 0 on Android (no Metal) even on high-RAM devices, + // so keying off it causes every model to falsely show "Low memory". ModelMemoryFit fit; - if (params.gpuLayers == 0) { + if (headroomMB < 100) { fit = ModelMemoryFit.cpuFallback; } else if (params.contextSize < nCtx) { fit = ModelMemoryFit.reduced; @@ -764,6 +757,7 @@ class LlmService { presencePenalty: presencePenalty, contextSize: params.contextSize, logger: _logFilter, + enableThinking: false, ); _runningRequestId = await fllamaChat( diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index 1630732..de73c57 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -46,7 +46,7 @@ class AiModelsSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - appBar: AppBar(title: const Text('AI & Transcription')), + appBar: AppBar(title: const Text('Voice Memo AI')), body: ListView( padding: const EdgeInsets.only(bottom: 32), children: [ @@ -69,6 +69,7 @@ class AiModelsSettingsScreen extends ConsumerWidget { ), const _AiTogglesTile(), const _AiModelSelector(), + const _ImportModelTile(), // ---- GPU / Metal section (iOS only) ---- if (Platform.isIOS) ...[ @@ -90,7 +91,9 @@ class AiModelsSettingsScreen extends ConsumerWidget { // ---- Calendar / Reminders section ---- const _SectionHeader( title: 'Calendar Integration', - subtitle: 'Permissions for AI-created events & reminders', + subtitle: 'When a voice memo mentions a meeting, deadline, or ' + 'reminder, the AI can create it directly in your calendar. ' + 'Grant access below to enable this.', ), const _CalendarPermissionTile(), if (Platform.isIOS) const _RemindersPermissionTile(), @@ -498,20 +501,26 @@ class _RetranscribeButton extends ConsumerWidget { return Padding( padding: const EdgeInsets.fromLTRB( AppTheme.spacingMd, - AppTheme.spacingSm, + 4, AppTheme.spacingMd, 0, ), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + textStyle: Theme.of(context).textTheme.bodySmall, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), icon: isBusy ? const SizedBox( - width: 16, - height: 16, + width: 14, + height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.refresh, size: 18), + : const Icon(Icons.refresh, size: 16), label: Text( isBusy ? 'Re-transcribing...' : 'Re-transcribe all with selected model', ), @@ -779,43 +788,6 @@ class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { } } - Future _importModel() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.any, - dialogTitle: 'Select a GGUF model file', - ); - final path = result?.files.single.path; - if (path == null) return; - - if (!path.toLowerCase().endsWith('.gguf')) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Only .gguf model files can be imported')), - ); - } - return; - } - - final llm = ref.read(llmServiceProvider); - final imported = await llm.importModel(path); - ref.read(selectedAiModelIdProvider.notifier).setModelId(imported.id); - _refreshProviders(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Imported ${imported.filename}')), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import failed: $e')), - ); - } - } - } - @override Widget build(BuildContext context) { final selectedModelId = ref.watch(selectedAiModelIdProvider); @@ -865,15 +837,67 @@ class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - items: models - .map((m) => DropdownMenuItem( - value: m.id, - child: Text( - m.displayName, - overflow: TextOverflow.ellipsis, - ), - )) - .toList(), + items: () { + // Assign ranks only to catalog models that + // have a benchmarkScore (already sorted best→worst). + final ranked = {}; + var rank = 1; + for (final m in models) { + if (m.benchmarkScore != null) { + ranked[m.id] = rank++; + } + } + return models.map((m) { + final modelRank = ranked[m.id]; + final Color rankColor; + switch (modelRank) { + case 1: + rankColor = const Color(0xFFFFD700); + case 2: + rankColor = const Color(0xFFC0C0C0); + case 3: + rankColor = const Color(0xFFCD7F32); + default: + rankColor = AppTheme.textSecondary; + } + return DropdownMenuItem( + value: m.id, + child: Row( + children: [ + if (modelRank != null) + Container( + margin: const EdgeInsets.only( + right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: rankColor + .withValues(alpha: 0.15), + borderRadius: + BorderRadius.circular(4), + ), + child: Text( + '#$modelRank', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: rankColor, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + child: Text( + m.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(); + }(), onChanged: isDownloading ? null : (value) { @@ -1002,11 +1026,6 @@ class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { onPressed: isDownloading ? null : _downloadModel, showSpinner: isDownloading, ), - _CompactButton( - icon: Icons.upload_file, - label: 'Import .gguf', - onPressed: isDownloading ? null : _importModel, - ), ], ), ], @@ -1050,6 +1069,70 @@ class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { } } +// --------------------------------------------------------------------------- +// Import custom model tile +// --------------------------------------------------------------------------- + +/// Standalone tile that lets the user sideload a local .gguf file. +/// Kept separate from the catalog model selector so it's clear it's a +/// one-time "bring your own model" action, not tied to the selected model. +class _ImportModelTile extends ConsumerWidget { + const _ImportModelTile(); + + Future _importModel(BuildContext context, WidgetRef ref) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + dialogTitle: 'Select a GGUF model file', + ); + final path = result?.files.single.path; + if (path == null) return; + + if (!path.toLowerCase().endsWith('.gguf')) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Only .gguf model files can be imported')), + ); + } + return; + } + + final llm = ref.read(llmServiceProvider); + final imported = await llm.importModel(path); + ref.read(selectedAiModelIdProvider.notifier).setModelId(imported.id); + ref.invalidate(llmAvailableModelsProvider); + ref.invalidate(selectedLlmModelInfoProvider); + ref.invalidate(llmModelDownloadedProvider); + ref.invalidate(llmModelSizeProvider); + ref.invalidate(llmServiceStateProvider); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Imported ${imported.filename}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(Icons.upload_file_outlined), + title: const Text('Import custom model'), + subtitle: const Text('Use a local .gguf file from your device'), + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: () => _importModel(context, ref), + ); + } +} + class _ProcessAllButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/zswatch_app/lib/ui/screens/settings/settings_screen.dart b/zswatch_app/lib/ui/screens/settings/settings_screen.dart index 475627e..1d8c24f 100644 --- a/zswatch_app/lib/ui/screens/settings/settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/settings_screen.dart @@ -108,10 +108,8 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 32), - const Divider(height: 32), - - // AI & Transcription Models (sub-page) - _SectionHeader(title: 'AI & Transcription'), + // Voice Memo AI (sub-page) + _SectionHeader(title: 'Voice Memo AI'), _AiTranscriptionNavTile(), const Divider(height: 32), @@ -366,8 +364,7 @@ class _InfoRow extends StatelessWidget { } } -/// Compact summary tile that navigates to the consolidated AI & Transcription -/// models sub-page. +/// Compact summary tile that navigates to the Voice Memo AI sub-page. class _AiTranscriptionNavTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { @@ -379,11 +376,11 @@ class _AiTranscriptionNavTile extends ConsumerWidget { ); return _SettingsTile( - leading: const Icon(Icons.psychology, color: AppTheme.primaryColor), - title: 'Model Configuration', + leading: const Icon(Icons.mic, color: AppTheme.primaryColor), + title: 'Voice Memo AI', subtitle: - 'Transcription: ${transcriptionInfo.name}\n' - 'AI: ${localAiEnabled ? (aiModelName ?? 'Loading...') : 'Disabled'}', + 'Transcription: ${transcriptionInfo.name} · ' + 'AI: ${localAiEnabled ? (aiModelName ?? 'Loading...') : 'Off'}', trailing: const Icon(Icons.chevron_right), onTap: () => context.push(AppRoutes.aiModels), ); From 288369810fc2e6cf0e80b658d9e5349f57b2670b Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 21:44:41 +0100 Subject: [PATCH 12/58] fix: conservative memory thresholds to prevent iOS GPU page-fault crashes - Raise GPU threshold to >=1200MB available (was >=600MB) to avoid Metal KV cache allocation failures on memory-constrained devices - Prefer CPU + full nCtx=2048 over GPU when 600-1200MB available, ensuring the ~1136-token classify prompt fits without truncation - Add real-time available memory queries (os_proc_available_memory on iOS, ActivityManager.availMem on Android) instead of cached total RAM - Add 500ms delay between whisper and fllama inference for memory settling - Add memory diagnostics to AI debug view (available RAM, headroom, nCtx, GPU layers, max tokens cap) with low-memory warning indicator - Fix double-counting bug where model size was subtracted from available memory even though it was already loaded when queried - Never skip LLM inference on low memory; reduce maxTokens to 256 instead --- third_party/fllama | 2 +- .../kotlin/dev/zswatch/app/MainActivity.kt | 6 + zswatch_app/ios/Runner/AppDelegate.swift | 10 + .../lib/providers/voice_memo_providers.dart | 6 +- zswatch_app/lib/services/ai/llm_service.dart | 162 ++- .../services/ai/voice_note_ai_pipeline.dart | 46 + .../voice_memo/voice_memo_sync_service.dart | 42 +- .../voice_memos/voice_memos_screen.dart | 1020 ++++++++--------- 8 files changed, 728 insertions(+), 566 deletions(-) diff --git a/third_party/fllama b/third_party/fllama index 79f77b8..5f1010a 160000 --- a/third_party/fllama +++ b/third_party/fllama @@ -1 +1 @@ -Subproject commit 79f77b83a8df990693c57dade4f9975ebfb434aa +Subproject commit 5f1010a15c91eee9ff581fe31ad7401538c2b998 diff --git a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt index e6b43dc..77bdffc 100644 --- a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt +++ b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt @@ -410,6 +410,12 @@ class MainActivity : FlutterActivity() { am.getMemoryInfo(memInfo) result.success((memInfo.totalMem / (1024 * 1024)).toInt()) } + "getAvailableMemoryMB" -> { + val am = getSystemService(android.content.Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val memInfo = android.app.ActivityManager.MemoryInfo() + am.getMemoryInfo(memInfo) + result.success((memInfo.availMem / (1024 * 1024)).toInt()) + } else -> result.notImplemented() } } diff --git a/zswatch_app/ios/Runner/AppDelegate.swift b/zswatch_app/ios/Runner/AppDelegate.swift index d4e26ed..e9e7a38 100644 --- a/zswatch_app/ios/Runner/AppDelegate.swift +++ b/zswatch_app/ios/Runner/AppDelegate.swift @@ -34,6 +34,16 @@ import UIKit case "getDeviceMemoryMB": let bytes = ProcessInfo.processInfo.physicalMemory result(Int(bytes / (1024 * 1024))) + case "getAvailableMemoryMB": + // On iOS, use os_proc_available_memory (iOS 13.0+) for real-time free RAM. + let availableMB: Int + if #available(iOS 13.0, *) { + availableMB = Int(os_proc_available_memory()) / (1024 * 1024) + } else { + // Fallback: conservative estimate of 40% of physical memory + availableMB = Int(ProcessInfo.processInfo.physicalMemory * 40 / 100) / (1024 * 1024) + } + result(availableMB) default: result(FlutterMethodNotImplemented) } diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart index 93d7b38..4e806b5 100644 --- a/zswatch_app/lib/providers/voice_memo_providers.dart +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -191,8 +191,12 @@ Future _autoTranscribeAndProcess({ } } - // After transcription, run AI processing on all unprocessed memos + // After transcription, wait briefly to allow the whisper memory to be reclaimed + // and system to stabilize before starting LLM inference. This reduces memory + // pressure when the two models might both be in RAM simultaneously. if (pipeline != null) { + debugPrint('[VoiceMemoProviders] Waiting 500ms before AI processing'); + await Future.delayed(const Duration(milliseconds: 500)); debugPrint('[VoiceMemoProviders] Starting auto AI processing'); await pipeline.processAllUnprocessed(); } diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 733442f..2cbca50 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -74,10 +74,10 @@ enum ModelMemoryFit { /// Plenty of headroom — full context, full GPU. comfortable, - /// Tight — context will be reduced but GPU is still used. + /// Moderate — full context but CPU-only (GPU memory too tight). reduced, - /// Very tight — minimal context and CPU-only fallback. + /// Very tight — reduced context and/or CPU-only fallback. cpuFallback, } @@ -620,6 +620,16 @@ class LlmService { static const MethodChannel _deviceChannel = MethodChannel('dev.zswatch.app/productivity'); + /// Snapshot of memory state from the most recent `_computeInferenceParams` + /// call. Exposed so callers (e.g. the AI pipeline) can surface this in the + /// debug UI. + ({int deviceMB, int availableMB, int modelMB, int headroomMB, + int contextSize, int gpuLayers, int? maxTokensCap})? + get lastInferenceMemoryInfo => _lastInferenceMemoryInfo; + ({int deviceMB, int availableMB, int modelMB, int headroomMB, + int contextSize, int gpuLayers, int? maxTokensCap})? + _lastInferenceMemoryInfo; + /// Query device physical RAM (MB), cached after first call. Future _queryDeviceMemoryMB() async { if (_deviceMemoryMB != null) return _deviceMemoryMB!; @@ -636,57 +646,123 @@ class LlmService { return _deviceMemoryMB!; } - /// Compute context size dynamically based on available device RAM and model - /// size. Uses [LlmModelInfo.contextSize] if explicitly set, otherwise scales + /// Query currently available free RAM (MB) at inference time. + /// This is more accurate than using cached total, since it accounts for + /// memory used by other processes and the OS. + Future _queryAvailableMemoryMB() async { + try { + final mb = await _deviceChannel.invokeMethod('getAvailableMemoryMB'); + return mb ?? 512; // conservative fallback if platform returns null + } on MissingPluginException { + // Fallback: estimate 50% of total is available (conservative) + final total = await _queryDeviceMemoryMB(); + return (total * 0.5).toInt(); + } catch (e) { + debugPrint('[LlmService] Failed to query available memory: $e'); + return 512; // very conservative fallback + } + } + + /// Compute context size, GPU layers, and max output tokens dynamically + /// based on real-time available RAM and model size. + /// + /// Uses [LlmModelInfo.contextSize] if explicitly set, otherwise scales /// down when the model weight file would leave too little headroom for the - /// KV cache + compute buffers on the GPU. + /// KV cache + compute buffers. + /// + /// IMPORTANT: This method runs AFTER `_ensureModel()` has loaded the model, + /// so `os_proc_available_memory()` already reflects the model's RAM usage. + /// We do NOT subtract model size again — that would double-count. + /// The available memory IS the headroom for KV cache + compute buffers. /// - /// Heuristic budget (conservative): - /// usableGPU ≈ deviceRAM × 0.55 (OS + app + Flutter overhead) - /// headroom = usableGPU − modelFileSize - /// if headroom ≥ 600 MB → nCtx 2048, full GPU - /// if headroom ≥ 300 MB → nCtx 1024, full GPU - /// if headroom ≥ 100 MB → nCtx 512, full GPU - /// else → nCtx 512, CPU-only (avoid Metal OOM crash) - Future<({int contextSize, int gpuLayers})> _computeInferenceParams( + /// Metal (GPU) pre-allocates the FULL KV cache for nCtx upfront, so + /// nCtx=2048 on GPU needs ~1–1.5 GB of GPU-accessible memory. On 4 GB + /// iPhones with ~1 GB free after model load, GPU+2048 causes a page-fault + /// crash. The thresholds below prefer CPU with full context over GPU with + /// a crash: + /// + /// available ≥ 1200 MB → nCtx 2048, full GPU, maxTokens unchanged + /// available ≥ 600 MB → nCtx 2048, CPU-only, maxTokens unchanged + /// available ≥ 300 MB → nCtx 1024, CPU-only, maxTokens unchanged + /// available ≥ 100 MB → nCtx 512, CPU-only, maxTokens unchanged + /// available < 100 MB → nCtx 512, CPU-only, maxTokens capped at 256 + Future<({int contextSize, int gpuLayers, int? maxTokensCap})> + _computeInferenceParams( LlmModelInfo modelInfo, ) async { // Explicit per-model overrides win. final explicitCtx = modelInfo.contextSize; final explicitGpu = modelInfo.maxGpuLayers; if (explicitCtx != null && explicitGpu != null) { - return (contextSize: explicitCtx, gpuLayers: explicitGpu); + return ( + contextSize: explicitCtx, + gpuLayers: explicitGpu, + maxTokensCap: null, + ); } final deviceMB = await _queryDeviceMemoryMB(); + final availableMB = await _queryAvailableMemoryMB(); final modelMB = (modelInfo.expectedSizeBytes ?? 0) ~/ (1024 * 1024); - // On iOS, Metal shares unified memory with the system. After OS + app + - // Flutter + BLE overhead, roughly 55% is available for the GPU working set. - // On Android the GPU typically has even less available headroom. - final usableMB = (deviceMB * 0.55).round(); - final headroomMB = usableMB - modelMB; + // The model is already loaded by `_ensureModel()` before this method runs, + // so `availableMB` already reflects the model's memory footprint. + // availableMB IS the headroom for KV cache + compute buffers — do NOT + // subtract modelMB again (that would double-count and go negative). + final headroomMB = availableMB; + + debugPrint( + '[LlmService] Memory check: available=${availableMB}MB ' + 'model=${modelMB}MB (already loaded) headroom=${headroomMB}MB ' + 'deviceTotal=${deviceMB}MB', + ); int ctx; int gpu; + int? tokensCap; // null = use default maxTokens - if (headroomMB >= 600) { + if (headroomMB >= 1200) { + // Plenty of room: full context on GPU. Metal needs ~1–1.5GB for KV cache + // + compute scratch buffers at nCtx=2048 on top of the model weights. ctx = nCtx; // full context (default 2048) gpu = numGpuLayers; + } else if (headroomMB >= 600) { + // Moderate room: full context but on CPU. This avoids Metal page-fault + // crashes (GPU pre-allocates the full nCtx KV cache upfront). CPU is + // slower (~5–7 tok/s vs ~20 tok/s) but doesn't crash and handles long + // prompts (e.g. classify prompt at ~1100 tokens). + ctx = nCtx; + gpu = 0; + debugPrint( + '[LlmService] Moderate memory (${headroomMB}MB). ' + 'Using CPU with full nCtx=$nCtx to avoid GPU memory pressure.', + ); } else if (headroomMB >= 300) { ctx = 1024; - gpu = numGpuLayers; + gpu = 0; + debugPrint( + '[LlmService] Low memory (${headroomMB}MB). ' + 'Using CPU with nCtx=1024.', + ); } else if (headroomMB >= 100) { ctx = 512; - gpu = numGpuLayers; + gpu = 0; + debugPrint( + '[LlmService] WARNING: Very low memory (${headroomMB}MB). ' + 'Using CPU with nCtx=512. ' + 'Model ${modelInfo.id} ($modelMB MB), ' + 'available=${availableMB}MB.', + ); } else { - // Very tight — fall back to CPU-only to avoid Metal page-fault crash. + // Critically low — still run but with absolute minimum settings. ctx = 512; gpu = 0; + tokensCap = 256; debugPrint( - '[LlmService] WARNING: Low memory headroom (${headroomMB}MB). ' - 'Falling back to CPU-only inference to prevent GPU crash. ' - 'Model ${modelInfo.id} ($modelMB MB) on device with $deviceMB MB RAM.', + '[LlmService] CRITICAL: Extremely low memory (${headroomMB}MB). ' + 'Using minimum settings: nCtx=512, CPU-only, maxTokens=256. ' + 'Model ${modelInfo.id} ($modelMB MB), ' + 'available=${availableMB}MB, device=${deviceMB}MB.', ); } @@ -703,9 +779,23 @@ class LlmService { resolvedGpu = 0; } + final resolvedCtx = explicitCtx ?? ctx; + + // Store memory snapshot for debug UI. + _lastInferenceMemoryInfo = ( + deviceMB: deviceMB, + availableMB: availableMB, + modelMB: modelMB, + headroomMB: headroomMB, + contextSize: resolvedCtx, + gpuLayers: resolvedGpu, + maxTokensCap: tokensCap, + ); + return ( - contextSize: explicitCtx ?? ctx, + contextSize: resolvedCtx, gpuLayers: resolvedGpu, + maxTokensCap: tokensCap, ); } @@ -741,15 +831,29 @@ class LlmService { final modelInfo = await currentModelInfo(); final params = await _computeInferenceParams(modelInfo); + // Apply maxTokens cap from memory check. The cap is the hard ceiling; + // the caller's overrideMaxTokens or default maxTokens is also respected + // by taking the minimum. + int effectiveMaxTokens = overrideMaxTokens ?? maxTokens; + if (params.maxTokensCap != null && + effectiveMaxTokens > params.maxTokensCap!) { + debugPrint( + '[LlmService] Capping maxTokens from $effectiveMaxTokens ' + 'to ${params.maxTokensCap} due to low memory.', + ); + effectiveMaxTokens = params.maxTokensCap!; + } + debugPrint( '[LlmService] Inference: model=${modelInfo.id} nCtx=${params.contextSize} ' - 'gpuLayers=${params.gpuLayers} deviceRAM=${_deviceMemoryMB ?? "?"}MB', + 'gpuLayers=${params.gpuLayers} maxTokens=$effectiveMaxTokens ' + 'deviceRAM=${_deviceMemoryMB ?? "?"}MB', ); final request = OpenAiRequest( messages: [Message(Role.user, prompt)], modelPath: _modelPath!, - maxTokens: overrideMaxTokens ?? maxTokens, + maxTokens: effectiveMaxTokens, numGpuLayers: params.gpuLayers, temperature: temperature, topP: topP, diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart index 1c6a16b..1f9e381 100644 --- a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -54,6 +54,29 @@ class AiProcessingDebugInfo { /// Whether processing has finished (final snapshot vs live update). final bool isComplete; + // --- Memory & inference parameter debug info --- + + /// Device total physical RAM in MB. + final int? deviceMemoryMB; + + /// Available (free) RAM in MB at inference time. + final int? availableMemoryMB; + + /// Model file size in MB. + final int? modelSizeMB; + + /// Headroom = availableMemoryMB - modelSizeMB. + final int? memoryHeadroomMB; + + /// Context size actually used for this inference. + final int? inferenceContextSize; + + /// GPU layers actually used for this inference. + final int? inferenceGpuLayers; + + /// Max tokens cap applied due to memory pressure (null = no cap). + final int? inferenceMaxTokensCap; + const AiProcessingDebugInfo({ required this.filename, required this.modelName, @@ -87,6 +110,13 @@ class AiProcessingDebugInfo { this.liveElapsed, this.liveTokensPerSecond, this.isComplete = true, + this.deviceMemoryMB, + this.availableMemoryMB, + this.modelSizeMB, + this.memoryHeadroomMB, + this.inferenceContextSize, + this.inferenceGpuLayers, + this.inferenceMaxTokensCap, }); } @@ -175,6 +205,7 @@ class VoiceNoteAiPipeline { void emitLive(String phase, String partial, int tokens) { final elapsedMs = sw.elapsedMilliseconds; final tps = elapsedMs > 0 ? tokens / (elapsedMs / 1000.0) : 0.0; + final mem = _llmService.lastInferenceMemoryInfo; _debugInfoSubject.add(AiProcessingDebugInfo( filename: filename, modelName: _llmService.modelName, @@ -186,6 +217,13 @@ class VoiceNoteAiPipeline { liveTokensPerSecond: tps, isComplete: false, timestamp: DateTime.now(), + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + inferenceContextSize: mem?.contextSize, + inferenceGpuLayers: mem?.gpuLayers, + inferenceMaxTokensCap: mem?.maxTokensCap, )); } @@ -249,6 +287,7 @@ class VoiceNoteAiPipeline { onProcessingComplete?.call(filename, result.summary); // Publish final debug info and store per-file + final mem = _llmService.lastInferenceMemoryInfo; final finalDebug = AiProcessingDebugInfo( filename: filename, modelName: _llmService.modelName, @@ -278,6 +317,13 @@ class VoiceNoteAiPipeline { currentPhase: 'done', isComplete: true, timestamp: DateTime.now(), + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + inferenceContextSize: mem?.contextSize, + inferenceGpuLayers: mem?.gpuLayers, + inferenceMaxTokensCap: mem?.maxTokensCap, ); _debugInfoByFile[filename] = finalDebug; _debugInfoSubject.add(finalDebug); diff --git a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart index 0b2edf2..0616f99 100644 --- a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart +++ b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart @@ -143,6 +143,7 @@ class VoiceMemoSyncService { }); } else if (connection.isDisconnected) { _hasAutoSynced = false; + unawaited(_resetFsManager()); } } @@ -286,6 +287,7 @@ class VoiceMemoSyncService { } catch (e) { _log('Failed to disable SMP: $e'); } + await _resetFsManager(); } _updateState(VoiceMemoSyncState( @@ -319,11 +321,22 @@ class VoiceMemoSyncService { return false; } - _fsManager ??= FsManager(device.remoteId.str); + // Recreate FsManager for each transfer attempt to avoid stale native + // transport handles after SMP disable/enable or reconnection cycles. + await _resetFsManager(); + _fsManager = FsManager(device.remoteId.str); final remotePath = '$_recordingDir/${memo.filename}.zsw_opus'; _log('Downloading: $remotePath'); + // Preflight check to distinguish path/FS errors from transfer errors. + try { + final remoteSize = await _fsManager!.status(remotePath); + _log('Remote file status: $remoteSize bytes'); + } catch (e) { + _log('Remote file status check failed: $e'); + } + // Set up download completion listener _downloadCompleter = Completer(); await _downloadSubscription?.cancel(); @@ -346,6 +359,10 @@ class VoiceMemoSyncService { _downloadCompleter?.complete(null); _downloadCompleter = null; } + }, onError: (Object error) { + _log('Download callback stream error: $error'); + _downloadCompleter?.complete(null); + _downloadCompleter = null; }); // Start download @@ -414,10 +431,29 @@ class VoiceMemoSyncService { return true; } catch (e) { _log('Error downloading ${memo.filename}: $e'); + await _resetFsManager(); return false; } } + Future _resetFsManager() async { + await _downloadSubscription?.cancel(); + _downloadSubscription = null; + + _downloadCompleter?.complete(null); + _downloadCompleter = null; + + final manager = _fsManager; + _fsManager = null; + if (manager != null) { + try { + await manager.kill(); + } catch (e) { + _log('FsManager cleanup failed: $e'); + } + } + } + /// Save downloaded recording to app's local storage Future _saveToLocalStorage( String filename, Uint8List data) async { @@ -525,10 +561,8 @@ class VoiceMemoSyncService { void dispose() { _messageSubscription?.cancel(); _connectionSubscription?.cancel(); - _downloadSubscription?.cancel(); - _downloadCompleter?.complete(null); + unawaited(_resetFsManager()); _listCompleter?.complete([]); - _fsManager?.kill(); _syncState.close(); } } diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart index a883dfb..fe2688a 100644 --- a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -14,12 +14,10 @@ import '../../../providers/ai_providers.dart'; import '../../../providers/settings_providers.dart'; import '../../../providers/voice_memo_providers.dart'; import '../../../providers/watch_service_provider.dart'; -import '../../../services/ai/extracted_action_creation_service.dart'; import '../../../services/ai/voice_note_ai_pipeline.dart'; import '../../../services/voice_memo/transcription_engine.dart'; import '../../../services/voice_memo/voice_memo_sync_service.dart'; import '../../navigation/app_router.dart'; -import '../../widgets/ai_debug_widgets.dart'; /// Transcript-first timeline view for synced voice notes. class VoiceMemosScreen extends ConsumerStatefulWidget { @@ -502,21 +500,12 @@ class _AISummarySection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final aiEnabled = ref.watch(localAiEnabledProvider); final modelDownloadedAsync = ref.watch(llmModelDownloadedProvider); - ref.listen>(aiActionsProvider, (previous, next) { - next.whenOrNull( - error: (error, _) { - final message = error is StateError - ? error.message.toString() - : error.toString(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); - }, - ); - }); + if (!aiEnabled) { + return const SizedBox.shrink(); + } return modelDownloadedAsync.when( data: (modelDownloaded) { @@ -525,7 +514,6 @@ class _AISummarySection extends ConsumerWidget { } final hasSummary = memo.summary != null && memo.summary!.isNotEmpty; - final hasTranscript = memo.transcription?.trim().isNotEmpty == true; final hasCategory = memo.aiCategory != null; final isProcessing = memo.isAiProcessing; final hasFailed = memo.aiProcessingStatus == VoiceNoteProcessingStatus.failed; @@ -545,7 +533,7 @@ class _AISummarySection extends ConsumerWidget { const SizedBox(height: 12), OutlinedButton.icon( style: _compactOutlinedButtonStyle(), - onPressed: !hasTranscript + onPressed: memo.transcription?.trim().isEmpty == true ? null : () => ref .read(aiActionsProvider.notifier) @@ -553,7 +541,7 @@ class _AISummarySection extends ConsumerWidget { icon: const Icon(Icons.auto_awesome), label: const Text('Process with AI'), ), - if (!hasTranscript) + if (memo.transcription?.trim().isEmpty == true) Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -605,39 +593,11 @@ class _AISummarySection extends ConsumerWidget { ), ) else if (hasFailed) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AI processing failed. Please try again.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.errorColor, - ), - ), - const SizedBox(height: 12), - Wrap( - spacing: AppTheme.spacingSm, - runSpacing: AppTheme.spacingSm, - children: [ - OutlinedButton.icon( - style: _compactOutlinedButtonStyle(), - onPressed: !hasTranscript - ? null - : () => ref - .read(aiActionsProvider.notifier) - .processVoiceMemo(memo.filename), - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), - OutlinedButton.icon( - style: _compactOutlinedButtonStyle(), - onPressed: () => _showAiDebugDialog(context, ref), - icon: const Icon(Icons.bug_report_outlined), - label: const Text('Debug'), - ), - ], - ), - ], + Text( + 'AI processing failed. Please try again.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.errorColor, + ), ) else ...[ if (hasCategory) @@ -653,7 +613,7 @@ class _AISummarySection extends ConsumerWidget { ), ), ], - if (!isProcessing && (hasSummary || hasCategory || hasFailed)) + if (!isProcessing && (hasSummary || hasCategory)) Padding( padding: const EdgeInsets.only(top: 12), child: Wrap( @@ -741,185 +701,141 @@ class _AiDebugSheet extends ConsumerWidget { .getDebugInfoForFile(memo.filename); // Prefer live (in-progress or just-completed) over stored final debugInfo = liveInfo ?? storedInfo; + final theme = Theme.of(context); return Column( children: [ - aiDebugHandleBar(), - aiDebugSheetHeader( - context, - title: 'AI Debug Info', - showSpinner: debugInfo != null && !debugInfo.isComplete, - onClose: () => Navigator.of(context).pop(), + // Handle bar + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.bug_report_outlined, size: 20), + const SizedBox(width: 8), + Text('AI Debug Info', style: theme.textTheme.titleMedium), + const Spacer(), + if (debugInfo != null && !debugInfo.isComplete) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), ), const Divider(), Expanded( child: ListView( controller: scrollController, padding: const EdgeInsets.all(16), - children: _buildBody(context, debugInfo), + children: [ + if (debugInfo == null) ...[ + _debugNote( + context, + 'No debug data available for the latest run. ' + 'Re-process this memo to see debug info.', + ), + const SizedBox(height: 16), + _debugInfoFromMemo(context), + ] else if (!debugInfo.isComplete) ...[ + // --- Live / in-progress view --- + _livePhaseHeader(context, debugInfo), + const SizedBox(height: 12), + if (debugInfo.originalTranscription != null) ...[ + _debugBlock( + context, + title: 'Original Transcription', + content: debugInfo.originalTranscription!, + icon: Icons.mic, + ), + const SizedBox(height: 12), + ], + // Only show the partial-response block once tokens are flowing + if (debugInfo.currentPhase != 'loading') + _debugBlock( + context, + title: '${_phaseLabel(debugInfo.currentPhase)} (live)', + content: debugInfo.partialResponse.isEmpty + ? '...' + : debugInfo.partialResponse, + icon: debugInfo.currentPhase == 'correcting' + ? Icons.auto_fix_high + : Icons.code, + mono: debugInfo.currentPhase == 'classifying', + ), + ] else ...[ + // --- Completed view --- + _metricsRow(context, debugInfo), + const SizedBox(height: 16), + if (debugInfo.originalTranscription != null && + debugInfo.correctedTranscription != null && + debugInfo.correctedTranscription != + debugInfo.originalTranscription) ...[ + _transcriptionDiffBlock( + context, + original: debugInfo.originalTranscription!, + corrected: debugInfo.correctedTranscription!, + ), + const SizedBox(height: 12), + ] else if (debugInfo.originalTranscription != null) ...[ + _debugBlock( + context, + title: 'Transcription', + content: debugInfo.originalTranscription!, + icon: Icons.mic, + ), + const SizedBox(height: 12), + ], + if (debugInfo.rawLlmResponse != null) ...[ + _debugBlock( + context, + title: 'Raw LLM Response', + content: debugInfo.rawLlmResponse!, + icon: Icons.code, + mono: true, + ), + const SizedBox(height: 12), + ], + if (debugInfo.parsedJson != null) ...[ + _debugBlock( + context, + title: 'Parsed JSON', + content: debugInfo.parsedJson!, + icon: Icons.data_object, + mono: true, + ), + const SizedBox(height: 12), + ], + _resultRow(context, debugInfo), + ], + ], ), ), ], ); } - List _buildBody( - BuildContext context, AiProcessingDebugInfo? debugInfo) { - if (debugInfo == null) { - return [ - aiDebugNote( - context, - 'No debug data available for the latest run. ' - 'Re-process this memo to see debug info.', - ), - const SizedBox(height: 16), - _debugInfoFromMemo(context), - ]; - } - - if (!debugInfo.isComplete) { - // --- Live / in-progress view --- - final phaseText = switch (debugInfo.currentPhase) { - 'loading' => 'Loading model...', - 'correcting' => 'Correcting transcription...', - 'classifying' => 'Classifying & summarizing...', - _ => 'Processing...', - }; - return [ - aiLivePhaseHeader( - context, - modelName: debugInfo.modelName, - phaseText: phaseText, - tokens: debugInfo.liveTokenCount, - tokensPerSecond: debugInfo.liveTokensPerSecond, - elapsed: debugInfo.liveElapsed, - ), - if (debugInfo.originalTranscription != null) ...[ - const SizedBox(height: 12), - aiDebugBlock( - context, - title: 'Original Transcription', - content: debugInfo.originalTranscription!, - icon: Icons.mic, - showCopyButton: true, - ), - ], - // Only show the partial-response block once tokens are flowing - if (debugInfo.currentPhase != 'loading') ...[ - const SizedBox(height: 12), - aiDebugBlock( - context, - title: '${_phaseLabel(debugInfo.currentPhase)} (live)', - content: debugInfo.partialResponse.isEmpty - ? '...' - : debugInfo.partialResponse, - icon: debugInfo.currentPhase == 'correcting' - ? Icons.auto_fix_high - : Icons.code, - mono: debugInfo.currentPhase == 'classifying', - showCopyButton: true, - ), - ], - ]; - } - - // --- Completed view --- - return [ - _metricsRow(context, debugInfo), - const SizedBox(height: 12), - aiDebugBlock( - context, - title: 'Prompt / Flow', - content: aiFormatPromptFlow( - strategy: debugInfo.classifyPromptStrategy, - retryEnabled: debugInfo.retryEnabled, - attempts: debugInfo.classifyAttempts ?? 1, - ), - icon: Icons.tune, - showCopyButton: true, - ), - if (aiHasChronoDetails( - extractedIntent: debugInfo.extractedIntent, - extractedTitle: debugInfo.extractedTitle, - datetimeExpressionOriginal: debugInfo.datetimeExpressionOriginal, - datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, - resolvedDateTime: debugInfo.resolvedDateTime, - resolverMethod: debugInfo.resolverMethod, - )) ...[ - const SizedBox(height: 12), - aiDebugBlock( - context, - title: 'Chrono Extraction / Resolution', - content: aiFormatChronoDetails( - extractedIntent: debugInfo.extractedIntent, - extractedTitle: debugInfo.extractedTitle, - datetimeExpressionOriginal: debugInfo.datetimeExpressionOriginal, - datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, - resolvedDateTime: debugInfo.resolvedDateTime, - resolverMethod: debugInfo.resolverMethod, - ), - icon: Icons.schedule, - showCopyButton: true, - ), - ], - const SizedBox(height: 16), - if (debugInfo.originalTranscription != null && - debugInfo.correctedTranscription != null && - debugInfo.correctedTranscription != - debugInfo.originalTranscription) ...[ - _transcriptionDiffBlock( - context, - original: debugInfo.originalTranscription!, - corrected: debugInfo.correctedTranscription!, - ), - const SizedBox(height: 12), - ] else if (debugInfo.originalTranscription != null) ...[ - aiDebugBlock( - context, - title: 'Transcription', - content: debugInfo.originalTranscription!, - icon: Icons.mic, - showCopyButton: true, - ), - const SizedBox(height: 12), - ], - if (debugInfo.rawLlmResponse != null) ...[ - aiDebugBlock( - context, - title: 'Raw LLM Response', - content: debugInfo.rawLlmResponse!, - icon: Icons.code, - mono: true, - showCopyButton: true, - ), - const SizedBox(height: 12), - ], - if (debugInfo.parsedJson != null) ...[ - aiDebugBlock( - context, - title: 'Parsed JSON', - content: debugInfo.parsedJson!, - icon: Icons.data_object, - mono: true, - showCopyButton: true, - ), - const SizedBox(height: 12), - ], - if (debugInfo.classifyPrompt != null) ...[ - aiDebugBlock( - context, - title: 'Prompt Sent', - content: debugInfo.classifyPrompt!, - icon: Icons.text_snippet_outlined, - mono: true, - showCopyButton: true, - ), - const SizedBox(height: 12), - ], - _resultRow(context, debugInfo), - ]; - } - String _phaseLabel(String? phase) { switch (phase) { case 'correcting': @@ -931,6 +847,95 @@ class _AiDebugSheet extends ConsumerWidget { } } + Widget _livePhaseHeader(BuildContext context, AiProcessingDebugInfo info) { + final theme = Theme.of(context); + final phaseText = switch (info.currentPhase) { + 'loading' => 'Loading model...', + 'correcting' => 'Correcting transcription...', + 'classifying' => 'Classifying & summarizing...', + _ => 'Processing...', + }; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 8), + Text( + phaseText, + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _metricChip( + context, + 'Tokens', + '${info.liveTokenCount}', + Icons.token, + ), + ], + ), + if (info.availableMemoryMB != null) ...[ const SizedBox(height: 8), + _memoryInfoRow(context, info), + ], + ], + ), + ); + } + + Widget _debugNote(BuildContext context, String text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); + } + Widget _debugInfoFromMemo(BuildContext context) { final theme = Theme.of(context); return Column( @@ -956,55 +961,241 @@ class _AiDebugSheet extends ConsumerWidget { } Widget _metricsRow(BuildContext context, AiProcessingDebugInfo info) { - final chips = [ - if (info.correctionTime != null) - aiMetricChip( - context, - 'Correction', - '${info.correctionTime!.inMilliseconds}ms', - Icons.timer_outlined, - ), - if (info.correctionTokensPerSec != null) - aiMetricChip( - context, - 'Correction tok/s', - info.correctionTokensPerSec!.toStringAsFixed(1), - Icons.speed, - ), - if (info.correctionTokens != null) - aiMetricChip( - context, - 'Correction tokens', - '${info.correctionTokens}', - Icons.token, + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.modelName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (info.correctionTime != null) + _metricChip( + context, + 'Correction', + '${info.correctionTime!.inMilliseconds}ms', + Icons.timer_outlined, + ), + if (info.correctionTokensPerSec != null) + _metricChip( + context, + 'Correction tok/s', + info.correctionTokensPerSec!.toStringAsFixed(1), + Icons.speed, + ), + if (info.correctionTokens != null) + _metricChip( + context, + 'Correction tokens', + '${info.correctionTokens}', + Icons.token, + ), + if (info.classifyTime != null) + _metricChip( + context, + 'Classify', + '${info.classifyTime!.inMilliseconds}ms', + Icons.timer_outlined, + ), + if (info.classifyTokensPerSec != null) + _metricChip( + context, + 'Classify tok/s', + info.classifyTokensPerSec!.toStringAsFixed(1), + Icons.speed, + ), + if (info.classifyTokens != null) + _metricChip( + context, + 'Classify tokens', + '${info.classifyTokens}', + Icons.token, + ), + ], + ), + if (info.availableMemoryMB != null) ...[ + const SizedBox(height: 8), + _memoryInfoRow(context, info), + ], + ], + ), + ); + } + + Widget _memoryInfoRow(BuildContext context, AiProcessingDebugInfo info) { + final theme = Theme.of(context); + final isLowMemory = (info.memoryHeadroomMB ?? 999) < 100; + final statusColor = isLowMemory ? Colors.orange : AppTheme.textSecondary; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isLowMemory + ? Colors.orange.withValues(alpha: 0.08) + : AppTheme.textSecondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: isLowMemory + ? Border.all(color: Colors.orange.withValues(alpha: 0.3)) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.memory, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + 'Memory & Inference', + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + if (isLowMemory) ...[ + const SizedBox(width: 6), + Icon(Icons.warning_amber_rounded, + size: 13, color: Colors.orange), + ], + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 16, + runSpacing: 4, + children: [ + if (info.availableMemoryMB != null) + _metricChip(context, 'Available RAM', + '${info.availableMemoryMB}MB', Icons.memory), + if (info.deviceMemoryMB != null) + _metricChip(context, 'Total RAM', + '${info.deviceMemoryMB}MB', Icons.phone_android), + if (info.modelSizeMB != null) + _metricChip(context, 'Model', + '${info.modelSizeMB}MB', Icons.smart_toy_outlined), + if (info.memoryHeadroomMB != null) + _metricChip(context, 'Headroom', + '${info.memoryHeadroomMB}MB', Icons.expand), + if (info.inferenceContextSize != null) + _metricChip(context, 'nCtx', + '${info.inferenceContextSize}', Icons.tune), + if (info.inferenceGpuLayers != null) + _metricChip(context, 'GPU layers', + info.inferenceGpuLayers == 0 + ? 'CPU only' + : '${info.inferenceGpuLayers}', + Icons.developer_board), + if (info.inferenceMaxTokensCap != null) + _metricChip(context, 'Max tokens cap', + '${info.inferenceMaxTokensCap}', Icons.compress), + ], + ), + ], + ), + ); + } + + Widget _metricChip( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 11, + ), ), - if (info.classifyTime != null) - aiMetricChip( - context, - 'Classify', - '${info.classifyTime!.inMilliseconds}ms', - Icons.timer_outlined, + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 11, + ), ), - if (info.classifyTokensPerSec != null) - aiMetricChip( - context, - 'Classify tok/s', - info.classifyTokensPerSec!.toStringAsFixed(1), - Icons.speed, + ], + ); + } + + Widget _debugBlock( + BuildContext context, { + required String title, + required String content, + required IconData icon, + bool mono = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + title, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: content)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied $title'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], ), - if (info.classifyTokens != null) - aiMetricChip( - context, - 'Classify tokens', - '${info.classifyTokens}', - Icons.token, + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.12), + ), + ), + child: SelectableText( + content, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), + ), ), - ]; - - return aiCompletedMetricsHeader( - context, - modelName: info.modelName, - extraChips: chips, + ], ); } @@ -1025,8 +1216,7 @@ class _AiDebugSheet extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.compare_arrows, - size: 16, color: AppTheme.textSecondary), + const Icon(Icons.compare_arrows, size: 16, color: AppTheme.textSecondary), const SizedBox(width: 6), Text( 'Transcription Diff', @@ -1053,8 +1243,7 @@ class _AiDebugSheet extends ConsumerWidget { ), child: Text.rich( TextSpan(children: spans), - style: - Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6), + style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6), ), ), ], @@ -1088,8 +1277,7 @@ class _AiDebugSheet extends ConsumerWidget { final dp = List.generate(n + 1, (_) => List.filled(m + 1, 0)); for (var i = 1; i <= n; i++) { for (var j = 1; j <= m; j++) { - if (origWords[i - 1].toLowerCase() == - corrWords[j - 1].toLowerCase()) { + if (origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = dp[i - 1][j] > dp[i][j - 1] @@ -1106,8 +1294,7 @@ class _AiDebugSheet extends ConsumerWidget { while (i > 0 || j > 0) { if (i > 0 && j > 0 && - origWords[i - 1].toLowerCase() == - corrWords[j - 1].toLowerCase()) { + origWords[i - 1].toLowerCase() == corrWords[j - 1].toLowerCase()) { ops.add(_DiffOp.equal(corrWords[j - 1])); i--; j--; @@ -1119,6 +1306,7 @@ class _AiDebugSheet extends ConsumerWidget { i--; } } + ops.reversed; // reversed is lazy, need toList final orderedOps = ops.reversed.toList(); // Convert to TextSpans @@ -1161,8 +1349,7 @@ class _AiDebugSheet extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.check_circle_outline, - size: 16, color: AppTheme.textSecondary), + Icon(Icons.check_circle_outline, size: 16, color: AppTheme.textSecondary), const SizedBox(width: 6), Text( 'Parsed Result', @@ -1228,6 +1415,12 @@ class _ExtractedActionsSectionState @override Widget build(BuildContext context) { + final aiEnabled = ref.watch(localAiEnabledProvider); + + if (!aiEnabled) { + return const SizedBox.shrink(); + } + final actionsAsync = ref.watch( extractedActionsForMemoProvider(widget.memo.id), ); @@ -1276,22 +1469,11 @@ class _ExtractedActionsSectionState } } -class _ActionItem extends ConsumerStatefulWidget { +class _ActionItem extends StatelessWidget { final ExtractedAction action; const _ActionItem({required this.action}); - @override - ConsumerState<_ActionItem> createState() => _ActionItemState(); -} - -class _ActionItemState extends ConsumerState<_ActionItem> { - bool _isCreating = false; - bool _isDismissing = false; - bool _isOpening = false; - - ExtractedAction get action => widget.action; - @override Widget build(BuildContext context) { return Row( @@ -1315,32 +1497,14 @@ class _ActionItemState extends ConsumerState<_ActionItem> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - action.title, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - const SizedBox(width: AppTheme.spacingSm), - _ActionStatusBadge(action: action), - ], + Text( + action.title, + style: Theme.of(context).textTheme.bodyMedium, ), - if (_timingLabel(action) case final timingLabel?) ...[ + if (action.dueDate != null) ...[ const SizedBox(height: 4), Text( - timingLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - if (action.notes != null && action.notes!.trim().isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - action.notes!.trim(), + 'Due: ${DateFormat.yMMMd().format(action.dueDate!)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), @@ -1355,52 +1519,6 @@ class _ActionItemState extends ConsumerState<_ActionItem> { ), ), ], - const SizedBox(height: 10), - Wrap( - spacing: AppTheme.spacingSm, - runSpacing: AppTheme.spacingSm, - children: [ - if (!action.created && !action.dismissed) - FilledButton.icon( - style: _compactFilledButtonStyle(), - onPressed: _isCreating ? null : _createAction, - icon: _isCreating - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.add_task_outlined), - label: Text(_isCreating ? 'Creating…' : 'Create'), - ), - if (!action.created && !action.dismissed) - OutlinedButton.icon( - style: _compactOutlinedButtonStyle(), - onPressed: _isDismissing ? null : _dismissAction, - icon: _isDismissing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.close_rounded), - label: const Text('Dismiss'), - ), - if (action.created && action.platformTargetId != null) - OutlinedButton.icon( - style: _compactOutlinedButtonStyle(), - onPressed: _isOpening ? null : _openCreatedAction, - icon: _isOpening - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.open_in_new_rounded), - label: const Text('Open'), - ), - ], - ), ], ), ), @@ -1408,81 +1526,6 @@ class _ActionItemState extends ConsumerState<_ActionItem> { ); } - Future _createAction() async { - setState(() => _isCreating = true); - try { - final selectedCalendarId = ref.read(selectedProductivityCalendarIdProvider); - final draft = ActionCreationDraft.fromAction(action).copyWith( - platformCalendarId: Platform.isAndroid ? selectedCalendarId : null, - ); - - final message = await ref.read(extractedActionOperationsProvider).createAction( - action: action, - draft: draft, - ); - - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); - } catch (error) { - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to create action: $error')), - ); - } finally { - if (mounted) { - setState(() => _isCreating = false); - } - } - } - - Future _dismissAction() async { - setState(() => _isDismissing = true); - try { - await ref.read(extractedActionOperationsProvider).dismissAction(action.id); - } catch (error) { - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to dismiss action: $error')), - ); - } finally { - if (mounted) { - setState(() => _isDismissing = false); - } - } - } - - Future _openCreatedAction() async { - setState(() => _isOpening = true); - try { - await ref - .read(extractedActionCreationServiceProvider) - .openCreatedAction(action); - } catch (error) { - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to open created action: $error')), - ); - } finally { - if (mounted) { - setState(() => _isOpening = false); - } - } - } - IconData _actionTypeIcon(ExtractedActionType type) { return switch (type) { ExtractedActionType.task => Icons.check_box_outlined, @@ -1498,56 +1541,6 @@ class _ActionItemState extends ConsumerState<_ActionItem> { ExtractedActionType.calendarEvent => AppTheme.infoColor, }; } - - String? _timingLabel(ExtractedAction action) { - final dateFormat = DateFormat.yMMMd(); - final dateTimeFormat = DateFormat.yMMMd().add_jm(); - - if (action.startTime != null) { - final start = action.startTime!.toLocal(); - if (action.endTime != null) { - final end = action.endTime!.toLocal(); - return 'When: ${dateTimeFormat.format(start)} → ${dateTimeFormat.format(end)}'; - } - return 'When: ${dateTimeFormat.format(start)}'; - } - - if (action.dueDate != null) { - return 'Due: ${dateFormat.format(action.dueDate!.toLocal())}'; - } - - return null; - } -} - -class _ActionStatusBadge extends StatelessWidget { - final ExtractedAction action; - - const _ActionStatusBadge({required this.action}); - - @override - Widget build(BuildContext context) { - final (label, color) = switch ((action.created, action.dismissed)) { - (true, _) => ('Created', AppTheme.successColor), - (_, true) => ('Dismissed', AppTheme.textSecondary), - _ => ('Pending', AppTheme.warningColor), - }; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w700, - ), - ), - ); - } } class _CategoryBadge extends StatelessWidget { @@ -2347,9 +2340,6 @@ class _TranscribeButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final engineStateAsync = ref.watch(transcriptionEngineStateProvider); final configuredAsync = ref.watch(transcriptionConfiguredProvider); - final actionState = ref.watch(voiceMemoActionsProvider); - // Also watch background auto-transcription so buttons update immediately - final autoTranscribeState = ref.watch(autoTranscribeStateProvider); return configuredAsync.when( data: (configured) { @@ -2367,61 +2357,28 @@ class _TranscribeButton extends ConsumerWidget { return engineStateAsync.when( data: (engineState) { - final isTranscribing = actionState.isTranscribingMemo(memo.filename) || - autoTranscribeState.isTranscribingMemo(memo.filename); + final isTranscribing = + engineState.status == TranscriptionEngineStatus.transcribing; final buttonLabel = memo.transcription == null ? 'Transcribe' : 'Re-transcribe'; - // Build a status label from the engine state while transcribing - String? statusLabel; - if (isTranscribing) { - statusLabel = switch (engineState.status) { - TranscriptionEngineStatus.downloading => - 'Downloading model (${(engineState.downloadProgress * 100).toInt()}%)…', - TranscriptionEngineStatus.transcribing => 'Transcribing audio…', - TranscriptionEngineStatus.ready => 'Preparing…', - TranscriptionEngineStatus.error => - engineState.errorMessage ?? 'Error', - _ => 'Initializing…', - }; - } - return _ButtonBox( expand: expand, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: expand - ? CrossAxisAlignment.stretch - : CrossAxisAlignment.start, - children: [ - OutlinedButton.icon( - style: _compactOutlinedButtonStyle(), - icon: isTranscribing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.transcribe), - label: Text(isTranscribing ? 'Transcribing...' : buttonLabel), - onPressed: isTranscribing - ? null - : () => ref - .read(voiceMemoActionsProvider.notifier) - .retranscribe(memo), - ), - if (statusLabel != null) - Padding( - padding: const EdgeInsets.only(top: 4, left: 4), - child: Text( - statusLabel, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - fontSize: 11, - ), - ), - ), - ], + child: OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + icon: isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.transcribe), + label: Text(isTranscribing ? 'Transcribing...' : buttonLabel), + onPressed: isTranscribing + ? null + : () => ref + .read(voiceMemoActionsProvider.notifier) + .retranscribe(memo), ), ); }, @@ -2442,7 +2399,8 @@ class _TranscribeButton extends ConsumerWidget { loading: () => const SizedBox.shrink(), error: (_, _) => engineStateAsync.when( data: (engineState) { - final isTranscribing = actionState.isTranscribingMemo(memo.filename); + final isTranscribing = + engineState.status == TranscriptionEngineStatus.transcribing; return _ButtonBox( expand: expand, From 2384d4c43b87680114fc5a9fc8f9ad1ce09b63da Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 22:06:55 +0100 Subject: [PATCH 13/58] Make 'Process all unprocessed' button smaller to match 'Re-Transcribe' style --- .../settings/ai_models_settings_screen.dart | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index de73c57..7a357aa 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -1033,15 +1033,7 @@ class _AiModelSelectorState extends ConsumerState<_AiModelSelector> { ), // Process all button - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: _ProcessAllButton(), - ), + _ProcessAllButton(), ], ); }, @@ -1140,17 +1132,30 @@ class _ProcessAllButton extends ConsumerWidget { final aiActionsState = ref.watch(aiActionsProvider); final isBusy = aiActionsState.isLoading; - return SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - icon: isBusy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.auto_awesome, size: 18), - label: Text(isBusy ? 'Processing...' : 'Process all unprocessed'), + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 4, + AppTheme.spacingMd, + 0, + ), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + textStyle: Theme.of(context).textTheme.bodySmall, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome, size: 16), + label: Text(isBusy ? 'Processing...' : 'Process all unprocessed'), onPressed: isBusy || !localAiEnabled ? null : () async { @@ -1196,6 +1201,7 @@ class _ProcessAllButton extends ConsumerWidget { } } }, + ), ), ); } From 2f8b34fac0fe41d0c9894986155ee1ccd2a27b6e Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Thu, 12 Mar 2026 22:56:23 +0100 Subject: [PATCH 14/58] Add ai_testbench for LLM model benchmarking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone Flutter desktop app for evaluating on-device GGUF models used by the companion app's voice memo pipeline. Benchmarks: - Structured extraction (intent/title/datetime JSON) - Time expression resolution (transcript → chrono_dart → DateTime) - STT correction (homophone/filler/punctuation fixes) Supports both interactive GUI and headless CLI modes. Also updated copilot-instructions.md with ai_testbench docs. --- .github/copilot-instructions.md | 48 + ai_testbench/.gitignore | 58 ++ ai_testbench/.metadata | 30 + ai_testbench/README.md | 114 +++ ai_testbench/analysis_options.yaml | 28 + ai_testbench/android/.gitignore | 14 + ai_testbench/android/app/build.gradle.kts | 47 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 47 + .../com/example/ai_testbench/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + ai_testbench/android/build.gradle.kts | 24 + ai_testbench/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + ai_testbench/android/settings.gradle.kts | 26 + .../headless_benchmark_2026-03-08_summary.md | 52 + ai_testbench/bin/test_time_extraction.dart | 487 +++++++++ ai_testbench/lib/benchmark_main.dart | 220 ++++ ai_testbench/lib/correction_main.dart | 168 ++++ ai_testbench/lib/main.dart | 91 ++ .../lib/prompts/prompt_templates.dart | 50 + .../lib/prompts/time_extraction_prompts.dart | 58 ++ .../lib/screens/testbench_screen.dart | 947 ++++++++++++++++++ .../lib/screens/time_extraction_screen.dart | 312 ++++++ .../correction_benchmark_service.dart | 675 +++++++++++++ ai_testbench/lib/services/llm_service.dart | 223 +++++ .../lib/services/model_benchmark_service.dart | 534 ++++++++++ .../services/time_expression_resolver.dart | 2 + .../time_extraction_benchmark_service.dart | 565 +++++++++++ ai_testbench/lib/time_extraction_main.dart | 136 +++ ai_testbench/lib/widgets/memo_card.dart | 147 +++ ai_testbench/linux/.gitignore | 1 + ai_testbench/linux/CMakeLists.txt | 128 +++ ai_testbench/linux/flutter/CMakeLists.txt | 88 ++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 24 + ai_testbench/linux/runner/CMakeLists.txt | 26 + ai_testbench/linux/runner/main.cc | 6 + ai_testbench/linux/runner/my_application.cc | 148 +++ ai_testbench/linux/runner/my_application.h | 21 + ai_testbench/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 14 + .../macos/Runner.xcodeproj/project.pbxproj | 705 +++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 99 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + ai_testbench/macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 +++++++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + ai_testbench/macos/Runner/Info.plist | 32 + .../macos/Runner/MainFlutterWindow.swift | 15 + .../macos/Runner/Release.entitlements | 8 + .../macos/RunnerTests/RunnerTests.swift | 12 + ai_testbench/pubspec.lock | 544 ++++++++++ ai_testbench/pubspec.yaml | 97 ++ ai_testbench/test/widget_test.dart | 17 + ai_testbench/windows/.gitignore | 17 + ai_testbench/windows/CMakeLists.txt | 108 ++ ai_testbench/windows/flutter/CMakeLists.txt | 109 ++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 24 + ai_testbench/windows/runner/CMakeLists.txt | 40 + ai_testbench/windows/runner/Runner.rc | 121 +++ .../windows/runner/flutter_window.cpp | 71 ++ ai_testbench/windows/runner/flutter_window.h | 33 + ai_testbench/windows/runner/main.cpp | 43 + ai_testbench/windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 14 + ai_testbench/windows/runner/utils.cpp | 65 ++ ai_testbench/windows/runner/utils.h | 19 + ai_testbench/windows/runner/win32_window.cpp | 288 ++++++ ai_testbench/windows/runner/win32_window.h | 102 ++ 99 files changed, 8795 insertions(+) create mode 100644 ai_testbench/.gitignore create mode 100644 ai_testbench/.metadata create mode 100644 ai_testbench/README.md create mode 100644 ai_testbench/analysis_options.yaml create mode 100644 ai_testbench/android/.gitignore create mode 100644 ai_testbench/android/app/build.gradle.kts create mode 100644 ai_testbench/android/app/src/debug/AndroidManifest.xml create mode 100644 ai_testbench/android/app/src/main/AndroidManifest.xml create mode 100644 ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt create mode 100644 ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 ai_testbench/android/app/src/main/res/drawable/launch_background.xml create mode 100644 ai_testbench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 ai_testbench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 ai_testbench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 ai_testbench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 ai_testbench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 ai_testbench/android/app/src/main/res/values-night/styles.xml create mode 100644 ai_testbench/android/app/src/main/res/values/styles.xml create mode 100644 ai_testbench/android/app/src/profile/AndroidManifest.xml create mode 100644 ai_testbench/android/build.gradle.kts create mode 100644 ai_testbench/android/gradle.properties create mode 100644 ai_testbench/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 ai_testbench/android/settings.gradle.kts create mode 100644 ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md create mode 100644 ai_testbench/bin/test_time_extraction.dart create mode 100644 ai_testbench/lib/benchmark_main.dart create mode 100644 ai_testbench/lib/correction_main.dart create mode 100644 ai_testbench/lib/main.dart create mode 100644 ai_testbench/lib/prompts/prompt_templates.dart create mode 100644 ai_testbench/lib/prompts/time_extraction_prompts.dart create mode 100644 ai_testbench/lib/screens/testbench_screen.dart create mode 100644 ai_testbench/lib/screens/time_extraction_screen.dart create mode 100644 ai_testbench/lib/services/correction_benchmark_service.dart create mode 100644 ai_testbench/lib/services/llm_service.dart create mode 100644 ai_testbench/lib/services/model_benchmark_service.dart create mode 100644 ai_testbench/lib/services/time_expression_resolver.dart create mode 100644 ai_testbench/lib/services/time_extraction_benchmark_service.dart create mode 100644 ai_testbench/lib/time_extraction_main.dart create mode 100644 ai_testbench/lib/widgets/memo_card.dart create mode 100644 ai_testbench/linux/.gitignore create mode 100644 ai_testbench/linux/CMakeLists.txt create mode 100644 ai_testbench/linux/flutter/CMakeLists.txt create mode 100644 ai_testbench/linux/flutter/generated_plugin_registrant.cc create mode 100644 ai_testbench/linux/flutter/generated_plugin_registrant.h create mode 100644 ai_testbench/linux/flutter/generated_plugins.cmake create mode 100644 ai_testbench/linux/runner/CMakeLists.txt create mode 100644 ai_testbench/linux/runner/main.cc create mode 100644 ai_testbench/linux/runner/my_application.cc create mode 100644 ai_testbench/linux/runner/my_application.h create mode 100644 ai_testbench/macos/.gitignore create mode 100644 ai_testbench/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 ai_testbench/macos/Flutter/Flutter-Release.xcconfig create mode 100644 ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 ai_testbench/macos/Runner.xcodeproj/project.pbxproj create mode 100644 ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ai_testbench/macos/Runner/AppDelegate.swift create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 ai_testbench/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 ai_testbench/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 ai_testbench/macos/Runner/Configs/Debug.xcconfig create mode 100644 ai_testbench/macos/Runner/Configs/Release.xcconfig create mode 100644 ai_testbench/macos/Runner/Configs/Warnings.xcconfig create mode 100644 ai_testbench/macos/Runner/DebugProfile.entitlements create mode 100644 ai_testbench/macos/Runner/Info.plist create mode 100644 ai_testbench/macos/Runner/MainFlutterWindow.swift create mode 100644 ai_testbench/macos/Runner/Release.entitlements create mode 100644 ai_testbench/macos/RunnerTests/RunnerTests.swift create mode 100644 ai_testbench/pubspec.lock create mode 100644 ai_testbench/pubspec.yaml create mode 100644 ai_testbench/test/widget_test.dart create mode 100644 ai_testbench/windows/.gitignore create mode 100644 ai_testbench/windows/CMakeLists.txt create mode 100644 ai_testbench/windows/flutter/CMakeLists.txt create mode 100644 ai_testbench/windows/flutter/generated_plugin_registrant.cc create mode 100644 ai_testbench/windows/flutter/generated_plugin_registrant.h create mode 100644 ai_testbench/windows/flutter/generated_plugins.cmake create mode 100644 ai_testbench/windows/runner/CMakeLists.txt create mode 100644 ai_testbench/windows/runner/Runner.rc create mode 100644 ai_testbench/windows/runner/flutter_window.cpp create mode 100644 ai_testbench/windows/runner/flutter_window.h create mode 100644 ai_testbench/windows/runner/main.cpp create mode 100644 ai_testbench/windows/runner/resource.h create mode 100644 ai_testbench/windows/runner/resources/app_icon.ico create mode 100644 ai_testbench/windows/runner/runner.exe.manifest create mode 100644 ai_testbench/windows/runner/utils.cpp create mode 100644 ai_testbench/windows/runner/utils.h create mode 100644 ai_testbench/windows/runner/win32_window.cpp create mode 100644 ai_testbench/windows/runner/win32_window.h diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b19d5c2..0b90772 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -135,3 +135,51 @@ Providers expose `DfuService`, `FirmwareManager`, and `FilesystemUploadService` - **Protocol reference**: `gadgetbridge_api.txt` — complete JSON message format for phone↔watch communication - **Firmware counterpart**: `ZSWatch-Firmware/app/src/ble/gadgetbridge/ble_gadgetbridge.c` — firmware-side protocol implementation - **Known issues**: `Issues:.md` — current bugs and TODOs at root level + +## AI Testbench (`ai_testbench/`) + +Standalone Flutter desktop app for benchmarking and evaluating on-device LLM models before shipping them in the companion app. It tests the same prompts and schemas used in production (`chrono_ai_flow` package) against local GGUF models via `fllama`. + +### What It Tests + +| Benchmark | Service | Purpose | +|-----------|---------|---------| +| **Structured extraction** | `model_benchmark_service.dart` | Validates LLM outputs valid JSON matching `chrono_ai_flow` schema (intent, title, datetime fields) | +| **Time extraction** | `time_extraction_benchmark_service.dart` | End-to-end: transcript → LLM extraction → `chrono_dart` parsing → resolved `DateTime` | +| **Correction** | `correction_benchmark_service.dart` | Verifies LLM can fix common STT errors (homophones, filler, punctuation) | + +### Running Benchmarks + +```bash +cd ai_testbench +flutter pub get + +# Interactive GUI +flutter run -d linux + +# Headless (compiled for consistent timing) +flutter build linux --release +./build/linux/x64/release/bundle/ai_testbench --headless --output results.json +./build/linux/x64/release/bundle/ai_testbench --headless-time --model Qwen3.5-2B-Q4_K_M.gguf +./build/linux/x64/release/bundle/ai_testbench --headless-correction --model-dir models/ +``` + +### Key Files + +- `lib/main.dart` — Entry point with headless mode dispatch (`--headless`, `--headless-time`, `--headless-correction`) +- `lib/services/llm_service.dart` — `fllama` wrapper (`LlmService`) for inference with configurable parameters +- `lib/prompts/` — Prompt templates (delegates to `chrono_ai_flow` for production prompts) +- `benchmark_results/` — Saved benchmark summaries (Markdown) + +### Dependencies + +- **fllama** — Flutter llama.cpp bindings for on-device inference +- **chrono_ai_flow** — Shared prompt templates and JSON schema (local package at `../packages/chrono_ai_flow`) +- **chrono_dart** — Natural language time expression parsing + +### Notes + +- GGUF model files go in `models/` (gitignored — download separately from HuggingFace) +- Native `.so` libraries go in `native_libs/` (gitignored — build from llama.cpp if using the CLI `bin/` runner) +- Test cases are defined inline in the service files — add new cases there +- The testbench shares the same `chrono_ai_flow` package as the main app, so prompt changes are tested against both diff --git a/ai_testbench/.gitignore b/ai_testbench/.gitignore new file mode 100644 index 0000000..29235f2 --- /dev/null +++ b/ai_testbench/.gitignore @@ -0,0 +1,58 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Large model files (download separately) +models/ +*.gguf + +# Native shared libraries (build separately) +native_libs/ + +# Agent tools +.agent_tools/ + +# MLCEngine virtualenv +.mlc_venv/ diff --git a/ai_testbench/.metadata b/ai_testbench/.metadata new file mode 100644 index 0000000..792284a --- /dev/null +++ b/ai_testbench/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: android + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ai_testbench/README.md b/ai_testbench/README.md new file mode 100644 index 0000000..c52c234 --- /dev/null +++ b/ai_testbench/README.md @@ -0,0 +1,114 @@ +# AI Testbench + +Standalone Flutter app for benchmarking and evaluating on-device LLM models used by the ZSWatch companion app. It tests structured JSON extraction (intent classification, time extraction, correction) against local GGUF models via [fllama](https://pub.dev/packages/fllama), producing pass/fail results and tokens-per-second metrics. + +## Purpose + +The ZSWatch companion app uses on-device LLMs to process voice memos — classifying intents (reminder, event, note), extracting time expressions, and correcting transcription errors. This testbench: + +- **Evaluates candidate GGUF models** for accuracy and speed before shipping them in the app. +- **Benchmarks structured extraction** — verifies the model outputs valid JSON matching the `chrono_ai_flow` schema. +- **Tests time expression resolution** — end-to-end from transcript → LLM extraction → `chrono_dart` parsing → resolved `DateTime`. +- **Tests transcript correction** — verifies the model can fix common STT errors (homophones, filler words, punctuation). + +## Directory Structure + +``` +ai_testbench/ +├── lib/ +│ ├── main.dart # Entry point (GUI + headless modes) +│ ├── benchmark_main.dart # Model benchmark runner (structured extraction) +│ ├── correction_main.dart # Correction benchmark runner +│ ├── time_extraction_main.dart # Time extraction benchmark runner +│ ├── prompts/ # Prompt templates (shared via chrono_ai_flow) +│ ├── screens/ # Flutter UI screens for interactive testing +│ └── services/ +│ ├── llm_service.dart # fllama wrapper for inference +│ ├── model_benchmark_service.dart # Structured extraction benchmark logic +│ ├── correction_benchmark_service.dart # Correction benchmark logic +│ ├── time_extraction_benchmark_service.dart # Time extraction benchmark logic +│ └── time_expression_resolver.dart # chrono_dart time resolution +├── bin/ +│ └── test_time_extraction.dart # CLI test runner (uses llama_cpp_dart directly) +├── models/ # GGUF model files (gitignored, download separately) +├── native_libs/ # Native shared libraries (gitignored, build separately) +├── benchmark_results/ # Saved benchmark summaries +└── test/ +``` + +## Prerequisites + +- Flutter SDK (channel stable) +- GGUF model files placed in `models/` (not committed — download from HuggingFace or equivalent) +- Linux desktop support enabled (`flutter config --enable-linux-desktop`) + +## Setup + +```bash +cd ai_testbench +flutter pub get +``` + +Place one or more `.gguf` model files in the `models/` directory. Recommended starting model: `Qwen3.5-2B-Q4_K_M.gguf`. + +## Usage + +### Interactive GUI + +```bash +flutter run -d linux +``` + +Opens a desktop window with screens for running benchmarks interactively. + +### Headless Benchmarks + +Run from a compiled release build for consistent timing: + +```bash +flutter build linux --release +``` + +**Structured extraction benchmark** (all models in `models/`): +```bash +./build/linux/x64/release/bundle/ai_testbench --headless --output results.json +``` + +**Time extraction benchmark** (single model): +```bash +./build/linux/x64/release/bundle/ai_testbench --headless-time --model Qwen3.5-2B-Q4_K_M.gguf +``` + +**Correction benchmark**: +```bash +./build/linux/x64/release/bundle/ai_testbench --headless-correction --model-dir models/ --output correction.json +``` + +### CLI Options + +| Flag | Description | +|------|-------------| +| `--headless` | Run structured extraction benchmark (all models) | +| `--headless-time` | Run time extraction benchmark | +| `--headless-correction` | Run correction benchmark | +| `--model ` | Filter to a specific model filename | +| `--model-dir ` | Path to directory containing `.gguf` files (default: `models/`) | +| `--output ` | Write JSON results to file | +| `--language-hint` | Include language hint in time extraction prompts | +| `--retry-invalid` | Retry on invalid JSON output | +| `--prompt-variant ` | Select prompt template variant | + +## Key Dependencies + +- **[fllama](https://pub.dev/packages/fllama)** — Flutter bindings for llama.cpp (model inference) +- **chrono_ai_flow** — Shared prompt templates and JSON schema for voice memo classification (local package in `../packages/chrono_ai_flow`) +- **chrono_dart** — Natural language time expression parsing + +## Adding New Test Cases + +Benchmark cases are defined directly in the service files: +- `lib/services/model_benchmark_service.dart` — structured extraction cases +- `lib/services/correction_benchmark_service.dart` — correction cases +- `lib/services/time_extraction_benchmark_service.dart` — time extraction cases + +Each case specifies input transcript, expected intent, expected outputs, and validation criteria. diff --git a/ai_testbench/analysis_options.yaml b/ai_testbench/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/ai_testbench/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/ai_testbench/android/.gitignore b/ai_testbench/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/ai_testbench/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/ai_testbench/android/app/build.gradle.kts b/ai_testbench/android/app/build.gradle.kts new file mode 100644 index 0000000..d367f34 --- /dev/null +++ b/ai_testbench/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.ai_testbench" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.ai_testbench" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 24 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/ai_testbench/android/app/src/debug/AndroidManifest.xml b/ai_testbench/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/ai_testbench/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/ai_testbench/android/app/src/main/AndroidManifest.xml b/ai_testbench/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..90d976b --- /dev/null +++ b/ai_testbench/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt b/ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt new file mode 100644 index 0000000..27f8187 --- /dev/null +++ b/ai_testbench/android/app/src/main/kotlin/com/example/ai_testbench/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.ai_testbench + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml b/ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/ai_testbench/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ai_testbench/android/app/src/main/res/drawable/launch_background.xml b/ai_testbench/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/ai_testbench/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ai_testbench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/ai_testbench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/ai_testbench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/ai_testbench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ai_testbench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/ai_testbench/android/app/src/main/res/values-night/styles.xml b/ai_testbench/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/ai_testbench/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/ai_testbench/android/app/src/main/res/values/styles.xml b/ai_testbench/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/ai_testbench/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/ai_testbench/android/app/src/profile/AndroidManifest.xml b/ai_testbench/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/ai_testbench/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/ai_testbench/android/build.gradle.kts b/ai_testbench/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/ai_testbench/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/ai_testbench/android/gradle.properties b/ai_testbench/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/ai_testbench/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/ai_testbench/android/gradle/wrapper/gradle-wrapper.properties b/ai_testbench/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/ai_testbench/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/ai_testbench/android/settings.gradle.kts b/ai_testbench/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/ai_testbench/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md b/ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md new file mode 100644 index 0000000..6f88d51 --- /dev/null +++ b/ai_testbench/benchmark_results/headless_benchmark_2026-03-08_summary.md @@ -0,0 +1,52 @@ +# Headless benchmark summary — 2026-03-08 + +Benchmark scope: +- 9 GGUF models +- 6 strict structured-extraction cases +- Ranking by strict passes first, then average tokens/second + +## Ranking + +| Rank | Model | Strict passes | Avg tok/s | Total time | +| --- | --- | ---: | ---: | ---: | +| 1 | Qwen3.5-2B-Q4_K_M.gguf | 5/6 | 19.3 | 61.3s | +| 2 | SmolLM3-Q4_K_M.gguf | 4/6 | 16.2 | 51.5s | +| 3 | qwen2.5-1.5b-instruct-q5_k_m.gguf | 2/6 | 26.6 | 39.8s | +| 4 | qwen2.5-1.5b-instruct-q8_0.gguf | 2/6 | 23.6 | 44.2s | +| 5 | Qwen2.5-3B-Instruct-Q4_K_M.gguf | 1/6 | 17.0 | 50.8s | +| 6 | Llama-3.2-3B-Instruct-Q4_K_M.gguf | 1/6 | 16.3 | 42.9s | +| 7 | Qwen2.5-0.5B-Instruct-Q4_K_M.gguf | 0/6 | 71.0 | 13.3s | +| 8 | Qwen3-1.7B-Q4_K_M.gguf | 0/6 | 32.8 | 70.5s | +| 9 | Qwen2.5-1.5B-Instruct-Q4_K_M.gguf | 0/6 | 32.1 | 25.6s | + +## Top-model notes + +### Qwen3.5-2B-Q4_K_M.gguf +- Passed 5 of 6 cases. +- Passed all calendar/reminder cases except `task_de_deadline`. +- The only miss was action count on the German task case. +- Best overall model in this run. + +### SmolLM3-Q4_K_M.gguf +- Passed 4 of 6 cases. +- Strong fallback model. +- Missed `calendar_en_precise` and `calendar_en_with_reminder`. + +### Qwen3-1.7B-Q4_K_M.gguf +- Fast, but failed 6 of 6 cases. +- Main failure mode was invalid JSON across the board. +- Not recommended for structured extraction in the app. + +## Recommendation + +1. Primary recommendation: **Qwen3.5-2B-Q4_K_M.gguf** + - Best strict accuracy by a clear margin. + - Speed is acceptable. + - Use this first if the target runtime remains stable. + +2. Safe backup: **SmolLM3-Q4_K_M.gguf** + - Second-best accuracy. + - Good fallback if Qwen3.5 shows runtime-specific issues. + +3. Do not promote **Qwen3-1.7B-Q4_K_M.gguf** + - Good speed, but unusable here for strict JSON extraction. diff --git a/ai_testbench/bin/test_time_extraction.dart b/ai_testbench/bin/test_time_extraction.dart new file mode 100644 index 0000000..b2a08f2 --- /dev/null +++ b/ai_testbench/bin/test_time_extraction.dart @@ -0,0 +1,487 @@ +/// CLI testbench for the voice memo → time extraction pipeline. +/// +/// Run with: +/// cd ai_testbench +/// LD_LIBRARY_PATH=native_libs dart run bin/test_time_extraction.dart +/// +/// Options: +/// --model Model file in models/ (default: Qwen3.5-2B-Q4_K_M.gguf) +/// --verbose Print full raw LLM output +/// +/// Requires native_libs/libllama.so built first. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:chrono_dart/chrono_dart.dart'; +import 'package:llama_cpp_dart/llama_cpp_dart.dart'; + +import '../lib/prompts/time_extraction_prompts.dart'; +import '../lib/services/time_expression_resolver.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class TestCase { + final String name; + final String transcript; + final String expectedIntent; // 'reminder', 'event', 'note' + final String? expectedTimeEnglish; // null = no time expected + final DateTime? expectedDateTime; // null = no time expected + final int toleranceMinutes; // for relative times like "in 30 minutes" + + const TestCase({ + required this.name, + required this.transcript, + required this.expectedIntent, + this.expectedTimeEnglish, + this.expectedDateTime, + this.toleranceMinutes = 2, + }); +} + +// ── LLM response structure ────────────────────────────────────────────── + +class LlmExtractionResult { + final String? intent; + final String? title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String rawOutput; + + const LlmExtractionResult({ + this.intent, + this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + required this.rawOutput, + }); +} + +// ── Test result ───────────────────────────────────────────────────────── + +enum TestStatus { pass, fail, partial } + +class TestResult { + final TestCase testCase; + final LlmExtractionResult? llmResult; + final ResolvedTime? resolvedTime; + final Duration llmDuration; + final int tokenCount; + final TestStatus status; + final List failures; + + const TestResult({ + required this.testCase, + this.llmResult, + this.resolvedTime, + required this.llmDuration, + required this.tokenCount, + required this.status, + this.failures = const [], + }); +} + +// ── Main ──────────────────────────────────────────────────────────────── + +void main(List args) async { + // Parse CLI args + var modelFile = 'Qwen3.5-2B-Q4_K_M.gguf'; + var verbose = false; + + for (var i = 0; i < args.length; i++) { + if (args[i] == '--model' && i + 1 < args.length) { + modelFile = args[++i]; + } else if (args[i] == '--verbose') { + verbose = true; + } + } + + print('╔══════════════════════════════════════════════════════════╗'); + print('║ ZSWatch Time Extraction Testbench — CLI ║'); + print('╚══════════════════════════════════════════════════════════╝'); + print(''); + + // ── 1. Resolve paths ───────────────────────────────────────────────── + final scriptDir = File(Platform.script.toFilePath()).parent.parent.path; + final nativeLib = '$scriptDir/native_libs/libllama.so'; + final modelPath = '$scriptDir/models/$modelFile'; + + if (!File(nativeLib).existsSync()) { + stderr.writeln('ERROR: Native library not found at $nativeLib'); + stderr.writeln('Build it first — see README.'); + exit(1); + } + if (!File(modelPath).existsSync()) { + stderr.writeln('ERROR: Model not found at $modelPath'); + stderr.writeln('Available models:'); + Directory('$scriptDir/models') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.gguf')) + .forEach((f) => stderr.writeln(' ${f.uri.pathSegments.last}')); + exit(1); + } + + // ── 2. Set native lib path ──────────────────────────────────────────── + print('[1/4] Setting native library path'); + Llama.libraryPath = nativeLib; + + // ── 3. Load model ───────────────────────────────────────────────────── + print('[2/4] Loading model: ${modelFile}'); + final sw = Stopwatch()..start(); + + late Llama llama; + try { + llama = Llama( + modelPath, + modelParams: ModelParams() + ..nGpuLayers = 0 + ..mainGpu = -1, + contextParams: ContextParams() + ..nCtx = 2048 + ..nBatch = 512 + ..nThreads = 4 + ..nThreadsBatch = 4 + ..nPredict = 300, + samplerParams: SamplerParams() + ..temp = 0.1 + ..greedy = false + ..topK = 40 + ..topP = 0.9 + ..penaltyRepeat = 1.1, + verbose: false, + ); + } catch (e) { + stderr.writeln('FAILED to load model: $e'); + exit(1); + } + sw.stop(); + print(' Model loaded in ${sw.elapsed.inMilliseconds}ms ✓'); + print(''); + + // ── 4. Define reference time ────────────────────────────────────────── + // Use a fixed reference time so tests are deterministic + final referenceTime = DateTime(2026, 3, 9, 10, 15); // Monday March 9, 10:15 + print('[3/4] Reference time: $referenceTime (Monday)'); + print(''); + + // ── 5. Define test cases ────────────────────────────────────────────── + final testCases = [ + TestCase( + name: 'EN: Simple reminder with time', + transcript: 'Remind me tomorrow at 10 am to buy milk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10 am', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TestCase( + name: 'SV: Reminder with time', + transcript: 'påminn mig imorgon klockan 10 att köpa mjölk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TestCase( + name: 'DE: Reminder with time', + transcript: 'erinnere mich morgen um 10 milch zu kaufen', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TestCase( + name: 'EN: Meeting next Tuesday', + transcript: 'meeting with John next Tuesday at 2 pm', + expectedIntent: 'event', + expectedTimeEnglish: 'next Tuesday at 2 pm', + expectedDateTime: DateTime(2026, 3, 10, 14, 0), // next Tue from Mon Mar 9 + ), + TestCase( + name: 'EN: No time mentioned', + transcript: 'remember to buy milk', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TestCase( + name: 'SV: Relative minutes', + transcript: 'ring tandläkaren om 30 minuter', + expectedIntent: 'reminder', + expectedTimeEnglish: 'in 30 minutes', + expectedDateTime: referenceTime.add(const Duration(minutes: 30)), + toleranceMinutes: 5, + ), + TestCase( + name: 'FR: Friday at 3pm', + transcript: "rappelle-moi vendredi à 15h d'appeler le médecin", + expectedIntent: 'reminder', + expectedTimeEnglish: 'Friday at 3 pm', + expectedDateTime: DateTime(2026, 3, 13, 15, 0), // next Friday + ), + TestCase( + name: 'EN: Specific date', + transcript: 'dentist appointment on March 15th at 9:30', + expectedIntent: 'event', + expectedTimeEnglish: 'March 15th at 9:30', + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + TestCase( + name: 'SV: No time, just task', + transcript: 'köp bröd på vägen hem', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TestCase( + name: 'EN: This afternoon', + transcript: 'call the plumber this afternoon at 3', + expectedIntent: 'reminder', + expectedTimeEnglish: 'this afternoon at 3', + expectedDateTime: DateTime(2026, 3, 9, 15, 0), + ), + ]; + + // ── 6. Run tests ───────────────────────────────────────────────────── + print('[4/4] Running ${testCases.length} test cases...'); + print(''); + + final chatml = ChatMLFormat(); + final resolver = TimeExpressionResolver(); + final results = []; + + for (var i = 0; i < testCases.length; i++) { + final tc = testCases[i]; + print('─── Test ${i + 1}/${testCases.length}: ${tc.name} ───────────────────────'); + print(' Input: "${tc.transcript}"'); + + // Build prompt + final formatted = chatml.formatMessages([ + {'role': 'system', 'content': TimeExtractionPrompts.systemPrompt}, + { + 'role': 'user', + 'content': TimeExtractionPrompts.userMessage( + transcript: tc.transcript, + now: referenceTime, + timezone: 'Europe/Stockholm', + ), + }, + ]); + + // Run inference + llama.clear(); + llama.setPrompt(formatted); + + final genSw = Stopwatch()..start(); + final buffer = StringBuffer(); + int tokenCount = 0; + + try { + await for (final chunk in llama.generateText()) { + buffer.write(chunk); + tokenCount++; + if (tokenCount >= 300) break; + } + } catch (e) { + stderr.writeln(' ERROR during generation: $e'); + results.add(TestResult( + testCase: tc, + llmDuration: genSw.elapsed, + tokenCount: tokenCount, + status: TestStatus.fail, + failures: ['LLM generation error: $e'], + )); + print(''); + continue; + } + + genSw.stop(); + + String raw = buffer.toString().trim(); + // Strip end-of-turn tokens + raw = raw.replaceAll('<|im_end|>', '').trim(); + // Strip thinking blocks (Qwen3 models may use these) + raw = raw.replaceAll(RegExp(r'.*?', dotAll: true), '').trim(); + + final secs = genSw.elapsed.inMilliseconds / 1000; + print(' LLM time: ${secs.toStringAsFixed(2)}s (~${(tokenCount / secs).toStringAsFixed(1)} tok/s)'); + + if (verbose) { + print(' Raw output:'); + print(' $raw'); + } + + // Parse JSON from LLM output + LlmExtractionResult? llmResult; + try { + final jsonStart = raw.indexOf('{'); + final jsonEnd = raw.lastIndexOf('}'); + if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) { + throw FormatException('No JSON object found in output'); + } + final jsonStr = raw.substring(jsonStart, jsonEnd + 1); + final parsed = jsonDecode(jsonStr) as Map; + + llmResult = LlmExtractionResult( + intent: parsed['intent'] as String?, + title: parsed['title'] as String?, + datetimeExpressionOriginal: + parsed['datetime_expression_original'] as String?, + datetimeExpressionEnglish: + parsed['datetime_expression_english'] as String?, + rawOutput: raw, + ); + + print(' LLM result:'); + print(' intent: ${llmResult.intent}'); + print(' title: ${llmResult.title}'); + print(' time (orig): ${llmResult.datetimeExpressionOriginal}'); + print(' time (EN): ${llmResult.datetimeExpressionEnglish}'); + } catch (e) { + print(' ❌ JSON parse failed: $e'); + if (!verbose) { + print(' Raw output: $raw'); + } + results.add(TestResult( + testCase: tc, + llmDuration: genSw.elapsed, + tokenCount: tokenCount, + status: TestStatus.fail, + failures: ['JSON parse failed: $e'], + )); + print(''); + continue; + } + + // Resolve time expression with chrono + ResolvedTime? resolvedTime; + // Try English translation first, fall back to original expression + final timeExpr = llmResult.datetimeExpressionEnglish ?? + llmResult.datetimeExpressionOriginal; + if (timeExpr != null) { + resolvedTime = resolver.resolve( + timeExpr, + referenceDate: referenceTime, + ); + if (resolvedTime != null) { + print(' Chrono parse: ${resolvedTime.dateTime} (via ${resolvedTime.method})'); + } else { + print(' Chrono parse: FAILED — could not resolve "$timeExpr"'); + } + } else { + print(' Chrono parse: N/A (no time expression)'); + } + + // Evaluate results + final failures = []; + + // Check 1: Intent + final intentMatch = _intentMatches(llmResult.intent, tc.expectedIntent); + if (!intentMatch) { + failures.add( + 'Intent mismatch: got "${llmResult.intent}", expected "${tc.expectedIntent}"'); + } + + // Check 2: Time expression present/absent + if (tc.expectedTimeEnglish != null && + llmResult.datetimeExpressionEnglish == null) { + failures.add('Expected time expression but got null'); + } + if (tc.expectedTimeEnglish == null && + llmResult.datetimeExpressionEnglish != null) { + failures.add( + 'Expected no time expression but got "${llmResult.datetimeExpressionEnglish}"'); + } + + // Check 3: Chrono parse succeeded when expected + if (tc.expectedDateTime != null && resolvedTime == null) { + failures.add('Chrono failed to parse time expression'); + } + if (tc.expectedDateTime == null && resolvedTime != null) { + failures.add( + 'Expected no resolved time but got ${resolvedTime.dateTime}'); + } + + // Check 4: DateTime accuracy + if (tc.expectedDateTime != null && resolvedTime != null) { + final diff = + resolvedTime.dateTime.difference(tc.expectedDateTime!).inMinutes.abs(); + if (diff > tc.toleranceMinutes) { + failures.add( + 'DateTime mismatch: got ${resolvedTime.dateTime}, expected ${tc.expectedDateTime} (diff: ${diff}min, tolerance: ${tc.toleranceMinutes}min)'); + } + } + + final status = failures.isEmpty + ? TestStatus.pass + : (failures.length == 1 && !failures.first.contains('Intent')) + ? TestStatus.partial + : TestStatus.fail; + + if (failures.isEmpty) { + print(' ✅ PASS'); + } else { + for (final f in failures) { + print(' ❌ $f'); + } + } + + if (tc.expectedDateTime != null) { + print(' Expected: ${tc.expectedDateTime}'); + } + + results.add(TestResult( + testCase: tc, + llmResult: llmResult, + resolvedTime: resolvedTime, + llmDuration: genSw.elapsed, + tokenCount: tokenCount, + status: status, + failures: failures, + )); + + print(''); + } + + // ── 7. Summary ──────────────────────────────────────────────────────── + llama.dispose(); + + final passed = results.where((r) => r.status == TestStatus.pass).length; + final partial = results.where((r) => r.status == TestStatus.partial).length; + final failed = results.where((r) => r.status == TestStatus.fail).length; + final totalLlmTime = results.fold( + Duration.zero, (sum, r) => sum + r.llmDuration); + + print('╔══════════════════════════════════════════════════════════╗'); + print('║ Results: $passed passed, $partial partial, $failed failed ' + 'out of ${testCases.length} tests'); + print('║ Total LLM time: ${(totalLlmTime.inMilliseconds / 1000).toStringAsFixed(1)}s'); + print('║ Model: $modelFile'); + print('╚══════════════════════════════════════════════════════════╝'); + + // Print detailed failure summary + if (failed + partial > 0) { + print(''); + print('Failed/partial tests:'); + for (final r in results.where( + (r) => r.status == TestStatus.fail || r.status == TestStatus.partial)) { + print(' ${r.testCase.name}:'); + for (final f in r.failures) { + print(' - $f'); + } + } + } + + exit(failed > 0 ? 1 : 0); +} + +/// Compare intents loosely — 'event' matches 'event', 'reminder' matches +/// 'reminder'. For 'note', also accept 'task' since the boundary is fuzzy. +bool _intentMatches(String? got, String expected) { + if (got == null) return false; + final g = got.toLowerCase().trim(); + final e = expected.toLowerCase().trim(); + if (g == e) return true; + // Allow note ↔ task since models often confuse these for simple items + if ({g, e}.containsAll({'note', 'task'})) return true; + return false; +} diff --git a/ai_testbench/lib/benchmark_main.dart b/ai_testbench/lib/benchmark_main.dart new file mode 100644 index 0000000..4722455 --- /dev/null +++ b/ai_testbench/lib/benchmark_main.dart @@ -0,0 +1,220 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'screens/testbench_screen.dart'; +import 'services/model_benchmark_service.dart'; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + final config = _parseConfig(args); + final modelDir = config.modelDir; + final hasModels = Directory(modelDir).existsSync(); + + if (!hasModels) { + stdout.writeln('[BenchmarkRunner] No models directory found at $modelDir'); + exitCode = 1; + return; + } else { + final modelPaths = _discoverModelPaths(modelDir); + final modelNames = modelPaths + .map((path) => path.split(Platform.pathSeparator).last) + .toList(); + + if (config.headless) { + if (modelPaths.isEmpty) { + stdout.writeln('[BenchmarkRunner] No .gguf files found yet'); + exitCode = 1; + return; + } + + await _runHeadlessBenchmark( + modelPaths: modelPaths, + outputPath: config.outputPath, + ); + return; + } + + stdout.writeln('[BenchmarkRunner] Launching benchmark UI'); + stdout.writeln('[BenchmarkRunner] Models directory: $modelDir'); + if (modelNames.isEmpty) { + stdout.writeln('[BenchmarkRunner] No .gguf files found yet'); + } else { + for (final modelName in modelNames) { + stdout.writeln(' - $modelName'); + } + } + } + + runApp( + BenchmarkApp( + modelDirectory: modelDir, + ), + ); +} + +class _RunnerConfig { + const _RunnerConfig({ + required this.headless, + required this.modelDir, + required this.outputPath, + }); + + final bool headless; + final String modelDir; + final String? outputPath; +} + +_RunnerConfig _parseConfig(List args) { + bool hasFlag(String flag) => args.contains(flag); + String? readValue(String name) { + for (var i = 0; i < args.length - 1; i++) { + if (args[i] == name) { + return args[i + 1]; + } + } + return null; + } + + final modelDir = readValue('--model-dir') ?? Directory('models').absolute.path; + final outputPath = readValue('--output'); + final headless = hasFlag('--headless') || Platform.environment['AI_BENCH_HEADLESS'] == '1'; + + return _RunnerConfig( + headless: headless, + modelDir: modelDir, + outputPath: outputPath, + ); +} + +List _discoverModelPaths(String modelDir) { + return Directory(modelDir) + .listSync() + .whereType() + .map((file) => file.path) + .where((path) => path.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); +} + +Future _runHeadlessBenchmark({ + required List modelPaths, + required String? outputPath, +}) async { + final service = ModelBenchmarkService(); + + stdout.writeln('[BenchmarkRunner] Running headless benchmark'); + stdout.writeln('[BenchmarkRunner] Model count: ${modelPaths.length}'); + for (final modelPath in modelPaths) { + stdout.writeln(' - ${modelPath.split(Platform.pathSeparator).last}'); + } + + final startedAt = DateTime.now().toUtc(); + final results = await service.runForModels( + modelPaths, + onProgress: (progress) { + final completed = progress.completedRuns; + final total = progress.totalRuns; + stdout.writeln( + '[BenchmarkRunner] Progress $completed/$total ' + 'model=${progress.currentModelName} ' + 'case=${progress.currentCaseName}', + ); + }, + ); + final finishedAt = DateTime.now().toUtc(); + + final report = { + 'startedAt': startedAt.toIso8601String(), + 'finishedAt': finishedAt.toIso8601String(), + 'modelCount': results.length, + 'caseCount': ModelBenchmarkService.benchmarkCases.length, + 'results': results.map(_serializeModelResult).toList(growable: false), + }; + + final resolvedOutputPath = outputPath ?? + '${Directory.current.path}${Platform.pathSeparator}benchmark_results_${DateTime.now().millisecondsSinceEpoch}.json'; + final outputFile = File(resolvedOutputPath); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(report)); + + stdout.writeln('[BenchmarkRunner] Headless benchmark complete'); + stdout.writeln('[BenchmarkRunner] Results written to ${outputFile.path}'); + + final ranked = [...results] + ..sort((a, b) { + final passCompare = b.passedCases.compareTo(a.passedCases); + if (passCompare != 0) return passCompare; + return b.avgTokensPerSecond.compareTo(a.avgTokensPerSecond); + }); + + for (final result in ranked) { + stdout.writeln( + '[BenchmarkRunner] Summary ${result.modelName}: ' + '${result.passedCases}/${result.cases.length} strict passes, ' + '${result.avgTokensPerSecond.toStringAsFixed(1)} tok/s avg, ' + '${result.totalElapsed.inSeconds}s total', + ); + } +} + +Map _serializeModelResult(BenchmarkModelResult result) { + return { + 'modelPath': result.modelPath, + 'modelName': result.modelName, + 'passedCases': result.passedCases, + 'totalCases': result.cases.length, + 'avgTokensPerSecond': result.avgTokensPerSecond, + 'totalElapsedMs': result.totalElapsed.inMilliseconds, + 'cases': result.cases.map((caseResult) { + return { + 'caseName': caseResult.caseName, + 'passed': caseResult.passed, + 'validJson': caseResult.validJson, + 'intentMatch': caseResult.intentMatch, + 'timePresenceMatch': caseResult.timePresenceMatch, + 'titleLanguageMatch': caseResult.titleLanguageMatch, + 'titleLanguageDetail': caseResult.titleLanguageDetail, + 'timeResolutionCorrect': caseResult.timeResolutionCorrect, + 'timeResolutionDetail': caseResult.timeResolutionDetail, + 'intent': caseResult.intent, + 'title': caseResult.title, + 'datetimeOriginal': caseResult.datetimeOriginal, + 'datetimeEnglish': caseResult.datetimeEnglish, + 'elapsedMs': caseResult.elapsed.inMilliseconds, + 'tokensPerSecond': caseResult.tokensPerSecond, + 'outputPreview': caseResult.outputPreview, + 'error': caseResult.error, + }; + }).toList(growable: false), + }; +} + +class BenchmarkApp extends StatelessWidget { + const BenchmarkApp({ + super.key, + required this.modelDirectory, + }); + + final String modelDirectory; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'ZSWatch AI Benchmark', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6750A4), + brightness: Brightness.dark, + ), + ), + home: TestbenchScreen( + autoStartBenchmark: true, + searchDirectories: [modelDirectory], + ), + ); + } +} diff --git a/ai_testbench/lib/correction_main.dart b/ai_testbench/lib/correction_main.dart new file mode 100644 index 0000000..81f9ac4 --- /dev/null +++ b/ai_testbench/lib/correction_main.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'services/correction_benchmark_service.dart'; + +/// Headless entry point for correction benchmark. +/// +/// Usage: +/// AI_BENCH_HEADLESS=1 ./build/linux/x64/release/bundle/ai_testbench \ +/// --headless-correction --model-dir /tmp/bench_single_model \ +/// --output /tmp/correction_bench.json +/// +/// Or interactively: +/// ./build/linux/x64/release/bundle/ai_testbench \ +/// --headless-correction --model Qwen3.5-2B-Q4_K_M.gguf +Future runHeadlessCorrectionBenchmark(List args) async { + String? readValue(String name) { + for (var i = 0; i < args.length - 1; i++) { + if (args[i] == name) return args[i + 1]; + } + return null; + } + + final modelDir = readValue('--model-dir') ?? Directory('models').absolute.path; + final outputPath = readValue('--output'); + + final modelPaths = Directory(modelDir) + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); + + if (modelPaths.isEmpty) { + stdout.writeln('[CorrectionBench] No .gguf files found in $modelDir'); + exitCode = 1; + return; + } + + stdout.writeln('╔═══════════════════════════════════════════════════════╗'); + stdout.writeln('║ Correction Benchmark — Headless ║'); + stdout.writeln('╚═══════════════════════════════════════════════════════╝'); + stdout.writeln('[CorrectionBench] Models: ${modelPaths.length}'); + for (final p in modelPaths) { + stdout.writeln(' - ${p.split(Platform.pathSeparator).last}'); + } + stdout.writeln('[CorrectionBench] Cases: ${CorrectionBenchmarkService.benchmarkCases.length}'); + + final service = CorrectionBenchmarkService(); + final startedAt = DateTime.now().toUtc(); + + final results = await service.runForModels( + modelPaths, + onProgress: (p) { + stdout.writeln( + '[CorrectionBench] ${p.completedRuns}/${p.totalRuns} ' + 'model=${p.currentModelName} case=${p.currentCaseName}', + ); + }, + ); + + final finishedAt = DateTime.now().toUtc(); + + // ── Print summary ─────────────────────────────────────────────────── + + stdout.writeln(''); + stdout.writeln('${'═' * 70}'); + stdout.writeln(' CORRECTION BENCHMARK RESULTS'); + stdout.writeln('${'═' * 70}'); + + for (final model in results) { + stdout.writeln(''); + stdout.writeln('┌── ${model.modelName} ── ' + '${model.passedCases}/${model.cases.length} passed ──┐'); + + for (final c in model.cases) { + final tag = c.passed ? 'PASS' : 'FAIL'; + final reasons = []; + + if (!c.modificationMatch) { + reasons.add(c.modificationExpected + ? 'NOT_MODIFIED' + : 'UNEXPECTED_MODIFICATION'); + } + if (!c.allMustContainFound) { + reasons.add('MISSING[${c.missingKeywords.join(",")}]'); + } + if (!c.allMustNotContainAbsent) { + reasons.add('UNWANTED[${c.unwantedKeywordsFound.join(",")}]'); + } + if (!c.cleanOutput) { + reasons.add('DIRTY(${c.cleanOutputDetail})'); + } + if (c.error != null) { + reasons.add('ERROR'); + } + + final reasonStr = reasons.isEmpty ? '' : ' [${reasons.join(", ")}]'; + stdout.writeln('│ $tag ${c.caseName}$reasonStr'); + + // Always print input vs output for failed cases + if (!c.passed) { + stdout.writeln('│ input: "${c.input}"'); + stdout.writeln('│ expected: "${c.expectedOutput}"'); + stdout.writeln('│ got: "${c.actualOutput}"'); + } + } + stdout.writeln('└${'─' * 68}┘'); + } + + // ── JSON output ───────────────────────────────────────────────────── + + if (outputPath != null) { + final report = { + 'startedAt': startedAt.toIso8601String(), + 'finishedAt': finishedAt.toIso8601String(), + 'modelCount': results.length, + 'caseCount': CorrectionBenchmarkService.benchmarkCases.length, + 'results': results.map((r) => { + 'modelPath': r.modelPath, + 'modelName': r.modelName, + 'passedCases': r.passedCases, + 'totalCases': r.cases.length, + 'avgTokensPerSecond': r.avgTokensPerSecond, + 'totalElapsedMs': r.totalElapsed.inMilliseconds, + 'cases': r.cases.map((c) => { + 'caseName': c.caseName, + 'passed': c.passed, + 'wasModified': c.wasModified, + 'modificationExpected': c.modificationExpected, + 'modificationMatch': c.modificationMatch, + 'allMustContainFound': c.allMustContainFound, + 'missingKeywords': c.missingKeywords, + 'allMustNotContainAbsent': c.allMustNotContainAbsent, + 'unwantedKeywordsFound': c.unwantedKeywordsFound, + 'cleanOutput': c.cleanOutput, + 'cleanOutputDetail': c.cleanOutputDetail, + 'input': c.input, + 'expectedOutput': c.expectedOutput, + 'actualOutput': c.actualOutput, + 'elapsedMs': c.elapsed.inMilliseconds, + 'tokensPerSecond': c.tokensPerSecond, + 'error': c.error, + }).toList(growable: false), + }).toList(growable: false), + }; + + final file = File(outputPath); + file.parent.createSync(recursive: true); + file.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(report)); + stdout.writeln('\n[CorrectionBench] JSON results: ${file.path}'); + } + + // ── Ranked summary ────────────────────────────────────────────────── + + final ranked = [...results] + ..sort((a, b) => b.passedCases.compareTo(a.passedCases)); + stdout.writeln(''); + for (final r in ranked) { + stdout.writeln( + '[CorrectionBench] ${r.modelName}: ' + '${r.passedCases}/${r.cases.length} passed, ' + '${r.avgTokensPerSecond.toStringAsFixed(1)} tok/s, ' + '${r.totalElapsed.inSeconds}s total', + ); + } +} diff --git a/ai_testbench/lib/main.dart b/ai_testbench/lib/main.dart new file mode 100644 index 0000000..fd25e51 --- /dev/null +++ b/ai_testbench/lib/main.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'benchmark_main.dart' as model_bench; +import 'correction_main.dart'; +import 'screens/testbench_screen.dart'; +import 'screens/time_extraction_screen.dart'; +import 'time_extraction_main.dart'; + +void main(List args) async { + // Headless mode: run time extraction tests from CLI + if (args.contains('--headless-time')) { + await runHeadlessTimeExtraction(args); + exit(exitCode); + } + + // Headless mode: run correction benchmark from CLI + if (args.contains('--headless-correction')) { + WidgetsFlutterBinding.ensureInitialized(); + await runHeadlessCorrectionBenchmark(args); + exit(exitCode); + } + + // Headless mode: run model benchmark from CLI + if (args.contains('--headless') || + Platform.environment['AI_BENCH_HEADLESS'] == '1') { + await model_bench.main(args); + return; + } + + WidgetsFlutterBinding.ensureInitialized(); + runApp(const AiTestbenchApp()); +} + +class AiTestbenchApp extends StatelessWidget { + const AiTestbenchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'ZSWatch AI Testbench', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6750A4), + brightness: Brightness.dark, + ), + ), + home: const _HomeShell(), + ); + } +} + +class _HomeShell extends StatefulWidget { + const _HomeShell(); + + @override + State<_HomeShell> createState() => _HomeShellState(); +} + +class _HomeShellState extends State<_HomeShell> { + int _index = 0; + + static const _screens = [ + TestbenchScreen(autoStartBenchmark: false), + TimeExtractionScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _index, children: _screens), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.science), + label: 'Classify', + ), + NavigationDestination( + icon: Icon(Icons.access_time), + label: 'Time Extract', + ), + ], + ), + ); + } +} + diff --git a/ai_testbench/lib/prompts/prompt_templates.dart b/ai_testbench/lib/prompts/prompt_templates.dart new file mode 100644 index 0000000..30fbe17 --- /dev/null +++ b/ai_testbench/lib/prompts/prompt_templates.dart @@ -0,0 +1,50 @@ +/// Prompt templates for voice memo AI processing. +/// +/// Classification/extraction prompts are now shared via the chrono_ai_flow +/// package (ChronoPromptTemplate). This file retains only the summarise +/// prompt and the supported-language list used by the testbench UI. +class PromptTemplates { + PromptTemplates._(); + + // --------------------------------------------------------------------------- + // Summarisation prompt + // --------------------------------------------------------------------------- + + /// Build a summarisation prompt for showing a short preview in the UI. + /// + /// [language] – expected language of the transcript. + /// [transcript] – the raw STT output. + /// [maxWords] – target summary length. + static String summarize({ + required String language, + required String transcript, + int maxWords = 20, + }) { + return ''' +You are a concise summarisation assistant. +The following transcript is in $language. +Summarise the transcript in at most $maxWords words, in $language. +Output ONLY the summary text, no extra formatting. + +Transcript: "$transcript" +Summary: '''; + } + + /// All supported languages (displayed in the UI dropdown). + static const List supportedLanguages = [ + 'English', + 'Swedish', + 'German', + 'French', + 'Spanish', + 'Norwegian', + 'Danish', + 'Finnish', + 'Dutch', + 'Italian', + 'Portuguese', + 'Japanese', + 'Chinese', + 'Korean', + ]; +} diff --git a/ai_testbench/lib/prompts/time_extraction_prompts.dart b/ai_testbench/lib/prompts/time_extraction_prompts.dart new file mode 100644 index 0000000..dc0048c --- /dev/null +++ b/ai_testbench/lib/prompts/time_extraction_prompts.dart @@ -0,0 +1,58 @@ +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; + +enum TimeExtractionPromptVariant { full, medium, short } + +class TimeExtractionPrompts { + TimeExtractionPrompts._(); + + static String get fullSystemPrompt => ChronoPromptTemplate.defaultTemplate; + static String get mediumSystemPrompt => ChronoPromptTemplate.defaultTemplate; + static String get shortSystemPrompt => ChronoPromptTemplate.defaultTemplate; + + static String systemPromptForVariant(TimeExtractionPromptVariant variant) { + switch (variant) { + case TimeExtractionPromptVariant.full: + return fullSystemPrompt; + case TimeExtractionPromptVariant.medium: + return mediumSystemPrompt; + case TimeExtractionPromptVariant.short: + return shortSystemPrompt; + } + } + + static String get systemPrompt => fullSystemPrompt; + + /// Build the user message with context and transcript. + /// + /// [transcript] — the voice memo text. + /// [now] — current datetime for context (not for LLM to compute with, + /// but to help it understand what "today" means if needed). + /// [timezone] — timezone name (e.g. "Europe/Stockholm"). + static String userMessage({ + required String transcript, + DateTime? now, + String? timezone, + String? transcriptLanguage, + }) { + return ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, + transcript: transcript, + now: now, + ); + } + + /// Combine system + user message into a single prompt string + /// (for models that don't support separate system/user roles). + static String singlePrompt({ + required String transcript, + DateTime? now, + String? timezone, + String? transcriptLanguage, + }) { + return ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, + transcript: transcript, + now: now, + ); + } +} diff --git a/ai_testbench/lib/screens/testbench_screen.dart b/ai_testbench/lib/screens/testbench_screen.dart new file mode 100644 index 0000000..39dc4af --- /dev/null +++ b/ai_testbench/lib/screens/testbench_screen.dart @@ -0,0 +1,947 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; + +import '../prompts/prompt_templates.dart'; +import '../services/llm_service.dart'; +import '../services/model_benchmark_service.dart'; +import '../widgets/memo_card.dart'; + +/// Two-pane "Prompt Engineering Lab" for ZSWatch voice memo AI. +class TestbenchScreen extends StatefulWidget { + const TestbenchScreen({ + super.key, + this.autoStartBenchmark = true, + this.searchDirectories, + }); + + final bool autoStartBenchmark; + final List? searchDirectories; + + @override + State createState() => _TestbenchScreenState(); +} + +class _TestbenchScreenState extends State { + // ── Services ────────────────────────────────────────────────────────────── + final LlmService _llm = LlmService(); + final ModelBenchmarkService _benchmarkService = ModelBenchmarkService(); + + // ── Input state ─────────────────────────────────────────────────────────── + String _selectedLanguage = 'English'; + String? _modelPath; + List _availableModelPaths = const []; + final _transcriptController = TextEditingController( + text: + 'Remind me to call the mechanic tomorrow at 3 PM about the brakes and also pick up milk on the way home.', + ); + + // ── Config ──────────────────────────────────────────────────────────────── + int _nCtx = 2048; + int _nThreads = 4; + int _maxTokens = 512; + + // ── Mode ────────────────────────────────────────────────────────────────── + _RunMode _mode = _RunMode.classify; + + // ── Output state ────────────────────────────────────────────────────────── + String _rawOutput = ''; + String _formattedPrompt = ''; + Map? _parsedJson; + Duration _elapsed = Duration.zero; + bool _isRunning = false; + String? _error; + + // ── Streaming output ────────────────────────────────────────────────────── + String _streamBuffer = ''; + bool _isBenchmarking = false; + List _benchmarkResults = const []; + BenchmarkProgress? _benchmarkProgress; + DateTime? _benchmarkStartedAt; + + @override + void initState() { + super.initState(); + unawaited(_discoverModelsAndMaybeBenchmark()); + } + + @override + void dispose() { + _transcriptController.dispose(); + _llm.dispose(); + super.dispose(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Actions + // ───────────────────────────────────────────────────────────────────────── + + Future _pickModel() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + dialogTitle: 'Select a .gguf model file', + ); + if (result != null && result.files.single.path != null) { + final pickedPath = result.files.single.path!; + setState(() { + _modelPath = pickedPath; + _availableModelPaths = { + ..._availableModelPaths, + pickedPath, + }.toList() + ..sort(); + }); + } + } + + Future _discoverModelsAndMaybeBenchmark() async { + final discovered = {}; + + final candidateDirs = { + ...?widget.searchDirectories, + if (Platform.isAndroid) ...{ + '/data/user/0/com.example.ai_testbench/cache/file_picker', + '/sdcard/Download', + '/storage/emulated/0/Download', + }, + Directory('models').absolute.path, + }; + + for (final dirPath in candidateDirs) { + try { + final dir = Directory(dirPath); + if (!dir.existsSync()) continue; + for (final entity in dir.listSync(recursive: true)) { + if (entity is File && entity.path.toLowerCase().endsWith('.gguf')) { + discovered.add(entity.path); + } + } + } catch (_) { + // Ignore unreadable paths on Android scoped storage. + } + } + + final sorted = discovered.toList()..sort(); + if (!mounted) return; + + setState(() { + _availableModelPaths = sorted; + _modelPath ??= sorted.isNotEmpty ? sorted.first : null; + }); + + if (sorted.isNotEmpty && widget.autoStartBenchmark) { + await _runBenchmarks(sorted); + } + } + + void _loadModel() { + if (_modelPath == null) return; + setState(() { + _error = null; + }); + try { + _llm.setModel(_modelPath!); + _llm.nCtx = _nCtx; + _llm.nThreads = _nThreads; + _llm.maxTokens = _maxTokens; + setState(() { + _rawOutput = 'Model set ✓ (loads on first inference)'; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _rawOutput = ''; + }); + } + } + + Future _runBenchmarks([List? modelPaths]) async { + final models = modelPaths ?? _availableModelPaths; + if (models.isEmpty) return; + + setState(() { + _isBenchmarking = true; + _benchmarkResults = const []; + _benchmarkProgress = null; + _benchmarkStartedAt = DateTime.now(); + }); + + try { + final results = await _benchmarkService.runForModels( + models, + onProgress: (progress) { + if (!mounted) return; + setState(() { + _benchmarkProgress = progress; + }); + }, + ); + if (!mounted) return; + setState(() { + _benchmarkResults = results; + _benchmarkProgress = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Benchmark failed: $e'); + } finally { + if (mounted) { + setState(() { + _isBenchmarking = false; + _benchmarkStartedAt = null; + }); + } + } + } + + Future _runInference() async { + if (!_llm.isModelLoaded) { + setState(() => _error = 'Load a model first.'); + return; + } + + final transcript = _transcriptController.text.trim(); + if (transcript.isEmpty) { + setState(() => _error = 'Enter a transcript.'); + return; + } + + final prompt = _mode == _RunMode.classify + ? ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, transcript: transcript) + : PromptTemplates.summarize( + language: _selectedLanguage, transcript: transcript); + + setState(() { + _formattedPrompt = prompt; + _rawOutput = ''; + _streamBuffer = ''; + _parsedJson = null; + _error = null; + _isRunning = true; + _elapsed = Duration.zero; + }); + + try { + _llm.nCtx = _nCtx; + _llm.nThreads = _nThreads; + _llm.maxTokens = _maxTokens; + + final sw = Stopwatch()..start(); + + // Stream tokens for live preview (fllama yields cumulative responses) + int streamEvents = 0; + await for (final cumulative in _llm.generateStream(prompt)) { + streamEvents++; + _streamBuffer = cumulative; + setState(() => _rawOutput = _streamBuffer); + } + + sw.stop(); + debugPrint('[Testbench] Stream done: $streamEvents events, ' + '${_streamBuffer.length} chars, ${sw.elapsedMilliseconds}ms'); + if (_streamBuffer.isNotEmpty) { + debugPrint('[Testbench] Output preview: ${_streamBuffer.substring(0, _streamBuffer.length.clamp(0, 300))}'); + } else { + debugPrint('[Testbench] WARNING: output is empty!'); + } + + setState(() { + _elapsed = sw.elapsed; + _rawOutput = _streamBuffer.trim(); + _isRunning = false; + }); + + _tryParseJson(); + } catch (e) { + setState(() { + _error = e.toString(); + _isRunning = false; + }); + } + } + + void _tryParseJson() { + try { + final raw = _rawOutput; + final jsonStr = _extractFirstJsonObject(raw); + if (jsonStr != null) { + final parsed = jsonDecode(jsonStr) as Map; + setState(() => _parsedJson = parsed); + } + } catch (_) { + // Not valid JSON – that's fine, we still show raw output + } + } + + String? _extractFirstJsonObject(String raw) { + final start = raw.indexOf('{'); + if (start == -1) return null; + + var depth = 0; + var inString = false; + var escaping = false; + + for (var i = start; i < raw.length; i++) { + final char = raw[i]; + if (escaping) { + escaping = false; + continue; + } + if (char == '\\' && inString) { + escaping = true; + continue; + } + if (char == '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (char == '{') depth++; + if (char == '}') { + depth--; + if (depth == 0) { + return raw.substring(start, i + 1); + } + } + } + + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Build + // ───────────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('ZSWatch AI Testbench'), + actions: [ + if (_llm.isModelLoaded) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Chip( + avatar: const Icon(Icons.check_circle, size: 18, color: Colors.green), + label: Text( + _modelPath?.split(Platform.pathSeparator).last ?? '', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + body: Row( + children: [ + // ── LEFT PANE: Inputs ────────────────────────────────────────── + Expanded(flex: 2, child: _buildInputPane()), + const VerticalDivider(width: 1), + // ── RIGHT PANE: Outputs ──────────────────────────────────────── + Expanded(flex: 3, child: _buildOutputPane()), + ], + ), + ); + } + + Widget _buildInputPane() { + return Padding( + padding: const EdgeInsets.all(16), + child: ListView( + children: [ + // ── Model selection ──────────────────────────────────────────── + Text('Model', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _modelPath, + isExpanded: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + hint: const Text('No model selected'), + items: _availableModelPaths + .map( + (path) => DropdownMenuItem( + value: path, + child: Text( + path.split(Platform.pathSeparator).last, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (value) { + setState(() => _modelPath = value); + }, + ), + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: _pickModel, + icon: const Icon(Icons.folder_open, size: 18), + label: const Text('Browse'), + ), + ], + ), + if (_modelPath != null) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: _isRunning ? null : _loadModel, + icon: const Icon(Icons.memory, size: 18), + label: Text(_llm.isModelLoaded ? 'Reload Model' : 'Set Model'), + ), + OutlinedButton.icon( + onPressed: _isRunning || _isBenchmarking || _availableModelPaths.isEmpty + ? null + : () => _runBenchmarks(), + icon: _isBenchmarking + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined, size: 18), + label: Text( + _isBenchmarking && _benchmarkProgress != null + ? 'Benchmarking ${_benchmarkProgress!.completedRuns}/${_benchmarkProgress!.totalRuns}' + : _isBenchmarking + ? 'Benchmarking…' + : 'Benchmark All', + ), + ), + ], + ), + ], + + const SizedBox(height: 24), + + // ── Config ──────────────────────────────────────────────────── + Text('Configuration', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _NumberField( + label: 'Context (nCtx)', + value: _nCtx, + onChanged: (v) => setState(() => _nCtx = v), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _NumberField( + label: 'Threads', + value: _nThreads, + onChanged: (v) => setState(() => _nThreads = v), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _NumberField( + label: 'Max tokens', + value: _maxTokens, + onChanged: (v) => setState(() => _maxTokens = v), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // ── Language selector ────────────────────────────────────────── + Text('Language', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedLanguage, + isExpanded: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + items: PromptTemplates.supportedLanguages + .map((l) => DropdownMenuItem(value: l, child: Text(l))) + .toList(), + onChanged: (v) => setState(() => _selectedLanguage = v!), + ), + + const SizedBox(height: 24), + + // ── Mode selector ───────────────────────────────────────────── + Text('Mode', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SegmentedButton<_RunMode>( + segments: const [ + ButtonSegment( + value: _RunMode.classify, + label: Text('Classify'), + icon: Icon(Icons.category), + ), + ButtonSegment( + value: _RunMode.summarize, + label: Text('Summarize'), + icon: Icon(Icons.summarize), + ), + ], + selected: {_mode}, + onSelectionChanged: (s) => setState(() => _mode = s.first), + ), + + const SizedBox(height: 24), + + // ── Transcript input ────────────────────────────────────────── + Text('Transcript', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _transcriptController, + maxLines: 8, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Paste a fake Whisper transcript here…', + ), + ), + + const SizedBox(height: 16), + + // ── Run button ──────────────────────────────────────────────── + SizedBox( + height: 48, + child: FilledButton.icon( + onPressed: _isRunning ? null : _runInference, + icon: _isRunning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow), + label: Text(_isRunning ? 'Running…' : 'Run Inference'), + ), + ), + + const SizedBox(height: 24), + + // ── Sample transcripts ───────────────────────────────────────── + Text('Sample Transcripts', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._sampleTranscripts.map( + (sample) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ActionChip( + label: Text( + sample.label, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + _transcriptController.text = sample.text; + setState(() => _selectedLanguage = sample.language); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildOutputPane() { + return Padding( + padding: const EdgeInsets.all(16), + child: ListView( + children: [ + // ── Error banner ────────────────────────────────────────────── + if (_error != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.4)), + ), + child: Text(_error!, style: const TextStyle(color: Colors.red)), + ), + + // ── Stats bar ───────────────────────────────────────────────── + if (_isBenchmarking) ...[ + _buildBenchmarkProgressCard(), + const SizedBox(height: 16), + ], + + if (_elapsed.inMilliseconds > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Wrap( + spacing: 16, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_outlined, size: 16), + const SizedBox(width: 6), + Text( + '${(_elapsed.inMilliseconds / 1000).toStringAsFixed(2)}s', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, size: 16), + const SizedBox(width: 6), + Text( + _rawOutput.isNotEmpty && _elapsed.inMilliseconds > 0 + ? '~${(_rawOutput.split(RegExp(r'\s+')).length / (_elapsed.inMilliseconds / 1000)).toStringAsFixed(1)} tok/s' + : '–', + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.text_fields, size: 16), + const SizedBox(width: 6), + Text('${_rawOutput.length} chars'), + ], + ), + ], + ), + ), + + if (_benchmarkResults.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('Benchmark Results', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._benchmarkResults.map( + (result) => Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.modelName, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 6), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + Text('Pass: ${result.passedCases}/${result.cases.length}'), + Text( + 'Avg tok/s: ${result.avgTokensPerSecond.toStringAsFixed(1)}', + ), + Text( + 'Total: ${(result.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s', + ), + ], + ), + const SizedBox(height: 8), + ...result.cases.map( + (caseResult) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${caseResult.passed ? '✅' : '❌'} ${caseResult.caseName}: ' + 'json=${caseResult.validJson ? '✓' : '✗'} · ' + 'intent=${caseResult.intent}${caseResult.intentMatch ? '✓' : '✗'} · ' + 'time=${caseResult.timePresenceMatch ? '✓' : '✗'} · ' + 'lang=${caseResult.titleLanguageMatch ? '✓' : '✗'} · ' + 'resolve=${caseResult.timeResolutionCorrect ? '✓' : '✗'} · ' + '${(caseResult.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s · ' + '${caseResult.tokensPerSecond.toStringAsFixed(1)} tok/s', + ), + if (caseResult.title != null) + Text( + 'title: ${caseResult.title}', + style: const TextStyle(fontSize: 11), + ), + if (caseResult.timeResolutionDetail != null) + Text( + 'time: ${caseResult.timeResolutionDetail}', + style: TextStyle( + color: caseResult.timeResolutionCorrect ? Colors.green : Colors.orangeAccent, + fontSize: 11, + ), + ), + if (caseResult.titleLanguageDetail != null && + !caseResult.titleLanguageMatch) + Text( + 'lang: ${caseResult.titleLanguageDetail}', + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 11, + ), + ), + if (caseResult.error != null) + Text( + 'error: ${caseResult.error}', + style: const TextStyle( + color: Colors.redAccent, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + + // ── Parsed card preview ─────────────────────────────────────── + if (_parsedJson != null) ...[ + Text('Card Preview', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + MemoCard(data: _parsedJson!), + const SizedBox(height: 24), + ], + + // ── Raw JSON output ─────────────────────────────────────────── + Text('Raw Model Output', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white12), + ), + child: SelectableText( + _rawOutput.isEmpty ? '(output will appear here)' : _rawOutput, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + height: 1.5, + ), + ), + ), + + const SizedBox(height: 24), + + // ── Formatted prompt (expandable) ───────────────────────────── + if (_formattedPrompt.isNotEmpty) ...[ + ExpansionTile( + title: const Text('Full Prompt Sent to Model'), + initiallyExpanded: false, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black38, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + _formattedPrompt, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + height: 1.4, + color: Colors.amber, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildBenchmarkProgressCard() { + final progress = _benchmarkProgress; + final fraction = progress?.fractionComplete ?? 0; + final completed = progress?.completedRuns ?? 0; + final total = progress?.totalRuns ?? 0; + final startedAt = _benchmarkStartedAt; + + Duration? eta; + if (startedAt != null && completed > 0 && total > completed) { + final elapsed = DateTime.now().difference(startedAt); + final avgPerRunMs = elapsed.inMilliseconds / completed; + eta = Duration(milliseconds: (avgPerRunMs * (total - completed)).round()); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + progress == null + ? 'Preparing benchmark…' + : 'Running ${progress.currentModelName} · case ${progress.currentCaseIndex + 1}/${progress.totalCasesPerModel}', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator(value: total == 0 ? null : fraction.clamp(0, 1)), + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + Text('Completed: $completed/$total'), + if (progress != null) + Text('Model: ${progress.currentModelIndex + 1}/${progress.totalModels}'), + if (progress != null && progress.currentCaseName.isNotEmpty) + Text('Case: ${progress.currentCaseName}'), + if (eta != null) Text('ETA: ${_formatDuration(eta)}'), + ], + ), + ], + ), + ); + } + + String _formatDuration(Duration duration) { + final totalSeconds = duration.inSeconds; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } + return '${seconds}s'; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Run mode +// ═════════════════════════════════════════════════════════════════════════════ + +enum _RunMode { classify, summarize } + +// ═════════════════════════════════════════════════════════════════════════════ +// Number field helper +// ═════════════════════════════════════════════════════════════════════════════ + +class _NumberField extends StatelessWidget { + const _NumberField({ + required this.label, + required this.value, + required this.onChanged, + }); + + final String label; + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return TextField( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + controller: TextEditingController(text: value.toString()) + ..selection = TextSelection.collapsed(offset: value.toString().length), + onSubmitted: (v) { + final n = int.tryParse(v); + if (n != null && n > 0) onChanged(n); + }, + ); + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Sample transcripts for quick testing +// ═════════════════════════════════════════════════════════════════════════════ + +class _SampleTranscript { + final String label; + final String text; + final String language; + const _SampleTranscript(this.label, this.text, this.language); +} + +const _sampleTranscripts = [ + _SampleTranscript( + '🇬🇧 TODO: Errands', + 'I need to pick up the dry cleaning, buy groceries, and call the dentist to reschedule my appointment.', + 'English', + ), + _SampleTranscript( + '🇬🇧 EVENT: Meeting', + 'Remind me about the team standup tomorrow at 9 AM in the main conference room.', + 'English', + ), + _SampleTranscript( + '🇬🇧 NOTE: Idea', + 'Had an interesting idea about using sensor fusion for step detection. Should look into the BMI270 FIFO watermark interrupt as a trigger.', + 'English', + ), + _SampleTranscript( + '🇸🇪 TODO: Handla', + 'Påminn mig om att köpa mjölk och fixa dörren i helgen.', + 'Swedish', + ), + _SampleTranscript( + '🇸🇪 EVENT: Möte', + 'Jag har ett möte med tandläkaren på fredag klockan 14.', + 'Swedish', + ), + _SampleTranscript( + '🇸🇪 NOTE: Anteckning', + 'Bra presentation idag om maskininlärning och edge computing. Kolla upp TensorFlow Lite för mikrokontrollers.', + 'Swedish', + ), +]; diff --git a/ai_testbench/lib/screens/time_extraction_screen.dart b/ai_testbench/lib/screens/time_extraction_screen.dart new file mode 100644 index 0000000..3294393 --- /dev/null +++ b/ai_testbench/lib/screens/time_extraction_screen.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../services/time_extraction_benchmark_service.dart'; + +/// Screen for running the voice memo → time extraction pipeline testbench. +/// +/// Auto-discovers models, auto-runs tests, and displays results in a +/// scrollable log view. +class TimeExtractionScreen extends StatefulWidget { + const TimeExtractionScreen({super.key}); + + @override + State createState() => _TimeExtractionScreenState(); +} + +class _TimeExtractionScreenState extends State { + final TimeExtractionBenchmarkService _service = + TimeExtractionBenchmarkService(); + final ScrollController _scrollController = ScrollController(); + + List _availableModels = const []; + String? _selectedModel; + bool _isRunning = false; + TimeExtractionProgress? _progress; + List _results = const []; + final List _logLines = []; + + @override + void initState() { + super.initState(); + _discoverModels(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _discoverModels() { + final modelDir = Directory('models'); + if (!modelDir.existsSync()) { + _log('No models/ directory found'); + return; + } + final models = modelDir + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); + + setState(() { + _availableModels = models; + if (models.isNotEmpty) _selectedModel = models.first; + }); + _log('Found ${models.length} model(s)'); + for (final m in models) { + _log(' - ${m.split(Platform.pathSeparator).last}'); + } + } + + void _log(String line) { + setState(() => _logLines.add(line)); + // Auto-scroll after next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + }); + } + + Future _runTests() async { + if (_selectedModel == null) return; + + setState(() { + _isRunning = true; + _results = []; + _logLines.clear(); + }); + + _log('══════════════════════════════════════════════════'); + _log(' Time Extraction Testbench'); + _log(' Model: ${_selectedModel!.split(Platform.pathSeparator).last}'); + _log(' Reference: ${TimeExtractionBenchmarkService.referenceTime}'); + _log(' Cases: ${TimeExtractionBenchmarkService.testCases.length}'); + _log('══════════════════════════════════════════════════'); + _log(''); + + final results = await _service.runForModels( + [_selectedModel!], + onProgress: (p) { + setState(() => _progress = p); + _log('[${p.completedCases}/${p.totalCases}] ${p.currentCaseName}'); + }, + ); + + setState(() { + _results = results; + _isRunning = false; + _progress = null; + }); + + // Print formatted results to log + final formatted = TimeExtractionBenchmarkService.formatResults(results); + for (final line in formatted.split('\n')) { + _log(line); + } + } + + Future _runAllModels() async { + if (_availableModels.isEmpty) return; + + setState(() { + _isRunning = true; + _results = []; + _logLines.clear(); + }); + + _log('══════════════════════════════════════════════════'); + _log(' Time Extraction Testbench — ALL MODELS'); + _log(' Models: ${_availableModels.length}'); + _log('══════════════════════════════════════════════════'); + _log(''); + + final results = await _service.runForModels( + _availableModels, + onProgress: (p) { + setState(() => _progress = p); + _log('[${p.modelName}] [${p.completedCases}/${p.totalCases}] ' + '${p.currentCaseName}'); + }, + ); + + setState(() { + _results = results; + _isRunning = false; + _progress = null; + }); + + final formatted = TimeExtractionBenchmarkService.formatResults(results); + for (final line in formatted.split('\n')) { + _log(line); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Time Extraction Testbench'), + ), + body: Column( + children: [ + // ── Controls bar ── + _buildControlsBar(), + // ── Progress indicator ── + if (_isRunning && _progress != null) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: _progress!.fraction, + ), + const SizedBox(height: 4), + Text( + '${_progress!.modelName} — ' + '${_progress!.completedCases}/${_progress!.totalCases}: ' + '${_progress!.currentCaseName}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const Divider(height: 1), + // ── Results table ── + if (_results.isNotEmpty) ...[ + _buildResultsSummary(), + const Divider(height: 1), + ], + // ── Log output ── + Expanded(child: _buildLog()), + ], + ), + ); + } + + Widget _buildControlsBar() { + return Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Model dropdown + Expanded( + child: DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Model', + border: OutlineInputBorder(), + isDense: true, + ), + initialValue: _selectedModel, + items: _availableModels.map((path) { + final name = path.split(Platform.pathSeparator).last; + return DropdownMenuItem(value: path, child: Text(name)); + }).toList(), + onChanged: _isRunning + ? null + : (v) => setState(() => _selectedModel = v), + ), + ), + const SizedBox(width: 12), + // Run selected + FilledButton.icon( + onPressed: _isRunning ? null : _runTests, + icon: const Icon(Icons.play_arrow), + label: const Text('Run'), + ), + const SizedBox(width: 8), + // Run all + OutlinedButton.icon( + onPressed: _isRunning ? null : _runAllModels, + icon: const Icon(Icons.all_inclusive), + label: const Text('All Models'), + ), + ], + ), + ); + } + + Widget _buildResultsSummary() { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: DataTable( + columnSpacing: 20, + headingRowHeight: 36, + dataRowMinHeight: 28, + dataRowMaxHeight: 28, + columns: const [ + DataColumn(label: Text('Model')), + DataColumn(label: Text('Pass'), numeric: true), + DataColumn(label: Text('Partial'), numeric: true), + DataColumn(label: Text('Fail'), numeric: true), + DataColumn(label: Text('Time'), numeric: true), + DataColumn(label: Text('tok/s'), numeric: true), + ], + rows: _results.map((model) { + return DataRow(cells: [ + DataCell(Text(model.modelName, + style: const TextStyle(fontWeight: FontWeight.bold))), + DataCell(Text('${model.passedCount}', + style: const TextStyle(color: Colors.green))), + DataCell(Text('${model.partialCount}', + style: const TextStyle(color: Colors.orange))), + DataCell(Text('${model.failedCount}', + style: const TextStyle(color: Colors.red))), + DataCell(Text( + '${(model.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s')), + DataCell( + Text(model.avgTokensPerSecond.toStringAsFixed(1))), + ]); + }).toList(), + ), + ); + } + + Widget _buildLog() { + return Container( + color: Colors.black, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(12), + itemCount: _logLines.length, + itemBuilder: (context, index) { + final line = _logLines[index]; + // Color-code based on content + Color color = Colors.grey.shade300; + if (line.contains('✅')) { + color = Colors.green; + } else if (line.contains('❌')) { + color = Colors.red; + } else if (line.contains('⚠️')) { + color = Colors.orange; + } else if (line.contains('═') || line.contains('║')) { + color = Colors.cyan; + } + + return Text( + line, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: color, + height: 1.4, + ), + ); + }, + ), + ); + } +} diff --git a/ai_testbench/lib/services/correction_benchmark_service.dart b/ai_testbench/lib/services/correction_benchmark_service.dart new file mode 100644 index 0000000..480aa6f --- /dev/null +++ b/ai_testbench/lib/services/correction_benchmark_service.dart @@ -0,0 +1,675 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class CorrectionBenchmarkCase { + /// Unique name for the test case. + final String name; + + /// The "bad" transcript as Whisper might produce it. + final String input; + + /// The ideal corrected output (used for display / human review). + final String expectedOutput; + + /// Words/phrases that MUST appear in the corrected output (case-insensitive). + /// Used to verify the model actually performed a specific fix. + final List mustContain; + + /// Words/phrases that must NOT appear in the corrected output (case-insensitive). + /// Used to verify the model removed errors / filler. + final List mustNotContain; + + /// If true, the model MUST change the text (detect and fix an error). + /// If false (clean input), the model should return it mostly unchanged. + final bool expectModification; + + /// Language of the input. + final String language; + + const CorrectionBenchmarkCase({ + required this.name, + required this.input, + required this.expectedOutput, + this.mustContain = const [], + this.mustNotContain = const [], + this.expectModification = true, + this.language = 'en', + }); +} + +// ── Test result ────────────────────────────────────────────────────────── + +class CorrectionCaseResult { + final String caseName; + final String input; + final String expectedOutput; + final String actualOutput; + final bool wasModified; + final bool modificationExpected; + final bool modificationMatch; + final bool allMustContainFound; + final List missingKeywords; + final bool allMustNotContainAbsent; + final List unwantedKeywordsFound; + final bool cleanOutput; + final String? cleanOutputDetail; + final Duration elapsed; + final double tokensPerSecond; + final String? error; + + const CorrectionCaseResult({ + required this.caseName, + required this.input, + required this.expectedOutput, + required this.actualOutput, + required this.wasModified, + required this.modificationExpected, + required this.modificationMatch, + required this.allMustContainFound, + required this.missingKeywords, + required this.allMustNotContainAbsent, + required this.unwantedKeywordsFound, + required this.cleanOutput, + this.cleanOutputDetail, + required this.elapsed, + required this.tokensPerSecond, + this.error, + }); + + bool get passed => + error == null && + modificationMatch && + allMustContainFound && + allMustNotContainAbsent && + cleanOutput; +} + +// ── Progress ───────────────────────────────────────────────────────────── + +class CorrectionBenchmarkProgress { + final int totalModels; + final int totalCasesPerModel; + final int totalRuns; + final int completedRuns; + final int currentModelIndex; + final int currentCaseIndex; + final String currentModelPath; + final String currentCaseName; + + const CorrectionBenchmarkProgress({ + required this.totalModels, + required this.totalCasesPerModel, + required this.totalRuns, + required this.completedRuns, + required this.currentModelIndex, + required this.currentCaseIndex, + required this.currentModelPath, + required this.currentCaseName, + }); + + double get fractionComplete => totalRuns == 0 ? 0 : completedRuns / totalRuns; + String get currentModelName => + currentModelPath.split(Platform.pathSeparator).last; +} + +// ── Aggregate result for a model ───────────────────────────────────────── + +class CorrectionModelResult { + final String modelPath; + final List cases; + + const CorrectionModelResult({ + required this.modelPath, + required this.cases, + }); + + String get modelName => modelPath.split(Platform.pathSeparator).last; + int get passedCases => cases.where((c) => c.passed).length; + double get avgTokensPerSecond => cases.isEmpty + ? 0 + : cases.fold(0, (sum, c) => sum + c.tokensPerSecond) / + cases.length; + Duration get totalElapsed => + cases.fold(Duration.zero, (sum, c) => sum + c.elapsed); +} + +// ── Service ────────────────────────────────────────────────────────────── + +class CorrectionBenchmarkService { + static const Duration perCaseTimeout = Duration(seconds: 60); + + // ── The correction prompt — uses the shared package ─────────────────── + + static String buildCorrectionPrompt(String transcript) { + return CorrectionPromptTemplate.render( + CorrectionPromptTemplate.defaultTemplate, + transcript: transcript, + ); + } + + // ── Benchmark cases ─────────────────────────────────────────────────── + + static final benchmarkCases = [ + // ── English: Whisper homophone / wrong-word errors ────────────────── + + const CorrectionBenchmarkCase( + name: 'en_homophone_weak_week', + input: 'I need to finish the report by next weak', + expectedOutput: 'I need to finish the report by next week.', + mustContain: ['week'], + mustNotContain: ['weak'], + ), + const CorrectionBenchmarkCase( + name: 'en_homophone_by_buy', + input: 'remind me to by milk on the way home', + expectedOutput: 'Remind me to buy milk on the way home.', + mustContain: ['buy'], + mustNotContain: ['by milk'], // "by" alone might appear in "nearby" etc + ), + const CorrectionBenchmarkCase( + name: 'en_homophone_meat_meet', + input: "let's meat at the café at half passed three", + expectedOutput: "Let's meet at the café at half past three.", + mustContain: ['meet', 'past'], + mustNotContain: ['meat', 'passed'], + ), + const CorrectionBenchmarkCase( + name: 'en_homophone_wood_would', + input: 'she said she wood come at for a clock', + expectedOutput: "She said she would come at four o'clock.", + mustContain: ['would'], + mustNotContain: ['wood'], + ), + const CorrectionBenchmarkCase( + name: 'en_wrong_word_nonsense', + input: 'I have a dentist appointment and I need to cancel it because I have a cold and a terrible headache', + expectedOutput: 'I have a dentist appointment and I need to cancel it because I have a cold and a terrible headache.', + mustContain: ['dentist', 'cancel', 'headache'], + expectModification: false, // Clean input — should pass through + ), + + // ── English: filler words + stuttering ────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'en_filler_um_stutter', + input: 'I I need to um finish the report and and send it to the the boss', + expectedOutput: 'I need to finish the report and send it to the boss.', + mustContain: ['finish the report', 'send it to the boss'], + mustNotContain: ['I I', 'and and', 'the the', ' um '], + ), + const CorrectionBenchmarkCase( + name: 'en_filler_heavy', + input: 'so uh you know I was like thinking we should you know maybe like schedule a meeting', + expectedOutput: 'I was thinking we should maybe schedule a meeting.', + mustContain: ['schedule', 'meeting'], + mustNotContain: [' uh '], + ), + + // ── English: missing/wrong punctuation ────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'en_missing_punctuation', + input: 'call the plumber tomorrow at 3 then pick up the kids at 5 and dont forget to buy groceries', + expectedOutput: "Call the plumber tomorrow at 3, then pick up the kids at 5, and don't forget to buy groceries.", + mustContain: ['plumber', 'kids', 'groceries'], + mustNotContain: [], + expectModification: false, // punctuation-only change is acceptable either way + ), + + // ── English: Whisper word-boundary / context errors ───────────────── + + const CorrectionBenchmarkCase( + name: 'en_word_boundary', + input: 'I can not believe they moved the meeting to an other day with out telling us', + expectedOutput: 'I cannot believe they moved the meeting to another day without telling us.', + mustContain: ['another', 'without'], + mustNotContain: ['an other', 'with out'], + ), + + // ── Swedish: Whisper errors ───────────────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'sv_filler_stutter', + input: 'jag ska eh köpa köpa mjölk och bröd på på hemvägen', + expectedOutput: 'Jag ska köpa mjölk och bröd på hemvägen.', + mustContain: ['köpa mjölk'], + mustNotContain: ['köpa köpa', 'på på', ' eh '], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_split_word_imorgon', + input: 'påminn mig att skicka rapporten till chefen i morgan', + expectedOutput: 'Påminn mig att skicka rapporten till chefen imorgon.', + mustContain: ['imorgon'], + mustNotContain: ['i morgan'], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_missing_umlaut', + input: 'vi har mote med kunden pa torsdag klockan tva', + expectedOutput: 'Vi har möte med kunden på torsdag klockan två.', + mustContain: ['möte', 'på', 'två'], + mustNotContain: ['mote', ' pa ', 'tva'], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_clean_passthrough', + input: 'Boka tandläkare på fredag klockan 10.', + expectedOutput: 'Boka tandläkare på fredag klockan 10.', + mustContain: ['tandläkare', 'fredag'], + expectModification: false, // Clean input + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_wrong_word_whisper', + input: 'ring veterinären och boka en tid för katten som har ont i ögat', + expectedOutput: 'Ring veterinären och boka en tid för katten som har ont i ögat.', + mustContain: ['veterinären', 'katten'], + expectModification: false, // Clean-ish input + language: 'sv', + ), + + // ── German: Whisper errors ────────────────────────────────────────── + + const CorrectionBenchmarkCase( + name: 'de_missing_umlaut', + input: 'ich muss den Arzt anrufen und einen Termin fur nachste Woche machen', + expectedOutput: 'Ich muss den Arzt anrufen und einen Termin für nächste Woche machen.', + mustContain: ['für', 'nächste'], + mustNotContain: [' fur ', 'nachste'], + language: 'de', + ), + const CorrectionBenchmarkCase( + name: 'de_filler_stutter', + input: 'also ich äh muss muss noch die die Präsentation fertig machen', + expectedOutput: 'Ich muss noch die Präsentation fertig machen.', + mustContain: ['Präsentation', 'fertig'], + mustNotContain: ['muss muss', 'die die', ' äh ', 'also ich'], + language: 'de', + ), + + // ── English: Whisper misheard context-dependent words ────────────── + + const CorrectionBenchmarkCase( + name: 'en_misheard_their_there', + input: 'I left my keys over their on the table', + expectedOutput: 'I left my keys over there on the table.', + mustContain: ['there'], + mustNotContain: ['their'], + ), + const CorrectionBenchmarkCase( + name: 'en_misheard_your_youre', + input: 'your going to be late if you dont leave now', + expectedOutput: "You're going to be late if you don't leave now.", + mustContain: ['late', 'leave'], + mustNotContain: [], + ), + const CorrectionBenchmarkCase( + name: 'en_clean_long_sentence', + input: 'I had a great meeting with the design team today and we agreed on the new color scheme for the watch face', + expectedOutput: 'I had a great meeting with the design team today and we agreed on the new color scheme for the watch face.', + mustContain: ['design team', 'color scheme', 'watch face'], + expectModification: false, + ), + + // ── English: Whisper garbled multi-word ───────────────────────── + + const CorrectionBenchmarkCase( + name: 'en_garbled_sentence', + input: 'the whether is really nice today so lets go for a walk in the park', + expectedOutput: "The weather is really nice today so let's go for a walk in the park.", + mustContain: ['weather'], + mustNotContain: ['whether'], + ), + const CorrectionBenchmarkCase( + name: 'en_multiple_errors_combined', + input: 'I need to by some flower for the party its on wendsday at there house', + expectedOutput: "I need to buy some flowers for the party, it's on Wednesday at their house.", + mustContain: ['buy', 'Wednesday'], + mustNotContain: ['by some', 'wendsday'], + ), + + // ── Swedish: more realistic Whisper errors ─────────────────── + + const CorrectionBenchmarkCase( + name: 'sv_filler_liksom', + input: 'jag tankte liksom att vi kanske liksom borde traffas imorgon', + expectedOutput: 'Jag tänkte att vi kanske borde träffas imorgon.', + mustContain: ['tänkte', 'träffas'], + mustNotContain: ['tankte', 'traffas'], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_whisper_number', + input: 'klockan ar halv atta och jag maste ga nu', + expectedOutput: 'Klockan är halv åtta och jag måste gå nu.', + mustContain: ['är', 'åtta', 'måste', 'gå'], + mustNotContain: [' ar ', ' atta', 'maste', ' ga '], + language: 'sv', + ), + const CorrectionBenchmarkCase( + name: 'sv_clean_longer', + input: 'Vi ses på kontoret imorgon bitti för att gå igenom rapporten.', + expectedOutput: 'Vi ses på kontoret imorgon bitti för att gå igenom rapporten.', + mustContain: ['kontoret', 'rapporten'], + expectModification: false, + language: 'sv', + ), + + // ── German: more realistic Whisper errors ─────────────────── + + const CorrectionBenchmarkCase( + name: 'de_eszett_and_umlaut', + input: 'ich weiss nicht ob er die strasse finden konnte', + expectedOutput: 'Ich weiß nicht, ob er die Straße finden konnte.', + mustContain: ['weiß', 'Straße'], + mustNotContain: ['weiss', 'strasse'], + language: 'de', + ), + const CorrectionBenchmarkCase( + name: 'de_clean_passthrough', + input: 'Bitte ruf mich morgen früh an.', + expectedOutput: 'Bitte ruf mich morgen früh an.', + mustContain: ['morgen', 'früh'], + expectModification: false, + language: 'de', + ), + ]; + + // ── Run benchmark ───────────────────────────────────────────────────── + + Future> runForModels( + List modelPaths, { + void Function(CorrectionBenchmarkProgress progress)? onProgress, + }) async { + final results = []; + final totalCases = benchmarkCases.length; + final totalRuns = modelPaths.length * totalCases; + var completedRuns = 0; + + for (var modelIndex = 0; modelIndex < modelPaths.length; modelIndex++) { + final modelPath = modelPaths[modelIndex]; + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 2048 + ..nThreads = Platform.numberOfProcessors + ..temperature = 0.0 + ..enableThinking = false; + + final caseResults = []; + try { + for (var caseIndex = 0; caseIndex < benchmarkCases.length; caseIndex++) { + final testCase = benchmarkCases[caseIndex]; + onProgress?.call( + CorrectionBenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + + final prompt = buildCorrectionPrompt(testCase.input); + final maxTok = + CorrectionPromptTemplate.estimateMaxTokens(testCase.input); + + try { + final result = await llm + .generate(prompt, overrideMaxTokens: maxTok) + .timeout(perCaseTimeout); + + final output = _cleanOutput(result.output); + final check = _evaluate(testCase, output); + + caseResults.add(CorrectionCaseResult( + caseName: testCase.name, + input: testCase.input, + expectedOutput: testCase.expectedOutput, + actualOutput: output, + wasModified: check.wasModified, + modificationExpected: testCase.expectModification, + modificationMatch: check.modificationMatch, + allMustContainFound: check.allMustContainFound, + missingKeywords: check.missingKeywords, + allMustNotContainAbsent: check.allMustNotContainAbsent, + unwantedKeywordsFound: check.unwantedKeywordsFound, + cleanOutput: check.cleanOutput, + cleanOutputDetail: check.cleanOutputDetail, + elapsed: result.elapsed, + tokensPerSecond: result.tokensPerSecond, + )); + } on TimeoutException { + llm.cancelInference(); + caseResults.add(CorrectionCaseResult( + caseName: testCase.name, + input: testCase.input, + expectedOutput: testCase.expectedOutput, + actualOutput: '', + wasModified: false, + modificationExpected: testCase.expectModification, + modificationMatch: false, + allMustContainFound: false, + missingKeywords: testCase.mustContain, + allMustNotContainAbsent: true, + unwantedKeywordsFound: const [], + cleanOutput: false, + cleanOutputDetail: 'Timed out', + elapsed: perCaseTimeout, + tokensPerSecond: 0, + error: 'Timed out after ${perCaseTimeout.inSeconds}s', + )); + } catch (e) { + llm.cancelInference(); + caseResults.add(CorrectionCaseResult( + caseName: testCase.name, + input: testCase.input, + expectedOutput: testCase.expectedOutput, + actualOutput: '', + wasModified: false, + modificationExpected: testCase.expectModification, + modificationMatch: false, + allMustContainFound: false, + missingKeywords: testCase.mustContain, + allMustNotContainAbsent: true, + unwantedKeywordsFound: const [], + cleanOutput: false, + cleanOutputDetail: 'Error: $e', + elapsed: Duration.zero, + tokensPerSecond: 0, + error: e.toString(), + )); + } + + completedRuns++; + onProgress?.call( + CorrectionBenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + } + } finally { + llm.dispose(); + } + + results.add(CorrectionModelResult(modelPath: modelPath, cases: caseResults)); + } + + return results; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /// Strip common LLM wrapper cruft from the output. + String _cleanOutput(String raw) { + var output = raw.trim(); + + // Strip markdown code fences (```text ... ``` or ```\n ... ```) + final codeFenceRe = RegExp(r'^```[a-zA-Z]*\n?(.*?)\n?```$', dotAll: true); + final codeFenceMatch = codeFenceRe.firstMatch(output); + if (codeFenceMatch != null) { + output = codeFenceMatch.group(1)!.trim(); + } + + // Remove surrounding quotes + if ((output.startsWith('"') && output.endsWith('"')) || + (output.startsWith("'") && output.endsWith("'"))) { + output = output.substring(1, output.length - 1).trim(); + } + + // If model prefixed with "Output:" or "Corrected:" etc., take what's after + final prefixes = [ + 'output:', + 'corrected:', + 'corrected transcription:', + 'corrected text:', + ]; + final lower = output.toLowerCase(); + for (final prefix in prefixes) { + if (lower.startsWith(prefix)) { + output = output.substring(prefix.length).trim(); + // Remove surrounding quotes again after prefix removal + if ((output.startsWith('"') && output.endsWith('"')) || + (output.startsWith("'") && output.endsWith("'"))) { + output = output.substring(1, output.length - 1).trim(); + } + break; + } + } + + return output; + } + + _EvalResult _evaluate(CorrectionBenchmarkCase testCase, String output) { + final outputLower = output.toLowerCase(); + final inputLower = testCase.input.toLowerCase(); + + // Check if modified (normalize whitespace + case for comparison) + final normalizedInput = inputLower.replaceAll(RegExp(r'\s+'), ' ').trim(); + final normalizedOutput = outputLower.replaceAll(RegExp(r'\s+'), ' ').trim(); + // Strip trailing punctuation for comparison + final normalizedInputNoPunct = + normalizedInput.replaceAll(RegExp(r'[.!?,;:]+$'), '').trim(); + final normalizedOutputNoPunct = + normalizedOutput.replaceAll(RegExp(r'[.!?,;:]+$'), '').trim(); + final wasModified = normalizedInputNoPunct != normalizedOutputNoPunct; + + // Modification expectation check + // If modification is expected, it must have changed + // If no modification expected, echoing it back is fine (but changing is also OK) + final modificationMatch = + testCase.expectModification ? wasModified : true; + + // Must-contain check + final missingKeywords = []; + for (final keyword in testCase.mustContain) { + if (!outputLower.contains(keyword.toLowerCase())) { + missingKeywords.add(keyword); + } + } + + // Must-not-contain check + final unwantedFound = []; + for (final keyword in testCase.mustNotContain) { + if (outputLower.contains(keyword.toLowerCase())) { + unwantedFound.add(keyword); + } + } + + // Clean output check — no markdown, no explanations + final isClean = _checkClean(output); + + return _EvalResult( + wasModified: wasModified, + modificationMatch: modificationMatch, + allMustContainFound: missingKeywords.isEmpty, + missingKeywords: missingKeywords, + allMustNotContainAbsent: unwantedFound.isEmpty, + unwantedKeywordsFound: unwantedFound, + cleanOutput: isClean.passed, + cleanOutputDetail: isClean.detail, + ); + } + + _CleanCheck _checkClean(String output) { + if (output.isEmpty) { + return const _CleanCheck(passed: false, detail: 'empty output'); + } + + // Check for markdown + if (output.contains('```') || output.contains('**') || output.contains('##')) { + return const _CleanCheck(passed: false, detail: 'contains markdown'); + } + + // Check for explanatory preamble + final lower = output.toLowerCase(); + final preambles = [ + 'here is', + 'the corrected', + 'i have fixed', + 'i corrected', + 'below is', + 'note:', + 'explanation:', + 'changes made:', + ]; + for (final p in preambles) { + if (lower.startsWith(p)) { + return _CleanCheck(passed: false, detail: 'starts with preamble: "$p"'); + } + } + + // Check for excessive length (more than 3x input length likely means explanations) + // Relaxed — just flag it + if (output.length > 500) { + return const _CleanCheck(passed: false, detail: 'output suspiciously long (>500 chars)'); + } + + return const _CleanCheck(passed: true, detail: null); + } +} + +class _EvalResult { + final bool wasModified; + final bool modificationMatch; + final bool allMustContainFound; + final List missingKeywords; + final bool allMustNotContainAbsent; + final List unwantedKeywordsFound; + final bool cleanOutput; + final String? cleanOutputDetail; + + const _EvalResult({ + required this.wasModified, + required this.modificationMatch, + required this.allMustContainFound, + required this.missingKeywords, + required this.allMustNotContainAbsent, + required this.unwantedKeywordsFound, + required this.cleanOutput, + this.cleanOutputDetail, + }); +} + +class _CleanCheck { + final bool passed; + final String? detail; + const _CleanCheck({required this.passed, this.detail}); +} diff --git a/ai_testbench/lib/services/llm_service.dart b/ai_testbench/lib/services/llm_service.dart new file mode 100644 index 0000000..d83afde --- /dev/null +++ b/ai_testbench/lib/services/llm_service.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fllama/fllama.dart'; +import 'package:flutter/foundation.dart'; + +/// Result of a single inference run. +class InferenceResult { + final String output; + final Duration elapsed; + final int promptTokens; + final int outputTokens; + + const InferenceResult({ + required this.output, + required this.elapsed, + this.promptTokens = 0, + this.outputTokens = 0, + }); + + double get tokensPerSecond { + if (elapsed.inMilliseconds == 0) return 0; + if (outputTokens > 0) { + return outputTokens / (elapsed.inMilliseconds / 1000); + } + // Rough estimate: count whitespace-separated words ≈ tokens + final estimatedTokens = output.split(RegExp(r'\s+')).length; + return estimatedTokens / (elapsed.inMilliseconds / 1000); + } +} + +/// Wraps fllama for LLM inference. +/// +/// Usage: +/// ```dart +/// final svc = LlmService(); +/// svc.setModel('/path/to/model.gguf'); +/// final result = await svc.generate(prompt); +/// svc.dispose(); +/// ``` +class LlmService { + String? _modelPath; + int _runningRequestId = -1; + bool _requestInFlight = false; + + // Configuration + int nCtx = 2048; + int nThreads = 2; + int maxTokens = 512; + double temperature = 0.1; + double topP = 0.9; + double presencePenalty = 1.1; + int numGpuLayers = 99; + /// When false, disables thinking/reasoning for models like Qwen3/3.5. + bool enableThinking = true; + + static void _logFilter(String log) { + if (log.contains('loaded') || log.contains('error') || log.contains('Error') || + log.contains('token') || log.contains('speed') || log.contains('FAILED') || + log.contains('Model loaded') || log.contains('Initialized')) { + debugPrint('[llama.cpp] $log'); + } + } + + bool get isModelLoaded => _modelPath != null; + String? get loadedModelPath => _modelPath; + + /// Set the model path. fllama loads on first inference and caches it. + void setModel(String path) { + if (!File(path).existsSync()) { + throw ArgumentError('Model file not found: $path'); + } + _modelPath = path; + } + + /// Run inference with the given [prompt] and return the complete result. + /// + /// Uses fllamaChat with the OpenAI-compatible API. + /// The prompt is sent as a user message; for system prompts, provide + /// [systemPrompt]. + Future generate( + String prompt, { + String? systemPrompt, + int? overrideMaxTokens, + }) async { + if (_modelPath == null) { + throw StateError('No model set – call setModel() first.'); + } + + final sw = Stopwatch()..start(); + final completer = Completer(); + + final messages = [ + if (systemPrompt != null) Message(Role.system, systemPrompt), + Message(Role.user, prompt), + ]; + + final request = OpenAiRequest( + messages: messages, + modelPath: _modelPath!, + maxTokens: overrideMaxTokens ?? maxTokens, + numGpuLayers: numGpuLayers, + temperature: temperature, + topP: topP, + frequencyPenalty: 0.0, + presencePenalty: presencePenalty, + contextSize: nCtx, + logger: _logFilter, + enableThinking: enableThinking, + ); + + _requestInFlight = true; + _runningRequestId = await fllamaChat( + request, + (String response, String responseJson, bool done) { + if (done && !completer.isCompleted) { + _requestInFlight = false; + _runningRequestId = -1; + completer.complete(response); + } + }, + ); + + final output = await completer.future; + sw.stop(); + _requestInFlight = false; + _runningRequestId = -1; + + // Count output tokens + int outputTokenCount = 0; + try { + outputTokenCount = await fllamaTokenize( + FllamaTokenizeRequest(input: output, modelPath: _modelPath!), + ); + } catch (_) { + // Fallback estimate + outputTokenCount = output.split(RegExp(r'\s+')).length; + } + + return InferenceResult( + output: _stripThinkingTags(output.trim()), + elapsed: sw.elapsed, + outputTokens: outputTokenCount, + ); + } + + /// Strip Qwen3-style reasoning blocks from output. + static String _stripThinkingTags(String text) { + // Remove complete ... blocks + var cleaned = text.replaceAll(RegExp(r'.*?', dotAll: true), ''); + // Remove unclosed (thinking consumed entire budget) + cleaned = cleaned.replaceAll(RegExp(r'.*', dotAll: true), ''); + return cleaned.trim(); + } + + /// Stream inference tokens as they are generated (for live preview). + /// + /// Yields cumulative responses (each yield is the full response so far). + Stream generateStream( + String prompt, { + String? systemPrompt, + int? overrideMaxTokens, + }) { + if (_modelPath == null) { + throw StateError('No model set – call setModel() first.'); + } + + final controller = StreamController(); + + final messages = [ + if (systemPrompt != null) Message(Role.system, systemPrompt), + Message(Role.user, prompt), + ]; + + final request = OpenAiRequest( + messages: messages, + modelPath: _modelPath!, + maxTokens: overrideMaxTokens ?? maxTokens, + numGpuLayers: numGpuLayers, + temperature: temperature, + topP: topP, + frequencyPenalty: 0.0, + presencePenalty: presencePenalty, + contextSize: nCtx, + logger: _logFilter, + enableThinking: enableThinking, + ); + + _requestInFlight = true; + fllamaChat( + request, + (String response, String responseJson, bool done) { + debugPrint('[LlmService] stream cb: done=$done, len=${response.length}'); + if (!controller.isClosed) { + controller.add(response); + if (done) { + _requestInFlight = false; + _runningRequestId = -1; + controller.close(); + } + } + }, + ).then((id) { + _runningRequestId = id; + }); + + return controller.stream; + } + + /// Cancel any running inference. + void cancelInference() { + if (_requestInFlight && _runningRequestId >= 0) { + fllamaCancelInference(_runningRequestId); + _runningRequestId = -1; + _requestInFlight = false; + } + } + + void dispose() { + cancelInference(); + _modelPath = null; + } +} diff --git a/ai_testbench/lib/services/model_benchmark_service.dart b/ai_testbench/lib/services/model_benchmark_service.dart new file mode 100644 index 0000000..614976d --- /dev/null +++ b/ai_testbench/lib/services/model_benchmark_service.dart @@ -0,0 +1,534 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class BenchmarkCase { + final String name; + final String transcript; + final String expectedIntent; // 'reminder', 'event', 'note' + final bool expectTime; // whether datetime fields should be non-null + + /// Keywords that should appear (case-insensitive) in the title to verify the + /// model kept the output in the native language. + /// Empty list => skip the check (always pass). + final List titleLanguageKeywords; + + /// Optional: expected resolved DateTime for time-resolution validation. + final DateTime? expectedDateTime; + + /// Tolerance in minutes for DateTime comparison. + final int toleranceMinutes; + + const BenchmarkCase({ + required this.name, + required this.transcript, + required this.expectedIntent, + required this.expectTime, + this.titleLanguageKeywords = const [], + this.expectedDateTime, + this.toleranceMinutes = 5, + }); +} + +// ── Test result ────────────────────────────────────────────────────────── + +class BenchmarkCaseResult { + final String caseName; + final bool validJson; + final bool intentMatch; + final bool timePresenceMatch; + final bool titleLanguageMatch; + final String? titleLanguageDetail; + final bool timeResolutionCorrect; + final String? timeResolutionDetail; + final String intent; + final String? title; + final String? datetimeOriginal; + final String? datetimeEnglish; + final Duration elapsed; + final double tokensPerSecond; + final String outputPreview; + final String? error; + + const BenchmarkCaseResult({ + required this.caseName, + required this.validJson, + required this.intentMatch, + required this.timePresenceMatch, + this.titleLanguageMatch = true, + this.titleLanguageDetail, + this.timeResolutionCorrect = true, + this.timeResolutionDetail, + required this.intent, + this.title, + this.datetimeOriginal, + this.datetimeEnglish, + required this.elapsed, + required this.tokensPerSecond, + required this.outputPreview, + this.error, + }); + + bool get passed => + validJson && + intentMatch && + timePresenceMatch && + titleLanguageMatch && + timeResolutionCorrect; +} + +// ── Progress ───────────────────────────────────────────────────────────── + +class BenchmarkProgress { + final int totalModels; + final int totalCasesPerModel; + final int totalRuns; + final int completedRuns; + final int currentModelIndex; + final int currentCaseIndex; + final String currentModelPath; + final String currentCaseName; + + const BenchmarkProgress({ + required this.totalModels, + required this.totalCasesPerModel, + required this.totalRuns, + required this.completedRuns, + required this.currentModelIndex, + required this.currentCaseIndex, + required this.currentModelPath, + required this.currentCaseName, + }); + + double get fractionComplete => totalRuns == 0 ? 0 : completedRuns / totalRuns; + int get remainingRuns => totalRuns - completedRuns; + String get currentModelName => + currentModelPath.split(Platform.pathSeparator).last; +} + +// ── Aggregate result for a model ───────────────────────────────────────── + +class BenchmarkModelResult { + final String modelPath; + final List cases; + + const BenchmarkModelResult({ + required this.modelPath, + required this.cases, + }); + + String get modelName => modelPath.split(Platform.pathSeparator).last; + int get passedCases => cases.where((c) => c.passed).length; + double get avgTokensPerSecond => cases.isEmpty + ? 0 + : cases.fold(0, (sum, c) => sum + c.tokensPerSecond) / + cases.length; + Duration get totalElapsed => + cases.fold(Duration.zero, (sum, c) => sum + c.elapsed); +} + +// ── Service ────────────────────────────────────────────────────────────── + +class ModelBenchmarkService { + static const Duration perCaseTimeout = Duration(seconds: 90); + static const ChronoLlmParser _parser = ChronoLlmParser(); + + /// Fixed reference time for deterministic tests. + /// Wednesday March 11, 2026, 10:15 AM. + static final DateTime referenceTime = DateTime(2026, 3, 11, 10, 15); + + static final benchmarkCases = [ + // ── English cases ────────────────────────────────────────────────── + + BenchmarkCase( + name: 'en_event_precise_time', + transcript: + 'Schedule a design review with Erik and Sara on March 14 at 3:30 PM in Lab 3.', + expectedIntent: 'event', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 14, 15, 30), + ), + BenchmarkCase( + name: 'en_reminder_tomorrow', + transcript: + 'Remind me tomorrow at 7:15 AM to take the prototype battery off the charger.', + expectedIntent: 'reminder', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 12, 7, 15), + ), + BenchmarkCase( + name: 'en_event_next_tuesday', + transcript: 'Meeting with John next Tuesday at 2 pm.', + expectedIntent: 'event', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 17, 14, 0), + ), + BenchmarkCase( + name: 'en_reminder_next_friday', + transcript: + 'I need to finish the PCB layout review and send it to the manufacturer by next Friday at 5 PM.', + expectedIntent: 'reminder', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 20, 17, 0), + ), + BenchmarkCase( + name: 'en_event_dentist', + transcript: + 'Dentist appointment on April 22nd at 10:30 AM at the clinic downtown.', + expectedIntent: 'event', + expectTime: true, + expectedDateTime: DateTime(2026, 4, 22, 10, 30), + ), + BenchmarkCase( + name: 'en_note_no_time', + transcript: + 'Had an interesting idea about using a pressure sensor to detect altitude changes for the hiking app.', + expectedIntent: 'note', + expectTime: false, + ), + BenchmarkCase( + name: 'en_reminder_this_afternoon', + transcript: 'Call the plumber this afternoon at 3.', + expectedIntent: 'reminder', + expectTime: true, + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + + // ── Swedish cases (native language title validation) ────────────── + + BenchmarkCase( + name: 'sv_reminder_tomorrow', + transcript: 'Påminn mig imorgon klockan 8 att ringa tandläkaren.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['ringa', 'tandläkare'], + expectedDateTime: DateTime(2026, 3, 12, 8, 0), + ), + BenchmarkCase( + name: 'sv_event_meeting', + transcript: + 'Möte med projektgruppen på torsdag klockan 14 i stora konferensrummet.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['möte', 'projektgrupp'], + expectedDateTime: DateTime(2026, 3, 12, 14, 0), + ), + BenchmarkCase( + name: 'sv_note_no_time', + transcript: 'Köp mjölk och bröd på vägen hem.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['köp', 'mjölk', 'bröd'], + ), + BenchmarkCase( + name: 'sv_note_idea', + transcript: + 'Bra idé om att lägga till stegräknare i klockan, kanske använda BMI270 sensorn.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['stegräknare', 'klocka', 'idé', 'sensor'], + ), + BenchmarkCase( + name: 'sv_event_specific_date', + transcript: 'Tandläkare den 15 mars klockan halv 10.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['tandläkare'], + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + + // ── German cases (native language title validation) ─────────────── + + BenchmarkCase( + name: 'de_event_appointment', + transcript: + 'Arzttermin am Donnerstag um 9 Uhr in der Praxis am Marktplatz.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['arzt', 'termin', 'praxis'], + expectedDateTime: DateTime(2026, 3, 12, 9, 0), + ), + BenchmarkCase( + name: 'de_reminder_deadline', + transcript: + 'Ich muss den Bericht bis Freitag um 17 Uhr fertig haben und an den Chef schicken.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['bericht', 'chef', 'schicken'], + expectedDateTime: DateTime(2026, 3, 13, 17, 0), + ), + ]; + + Future> runForModels( + List modelPaths, { + void Function(BenchmarkProgress progress)? onProgress, + }) async { + final results = []; + final totalCases = benchmarkCases.length; + final totalRuns = modelPaths.length * totalCases; + var completedRuns = 0; + final resolver = TimeExpressionResolver(); + + for (var modelIndex = 0; modelIndex < modelPaths.length; modelIndex++) { + final modelPath = modelPaths[modelIndex]; + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 2048 + ..nThreads = Platform.numberOfProcessors + ..maxTokens = 384 + ..temperature = 0.1 + ..enableThinking = false; + + final caseResults = []; + try { + for (var caseIndex = 0; + caseIndex < benchmarkCases.length; + caseIndex++) { + final testCase = benchmarkCases[caseIndex]; + onProgress?.call( + BenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + + // Use the shared ChronoPromptTemplate from chrono_ai_flow + final prompt = ChronoPromptTemplate.render( + ChronoPromptTemplate.defaultTemplate, + transcript: testCase.transcript, + now: referenceTime, + ); + + try { + final result = await llm + .generate(prompt) + .timeout(perCaseTimeout); + + // Parse using the shared ChronoLlmParser + final parseResult = _parser.parse(result.output); + final extraction = parseResult.extraction; + + final validJson = extraction != null; + final intent = extraction?.intent ?? ''; + final title = extraction?.title; + final dtOriginal = extraction?.datetimeExpressionOriginal; + final dtEnglish = extraction?.datetimeExpressionEnglish; + + // Intent validation + final intentMatch = + _intentMatches(intent, testCase.expectedIntent); + + // Time presence validation + final hasTime = dtOriginal != null || dtEnglish != null; + final timePresenceMatch = hasTime == testCase.expectTime; + + // Title language validation + final titleLang = _checkTitleLanguage(title, testCase); + + // Time resolution validation + final timeRes = _checkTimeResolution( + dtEnglish ?? dtOriginal, + testCase, + resolver, + ); + + caseResults.add( + BenchmarkCaseResult( + caseName: testCase.name, + validJson: validJson, + intentMatch: intentMatch, + timePresenceMatch: timePresenceMatch, + titleLanguageMatch: titleLang.passed, + titleLanguageDetail: titleLang.detail, + timeResolutionCorrect: timeRes.passed, + timeResolutionDetail: timeRes.detail, + intent: intent, + title: title, + datetimeOriginal: dtOriginal, + datetimeEnglish: dtEnglish, + elapsed: result.elapsed, + tokensPerSecond: result.tokensPerSecond, + outputPreview: result.output.length > 300 + ? '${result.output.substring(0, 300)}...' + : result.output, + ), + ); + } on TimeoutException { + llm.cancelInference(); + caseResults.add( + BenchmarkCaseResult( + caseName: testCase.name, + validJson: false, + intentMatch: false, + timePresenceMatch: false, + titleLanguageMatch: false, + timeResolutionCorrect: false, + intent: 'timeout', + elapsed: perCaseTimeout, + tokensPerSecond: 0, + outputPreview: + 'Timed out after ${perCaseTimeout.inSeconds}s', + error: 'Timed out after ${perCaseTimeout.inSeconds}s', + ), + ); + } catch (e) { + llm.cancelInference(); + caseResults.add( + BenchmarkCaseResult( + caseName: testCase.name, + validJson: false, + intentMatch: false, + timePresenceMatch: false, + titleLanguageMatch: false, + timeResolutionCorrect: false, + intent: 'error', + elapsed: Duration.zero, + tokensPerSecond: 0, + outputPreview: 'Error: $e', + error: e.toString(), + ), + ); + } + + completedRuns++; + onProgress?.call( + BenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: modelIndex, + currentCaseIndex: caseIndex, + currentModelPath: modelPath, + currentCaseName: testCase.name, + ), + ); + } + } finally { + llm.dispose(); + } + + results.add( + BenchmarkModelResult(modelPath: modelPath, cases: caseResults)); + } + + onProgress?.call( + BenchmarkProgress( + totalModels: modelPaths.length, + totalCasesPerModel: totalCases, + totalRuns: totalRuns, + completedRuns: completedRuns, + currentModelIndex: + modelPaths.isEmpty ? 0 : modelPaths.length - 1, + currentCaseIndex: totalCases == 0 ? 0 : totalCases - 1, + currentModelPath: modelPaths.isEmpty ? '' : modelPaths.last, + currentCaseName: + benchmarkCases.isEmpty ? '' : benchmarkCases.last.name, + ), + ); + + return results; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + bool _intentMatches(String got, String expected) { + final g = got.toLowerCase().trim(); + final e = expected.toLowerCase().trim(); + if (g == e) return true; + // Allow note <-> task fuzzy match (no time = note) + if ({g, e}.containsAll({'note', 'task'})) return true; + return false; + } + + _CheckResult _checkTitleLanguage(String? title, BenchmarkCase testCase) { + if (testCase.titleLanguageKeywords.isEmpty) { + return const _CheckResult(passed: true, detail: 'no keyword check'); + } + if (title == null || title.isEmpty) { + return const _CheckResult( + passed: false, + detail: 'no title in output', + ); + } + + final lower = title.toLowerCase(); + final matched = []; + for (final keyword in testCase.titleLanguageKeywords) { + if (lower.contains(keyword.toLowerCase())) { + matched.add(keyword); + } + } + + final passed = matched.isNotEmpty; + final detail = passed + ? 'found ${matched.join(", ")} in "$title"' + : 'none of [${testCase.titleLanguageKeywords.join(", ")}] found in "$title"'; + + return _CheckResult(passed: passed, detail: detail); + } + + _CheckResult _checkTimeResolution( + String? timeExpr, + BenchmarkCase testCase, + TimeExpressionResolver resolver, + ) { + if (testCase.expectedDateTime == null) { + return const _CheckResult(passed: true, detail: 'no time check'); + } + if (timeExpr == null || timeExpr.isEmpty) { + return const _CheckResult( + passed: false, + detail: 'no time expression to resolve', + ); + } + + final resolved = resolver.resolve( + timeExpr, + referenceDate: referenceTime, + ); + + if (resolved == null) { + return _CheckResult( + passed: false, + detail: 'chrono failed to parse "$timeExpr"', + ); + } + + final diff = resolved.dateTime + .difference(testCase.expectedDateTime!) + .inMinutes + .abs(); + if (diff > testCase.toleranceMinutes) { + return _CheckResult( + passed: false, + detail: + 'got ${resolved.dateTime}, expected ${testCase.expectedDateTime} ' + '(diff ${diff}min, tolerance ${testCase.toleranceMinutes}min)', + ); + } + + return _CheckResult( + passed: true, + detail: '${resolved.dateTime} OK (via ${resolved.method})', + ); + } +} + +class _CheckResult { + final bool passed; + final String? detail; + const _CheckResult({required this.passed, this.detail}); +} diff --git a/ai_testbench/lib/services/time_expression_resolver.dart b/ai_testbench/lib/services/time_expression_resolver.dart new file mode 100644 index 0000000..99afb42 --- /dev/null +++ b/ai_testbench/lib/services/time_expression_resolver.dart @@ -0,0 +1,2 @@ +export 'package:chrono_ai_flow/chrono_ai_flow.dart' + show ResolvedTime, TimeExpressionResolver; diff --git a/ai_testbench/lib/services/time_extraction_benchmark_service.dart b/ai_testbench/lib/services/time_extraction_benchmark_service.dart new file mode 100644 index 0000000..608a448 --- /dev/null +++ b/ai_testbench/lib/services/time_extraction_benchmark_service.dart @@ -0,0 +1,565 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:flutter/foundation.dart'; + +import 'llm_service.dart'; +import '../prompts/time_extraction_prompts.dart'; + +// ── Test case definition ───────────────────────────────────────────────── + +class TimeExtractionTestCase { + final String name; + final String transcript; + final String expectedIntent; // 'reminder', 'event', 'note' + final String? expectedTimeEnglish; // null = no time expected + final DateTime? expectedDateTime; // null = no time expected + final int toleranceMinutes; // for relative times like "in 30 minutes" + + const TimeExtractionTestCase({ + required this.name, + required this.transcript, + required this.expectedIntent, + this.expectedTimeEnglish, + this.expectedDateTime, + this.toleranceMinutes = 2, + }); +} + +// ── LLM response structure ────────────────────────────────────────────── + +class LlmExtractionResult { + final String? intent; + final String? title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String rawOutput; + + const LlmExtractionResult({ + this.intent, + this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + required this.rawOutput, + }); +} + +// ── Test result ───────────────────────────────────────────────────────── + +enum TestStatus { pass, fail, partial } + +class TimeExtractionTestResult { + final TimeExtractionTestCase testCase; + final LlmExtractionResult? llmResult; + final ResolvedTime? resolvedTime; + final Duration llmDuration; + final double tokensPerSecond; + final TestStatus status; + final List failures; + + const TimeExtractionTestResult({ + required this.testCase, + this.llmResult, + this.resolvedTime, + required this.llmDuration, + required this.tokensPerSecond, + required this.status, + this.failures = const [], + }); +} + +// ── Progress ──────────────────────────────────────────────────────────── + +class TimeExtractionProgress { + final int totalCases; + final int completedCases; + final String currentCaseName; + final String modelName; + + const TimeExtractionProgress({ + required this.totalCases, + required this.completedCases, + required this.currentCaseName, + required this.modelName, + }); + + double get fraction => + totalCases == 0 ? 0 : completedCases / totalCases; +} + +// ── Aggregate result for a model ──────────────────────────────────────── + +class TimeExtractionModelResult { + final String modelPath; + final List cases; + + const TimeExtractionModelResult({ + required this.modelPath, + required this.cases, + }); + + String get modelName => modelPath.split(Platform.pathSeparator).last; + int get passedCount => + cases.where((c) => c.status == TestStatus.pass).length; + int get partialCount => + cases.where((c) => c.status == TestStatus.partial).length; + int get failedCount => + cases.where((c) => c.status == TestStatus.fail).length; + double get avgTokensPerSecond => cases.isEmpty + ? 0 + : cases.fold(0, (sum, c) => sum + c.tokensPerSecond) / + cases.length; + Duration get totalElapsed => + cases.fold(Duration.zero, (sum, c) => sum + c.llmDuration); +} + +// ── Service ───────────────────────────────────────────────────────────── + +class TimeExtractionBenchmarkService { + static const Duration perCaseTimeout = Duration(seconds: 90); + static const ChronoLlmParser _parser = ChronoLlmParser(); + + /// Fixed reference time for deterministic tests. + /// Monday March 9, 2026, 10:15 AM. + static final DateTime referenceTime = DateTime(2026, 3, 9, 10, 15); + + static final testCases = [ + TimeExtractionTestCase( + name: 'EN: Simple reminder with time', + transcript: 'Remind me tomorrow at 10 am to buy milk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10 am', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TimeExtractionTestCase( + name: 'SV: Reminder with time', + transcript: 'påminn mig imorgon klockan 10 att köpa mjölk', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TimeExtractionTestCase( + name: 'DE: Reminder with time', + transcript: 'erinnere mich morgen um 10 milch zu kaufen', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tomorrow at 10', + expectedDateTime: DateTime(2026, 3, 10, 10, 0), + ), + TimeExtractionTestCase( + name: 'EN: Meeting next Tuesday', + transcript: 'meeting with John next Tuesday at 2 pm', + expectedIntent: 'event', + expectedTimeEnglish: 'next Tuesday at 2 pm', + expectedDateTime: DateTime(2026, 3, 10, 14, 0), + ), + TimeExtractionTestCase( + name: 'EN: No time mentioned', + transcript: 'remember to buy milk', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TimeExtractionTestCase( + name: 'SV: Relative minutes', + transcript: 'ring tandläkaren om 30 minuter', + expectedIntent: 'reminder', + expectedTimeEnglish: 'in 30 minutes', + expectedDateTime: DateTime(2026, 3, 9, 10, 45), + toleranceMinutes: 5, + ), + TimeExtractionTestCase( + name: 'FR: Friday at 3pm', + transcript: "rappelle-moi vendredi à 15h d'appeler le médecin", + expectedIntent: 'reminder', + expectedTimeEnglish: 'Friday at 3 pm', + expectedDateTime: DateTime(2026, 3, 13, 15, 0), + ), + TimeExtractionTestCase( + name: 'EN: Specific date', + transcript: 'dentist appointment on March 15th at 9:30', + expectedIntent: 'event', + expectedTimeEnglish: 'March 15th at 9:30', + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + TimeExtractionTestCase( + name: 'SV: No time, just task', + transcript: 'köp bröd på vägen hem', + expectedIntent: 'note', + expectedTimeEnglish: null, + expectedDateTime: null, + ), + TimeExtractionTestCase( + name: 'EN: This afternoon', + transcript: 'call the plumber this afternoon at 3', + expectedIntent: 'reminder', + expectedTimeEnglish: 'this afternoon at 3', + expectedDateTime: DateTime(2026, 3, 9, 15, 0), + ), + // ── Additional Swedish cases (translation stress tests) ────────── + TimeExtractionTestCase( + name: 'SV: Meeting next Tuesday', + transcript: 'möte med Erik nästa tisdag klockan 14', + expectedIntent: 'event', + expectedTimeEnglish: 'next Tuesday at 2 pm', + expectedDateTime: DateTime(2026, 3, 10, 14, 0), + ), + TimeExtractionTestCase( + name: 'SV: This afternoon', + transcript: 'ring rörmokaren i eftermiddag klockan 3', + expectedIntent: 'reminder', + expectedTimeEnglish: 'this afternoon at 3', + expectedDateTime: DateTime(2026, 3, 9, 15, 0), + ), + TimeExtractionTestCase( + name: 'SV: Specific date and time', + transcript: 'tandläkare den 15 mars klockan halv 10', + expectedIntent: 'event', + expectedTimeEnglish: 'March 15th at 9:30', + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + TimeExtractionTestCase( + name: 'SV: Friday with 24h time', + transcript: 'boka konferensrum på fredag klockan 15', + expectedIntent: 'event', + expectedTimeEnglish: 'Friday at 3 pm', + expectedDateTime: DateTime(2026, 3, 13, 15, 0), + ), + TimeExtractionTestCase( + name: 'SV: In two hours', + transcript: 'påminn mig om två timmar att ta medicinen', + expectedIntent: 'reminder', + expectedTimeEnglish: 'in 2 hours', + expectedDateTime: DateTime(2026, 3, 9, 12, 15), + toleranceMinutes: 5, + ), + TimeExtractionTestCase( + name: 'SV: Day after tomorrow morning', + transcript: 'skicka rapporten i övermorgon på morgonen klockan 8', + expectedIntent: 'reminder', + expectedTimeEnglish: 'day after tomorrow at 8 am', + expectedDateTime: DateTime(2026, 3, 11, 8, 0), + ), + TimeExtractionTestCase( + name: 'SV: Tonight', + transcript: 'handla mat ikväll klockan 6', + expectedIntent: 'reminder', + expectedTimeEnglish: 'tonight at 6', + expectedDateTime: DateTime(2026, 3, 9, 18, 0), + ), + ]; + + Future> runForModels( + List modelPaths, { + void Function(TimeExtractionProgress progress)? onProgress, + bool includeLanguageHint = false, + bool retryInvalidOutput = false, + TimeExtractionPromptVariant promptVariant = + TimeExtractionPromptVariant.full, + }) async { + final results = []; + final resolver = TimeExpressionResolver(); + + for (final modelPath in modelPaths) { + final modelName = modelPath.split(Platform.pathSeparator).last; + final llm = LlmService() + ..setModel(modelPath) + ..nCtx = 2048 + ..nThreads = 4 + ..maxTokens = 384 + ..temperature = 0.1; + + final caseResults = []; + + try { + for (var i = 0; i < testCases.length; i++) { + final tc = testCases[i]; + + onProgress?.call(TimeExtractionProgress( + totalCases: testCases.length, + completedCases: i, + currentCaseName: tc.name, + modelName: modelName, + )); + + debugPrint( + '\n─── Test ${i + 1}/${testCases.length}: ${tc.name} ───', + ); + debugPrint(' Input: "${tc.transcript}"'); + + try { + final prompt = TimeExtractionPrompts.singlePrompt( + transcript: tc.transcript, + now: referenceTime, + ); + + Duration totalElapsed = Duration.zero; + double lastTokensPerSecond = 0; + var attempts = 0; + InferenceResult? result; + LlmExtractionResult? llmResult; + + while (true) { + attempts++; + result = await llm + .generate(prompt) + .timeout(perCaseTimeout); + totalElapsed += result.elapsed; + lastTokensPerSecond = result.tokensPerSecond; + llmResult = _parseLlmOutput(result.output); + + if (!retryInvalidOutput || + attempts >= 2 || + !_shouldRetryInvalidOutput(tc, llmResult)) { + break; + } + + debugPrint(' ↻ Retrying invalid output (attempt ${attempts + 1}/2)'); + } + + debugPrint(' LLM time: ${totalElapsed.inMilliseconds}ms ' + '(${lastTokensPerSecond.toStringAsFixed(1)} tok/s)'); + debugPrint(' attempts: $attempts'); + debugPrint(' intent: ${llmResult.intent}'); + debugPrint(' title: ${llmResult.title}'); + debugPrint(' time (orig): ${llmResult.datetimeExpressionOriginal}'); + debugPrint(' time (EN): ${llmResult.datetimeExpressionEnglish}'); + + // Resolve time expression + ResolvedTime? resolvedTime; + final timeExpr = llmResult.datetimeExpressionEnglish ?? + llmResult.datetimeExpressionOriginal; + if (timeExpr != null) { + resolvedTime = resolver.resolve( + timeExpr, + referenceDate: referenceTime, + ); + debugPrint(resolvedTime != null + ? ' Chrono: ${resolvedTime.dateTime} (via ${resolvedTime.method})' + : ' Chrono: FAILED for "$timeExpr"'); + } + + // Evaluate + final failures = _evaluate(tc, llmResult, resolvedTime); + final status = failures.isEmpty + ? TestStatus.pass + : (failures.length == 1 && + !failures.first.contains('Intent')) + ? TestStatus.partial + : TestStatus.fail; + + for (final f in failures) { + debugPrint(' ❌ $f'); + } + if (failures.isEmpty) { + debugPrint(' ✅ PASS'); + } + + caseResults.add(TimeExtractionTestResult( + testCase: tc, + llmResult: llmResult, + resolvedTime: resolvedTime, + llmDuration: totalElapsed, + tokensPerSecond: lastTokensPerSecond, + status: status, + failures: failures, + )); + } on TimeoutException { + llm.cancelInference(); + debugPrint(' ⏱ TIMEOUT'); + caseResults.add(TimeExtractionTestResult( + testCase: tc, + llmDuration: perCaseTimeout, + tokensPerSecond: 0, + status: TestStatus.fail, + failures: ['Timed out after ${perCaseTimeout.inSeconds}s'], + )); + } catch (e) { + llm.cancelInference(); + debugPrint(' ❌ ERROR: $e'); + caseResults.add(TimeExtractionTestResult( + testCase: tc, + llmDuration: Duration.zero, + tokensPerSecond: 0, + status: TestStatus.fail, + failures: ['Error: $e'], + )); + } + + onProgress?.call(TimeExtractionProgress( + totalCases: testCases.length, + completedCases: i + 1, + currentCaseName: tc.name, + modelName: modelName, + )); + } + } finally { + llm.dispose(); + } + + results.add(TimeExtractionModelResult( + modelPath: modelPath, + cases: caseResults, + )); + } + + return results; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + LlmExtractionResult _parseLlmOutput(String raw) { + final parsed = _parser.parse(raw); + return LlmExtractionResult( + intent: parsed.extraction?.intent, + title: parsed.extraction?.title, + datetimeExpressionOriginal: parsed.extraction?.datetimeExpressionOriginal, + datetimeExpressionEnglish: parsed.extraction?.datetimeExpressionEnglish, + rawOutput: parsed.rawOutput, + ); + } + + bool _shouldRetryInvalidOutput( + TimeExtractionTestCase testCase, + LlmExtractionResult llmResult, + ) { + final hasIntent = llmResult.intent != null && llmResult.intent!.isNotEmpty; + final hasAnyTime = (llmResult.datetimeExpressionEnglish != null && + llmResult.datetimeExpressionEnglish!.isNotEmpty) || + (llmResult.datetimeExpressionOriginal != null && + llmResult.datetimeExpressionOriginal!.isNotEmpty); + final hasJsonFields = hasIntent || + llmResult.title != null || + llmResult.datetimeExpressionEnglish != null || + llmResult.datetimeExpressionOriginal != null; + + if (!hasJsonFields) { + return true; + } + + if (!hasIntent) { + return true; + } + + if (testCase.expectedTimeEnglish != null && !hasAnyTime) { + return true; + } + + return false; + } + + List _evaluate( + TimeExtractionTestCase tc, + LlmExtractionResult llm, + ResolvedTime? resolved, + ) { + final failures = []; + + // Intent match + if (!_intentMatches(llm.intent, tc.expectedIntent)) { + failures.add( + 'Intent: got "${llm.intent}", expected "${tc.expectedIntent}"', + ); + } + + // Time expression present/absent + if (tc.expectedTimeEnglish != null && + llm.datetimeExpressionEnglish == null && + llm.datetimeExpressionOriginal == null) { + failures.add('Expected time expression but got null'); + } + if (tc.expectedTimeEnglish == null && + llm.datetimeExpressionEnglish != null) { + failures.add( + 'Expected no time but got "${llm.datetimeExpressionEnglish}"', + ); + } + + // Chrono parse success + if (tc.expectedDateTime != null && resolved == null) { + failures.add('Chrono failed to parse time expression'); + } + if (tc.expectedDateTime == null && resolved != null) { + failures.add('Expected no resolved time but got ${resolved.dateTime}'); + } + + // DateTime accuracy + if (tc.expectedDateTime != null && resolved != null) { + final diff = resolved.dateTime + .difference(tc.expectedDateTime!) + .inMinutes + .abs(); + if (diff > tc.toleranceMinutes) { + failures.add( + 'DateTime: got ${resolved.dateTime}, expected ${tc.expectedDateTime} ' + '(diff ${diff}min, tolerance ${tc.toleranceMinutes}min)', + ); + } + } + + return failures; + } + + bool _intentMatches(String? got, String expected) { + if (got == null) return false; + final g = got.toLowerCase().trim(); + final e = expected.toLowerCase().trim(); + if (g == e) return true; + // note ↔ task are fuzzy + if ({g, e}.containsAll({'note', 'task'})) return true; + return false; + } + + /// Format results as a readable string for display. + static String formatResults(List results) { + final buf = StringBuffer(); + + for (final model in results) { + buf.writeln('╔══════════════════════════════════════════════════════╗'); + buf.writeln('║ Model: ${model.modelName}'); + buf.writeln('║ Results: ${model.passedCount} passed, ' + '${model.partialCount} partial, ${model.failedCount} failed ' + 'of ${model.cases.length}'); + buf.writeln('║ Total time: ' + '${(model.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s'); + buf.writeln('║ Avg: ' + '${model.avgTokensPerSecond.toStringAsFixed(1)} tok/s'); + buf.writeln('╚══════════════════════════════════════════════════════╝'); + buf.writeln(); + + for (final r in model.cases) { + final icon = switch (r.status) { + TestStatus.pass => '✅', + TestStatus.partial => '⚠️', + TestStatus.fail => '❌', + }; + buf.writeln('$icon ${r.testCase.name}'); + buf.writeln(' Input: "${r.testCase.transcript}"'); + if (r.llmResult != null) { + buf.writeln(' Intent: ${r.llmResult!.intent}'); + buf.writeln(' Title: ${r.llmResult!.title}'); + buf.writeln( + ' Time (orig): ${r.llmResult!.datetimeExpressionOriginal}'); + buf.writeln( + ' Time (EN): ${r.llmResult!.datetimeExpressionEnglish}'); + } + if (r.resolvedTime != null) { + buf.writeln( + ' Resolved: ${r.resolvedTime!.dateTime} (${r.resolvedTime!.method})'); + } + if (r.testCase.expectedDateTime != null) { + buf.writeln(' Expected: ${r.testCase.expectedDateTime}'); + } + buf.writeln( + ' LLM: ${r.llmDuration.inMilliseconds}ms, ' + '${r.tokensPerSecond.toStringAsFixed(1)} tok/s'); + for (final f in r.failures) { + buf.writeln(' ↳ $f'); + } + buf.writeln(); + } + } + + return buf.toString(); + } +} diff --git a/ai_testbench/lib/time_extraction_main.dart b/ai_testbench/lib/time_extraction_main.dart new file mode 100644 index 0000000..86b1244 --- /dev/null +++ b/ai_testbench/lib/time_extraction_main.dart @@ -0,0 +1,136 @@ +/// Headless entry point for time extraction benchmark. +/// +/// Runs the time extraction test suite and prints results to stdout. +/// Requires Flutter (for fllama), so it must be compiled as a Flutter app. +/// +/// Usage: +/// flutter run -d linux --dart-entrypoint-args '--model Qwen3.5-2B-Q4_K_M.gguf' +/// Or compiled: +/// ./build/linux/x64/release/bundle/ai_testbench --headless-time --model Qwen3.5-2B-Q4_K_M.gguf +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'prompts/time_extraction_prompts.dart'; +import 'services/time_extraction_benchmark_service.dart'; + +/// Run the time extraction benchmark headlessly. +/// +/// Call from main() when --headless-time flag is detected. +Future runHeadlessTimeExtraction(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + final modelDir = Directory('models').absolute.path; + String? modelFilter; + var includeLanguageHint = false; + var retryInvalidOutput = false; + var promptVariant = TimeExtractionPromptVariant.full; + + for (var i = 0; i < args.length; i++) { + if (args[i] == '--model' && i + 1 < args.length) { + modelFilter = args[++i]; + } else if (args[i] == '--language-hint') { + includeLanguageHint = true; + } else if (args[i] == '--retry-invalid') { + retryInvalidOutput = true; + } else if (args[i] == '--prompt-variant' && i + 1 < args.length) { + final value = args[++i].toLowerCase(); + switch (value) { + case 'full': + promptVariant = TimeExtractionPromptVariant.full; + break; + case 'medium': + promptVariant = TimeExtractionPromptVariant.medium; + break; + case 'short': + promptVariant = TimeExtractionPromptVariant.short; + break; + default: + stdout.writeln('ERROR: Unknown prompt variant "$value"'); + exitCode = 1; + return; + } + } + } + + // Discover models + final modelsDirectory = Directory(modelDir); + if (!modelsDirectory.existsSync()) { + stdout.writeln('ERROR: models/ directory not found at $modelDir'); + exitCode = 1; + return; + } + + var modelPaths = modelsDirectory + .listSync() + .whereType() + .map((f) => f.path) + .where((p) => p.toLowerCase().endsWith('.gguf')) + .toList() + ..sort(); + + if (modelFilter != null) { + modelPaths = modelPaths + .where((p) => + p.toLowerCase().contains(modelFilter!.toLowerCase())) + .toList(); + } + + if (modelPaths.isEmpty) { + stdout.writeln('ERROR: No matching .gguf models found'); + if (modelFilter != null) { + stdout.writeln(' Filter: $modelFilter'); + } + exitCode = 1; + return; + } + + stdout.writeln(''); + stdout.writeln('╔═══════════════════════════════════════════════════════╗'); + stdout.writeln('║ Time Extraction Benchmark — Headless ║'); + stdout.writeln('╚═══════════════════════════════════════════════════════╝'); + stdout.writeln(''); + stdout.writeln('Models: ${modelPaths.length}'); + for (final p in modelPaths) { + stdout.writeln(' - ${p.split(Platform.pathSeparator).last}'); + } + stdout.writeln('Test cases: ${TimeExtractionBenchmarkService.testCases.length}'); + stdout.writeln('Reference time: ${TimeExtractionBenchmarkService.referenceTime}'); + stdout.writeln('Prompt variant: ${promptVariant.name}'); + stdout.writeln('Language hint: ${includeLanguageHint ? 'enabled' : 'disabled'}'); + stdout.writeln('Retry invalid output: ${retryInvalidOutput ? 'enabled' : 'disabled'}'); + stdout.writeln(''); + + final service = TimeExtractionBenchmarkService(); + final results = await service.runForModels( + modelPaths, + includeLanguageHint: includeLanguageHint, + retryInvalidOutput: retryInvalidOutput, + promptVariant: promptVariant, + onProgress: (p) { + stdout.writeln( + '[${p.modelName}] ${p.completedCases}/${p.totalCases} ' + '${p.currentCaseName}', + ); + }, + ); + + stdout.writeln(''); + stdout.write(TimeExtractionBenchmarkService.formatResults(results)); + + // Exit summary + for (final model in results) { + stdout.writeln( + 'SUMMARY ${model.modelName}: ' + '${model.passedCount}/${model.cases.length} pass, ' + '${model.partialCount} partial, ' + '${model.failedCount} fail, ' + '${model.avgTokensPerSecond.toStringAsFixed(1)} tok/s, ' + '${(model.totalElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s total', + ); + } + + exitCode = results.any((m) => m.failedCount > 0) ? 1 : 0; +} diff --git a/ai_testbench/lib/widgets/memo_card.dart b/ai_testbench/lib/widgets/memo_card.dart new file mode 100644 index 0000000..f71452b --- /dev/null +++ b/ai_testbench/lib/widgets/memo_card.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +/// Visual preview card for a chrono-extracted memo. +/// +/// Expects the JSON map to follow the chrono_ai_flow schema: +/// intent, title, datetime_expression_original, datetime_expression_english +class MemoCard extends StatelessWidget { + const MemoCard({super.key, required this.data}); + + final Map data; + + @override + Widget build(BuildContext context) { + final intent = (data['intent'] as String?) ?? 'note'; + final title = (data['title'] as String?) ?? '—'; + final dtOriginal = data['datetime_expression_original'] as String?; + final dtEnglish = data['datetime_expression_english'] as String?; + + final (icon, color) = _intentStyle(intent); + + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────── + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + intent.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 1, + ), + ), + ), + const SizedBox(height: 4), + Text( + title, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + ), + + // ── Datetime expressions ──────────────────────────────────── + if (dtOriginal != null || dtEnglish != null) ...[ + const Divider(height: 24), + if (dtOriginal != null) + _dateTimeRow( + context, + label: 'Original', + value: dtOriginal, + color: color, + ), + if (dtEnglish != null && dtEnglish != dtOriginal) + _dateTimeRow( + context, + label: 'English', + value: dtEnglish, + color: color, + ), + ], + + if (dtOriginal == null && intent != 'note') ...[ + const Divider(height: 24), + Text( + 'No time expression extracted.', + style: TextStyle( + color: Colors.grey[500], + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + ); + } + + static Widget _dateTimeRow( + BuildContext context, { + required String label, + required String value, + required Color color, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.schedule, size: 18, color: color.withValues(alpha: 0.6)), + const SizedBox(width: 8), + Text( + '$label: ', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 14)), + ), + ], + ), + ); + } + + static (IconData, Color) _intentStyle(String intent) { + return switch (intent.toLowerCase()) { + 'reminder' || 'task' || 'todo' => (Icons.checklist, Colors.amber), + 'event' || 'meeting' => (Icons.event, Colors.lightBlue), + 'note' || 'idea' => (Icons.sticky_note_2, Colors.green), + _ => (Icons.notes, Colors.grey), + }; + } +} diff --git a/ai_testbench/linux/.gitignore b/ai_testbench/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/ai_testbench/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/ai_testbench/linux/CMakeLists.txt b/ai_testbench/linux/CMakeLists.txt new file mode 100644 index 0000000..bc8aa44 --- /dev/null +++ b/ai_testbench/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ai_testbench") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "dev.zswatch.ai_testbench") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/ai_testbench/linux/flutter/CMakeLists.txt b/ai_testbench/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/ai_testbench/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/ai_testbench/linux/flutter/generated_plugin_registrant.cc b/ai_testbench/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/ai_testbench/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/ai_testbench/linux/flutter/generated_plugin_registrant.h b/ai_testbench/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/ai_testbench/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/ai_testbench/linux/flutter/generated_plugins.cmake b/ai_testbench/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f2b498c --- /dev/null +++ b/ai_testbench/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/ai_testbench/linux/runner/CMakeLists.txt b/ai_testbench/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/ai_testbench/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/ai_testbench/linux/runner/main.cc b/ai_testbench/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/ai_testbench/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/ai_testbench/linux/runner/my_application.cc b/ai_testbench/linux/runner/my_application.cc new file mode 100644 index 0000000..baa4ba3 --- /dev/null +++ b/ai_testbench/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "ai_testbench"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "ai_testbench"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/ai_testbench/linux/runner/my_application.h b/ai_testbench/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/ai_testbench/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/ai_testbench/macos/.gitignore b/ai_testbench/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/ai_testbench/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig b/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/ai_testbench/macos/Flutter/Flutter-Release.xcconfig b/ai_testbench/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/ai_testbench/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift b/ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..d484543 --- /dev/null +++ b/ai_testbench/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker +import path_provider_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) +} diff --git a/ai_testbench/macos/Runner.xcodeproj/project.pbxproj b/ai_testbench/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ec67ab5 --- /dev/null +++ b/ai_testbench/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* ai_testbench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ai_testbench.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* ai_testbench.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* ai_testbench.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_testbench.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_testbench"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_testbench.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_testbench"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_testbench.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_testbench"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ai_testbench/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..30c0437 --- /dev/null +++ b/ai_testbench/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata b/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ai_testbench/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ai_testbench/macos/Runner/AppDelegate.swift b/ai_testbench/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/ai_testbench/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 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ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 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 literal 0 HcmV?d00001 diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 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 literal 0 HcmV?d00001 diff --git a/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/ai_testbench/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai_testbench/macos/Runner/Configs/AppInfo.xcconfig b/ai_testbench/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..10e5449 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = ai_testbench + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = dev.zswatch.aiTestbench + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 dev.zswatch. All rights reserved. diff --git a/ai_testbench/macos/Runner/Configs/Debug.xcconfig b/ai_testbench/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/ai_testbench/macos/Runner/Configs/Release.xcconfig b/ai_testbench/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/ai_testbench/macos/Runner/Configs/Warnings.xcconfig b/ai_testbench/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/ai_testbench/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/ai_testbench/macos/Runner/DebugProfile.entitlements b/ai_testbench/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/ai_testbench/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/ai_testbench/macos/Runner/Info.plist b/ai_testbench/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/ai_testbench/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/ai_testbench/macos/Runner/MainFlutterWindow.swift b/ai_testbench/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/ai_testbench/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/ai_testbench/macos/Runner/Release.entitlements b/ai_testbench/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/ai_testbench/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/ai_testbench/macos/RunnerTests/RunnerTests.swift b/ai_testbench/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/ai_testbench/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ai_testbench/pubspec.lock b/ai_testbench/pubspec.lock new file mode 100644 index 0000000..db6bdb1 --- /dev/null +++ b/ai_testbench/pubspec.lock @@ -0,0 +1,544 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + chrono_ai_flow: + dependency: "direct main" + description: + path: "../packages/chrono_ai_flow" + relative: true + source: path + version: "0.1.0" + chrono_dart: + dependency: "direct main" + description: + name: chrono_dart + sha256: ac121aeec8c8ea22765d6eff5bf5bc8caae3fda1473d996bb5ee915e1b4b8a9d + url: "https://pub.dev" + source: hosted + version: "2.0.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + day: + dependency: transitive + description: + name: day + sha256: "1e7068deb2f825a8b705d01d1116cc485ddc32531b43dc8c4bf58c5a1b87cd48" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fllama: + dependency: "direct main" + description: + path: "../third_party/fllama" + relative: true + source: path + version: "0.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jinja: + dependency: transitive + description: + name: jinja + sha256: "67485c43c8551688669a81b4e01fe94f6126578ba8c194908d00f254f23f9b8b" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + textwrap: + dependency: transitive + description: + name: textwrap + sha256: "7e79503c220a9c772d370075e0d4117204546ed4c6479ab1c9ee4d4c27add606" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" + flutter: ">=3.35.0" diff --git a/ai_testbench/pubspec.yaml b/ai_testbench/pubspec.yaml new file mode 100644 index 0000000..b5ea58b --- /dev/null +++ b/ai_testbench/pubspec.yaml @@ -0,0 +1,97 @@ +name: ai_testbench +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + fllama: + path: ../third_party/fllama + file_picker: ^10.3.10 + path_provider: ^2.1.5 + http: ^1.2.2 + chrono_dart: ^2.0.2 + chrono_ai_flow: + path: ../packages/chrono_ai_flow + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/ai_testbench/test/widget_test.dart b/ai_testbench/test/widget_test.dart new file mode 100644 index 0000000..4f8cd24 --- /dev/null +++ b/ai_testbench/test/widget_test.dart @@ -0,0 +1,17 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:ai_testbench/main.dart'; + +void main() { + testWidgets('App launches smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const AiTestbenchApp()); + expect(find.text('ZSWatch AI Testbench'), findsOneWidget); + }); +} diff --git a/ai_testbench/windows/.gitignore b/ai_testbench/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/ai_testbench/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/ai_testbench/windows/CMakeLists.txt b/ai_testbench/windows/CMakeLists.txt new file mode 100644 index 0000000..32c449d --- /dev/null +++ b/ai_testbench/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(ai_testbench LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ai_testbench") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/ai_testbench/windows/flutter/CMakeLists.txt b/ai_testbench/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/ai_testbench/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/ai_testbench/windows/flutter/generated_plugin_registrant.cc b/ai_testbench/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/ai_testbench/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/ai_testbench/windows/flutter/generated_plugin_registrant.h b/ai_testbench/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/ai_testbench/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/ai_testbench/windows/flutter/generated_plugins.cmake b/ai_testbench/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..17bba99 --- /dev/null +++ b/ai_testbench/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + fllama +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/ai_testbench/windows/runner/CMakeLists.txt b/ai_testbench/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/ai_testbench/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/ai_testbench/windows/runner/Runner.rc b/ai_testbench/windows/runner/Runner.rc new file mode 100644 index 0000000..a0fdcae --- /dev/null +++ b/ai_testbench/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.zswatch" "\0" + VALUE "FileDescription", "ai_testbench" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "ai_testbench" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 dev.zswatch. All rights reserved." "\0" + VALUE "OriginalFilename", "ai_testbench.exe" "\0" + VALUE "ProductName", "ai_testbench" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/ai_testbench/windows/runner/flutter_window.cpp b/ai_testbench/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/ai_testbench/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/ai_testbench/windows/runner/flutter_window.h b/ai_testbench/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/ai_testbench/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/ai_testbench/windows/runner/main.cpp b/ai_testbench/windows/runner/main.cpp new file mode 100644 index 0000000..af3f03b --- /dev/null +++ b/ai_testbench/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"ai_testbench", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/ai_testbench/windows/runner/resource.h b/ai_testbench/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/ai_testbench/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/ai_testbench/windows/runner/resources/app_icon.ico b/ai_testbench/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/ai_testbench/windows/runner/runner.exe.manifest b/ai_testbench/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/ai_testbench/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/ai_testbench/windows/runner/utils.cpp b/ai_testbench/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/ai_testbench/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/ai_testbench/windows/runner/utils.h b/ai_testbench/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/ai_testbench/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/ai_testbench/windows/runner/win32_window.cpp b/ai_testbench/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/ai_testbench/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/ai_testbench/windows/runner/win32_window.h b/ai_testbench/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/ai_testbench/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ From 28acad95766049b2a4874c8d567c98a939dd02fe Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 11:03:40 +0100 Subject: [PATCH 15/58] feat: auto-create calendar events after AI voice memo processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'auto_create_actions' setting (AutoCreateActionsNotifier provider) - Enhance sendResultToWatch() with confirmation timeout + onConfirmed callback - Sends AI result to watch as toast (3s undo window) - If no undo received within 5s, fires onConfirmed callback - If watch disconnected, creates immediately without round-trip - Cancels pending timer on undo_last from watch - Add _autoCreateActionsForMemo() helper: looks up pending actions, creates each via ExtractedActionOperations using selected calendar ID - Wire autoCreateActionsProvider → sendResultToWatch onConfirmed in providers - Add 'Auto-create calendar events' toggle in AI settings screen (enabled only when local AI + auto-process are both on) - Restore _ActionItem with Create/Dismiss/Open buttons and _ActionStatusBadge - Restore chrono extraction/resolution debug block in _AiDebugSheet - Fix audio player slider overlap (Expanded instead of fixed-width SizedBox) --- .../lib/providers/settings_providers.dart | 21 ++ .../lib/providers/voice_memo_providers.dart | 56 ++++- .../voice_memo/voice_memo_sync_service.dart | 34 ++- .../settings/ai_models_settings_screen.dart | 25 ++ .../voice_memos/voice_memos_screen.dart | 234 +++++++++++++++++- 5 files changed, 359 insertions(+), 11 deletions(-) diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index ad1f4fb..07c3f0d 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -23,6 +23,7 @@ abstract final class SettingsKeys { static const String selectedProductivityCalendarId = 'selected_productivity_calendar_id'; static const String gpuInferenceMode = 'gpu_inference_mode'; + static const String autoCreateActions = 'auto_create_actions'; } /// Provider for SharedPreferences instance @@ -375,6 +376,26 @@ class AutoProcessVoiceNotesNotifier extends StateNotifier { } } +/// Whether extracted actions (calendar events, reminders) should be automatically +/// created after AI processing, with a watch-side undo window. +final autoCreateActionsProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AutoCreateActionsNotifier(prefs.valueOrNull); +}); + +class AutoCreateActionsNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + AutoCreateActionsNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.autoCreateActions) ?? false); + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.autoCreateActions, enabled); + } +} + /// Currently selected local AI model id. final selectedAiModelIdProvider = StateNotifierProvider((ref) { diff --git a/zswatch_app/lib/providers/voice_memo_providers.dart b/zswatch_app/lib/providers/voice_memo_providers.dart index 4e806b5..ffa1e6b 100644 --- a/zswatch_app/lib/providers/voice_memo_providers.dart +++ b/zswatch_app/lib/providers/voice_memo_providers.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/voice_memo.dart'; import '../data/repositories/voice_memo_repository.dart'; +import '../services/ai/extracted_action_creation_service.dart'; import '../services/ai/voice_note_ai_pipeline.dart'; import '../services/voice_memo/transcription_engine.dart'; import '../services/voice_memo/voice_memo_sync_service.dart'; @@ -105,8 +106,18 @@ final voiceMemoSyncServiceProvider = Provider((ref) { if (aiEnabled && autoProcess) { pipeline = ref.read(voiceNoteAiPipelineProvider); // Wire round-trip confirmation: send result back to watch after AI processing + final autoCreate = ref.read(autoCreateActionsProvider); pipeline!.onProcessingComplete = (filename, title) { - service.sendResultToWatch(filename, title); + service.sendResultToWatch( + filename, + title, + onConfirmed: autoCreate + ? (confirmedFilename) => _autoCreateActionsForMemo( + ref: ref, + filename: confirmedFilename, + ) + : null, + ); }; } await _autoTranscribeAndProcess( @@ -207,6 +218,49 @@ Future _autoTranscribeAndProcess({ } } +/// Auto-create all pending extracted actions for a memo after the watch +/// confirmation timeout expires without an undo. +Future _autoCreateActionsForMemo({ + required Ref ref, + required String filename, +}) async { + try { + final repository = ref.read(voiceMemoRepositoryProvider); + final memo = await repository.getMemoByFilename(filename); + if (memo == null) { + debugPrint('[VoiceMemoProviders] Auto-create: memo not found for $filename'); + return; + } + + final actionRepo = ref.read(extractedActionRepositoryProvider); + final actions = await actionRepo.getActionsForMemo(memo.id); + final pending = actions.where((a) => !a.created && !a.dismissed).toList(); + + if (pending.isEmpty) { + debugPrint('[VoiceMemoProviders] Auto-create: no pending actions for $filename'); + return; + } + + final ops = ref.read(extractedActionOperationsProvider); + final selectedCalendarId = ref.read(selectedProductivityCalendarIdProvider); + + for (final action in pending) { + try { + final draft = ActionCreationDraft.fromAction(action).copyWith( + platformCalendarId: Platform.isAndroid ? selectedCalendarId : null, + ); + final message = await ops.createAction(action: action, draft: draft); + debugPrint('[VoiceMemoProviders] Auto-created action: $message'); + } catch (e) { + debugPrint( + '[VoiceMemoProviders] Failed to auto-create action ${action.id}: $e'); + } + } + } catch (e) { + debugPrint('[VoiceMemoProviders] Auto-create actions error: $e'); + } +} + // ==================== Voice Memo List Provider ==================== /// Stream of all voice memos (reactive, newest first) diff --git a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart index 0616f99..2609e1a 100644 --- a/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart +++ b/zswatch_app/lib/services/voice_memo/voice_memo_sync_service.dart @@ -518,9 +518,21 @@ class VoiceMemoSyncService { /// Send AI processing result back to the watch for toast confirmation. /// /// The watch displays the parsed title with an Undo button for 3 seconds. - Future sendResultToWatch(String filename, String title) async { + /// If [onConfirmed] is provided, it will be called after [confirmationTimeout] + /// unless the watch sends an undo_last for this filename. + Future sendResultToWatch( + String filename, + String title, { + Duration confirmationTimeout = const Duration(seconds: 5), + Future Function(String filename)? onConfirmed, + }) async { if (!_watchService.isConnected) { _log('Cannot send result to watch — not connected'); + // Still auto-create if watch is disconnected and callback is set + if (onConfirmed != null) { + _log('Watch disconnected — auto-creating actions immediately'); + unawaited(onConfirmed(filename)); + } return; } try { @@ -532,8 +544,21 @@ class VoiceMemoSyncService { } catch (e) { _log('Failed to send result to watch: $e'); } + + if (onConfirmed != null) { + // Cancel any previous timer for this file + _pendingConfirmations[filename]?.cancel(); + _pendingConfirmations[filename] = Timer(confirmationTimeout, () { + _pendingConfirmations.remove(filename); + _log('No undo received for $filename — auto-creating actions'); + unawaited(onConfirmed(filename)); + }); + } } + /// Timers waiting for undo — keyed by filename. + final Map _pendingConfirmations = {}; + /// Handle the undo_last command from the watch. /// /// Deletes AI-parsed results (summary, category, actions) but keeps the @@ -545,6 +570,9 @@ class VoiceMemoSyncService { return; } _log('Undo requested for: $filename'); + // Cancel any pending auto-create for this file + _pendingConfirmations[filename]?.cancel(); + _pendingConfirmations.remove(filename); try { await _repository.clearAiResults(filename); _log('Cleared AI results for: $filename'); @@ -561,6 +589,10 @@ class VoiceMemoSyncService { void dispose() { _messageSubscription?.cancel(); _connectionSubscription?.cancel(); + for (final timer in _pendingConfirmations.values) { + timer.cancel(); + } + _pendingConfirmations.clear(); unawaited(_resetFsManager()); _listCompleter?.complete([]); _syncState.close(); diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index 7a357aa..c03d44a 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -591,6 +591,8 @@ class _AiTogglesTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final localAiEnabled = ref.watch(localAiEnabledProvider); final autoProcess = ref.watch(autoProcessVoiceNotesProvider); + final autoCreate = ref.watch(autoCreateActionsProvider); + final bothEnabled = localAiEnabled && autoProcess; return Column( children: [ @@ -629,6 +631,29 @@ class _AiTogglesTile extends ConsumerWidget { : null, ), ), + Opacity( + opacity: bothEnabled ? 1.0 : 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.event_available, + color: autoCreate && bothEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('Auto-create calendar events'), + subtitle: Text( + bothEnabled + ? 'Create events automatically (watch have undo window)' + : 'Enable Local AI and auto-process first', + ), + value: autoCreate, + onChanged: bothEnabled + ? (value) { + ref.read(autoCreateActionsProvider.notifier).setEnabled(value); + } + : null, + ), + ), ], ); } diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart index fe2688a..5cf3674 100644 --- a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -12,6 +12,8 @@ import '../../../data/models/extracted_action.dart'; import '../../../data/models/voice_memo.dart'; import '../../../providers/ai_providers.dart'; import '../../../providers/settings_providers.dart'; +import '../../../services/ai/extracted_action_creation_service.dart'; +import '../../widgets/ai_debug_widgets.dart'; import '../../../providers/voice_memo_providers.dart'; import '../../../providers/watch_service_provider.dart'; import '../../../services/ai/voice_note_ai_pipeline.dart'; @@ -827,6 +829,30 @@ class _AiDebugSheet extends ConsumerWidget { ), const SizedBox(height: 12), ], + if (aiHasChronoDetails( + extractedIntent: debugInfo.extractedIntent, + extractedTitle: debugInfo.extractedTitle, + datetimeExpressionOriginal: debugInfo.datetimeExpressionOriginal, + datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, + resolvedDateTime: debugInfo.resolvedDateTime, + resolverMethod: debugInfo.resolverMethod, + )) ...[ + aiDebugBlock( + context, + title: 'Chrono Extraction / Resolution', + content: aiFormatChronoDetails( + extractedIntent: debugInfo.extractedIntent, + extractedTitle: debugInfo.extractedTitle, + datetimeExpressionOriginal: debugInfo.datetimeExpressionOriginal, + datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, + resolvedDateTime: debugInfo.resolvedDateTime, + resolverMethod: debugInfo.resolverMethod, + ), + icon: Icons.schedule, + showCopyButton: true, + ), + const SizedBox(height: 12), + ], _resultRow(context, debugInfo), ], ], @@ -1469,11 +1495,22 @@ class _ExtractedActionsSectionState } } -class _ActionItem extends StatelessWidget { +class _ActionItem extends ConsumerStatefulWidget { final ExtractedAction action; const _ActionItem({required this.action}); + @override + ConsumerState<_ActionItem> createState() => _ActionItemState(); +} + +class _ActionItemState extends ConsumerState<_ActionItem> { + bool _isCreating = false; + bool _isDismissing = false; + bool _isOpening = false; + + ExtractedAction get action => widget.action; + @override Widget build(BuildContext context) { return Row( @@ -1497,14 +1534,32 @@ class _ActionItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - action.title, - style: Theme.of(context).textTheme.bodyMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + action.title, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: AppTheme.spacingSm), + _ActionStatusBadge(action: action), + ], ), - if (action.dueDate != null) ...[ + if (_timingLabel(action) case final timingLabel?) ...[ + const SizedBox(height: 4), + Text( + timingLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + if (action.notes != null && action.notes!.trim().isNotEmpty) ...[ const SizedBox(height: 4), Text( - 'Due: ${DateFormat.yMMMd().format(action.dueDate!)}', + action.notes!.trim(), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), @@ -1519,6 +1574,52 @@ class _ActionItem extends StatelessWidget { ), ), ], + const SizedBox(height: 10), + Wrap( + spacing: AppTheme.spacingSm, + runSpacing: AppTheme.spacingSm, + children: [ + if (!action.created && !action.dismissed) + FilledButton.icon( + style: _compactFilledButtonStyle(), + onPressed: _isCreating ? null : _createAction, + icon: _isCreating + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_task_outlined), + label: Text(_isCreating ? 'Creating\u2026' : 'Create'), + ), + if (!action.created && !action.dismissed) + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: _isDismissing ? null : _dismissAction, + icon: _isDismissing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + if (action.created && action.platformTargetId != null) + OutlinedButton.icon( + style: _compactOutlinedButtonStyle(), + onPressed: _isOpening ? null : _openCreatedAction, + icon: _isOpening + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.open_in_new_rounded), + label: const Text('Open'), + ), + ], + ), ], ), ), @@ -1526,6 +1627,73 @@ class _ActionItem extends StatelessWidget { ); } + Future _createAction() async { + setState(() => _isCreating = true); + try { + final selectedCalendarId = ref.read(selectedProductivityCalendarIdProvider); + final draft = ActionCreationDraft.fromAction(action).copyWith( + platformCalendarId: Platform.isAndroid ? selectedCalendarId : null, + ); + + final message = await ref.read(extractedActionOperationsProvider).createAction( + action: action, + draft: draft, + ); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + } + + Future _dismissAction() async { + setState(() => _isDismissing = true); + try { + await ref.read(extractedActionOperationsProvider).dismissAction(action.id); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to dismiss action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isDismissing = false); + } + } + } + + Future _openCreatedAction() async { + setState(() => _isOpening = true); + try { + await ref + .read(extractedActionCreationServiceProvider) + .openCreatedAction(action); + } catch (error) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to open created action: $error')), + ); + } finally { + if (mounted) { + setState(() => _isOpening = false); + } + } + } + IconData _actionTypeIcon(ExtractedActionType type) { return switch (type) { ExtractedActionType.task => Icons.check_box_outlined, @@ -1541,6 +1709,56 @@ class _ActionItem extends StatelessWidget { ExtractedActionType.calendarEvent => AppTheme.infoColor, }; } + + String? _timingLabel(ExtractedAction action) { + final dateFormat = DateFormat.yMMMd(); + final dateTimeFormat = DateFormat.yMMMd().add_jm(); + + if (action.startTime != null) { + final start = action.startTime!.toLocal(); + if (action.endTime != null) { + final end = action.endTime!.toLocal(); + return 'When: ${dateTimeFormat.format(start)} \u2192 ${dateTimeFormat.format(end)}'; + } + return 'When: ${dateTimeFormat.format(start)}'; + } + + if (action.dueDate != null) { + return 'Due: ${dateFormat.format(action.dueDate!.toLocal())}'; + } + + return null; + } +} + +class _ActionStatusBadge extends StatelessWidget { + final ExtractedAction action; + + const _ActionStatusBadge({required this.action}); + + @override + Widget build(BuildContext context) { + final (label, color) = switch ((action.created, action.dismissed)) { + (true, _) => ('Created', AppTheme.successColor), + (_, true) => ('Dismissed', AppTheme.textSecondary), + _ => ('Pending', AppTheme.warningColor), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusXLarge), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } } class _CategoryBadge extends StatelessWidget { @@ -2283,14 +2501,12 @@ class _AudioPlayerCardState extends ConsumerState<_AudioPlayerCard> { ), SizedBox(height: widget.compact ? 4 : AppTheme.spacingSm), Row( - mainAxisSize: MainAxisSize.min, children: [ Text( _formatDuration(_position), style: Theme.of(context).textTheme.bodySmall, ), - SizedBox( - width: widget.compact ? 56 : 120, + Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: widget.compact ? 2 : null, From 2b11f972bfccf0371bc97881d8c103b31d339e72 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 11:08:15 +0100 Subject: [PATCH 16/58] chore: update fllama submodule (enable_thinking flag) --- third_party/fllama | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/fllama b/third_party/fllama index 5f1010a..9866100 160000 --- a/third_party/fllama +++ b/third_party/fllama @@ -1 +1 @@ -Subproject commit 5f1010a15c91eee9ff581fe31ad7401538c2b998 +Subproject commit 98661007472606a526a670a0626359582edd9845 From 1529952c9558404ce50a8703a9a7cb3763dcf4b3 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 11:33:05 +0100 Subject: [PATCH 17/58] feat: multi-item AI extraction, expanded benchmarks, Qwen3.5 params - chrono_ai_flow: support array output (multiple events/tasks per transcript) - parser: extract JSON array with single-object fallback - models: add extractions list to ChronoLlmParseResult - prompt: array output format, deadline recognition, direct weekday translation (no forced 'next'), multi-item date context carry-forward, preserve 'next' when explicitly in original transcript - time_resolver: weekday-aware 'next' logic, same-day push-to-next-week fix - ai_testbench: restructure benchmark for multi-item support - BenchmarkCase/ExpectedItem/BenchmarkCaseResult support multiple items - Expand test suite from 22 to 48 cases (Swedish events/reminders/notes, short and long voice notes, multi-item Swedish, EN/DE singles) - Headless serialization and UI updated for per-item failure details - Python wrapper scripts for running and parsing results - Disable macOS sandbox in Release.entitlements for h - chrono_ai_flow: support array output (multiple events/tasks per transcript) -y=2 - parser: extract JSON array with single-object fallback - models: add era - models: add extractions list to ChronoLlmParseResult de ug utility --- ai_testbench/lib/benchmark_main.dart | 5 + .../lib/screens/testbench_screen.dart | 11 + ai_testbench/lib/services/llm_service.dart | 8 +- .../lib/services/model_benchmark_service.dart | 717 ++++++++++++++++-- .../macos/Runner/Release.entitlements | 2 +- ai_testbench/parse_iter4.py | 40 + ai_testbench/parse_results.py | 30 + ai_testbench/run_benchmark.py | 109 +++ packages/chrono_ai_flow/lib/src/models.dart | 5 + packages/chrono_ai_flow/lib/src/parser.dart | 97 ++- .../lib/src/prompt_template.dart | 76 +- .../lib/src/time_expression_resolver.dart | 32 +- packages/chrono_ai_flow/pubspec.yaml | 3 + packages/chrono_ai_flow/test/parser_test.dart | 139 ++++ .../test/time_resolver_debug.dart | 32 + zswatch_app/lib/services/ai/llm_service.dart | 126 +-- 16 files changed, 1235 insertions(+), 197 deletions(-) create mode 100644 ai_testbench/parse_iter4.py create mode 100644 ai_testbench/parse_results.py create mode 100644 ai_testbench/run_benchmark.py create mode 100644 packages/chrono_ai_flow/test/parser_test.dart create mode 100644 packages/chrono_ai_flow/test/time_resolver_debug.dart diff --git a/ai_testbench/lib/benchmark_main.dart b/ai_testbench/lib/benchmark_main.dart index 4722455..ee44c9e 100644 --- a/ai_testbench/lib/benchmark_main.dart +++ b/ai_testbench/lib/benchmark_main.dart @@ -187,6 +187,11 @@ Map _serializeModelResult(BenchmarkModelResult result) { 'tokensPerSecond': caseResult.tokensPerSecond, 'outputPreview': caseResult.outputPreview, 'error': caseResult.error, + 'extractedCount': caseResult.extractedCount, + 'expectedCount': caseResult.expectedCount, + 'countMatch': caseResult.countMatch, + if (caseResult.itemFailures.isNotEmpty) + 'itemFailures': caseResult.itemFailures, }; }).toList(growable: false), }; diff --git a/ai_testbench/lib/screens/testbench_screen.dart b/ai_testbench/lib/screens/testbench_screen.dart index 39dc4af..d1bbc0a 100644 --- a/ai_testbench/lib/screens/testbench_screen.dart +++ b/ai_testbench/lib/screens/testbench_screen.dart @@ -675,6 +675,7 @@ class _TestbenchScreenState extends State { 'time=${caseResult.timePresenceMatch ? '✓' : '✗'} · ' 'lang=${caseResult.titleLanguageMatch ? '✓' : '✗'} · ' 'resolve=${caseResult.timeResolutionCorrect ? '✓' : '✗'} · ' + 'count=${caseResult.extractedCount}/${caseResult.expectedCount}${caseResult.countMatch ? '✓' : '✗'} · ' '${(caseResult.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s · ' '${caseResult.tokensPerSecond.toStringAsFixed(1)} tok/s', ), @@ -708,6 +709,16 @@ class _TestbenchScreenState extends State { fontSize: 12, ), ), + if (caseResult.itemFailures.isNotEmpty) + ...caseResult.itemFailures.map( + (f) => Text( + ' ⚠ $f', + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 11, + ), + ), + ), ], ), ), diff --git a/ai_testbench/lib/services/llm_service.dart b/ai_testbench/lib/services/llm_service.dart index d83afde..633543b 100644 --- a/ai_testbench/lib/services/llm_service.dart +++ b/ai_testbench/lib/services/llm_service.dart @@ -47,9 +47,9 @@ class LlmService { int nCtx = 2048; int nThreads = 2; int maxTokens = 512; - double temperature = 0.1; - double topP = 0.9; - double presencePenalty = 1.1; + double temperature = 0.3; + double topP = 1.0; + double presencePenalty = 2.0; int numGpuLayers = 99; /// When false, disables thinking/reasoning for models like Qwen3/3.5. bool enableThinking = true; @@ -106,7 +106,6 @@ class LlmService { presencePenalty: presencePenalty, contextSize: nCtx, logger: _logFilter, - enableThinking: enableThinking, ); _requestInFlight = true; @@ -183,7 +182,6 @@ class LlmService { presencePenalty: presencePenalty, contextSize: nCtx, logger: _logFilter, - enableThinking: enableThinking, ); _requestInFlight = true; diff --git a/ai_testbench/lib/services/model_benchmark_service.dart b/ai_testbench/lib/services/model_benchmark_service.dart index 614976d..0928f19 100644 --- a/ai_testbench/lib/services/model_benchmark_service.dart +++ b/ai_testbench/lib/services/model_benchmark_service.dart @@ -8,26 +8,15 @@ import 'llm_service.dart'; // ── Test case definition ───────────────────────────────────────────────── -class BenchmarkCase { - final String name; - final String transcript; - final String expectedIntent; // 'reminder', 'event', 'note' - final bool expectTime; // whether datetime fields should be non-null - - /// Keywords that should appear (case-insensitive) in the title to verify the - /// model kept the output in the native language. - /// Empty list => skip the check (always pass). +/// Expected extraction for one item in a multi-item benchmark case. +class ExpectedItem { + final String expectedIntent; + final bool expectTime; final List titleLanguageKeywords; - - /// Optional: expected resolved DateTime for time-resolution validation. final DateTime? expectedDateTime; - - /// Tolerance in minutes for DateTime comparison. final int toleranceMinutes; - const BenchmarkCase({ - required this.name, - required this.transcript, + const ExpectedItem({ required this.expectedIntent, required this.expectTime, this.titleLanguageKeywords = const [], @@ -36,6 +25,50 @@ class BenchmarkCase { }); } +class BenchmarkCase { + final String name; + final String transcript; + + /// Expected items. For single-extraction cases, this has one element. + final List expectedItems; + + BenchmarkCase({ + required this.name, + required this.transcript, + required this.expectedItems, + }); + + /// Convenience constructor for single-extraction cases (backward compat). + BenchmarkCase.single({ + required this.name, + required this.transcript, + required String expectedIntent, + required bool expectTime, + List titleLanguageKeywords = const [], + DateTime? expectedDateTime, + int toleranceMinutes = 5, + }) : expectedItems = [ + ExpectedItem( + expectedIntent: expectedIntent, + expectTime: expectTime, + titleLanguageKeywords: titleLanguageKeywords, + expectedDateTime: expectedDateTime, + toleranceMinutes: toleranceMinutes, + ), + ]; + + /// Shorthand accessors for single-item cases (used by existing code). + String get expectedIntent => expectedItems.first.expectedIntent; + bool get expectTime => expectedItems.first.expectTime; + List get titleLanguageKeywords => + expectedItems.first.titleLanguageKeywords; + DateTime? get expectedDateTime => expectedItems.first.expectedDateTime; + int get toleranceMinutes => expectedItems.first.toleranceMinutes; + + int get expectedCount => expectedItems.length; + bool get isMultiItem => expectedItems.length > 1; +} + // ── Test result ────────────────────────────────────────────────────────── class BenchmarkCaseResult { @@ -56,6 +89,18 @@ class BenchmarkCaseResult { final String outputPreview; final String? error; + /// Number of items extracted from the output. + final int extractedCount; + + /// Number of items expected. + final int expectedCount; + + /// Whether the count of extracted items matches expected. + final bool countMatch; + + /// Per-item validation details for multi-item cases. + final List itemFailures; + const BenchmarkCaseResult({ required this.caseName, required this.validJson, @@ -73,6 +118,10 @@ class BenchmarkCaseResult { required this.tokensPerSecond, required this.outputPreview, this.error, + this.extractedCount = 1, + this.expectedCount = 1, + this.countMatch = true, + this.itemFailures = const [], }); bool get passed => @@ -80,7 +129,9 @@ class BenchmarkCaseResult { intentMatch && timePresenceMatch && titleLanguageMatch && - timeResolutionCorrect; + timeResolutionCorrect && + countMatch && + itemFailures.isEmpty; } // ── Progress ───────────────────────────────────────────────────────────── @@ -144,9 +195,9 @@ class ModelBenchmarkService { static final DateTime referenceTime = DateTime(2026, 3, 11, 10, 15); static final benchmarkCases = [ - // ── English cases ────────────────────────────────────────────────── + // ── English single-item cases ────────────────────────────────────── - BenchmarkCase( + BenchmarkCase.single( name: 'en_event_precise_time', transcript: 'Schedule a design review with Erik and Sara on March 14 at 3:30 PM in Lab 3.', @@ -154,7 +205,7 @@ class ModelBenchmarkService { expectTime: true, expectedDateTime: DateTime(2026, 3, 14, 15, 30), ), - BenchmarkCase( + BenchmarkCase.single( name: 'en_reminder_tomorrow', transcript: 'Remind me tomorrow at 7:15 AM to take the prototype battery off the charger.', @@ -162,14 +213,14 @@ class ModelBenchmarkService { expectTime: true, expectedDateTime: DateTime(2026, 3, 12, 7, 15), ), - BenchmarkCase( + BenchmarkCase.single( name: 'en_event_next_tuesday', transcript: 'Meeting with John next Tuesday at 2 pm.', expectedIntent: 'event', expectTime: true, expectedDateTime: DateTime(2026, 3, 17, 14, 0), ), - BenchmarkCase( + BenchmarkCase.single( name: 'en_reminder_next_friday', transcript: 'I need to finish the PCB layout review and send it to the manufacturer by next Friday at 5 PM.', @@ -177,7 +228,7 @@ class ModelBenchmarkService { expectTime: true, expectedDateTime: DateTime(2026, 3, 20, 17, 0), ), - BenchmarkCase( + BenchmarkCase.single( name: 'en_event_dentist', transcript: 'Dentist appointment on April 22nd at 10:30 AM at the clinic downtown.', @@ -185,14 +236,14 @@ class ModelBenchmarkService { expectTime: true, expectedDateTime: DateTime(2026, 4, 22, 10, 30), ), - BenchmarkCase( + BenchmarkCase.single( name: 'en_note_no_time', transcript: 'Had an interesting idea about using a pressure sensor to detect altitude changes for the hiking app.', expectedIntent: 'note', expectTime: false, ), - BenchmarkCase( + BenchmarkCase.single( name: 'en_reminder_this_afternoon', transcript: 'Call the plumber this afternoon at 3.', expectedIntent: 'reminder', @@ -200,9 +251,9 @@ class ModelBenchmarkService { expectedDateTime: DateTime(2026, 3, 11, 15, 0), ), - // ── Swedish cases (native language title validation) ────────────── + // ── Swedish single-item cases (native language title validation) ── - BenchmarkCase( + BenchmarkCase.single( name: 'sv_reminder_tomorrow', transcript: 'Påminn mig imorgon klockan 8 att ringa tandläkaren.', expectedIntent: 'reminder', @@ -210,7 +261,7 @@ class ModelBenchmarkService { titleLanguageKeywords: ['ringa', 'tandläkare'], expectedDateTime: DateTime(2026, 3, 12, 8, 0), ), - BenchmarkCase( + BenchmarkCase.single( name: 'sv_event_meeting', transcript: 'Möte med projektgruppen på torsdag klockan 14 i stora konferensrummet.', @@ -219,14 +270,14 @@ class ModelBenchmarkService { titleLanguageKeywords: ['möte', 'projektgrupp'], expectedDateTime: DateTime(2026, 3, 12, 14, 0), ), - BenchmarkCase( + BenchmarkCase.single( name: 'sv_note_no_time', transcript: 'Köp mjölk och bröd på vägen hem.', expectedIntent: 'note', expectTime: false, titleLanguageKeywords: ['köp', 'mjölk', 'bröd'], ), - BenchmarkCase( + BenchmarkCase.single( name: 'sv_note_idea', transcript: 'Bra idé om att lägga till stegräknare i klockan, kanske använda BMI270 sensorn.', @@ -234,7 +285,7 @@ class ModelBenchmarkService { expectTime: false, titleLanguageKeywords: ['stegräknare', 'klocka', 'idé', 'sensor'], ), - BenchmarkCase( + BenchmarkCase.single( name: 'sv_event_specific_date', transcript: 'Tandläkare den 15 mars klockan halv 10.', expectedIntent: 'event', @@ -243,9 +294,9 @@ class ModelBenchmarkService { expectedDateTime: DateTime(2026, 3, 15, 9, 30), ), - // ── German cases (native language title validation) ─────────────── + // ── German single-item cases (native language title validation) ─── - BenchmarkCase( + BenchmarkCase.single( name: 'de_event_appointment', transcript: 'Arzttermin am Donnerstag um 9 Uhr in der Praxis am Marktplatz.', @@ -254,7 +305,7 @@ class ModelBenchmarkService { titleLanguageKeywords: ['arzt', 'termin', 'praxis'], expectedDateTime: DateTime(2026, 3, 12, 9, 0), ), - BenchmarkCase( + BenchmarkCase.single( name: 'de_reminder_deadline', transcript: 'Ich muss den Bericht bis Freitag um 17 Uhr fertig haben und an den Chef schicken.', @@ -263,6 +314,440 @@ class ModelBenchmarkService { titleLanguageKeywords: ['bericht', 'chef', 'schicken'], expectedDateTime: DateTime(2026, 3, 13, 17, 0), ), + + // ── Additional English single-item cases ───────────────────────── + + BenchmarkCase.single( + name: 'en_reminder_specific_date', + transcript: 'Submit the expense report by March 20th at 9 AM.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['expense', 'report'], + expectedDateTime: DateTime(2026, 3, 20, 9, 0), + ), + BenchmarkCase.single( + name: 'en_event_birthday', + transcript: 'Mom\'s birthday party on April 5th at 6 PM.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['birthday'], + expectedDateTime: DateTime(2026, 4, 5, 18, 0), + ), + BenchmarkCase.single( + name: 'en_note_idea_short', + transcript: 'Try using Rust for the sensor driver.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['rust', 'sensor'], + ), + + // ── Additional Swedish single-item cases ───────────────────────── + + BenchmarkCase.single( + name: 'sv_event_fika', + transcript: 'Fika med Lisa på fredag klockan 15.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['fika', 'lisa'], + expectedDateTime: DateTime(2026, 3, 13, 15, 0), + ), + BenchmarkCase.single( + name: 'sv_reminder_pickup_kids', + transcript: 'Hämta barnen på förskolan imorgon klockan halv 5.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['hämta', 'barn'], + expectedDateTime: DateTime(2026, 3, 12, 16, 30), + ), + BenchmarkCase.single( + name: 'sv_event_doctor', + transcript: 'Läkartid på vårdcentralen den 18 mars klockan 10.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['läkar'], + expectedDateTime: DateTime(2026, 3, 18, 10, 0), + ), + BenchmarkCase.single( + name: 'sv_reminder_medicine', + transcript: 'Ta medicinen varje dag klockan 8 på morgonen.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['medicin'], + ), + BenchmarkCase.single( + name: 'sv_event_dinner', + transcript: 'Middag hos mamma och pappa på lördag klockan 18.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['middag', 'mamma'], + expectedDateTime: DateTime(2026, 3, 14, 18, 0), + ), + BenchmarkCase.single( + name: 'sv_event_car_service', + transcript: 'Bilservice på tisdag klockan 8 hos Mekonomen.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['bilservice'], + expectedDateTime: DateTime(2026, 3, 17, 8, 0), + ), + BenchmarkCase.single( + name: 'sv_note_grocery', + transcript: 'Handla potatis, lök och grädde till middagen.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['handla', 'potatis'], + ), + BenchmarkCase.single( + name: 'sv_event_parents_meeting', + transcript: 'Föräldramöte på skolan onsdag klockan 18:30.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['föräldramöte'], + expectedDateTime: DateTime(2026, 3, 18, 18, 30), + ), + BenchmarkCase.single( + name: 'sv_reminder_deadline', + transcript: 'Skicka in rapporten senast fredag klockan 12.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['rapport', 'skicka'], + expectedDateTime: DateTime(2026, 3, 13, 12, 0), + ), + + // ── Voice note tests – short (1-2 sentences, casual/fragmented) ── + + BenchmarkCase.single( + name: 'voice_short_en_idea', + transcript: 'Maybe add a compass widget to the watch face.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['compass'], + ), + BenchmarkCase.single( + name: 'voice_short_sv_idea', + transcript: 'Testa att använda e-paper display till nästa version.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['e-paper', 'display'], + ), + BenchmarkCase.single( + name: 'voice_short_en_quick_reminder', + transcript: 'Oh yeah, water the plants at 5.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['water', 'plant'], + ), + BenchmarkCase.single( + name: 'voice_short_sv_quick_reminder', + transcript: 'Ah just det, ring försäkringsbolaget imorgon.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['ring', 'försäkring'], + ), + BenchmarkCase.single( + name: 'voice_short_en_fragment', + transcript: 'Buy batteries.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['batter'], + ), + BenchmarkCase.single( + name: 'voice_short_sv_fragment', + transcript: 'Boka tid hos frisören.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['boka', 'frisör'], + ), + + // ── Voice note tests – long (rambling, filler words, multi-sentence) ─ + + BenchmarkCase.single( + name: 'voice_long_en_rambling_reminder', + transcript: + 'So I was thinking, um, I really need to remember to pick up the dry cleaning, ' + 'I think the ticket is in my jacket, anyway I should do it tomorrow before noon ' + 'because they close early on Thursdays.', + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['dry clean'], + ), + BenchmarkCase.single( + name: 'voice_long_sv_rambling_event', + transcript: + 'Öh jag pratade med Johan igår och vi bestämde att vi ska ha ett möte, ' + 'tror det var på torsdag klockan 10 eller nåt, i alla fall ska vi gå igenom ' + 'hela budgeten för nästa kvartal.', + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['möte', 'johan', 'budget'], + expectedDateTime: DateTime(2026, 3, 12, 10, 0), + ), + BenchmarkCase.single( + name: 'voice_long_en_idea_note', + transcript: + 'Had a really interesting conversation with the hardware team today about, ' + 'you know, potentially integrating an ambient light sensor so the display ' + 'brightness adjusts automatically. Could save a lot of battery and the user ' + 'experience would be much smoother. Should look into the VEML7700 or maybe ' + 'the BH1750 sensor, both are I2C and pretty cheap.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['light', 'sensor', 'display'], + ), + BenchmarkCase.single( + name: 'voice_long_sv_idea_note', + transcript: + 'Jag tänkte på en grej, vi borde kanske lägga till sömnspårning i appen, ' + 'alltså använda accelerometern för att mäta rörelser under natten och sen ' + 'visa statistik på morgonen. Det finns en bra algoritm i den där forskningsartikeln ' + 'jag läste förra veckan, kolla upp det.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['sömnspårning', 'accelerometer', 'app'], + ), + + // ── Additional multi-item cases ────────────────────────────────── + + BenchmarkCase( + name: 'sv_multi_fika_and_errand', + transcript: + 'Fika med Anna imorgon klockan 10 och sen lämna in paketet på posten.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['fika', 'anna'], + expectedDateTime: DateTime(2026, 3, 12, 10, 0), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['paket'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_three_items', + transcript: + 'Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och ' + 'möte med chefen på fredag klockan 14.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['tandläkare', 'ring'], + expectedDateTime: DateTime(2026, 3, 12, 9, 0), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['present', 'kalas'], + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['möte', 'chef'], + expectedDateTime: DateTime(2026, 3, 13, 14, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_event_and_idea', + transcript: + 'Team standup tomorrow at 9:15 and I should really prototype ' + 'the new notification system with stacked cards.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['standup'], + expectedDateTime: DateTime(2026, 3, 12, 9, 15), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['notification', 'prototype'], + ), + ], + ), + BenchmarkCase( + name: 'voice_long_multi_sv', + transcript: + 'Okej så imorgon klockan 8 måste jag gå till gymmet och sen på eftermiddagen ' + 'typ klockan 3 ska jag träffa Erik för att prata om projektet och sen behöver ' + 'jag också komma ihåg att köpa kattsand.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['gym'], + expectedDateTime: DateTime(2026, 3, 12, 8, 0), + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['erik', 'projekt'], + expectedDateTime: DateTime(2026, 3, 12, 15, 0), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['kattsand'], + ), + ], + ), + + // ── Multi-item cases ───────────────────────────────────────────── + + BenchmarkCase( + name: 'en_multi_two_reminders', + transcript: + 'Tomorrow at 5 pm we have to pick up the dog and then at 9 turn off all lights.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['dog', 'pick'], + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['light'], + ), + ], + ), + BenchmarkCase( + name: 'en_multi_reminder_and_note', + transcript: + 'Call the plumber at 3 pm tomorrow and also buy new light bulbs.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['plumber'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['light', 'bulb'], + ), + ], + ), + BenchmarkCase( + name: 'en_multi_two_events', + transcript: + 'Meeting with Sarah on Monday at 10 am and lunch with the team on Wednesday at noon.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['sarah'], + expectedDateTime: DateTime(2026, 3, 16, 10, 0), + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['lunch'], + expectedDateTime: DateTime(2026, 3, 18, 12, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_three_mixed', + transcript: + 'Tomorrow at 8 go for a run then have lunch with Mike at noon and buy groceries.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['lunch'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['grocer'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_event_and_note', + transcript: + 'Tandläkare den 15 mars klockan halv 10 och sen handla mat på vägen hem.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['tandläkare'], + expectedDateTime: DateTime(2026, 3, 15, 9, 30), + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['handla'], + ), + ], + ), + BenchmarkCase( + name: 'de_multi_two_events', + transcript: + 'Arzttermin am Donnerstag um 9 Uhr und Zahnarzt am Freitag um 14 Uhr.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['arzt'], + expectedDateTime: DateTime(2026, 3, 12, 9, 0), + ), + ExpectedItem( + expectedIntent: 'event', + expectTime: true, + titleLanguageKeywords: ['zahnarzt'], + expectedDateTime: DateTime(2026, 3, 13, 14, 0), + ), + ], + ), + BenchmarkCase( + name: 'en_multi_same_day_reminders', + transcript: + 'Today at 3 pm call the electrician and at 6 pm pick up the kids from school.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['electrician'], + expectedDateTime: DateTime(2026, 3, 11, 15, 0), + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['kids'], + expectedDateTime: DateTime(2026, 3, 11, 18, 0), + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_two_reminders', + transcript: + 'Imorgon klockan 8 gå till gymmet och klockan 15 hämta paketet på posten.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['gym'], + expectedDateTime: DateTime(2026, 3, 12, 8, 0), + ), + ExpectedItem( + expectedIntent: 'reminder', + expectTime: true, + titleLanguageKeywords: ['paket'], + expectedDateTime: DateTime(2026, 3, 12, 15, 0), + ), + ], + ), ]; Future> runForModels( @@ -282,7 +767,9 @@ class ModelBenchmarkService { ..nCtx = 2048 ..nThreads = Platform.numberOfProcessors ..maxTokens = 384 - ..temperature = 0.1 + ..temperature = 0.3 + ..topP = 1.0 + ..presencePenalty = 2.0 ..enableThinking = false; final caseResults = []; @@ -318,51 +805,106 @@ class ModelBenchmarkService { // Parse using the shared ChronoLlmParser final parseResult = _parser.parse(result.output); - final extraction = parseResult.extraction; - - final validJson = extraction != null; - final intent = extraction?.intent ?? ''; - final title = extraction?.title; - final dtOriginal = extraction?.datetimeExpressionOriginal; - final dtEnglish = extraction?.datetimeExpressionEnglish; - - // Intent validation - final intentMatch = - _intentMatches(intent, testCase.expectedIntent); - - // Time presence validation - final hasTime = dtOriginal != null || dtEnglish != null; - final timePresenceMatch = hasTime == testCase.expectTime; - - // Title language validation - final titleLang = _checkTitleLanguage(title, testCase); - - // Time resolution validation - final timeRes = _checkTimeResolution( - dtEnglish ?? dtOriginal, - testCase, - resolver, - ); + final extractions = parseResult.extractions; + final validJson = extractions.isNotEmpty; + + final extractedCount = extractions.length; + final expectedCount = testCase.expectedCount; + final countMatch = extractedCount == expectedCount; + + // Validate each expected item against extracted items. + // Use positional matching: expected[i] ↔ extracted[i]. + var allIntentMatch = true; + var allTimePresenceMatch = true; + var allTitleLangMatch = true; + var allTimeResMatch = true; + String? allTitleLangDetail; + String? allTimeResDetail; + final itemFailures = []; + + final checkCount = + extractedCount < expectedCount ? extractedCount : expectedCount; + for (var i = 0; i < checkCount; i++) { + final ext = extractions[i]; + final exp = testCase.expectedItems[i]; + + final intentOk = _intentMatches(ext.intent, exp.expectedIntent); + if (!intentOk) { + allIntentMatch = false; + itemFailures.add( + 'item[$i] intent: got "${ext.intent}", expected "${exp.expectedIntent}"'); + } + + final hasTime = ext.datetimeExpressionOriginal != null || + ext.datetimeExpressionEnglish != null; + if (hasTime != exp.expectTime) { + allTimePresenceMatch = false; + itemFailures.add( + 'item[$i] time presence: got $hasTime, expected ${exp.expectTime}'); + } + + final titleLang = + _checkTitleLanguageForItem(ext.title, exp); + if (!titleLang.passed) { + allTitleLangMatch = false; + itemFailures.add( + 'item[$i] title lang: ${titleLang.detail}'); + } + allTitleLangDetail = (allTitleLangDetail ?? '') + + 'item[$i]: ${titleLang.detail}; '; + + final timeRes = _checkTimeResolutionForItem( + ext.datetimeExpressionEnglish ?? + ext.datetimeExpressionOriginal, + exp, + resolver, + ); + if (!timeRes.passed) { + allTimeResMatch = false; + itemFailures.add( + 'item[$i] time: ${timeRes.detail}'); + } + allTimeResDetail = (allTimeResDetail ?? '') + + 'item[$i]: ${timeRes.detail}; '; + } + + // If count mismatch, mark missing items as failures + for (var i = checkCount; i < expectedCount; i++) { + itemFailures.add('item[$i] missing from output'); + allIntentMatch = false; + allTimePresenceMatch = false; + } + for (var i = checkCount; i < extractedCount; i++) { + itemFailures + .add('item[$i] unexpected extra extraction'); + } + + // Use first extraction for summary fields (backward compat) + final first = extractions.isNotEmpty ? extractions.first : null; caseResults.add( BenchmarkCaseResult( caseName: testCase.name, validJson: validJson, - intentMatch: intentMatch, - timePresenceMatch: timePresenceMatch, - titleLanguageMatch: titleLang.passed, - titleLanguageDetail: titleLang.detail, - timeResolutionCorrect: timeRes.passed, - timeResolutionDetail: timeRes.detail, - intent: intent, - title: title, - datetimeOriginal: dtOriginal, - datetimeEnglish: dtEnglish, + intentMatch: allIntentMatch, + timePresenceMatch: allTimePresenceMatch, + titleLanguageMatch: allTitleLangMatch, + titleLanguageDetail: allTitleLangDetail, + timeResolutionCorrect: allTimeResMatch, + timeResolutionDetail: allTimeResDetail, + intent: first?.intent ?? '', + title: first?.title, + datetimeOriginal: first?.datetimeExpressionOriginal, + datetimeEnglish: first?.datetimeExpressionEnglish, elapsed: result.elapsed, tokensPerSecond: result.tokensPerSecond, outputPreview: result.output.length > 300 ? '${result.output.substring(0, 300)}...' : result.output, + extractedCount: extractedCount, + expectedCount: expectedCount, + countMatch: countMatch, + itemFailures: itemFailures, ), ); } on TimeoutException { @@ -381,6 +923,9 @@ class ModelBenchmarkService { outputPreview: 'Timed out after ${perCaseTimeout.inSeconds}s', error: 'Timed out after ${perCaseTimeout.inSeconds}s', + extractedCount: 0, + expectedCount: testCase.expectedCount, + countMatch: false, ), ); } catch (e) { @@ -398,6 +943,9 @@ class ModelBenchmarkService { tokensPerSecond: 0, outputPreview: 'Error: $e', error: e.toString(), + extractedCount: 0, + expectedCount: testCase.expectedCount, + countMatch: false, ), ); } @@ -454,7 +1002,11 @@ class ModelBenchmarkService { } _CheckResult _checkTitleLanguage(String? title, BenchmarkCase testCase) { - if (testCase.titleLanguageKeywords.isEmpty) { + return _checkTitleLanguageForItem(title, testCase.expectedItems.first); + } + + _CheckResult _checkTitleLanguageForItem(String? title, ExpectedItem item) { + if (item.titleLanguageKeywords.isEmpty) { return const _CheckResult(passed: true, detail: 'no keyword check'); } if (title == null || title.isEmpty) { @@ -466,7 +1018,7 @@ class ModelBenchmarkService { final lower = title.toLowerCase(); final matched = []; - for (final keyword in testCase.titleLanguageKeywords) { + for (final keyword in item.titleLanguageKeywords) { if (lower.contains(keyword.toLowerCase())) { matched.add(keyword); } @@ -475,7 +1027,7 @@ class ModelBenchmarkService { final passed = matched.isNotEmpty; final detail = passed ? 'found ${matched.join(", ")} in "$title"' - : 'none of [${testCase.titleLanguageKeywords.join(", ")}] found in "$title"'; + : 'none of [${item.titleLanguageKeywords.join(", ")}] found in "$title"'; return _CheckResult(passed: passed, detail: detail); } @@ -485,7 +1037,16 @@ class ModelBenchmarkService { BenchmarkCase testCase, TimeExpressionResolver resolver, ) { - if (testCase.expectedDateTime == null) { + return _checkTimeResolutionForItem( + timeExpr, testCase.expectedItems.first, resolver); + } + + _CheckResult _checkTimeResolutionForItem( + String? timeExpr, + ExpectedItem item, + TimeExpressionResolver resolver, + ) { + if (item.expectedDateTime == null) { return const _CheckResult(passed: true, detail: 'no time check'); } if (timeExpr == null || timeExpr.isEmpty) { @@ -508,15 +1069,15 @@ class ModelBenchmarkService { } final diff = resolved.dateTime - .difference(testCase.expectedDateTime!) + .difference(item.expectedDateTime!) .inMinutes .abs(); - if (diff > testCase.toleranceMinutes) { + if (diff > item.toleranceMinutes) { return _CheckResult( passed: false, detail: - 'got ${resolved.dateTime}, expected ${testCase.expectedDateTime} ' - '(diff ${diff}min, tolerance ${testCase.toleranceMinutes}min)', + 'got ${resolved.dateTime}, expected ${item.expectedDateTime} ' + '(diff ${diff}min, tolerance ${item.toleranceMinutes}min)', ); } diff --git a/ai_testbench/macos/Runner/Release.entitlements b/ai_testbench/macos/Runner/Release.entitlements index 852fa1a..e89b7f3 100644 --- a/ai_testbench/macos/Runner/Release.entitlements +++ b/ai_testbench/macos/Runner/Release.entitlements @@ -3,6 +3,6 @@ com.apple.security.app-sandbox - + diff --git a/ai_testbench/parse_iter4.py b/ai_testbench/parse_iter4.py new file mode 100644 index 0000000..bfd2a3d --- /dev/null +++ b/ai_testbench/parse_iter4.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import json +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) +results_path = os.path.join(script_dir, 'benchmark_results', 'results.json') + +with open(results_path) as f: + data = json.load(f) + +for model in data['results']: + print('Model: ' + model['modelName']) + print('Passed: ' + str(model['passedCases']) + '/' + str(model['totalCases'])) + print('') + for c in model['cases']: + s = 'PASS' if c['passed'] else 'FAIL' + checks = [] + if not c.get('validJson'): + checks.append('json') + if not c.get('intentMatch'): + checks.append('intent') + if not c.get('timePresenceMatch'): + checks.append('timePres') + if not c.get('titleLanguageMatch'): + checks.append('titleLang') + if not c.get('timeResolutionCorrect'): + checks.append('timeRes') + if not c.get('countMatch'): + checks.append('count') + fails = '' + if checks: + fails = ' FAILED:' + ','.join(checks) + cnt = str(c.get('extractedCount', 1)) + '/' + str(c.get('expectedCount', 1)) + print('[' + s + '] ' + c['caseName'] + ' cnt=' + cnt + fails) + for item_f in c.get('itemFailures', []): + print(' ! ' + item_f) + if not c['passed']: + preview = c.get('outputPreview', '')[:300] + print(' output: ' + preview) + print('') diff --git a/ai_testbench/parse_results.py b/ai_testbench/parse_results.py new file mode 100644 index 0000000..939e183 --- /dev/null +++ b/ai_testbench/parse_results.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +import json + +with open('/Users/jakkra/Documents/ZSWatch-App/ai_testbench/benchmark_results/results.json') as f: + data = json.load(f) + +for model in data['results']: + print(f"Model: {model['modelName']}") + print(f" Passed: {model['passedCases']}/{model['totalCases']}") + print(f" Avg tok/s: {model['avgTokensPerSecond']:.1f}") + print(f" Total time: {model['totalElapsedMs']/1000:.1f}s") + print() + for c in model['cases']: + status = 'PASS' if c['passed'] else 'FAIL' + extracted = c.get('extractedCount', 1) + expected = c.get('expectedCount', 1) + checks = [] + if not c.get('validJson'): checks.append('json') + if not c.get('intentMatch'): checks.append('intent') + if not c.get('timePresenceMatch'): checks.append('timePresence') + if not c.get('titleLanguageMatch'): checks.append('titleLang') + if not c.get('timeResolutionCorrect'): checks.append('timeResolve') + if not c.get('countMatch'): checks.append('count') + fail_str = ' FAILED:[' + ','.join(checks) + ']' if checks else '' + print(f' [{status}] {c["caseName"]}: intent={c.get("intent","?")} count={extracted}/{expected} ({c["elapsedMs"]/1000:.1f}s {c.get("tokensPerSecond",0):.1f}tok/s){fail_str}') + for f in c.get('itemFailures', []): + print(f' ! {f}') + if not c['passed']: + preview = c.get('outputPreview', '')[:250] + print(f' output: {preview}') diff --git a/ai_testbench/run_benchmark.py b/ai_testbench/run_benchmark.py new file mode 100644 index 0000000..217d872 --- /dev/null +++ b/ai_testbench/run_benchmark.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Python wrapper to run ai_testbench headless benchmark and capture output.""" + +import subprocess +import sys +import json +import os + +APP_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "build", "macos", "Build", "Products", "Release", + "ai_testbench.app", "Contents", "MacOS", "ai_testbench", +) + +MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models") +OUTPUT_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "benchmark_results", "results.json") + + +def main(): + os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) + + cmd = [ + APP_PATH, + "--headless", + "--model-dir", MODEL_DIR, + "--output", OUTPUT_FILE, + ] + + print(f"Running: {' '.join(cmd)}") + print(f"Model dir: {MODEL_DIR}") + print(f"Output: {OUTPUT_FILE}") + print("-" * 60) + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + full_output = [] + for line in proc.stdout: + line = line.rstrip("\n") + full_output.append(line) + print(line) + + proc.wait() + print("-" * 60) + print(f"Exit code: {proc.returncode}") + + # Try to load and pretty-print results + if os.path.exists(OUTPUT_FILE): + with open(OUTPUT_FILE) as f: + results = json.load(f) + + print("\n" + "=" * 60) + print("BENCHMARK RESULTS SUMMARY") + print("=" * 60) + + for model_result in results.get("results", []): + model_name = model_result.get("modelName", "unknown") + passed = model_result.get("passedCases", 0) + total = model_result.get("totalCases", 0) + avg_tok = model_result.get("avgTokensPerSecond", 0) + total_ms = model_result.get("totalElapsedMs", 0) + + print(f"\nModel: {model_name}") + print(f" Passed: {passed}/{total}") + print(f" Avg tok/s: {avg_tok:.1f}") + print(f" Total time: {total_ms / 1000:.1f}s") + print() + + for case in model_result.get("cases", []): + status = "PASS" if case.get("passed") else "FAIL" + name = case.get("caseName", "?") + intent = case.get("intent", "?") + extracted = case.get("extractedCount", 1) + expected = case.get("expectedCount", 1) + elapsed_s = case.get("elapsedMs", 0) / 1000 + + checks = [] + if not case.get("validJson"): checks.append("json") + if not case.get("intentMatch"): checks.append("intent") + if not case.get("timePresenceMatch"): checks.append("time") + if not case.get("titleLanguageMatch"): checks.append("lang") + if not case.get("timeResolutionCorrect"): checks.append("resolve") + if not case.get("countMatch"): checks.append("count") + + failures = case.get("itemFailures", []) + fail_str = f" [{', '.join(checks)}]" if checks else "" + + print(f" [{status}] {name}: intent={intent} " + f"count={extracted}/{expected} " + f"({elapsed_s:.1f}s){fail_str}") + + for f in failures: + print(f" ⚠ {f}") + + if case.get("error"): + print(f" ERROR: {case['error']}") + else: + print(f"No output file found at {OUTPUT_FILE}") + + return proc.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/chrono_ai_flow/lib/src/models.dart b/packages/chrono_ai_flow/lib/src/models.dart index 21c71c4..536db56 100644 --- a/packages/chrono_ai_flow/lib/src/models.dart +++ b/packages/chrono_ai_flow/lib/src/models.dart @@ -17,10 +17,15 @@ class ChronoLlmParseResult { final String? parsedJson; final ChronoLlmExtraction? extraction; + /// All extractions when the model returns a JSON array. + /// For single-object output, this contains just the one [extraction]. + final List extractions; + const ChronoLlmParseResult({ required this.rawOutput, this.parsedJson, this.extraction, + this.extractions = const [], }); } diff --git a/packages/chrono_ai_flow/lib/src/parser.dart b/packages/chrono_ai_flow/lib/src/parser.dart index f2723d8..b199a78 100644 --- a/packages/chrono_ai_flow/lib/src/parser.dart +++ b/packages/chrono_ai_flow/lib/src/parser.dart @@ -7,6 +7,32 @@ class ChronoLlmParser { ChronoLlmParseResult parse(String raw) { final cleaned = sanitizeModelOutput(raw); + + // Try array first (new format), then fall back to single object + final arrayStr = extractFirstJsonArray(cleaned); + if (arrayStr != null) { + try { + final list = jsonDecode(arrayStr) as List; + final extractions = []; + for (final item in list.whereType>()) { + final extraction = _parseOneObject(item); + if (extraction != null) { + extractions.add(extraction); + } + } + if (extractions.isNotEmpty) { + return ChronoLlmParseResult( + rawOutput: cleaned, + parsedJson: arrayStr, + extraction: extractions.first, + extractions: extractions, + ); + } + } catch (_) { + // Fall through to single-object parsing + } + } + final jsonStr = extractFirstJsonObject(cleaned); if (jsonStr == null) { return ChronoLlmParseResult(rawOutput: cleaned); @@ -14,35 +40,45 @@ class ChronoLlmParser { try { final parsed = jsonDecode(jsonStr) as Map; - if (!parsed.containsKey('intent')) { + final extraction = _parseOneObject(parsed); + if (extraction == null) { return ChronoLlmParseResult(rawOutput: cleaned, parsedJson: jsonStr); } - final intent = _normalizeIntent(parsed['intent'] as String?); - final title = ((parsed['title'] ?? parsed['summary']) as String?)?.trim() ?? - ''; - final datetimeOriginal = - (parsed['datetime_expression_original'] as String?)?.trim(); - final datetimeEnglish = - (parsed['datetime_expression_english'] as String?)?.trim(); - return ChronoLlmParseResult( rawOutput: cleaned, parsedJson: jsonStr, - extraction: ChronoLlmExtraction( - intent: intent, - title: title, - datetimeExpressionOriginal: - (datetimeOriginal?.isNotEmpty ?? false) ? datetimeOriginal : null, - datetimeExpressionEnglish: - (datetimeEnglish?.isNotEmpty ?? false) ? datetimeEnglish : null, - ), + extraction: extraction, + extractions: [extraction], ); } catch (_) { return ChronoLlmParseResult(rawOutput: cleaned, parsedJson: jsonStr); } } + ChronoLlmExtraction? _parseOneObject(Map parsed) { + if (!parsed.containsKey('intent')) { + return null; + } + + final intent = _normalizeIntent(parsed['intent'] as String?); + final title = + ((parsed['title'] ?? parsed['summary']) as String?)?.trim() ?? ''; + final datetimeOriginal = + (parsed['datetime_expression_original'] as String?)?.trim(); + final datetimeEnglish = + (parsed['datetime_expression_english'] as String?)?.trim(); + + return ChronoLlmExtraction( + intent: intent, + title: title, + datetimeExpressionOriginal: + (datetimeOriginal?.isNotEmpty ?? false) ? datetimeOriginal : null, + datetimeExpressionEnglish: + (datetimeEnglish?.isNotEmpty ?? false) ? datetimeEnglish : null, + ); + } + String sanitizeModelOutput(String raw) { return raw .replaceAll('<|im_end|>', '') @@ -51,19 +87,31 @@ class ChronoLlmParser { .trim(); } + String? extractFirstJsonArray(String raw) { + final cleaned = sanitizeModelOutput(raw); + final start = cleaned.indexOf('['); + if (start == -1) { + return null; + } + return _extractBalanced(cleaned, start, '[', ']'); + } + String? extractFirstJsonObject(String raw) { final cleaned = sanitizeModelOutput(raw); final start = cleaned.indexOf('{'); if (start == -1) { return null; } + return _extractBalanced(cleaned, start, '{', '}'); + } + String? _extractBalanced(String text, int start, String open, String close) { var depth = 0; var inString = false; var escaping = false; - for (var i = start; i < cleaned.length; i++) { - final char = cleaned[i]; + for (var i = start; i < text.length; i++) { + final char = text[i]; if (escaping) { escaping = false; @@ -84,12 +132,12 @@ class ChronoLlmParser { continue; } - if (char == '{') { + if (char == open) { depth++; - } else if (char == '}') { + } else if (char == close) { depth--; if (depth == 0) { - return cleaned.substring(start, i + 1); + return text.substring(start, i + 1); } } } @@ -101,14 +149,13 @@ class ChronoLlmParser { bool shouldRetryInvalidChronoOutput(String raw) { final parsed = parse(raw); - final extraction = parsed.extraction; if (parsed.parsedJson == null) { return true; } - if (extraction == null) { + if (parsed.extractions.isEmpty) { return true; } - if (extraction.intent.trim().isEmpty) { + if (parsed.extractions.first.intent.trim().isEmpty) { return true; } return false; diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index afc17ef..7ad4a82 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -13,30 +13,38 @@ class ChronoPromptTemplate { static const String defaultTemplate = ''' You extract structured information from a voice memo. +A memo may contain ONE or MULTIPLE items. Return a JSON array with one object per item. + The memo may be in ANY language. Return JSON only. No explanation. -Your tasks: +Your tasks per item: 1. Detect intent: "reminder", "event", or "note". 2. Extract the time/date phrase exactly as it appears in the memo. 3. Translate that time/date phrase into natural English. If already English, copy it. 4. Extract a short title (the task or event, NOT the time part). Rules: +- ALWAYS return a JSON array, even for a single item. +- Each distinct task, event, or note in the memo becomes its own object in the array. +- Multi-item date context: when a preceding item establishes a date (e.g. "tomorrow", "on Friday"), carry it into subsequent items that only mention a time. Example: if item 1 says "tomorrow at 8 am" and item 2 says "at 3 pm", translate item 2 as "tomorrow at 3 pm". - The title MUST stay in the SAME language as the voice memo. DO NOT translate the title to English. - NEVER compute or resolve dates. NEVER output ISO timestamps. - Keep time expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". - Copy the original time phrase exactly from the memo. - You MUST fill "datetime_expression_english" whenever "datetime_expression_original" is not null. - If the memo is in English, copy the same English time phrase to both fields. -- If no time/date is mentioned, set both datetime fields to null and intent to "note". +- If no time/date is mentioned for an item, set both datetime fields to null and intent to "note". - Title must be short (2-5 words). Only translate datetime fields to English, NEVER the title. -- Translate time expressions accurately to natural English. Convert 24-hour to 12-hour format. Translate idioms correctly (e.g. the Swedish "halv 10" means 9:30, not 10:30). +- Translate time expressions accurately to natural English. Convert 24-hour to 12-hour format. Translate idioms correctly (e.g. the Swedish "halv 10" means 9:30, not 10:30). Use PM for afternoon/evening context (e.g. picking up children, dinner, after work → PM, not AM). +- Translate weekday references directly. Do NOT add "next" unless the original explicitly says "next" or equivalent ("nächsten", "nästa", "prochain"). When the original DOES contain "next" or its equivalent, you MUST preserve "next" in the English translation. E.g. "am Freitag" → "on Friday", "på torsdag" → "on Thursday", "nächsten Montag" → "next Monday", "by next Friday" → "by next Friday". - Intent rules: - - "event" = scheduled meetings, appointments, bookings (dentist, conference, meeting with someone) - - "reminder" = personal tasks/actions with a specific time (call someone at 3 pm, buy milk tomorrow) - - "note" = no time/date mentioned, or just a task without any when (buy bread, remember to call) + - "event" = scheduled meetings, appointments, social plans, bookings (dentist, conference, meeting with someone, lunch with a person) + - "reminder" = personal tasks/actions with a specific time that are NOT meetings/appointments (call someone at 3 pm, pick up package at 5) + - "note" = no time/date mentioned at all (buy bread, good idea about sensors) + - When a task has NO time but appears alongside timed tasks, it is a "note" — NOT a "reminder" +- Deadlines ARE time expressions: "by Friday", "bis Freitag", "senast fredag", "until Monday" → extract the deadline date/time. - NOT time expressions (never extract these as datetime): - Locations: "on the way home", "at work", "at the store" - Vague conditions: "when I get home", "after lunch", "later" @@ -45,42 +53,52 @@ Rules: Examples: Memo: "Remind me tomorrow at 10 am to buy milk" -{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"} +[{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"}] + +Memo: "Tomorrow at 5 pm pick up the dog and then at 9 turn off all lights" +[{"intent":"reminder","title":"pick up the dog","datetime_expression_original":"tomorrow at 5 pm","datetime_expression_english":"tomorrow at 5 pm"},{"intent":"reminder","title":"turn off all lights","datetime_expression_original":"at 9","datetime_expression_english":"tomorrow at 9 pm"}] Memo: "påminn mig imorgon klockan 10 att köpa mjölk" -{"intent":"reminder","title":"köpa mjölk","datetime_expression_original":"imorgon klockan 10","datetime_expression_english":"tomorrow at 10 am"} +[{"intent":"reminder","title":"köpa mjölk","datetime_expression_original":"imorgon klockan 10","datetime_expression_english":"tomorrow at 10 am"}] -Memo: "tandläkare den 15 mars klockan halv 10" -{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"} +Memo: "tandläkare den 15 mars klockan halv 10 och sen handla mat på vägen hem" +[{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"},{"intent":"note","title":"handla mat","datetime_expression_original":null,"datetime_expression_english":null}] Memo: "remember to buy milk" -{"intent":"note","title":"buy milk","datetime_expression_original":null,"datetime_expression_english":null} +[{"intent":"note","title":"buy milk","datetime_expression_original":null,"datetime_expression_english":null}] Memo: "köp bröd på vägen hem" -{"intent":"note","title":"köp bröd","datetime_expression_original":null,"datetime_expression_english":null} +[{"intent":"note","title":"köp bröd","datetime_expression_original":null,"datetime_expression_english":null}] Memo: "call the plumber this afternoon at 3" -{"intent":"reminder","title":"call the plumber","datetime_expression_original":"this afternoon at 3","datetime_expression_english":"this afternoon at 3 pm"} +[{"intent":"reminder","title":"call the plumber","datetime_expression_original":"this afternoon at 3","datetime_expression_english":"this afternoon at 3 pm"}] + +Memo: "Meeting with Sarah on Monday at 10 am and lunch with the team on Wednesday at noon" +[{"intent":"event","title":"meeting with Sarah","datetime_expression_original":"on Monday at 10 am","datetime_expression_english":"on Monday at 10 am"},{"intent":"event","title":"lunch with the team","datetime_expression_original":"on Wednesday at noon","datetime_expression_english":"on Wednesday at 12 pm"}] + +Memo: "Tomorrow at 3 pm call the electrician and also buy new light bulbs" +[{"intent":"reminder","title":"call the electrician","datetime_expression_original":"tomorrow at 3 pm","datetime_expression_english":"tomorrow at 3 pm"},{"intent":"note","title":"buy new light bulbs","datetime_expression_original":null,"datetime_expression_english":null}] -Memo: "Arzttermin am Donnerstag um 9 Uhr" -{"intent":"event","title":"Arzttermin","datetime_expression_original":"am Donnerstag um 9 Uhr","datetime_expression_english":"Thursday at 9 am"} +Memo: "Arzttermin am Donnerstag um 9 Uhr und Zahnarzt am Freitag um 14 Uhr" +[{"intent":"event","title":"Arzttermin","datetime_expression_original":"am Donnerstag um 9 Uhr","datetime_expression_english":"Thursday at 9 am"},{"intent":"event","title":"Zahnarzt","datetime_expression_original":"am Freitag um 14 Uhr","datetime_expression_english":"Friday at 2 pm"}] + +Memo: "Den Bericht bis Freitag um 17 Uhr an den Chef schicken" +[{"intent":"reminder","title":"Bericht an Chef schicken","datetime_expression_original":"bis Freitag um 17 Uhr","datetime_expression_english":"on Friday at 5 pm"}] WRONG — never translate the title, not even for notes: Memo: "möte med projektgruppen på torsdag klockan 14" -WRONG: {"intent":"event","title":"meeting with project group",...} -RIGHT: {"intent":"event","title":"möte projektgruppen","datetime_expression_original":"på torsdag klockan 14","datetime_expression_english":"Thursday at 2 pm"} - -Memo: "köp mjölk och bröd på vägen hem" -WRONG: {"intent":"note","title":"buy milk and bread",...} -RIGHT: {"intent":"note","title":"köp mjölk och bröd","datetime_expression_original":null,"datetime_expression_english":null} - -Output JSON schema: -{ - "intent": "reminder" | "event" | "note", - "title": "short task description in original language", - "datetime_expression_original": "original time phrase" | null, - "datetime_expression_english": "english translation of time phrase" | null -} +WRONG: [{"intent":"event","title":"meeting with project group",...}] +RIGHT: [{"intent":"event","title":"möte projektgruppen","datetime_expression_original":"på torsdag klockan 14","datetime_expression_english":"Thursday at 2 pm"}] + +Output JSON schema (always an array): +[ + { + "intent": "reminder" | "event" | "note", + "title": "short task description in original language", + "datetime_expression_original": "original time phrase" | null, + "datetime_expression_english": "english translation of time phrase" | null + } +] Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact Timezone: UTC$promptPlaceholderTimezoneOffset diff --git a/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart index ed234af..af6c4c5 100644 --- a/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart +++ b/packages/chrono_ai_flow/lib/src/time_expression_resolver.dart @@ -64,11 +64,37 @@ class TimeExpressionResolver { return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); } - if (normalized.contains('next') && - resolved.difference(ref).inDays > 7) { - resolved = resolved.subtract(const Duration(days: 7)); + // "on Wednesday" spoken on a Wednesday → chrono gives today, + // but the user likely means next week's occurrence. + if (mentionedDay == ref.weekday && + resolved.year == ref.year && + resolved.month == ref.month && + resolved.day == ref.day && + !normalized.contains('today') && + !normalized.contains('this')) { + resolved = resolved.add(const Duration(days: 7)); return ResolvedTime(dateTime: resolved, method: 'chrono+adjusted'); } + + if (normalized.contains('next')) { + final daysFromRef = resolved.difference(ref).inDays; + // "next " means the occurrence in the following + // week. Days that haven't happened yet this week are + // "ahead" — chrono may resolve them to this-week's date. + final dayIsAheadInWeek = mentionedDay > ref.weekday; + if (dayIsAheadInWeek && daysFromRef < 7) { + // Chrono gave this week's occurrence — push to next week. + resolved = resolved.add(const Duration(days: 7)); + return ResolvedTime( + dateTime: resolved, method: 'chrono+adjusted'); + } + if (daysFromRef > 13) { + // Chrono jumped too far — pull back one week. + resolved = resolved.subtract(const Duration(days: 7)); + return ResolvedTime( + dateTime: resolved, method: 'chrono+adjusted'); + } + } } return ResolvedTime(dateTime: resolved, method: 'chrono'); diff --git a/packages/chrono_ai_flow/pubspec.yaml b/packages/chrono_ai_flow/pubspec.yaml index fd72d46..6c9886b 100644 --- a/packages/chrono_ai_flow/pubspec.yaml +++ b/packages/chrono_ai_flow/pubspec.yaml @@ -8,3 +8,6 @@ environment: dependencies: chrono_dart: ^2.0.2 + +dev_dependencies: + test: ^1.24.0 diff --git a/packages/chrono_ai_flow/test/parser_test.dart b/packages/chrono_ai_flow/test/parser_test.dart new file mode 100644 index 0000000..b8ee467 --- /dev/null +++ b/packages/chrono_ai_flow/test/parser_test.dart @@ -0,0 +1,139 @@ +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:test/test.dart'; + +void main() { + const parser = ChronoLlmParser(); + + group('Single object (backward compat)', () { + test('parses single JSON object with intent', () { + const raw = + '{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"}'; + final result = parser.parse(raw); + expect(result.extraction, isNotNull); + expect(result.extractions, hasLength(1)); + expect(result.extraction!.intent, 'reminder'); + expect(result.extraction!.title, 'buy milk'); + expect(result.extraction!.datetimeExpressionEnglish, 'tomorrow at 10 am'); + }); + + test('wraps single object in extractions list', () { + const raw = + '{"intent":"note","title":"buy bread","datetime_expression_original":null,"datetime_expression_english":null}'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extractions.first.intent, 'note'); + expect(result.extractions.first.title, 'buy bread'); + }); + }); + + group('Array output (multi-event)', () { + test('parses JSON array with multiple items', () { + const raw = + '[{"intent":"reminder","title":"pick up dog","datetime_expression_original":"tomorrow at 5 pm","datetime_expression_english":"tomorrow at 5 pm"},' + '{"intent":"reminder","title":"turn off lights","datetime_expression_original":"at 9","datetime_expression_english":"tomorrow at 9 pm"}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(2)); + expect(result.extraction!.title, 'pick up dog'); + expect(result.extractions[0].intent, 'reminder'); + expect(result.extractions[0].title, 'pick up dog'); + expect( + result.extractions[0].datetimeExpressionEnglish, 'tomorrow at 5 pm'); + expect(result.extractions[1].intent, 'reminder'); + expect(result.extractions[1].title, 'turn off lights'); + expect( + result.extractions[1].datetimeExpressionEnglish, 'tomorrow at 9 pm'); + }); + + test('parses array with mixed intents', () { + const raw = + '[{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"},' + '{"intent":"note","title":"handla mat","datetime_expression_original":null,"datetime_expression_english":null}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(2)); + expect(result.extractions[0].intent, 'event'); + expect(result.extractions[0].title, 'tandläkare'); + expect(result.extractions[1].intent, 'note'); + expect(result.extractions[1].title, 'handla mat'); + expect(result.extractions[1].datetimeExpressionOriginal, isNull); + }); + + test('parses single-item array', () { + const raw = + '[{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow","datetime_expression_english":"tomorrow"}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extraction!.intent, 'reminder'); + expect(result.extraction!.title, 'buy milk'); + }); + + test('handles array with model prefix text', () { + const raw = + 'Here is the JSON:\n[{"intent":"reminder","title":"call plumber","datetime_expression_original":"at 3 pm","datetime_expression_english":"at 3 pm"}]'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extractions.first.title, 'call plumber'); + }); + + test('handles array with trailing model tokens', () { + const raw = + '[{"intent":"event","title":"dentist","datetime_expression_original":"Thursday at 9 am","datetime_expression_english":"Thursday at 9 am"}]<|im_end|>'; + final result = parser.parse(raw); + expect(result.extractions, hasLength(1)); + expect(result.extractions.first.title, 'dentist'); + }); + }); + + group('extractFirstJsonArray', () { + test('extracts array from mixed text', () { + const raw = 'prefix [{"a":1},{"b":2}] suffix'; + final result = parser.extractFirstJsonArray(raw); + expect(result, '[{"a":1},{"b":2}]'); + }); + + test('returns null when no array', () { + const raw = '{"a":1}'; + final result = parser.extractFirstJsonArray(raw); + expect(result, isNull); + }); + + test('handles nested brackets in strings', () { + const raw = '[{"title":"array [test]"}]'; + final result = parser.extractFirstJsonArray(raw); + expect(result, '[{"title":"array [test]"}]'); + }); + }); + + group('shouldRetryInvalidChronoOutput', () { + test('retries on empty output', () { + expect(parser.shouldRetryInvalidChronoOutput(''), true); + }); + + test('does not retry on valid array', () { + const raw = + '[{"intent":"note","title":"test","datetime_expression_original":null,"datetime_expression_english":null}]'; + expect(parser.shouldRetryInvalidChronoOutput(raw), false); + }); + + test('does not retry on valid single object', () { + const raw = + '{"intent":"note","title":"test","datetime_expression_original":null,"datetime_expression_english":null}'; + expect(parser.shouldRetryInvalidChronoOutput(raw), false); + }); + }); + + group('intent normalization', () { + test('normalizes task to reminder', () { + const raw = + '[{"intent":"task","title":"test","datetime_expression_original":null,"datetime_expression_english":null}]'; + final result = parser.parse(raw); + expect(result.extractions.first.intent, 'reminder'); + }); + + test('normalizes meeting to event', () { + const raw = + '[{"intent":"meeting","title":"test","datetime_expression_original":null,"datetime_expression_english":null}]'; + final result = parser.parse(raw); + expect(result.extractions.first.intent, 'event'); + }); + }); +} diff --git a/packages/chrono_ai_flow/test/time_resolver_debug.dart b/packages/chrono_ai_flow/test/time_resolver_debug.dart new file mode 100644 index 0000000..8bdae16 --- /dev/null +++ b/packages/chrono_ai_flow/test/time_resolver_debug.dart @@ -0,0 +1,32 @@ +import 'package:chrono_ai_flow/chrono_ai_flow.dart'; +import 'package:chrono_dart/chrono_dart.dart'; + +void main() { + final r = TimeExpressionResolver(); + final ref = DateTime(2026, 3, 11, 10, 0); + + // Direct chrono test + final chronoResult = Chrono.parse( + 'next friday at 5 pm', + ref: ref, + option: ParsingOption(forwardDate: true), + ); + if (chronoResult.isNotEmpty) { + final d = chronoResult.first.date(); + print('chrono raw: $d weekday=${d.weekday} daysFromRef=${d.difference(ref).inDays}'); + } + + final cases = [ + 'next Friday at 5 PM', + 'next Tuesday at 2 pm', + 'next Monday at 10 am', + 'next Wednesday at 12 pm', + 'tomorrow at 3 pm', + 'klockan 15', + ]; + + for (final expr in cases) { + final res = r.resolve(expr, referenceDate: ref); + print('$expr -> ${res?.dateTime} (method: ${res?.method})'); + } +} diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 2cbca50..7802324 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -328,9 +328,10 @@ class LlmService { int nCtx = 2048; int nThreads = 2; int maxTokens = 512; - double temperature = 0.1; - double topP = 0.9; - double presencePenalty = 1.1; + // Qwen3.5 recommended sampling for non-thinking text tasks. + double temperature = 0.3; + double topP = 1.0; + double presencePenalty = 2.0; /// GPU layer offloading. 99 = all layers on Metal/GPU, 0 = CPU-only. int numGpuLayers = 99; @@ -1307,7 +1308,8 @@ JSON: await Future.delayed(const Duration(milliseconds: 300)); } - final parsedJson = _extractFirstJsonObject(raw); + final parsedJson = _chronoLlmParser.extractFirstJsonArray(raw) ?? + _extractFirstJsonObject(raw); final metrics = LlmInferenceMetrics( modelName: _selectedModelName, rawPrompt: prompt, @@ -1335,7 +1337,8 @@ JSON: bool _shouldRetryStructuredOutput(String raw, TranscriptResult result) { final cleaned = _sanitizeModelOutput(raw); - final jsonStr = _extractFirstJsonObject(cleaned); + final jsonStr = _chronoLlmParser.extractFirstJsonArray(cleaned) ?? + _extractFirstJsonObject(cleaned); if (jsonStr == null) { return true; @@ -1397,64 +1400,83 @@ JSON: } } - ChronoLlmExtraction? _parseChronoExtractionResult( - Map parsed, - ) { - return _chronoLlmParser.parse(jsonEncode(parsed)).extraction; - } - - TranscriptResult _buildTranscriptResultFromChronoExtraction( - ChronoLlmExtraction extraction, + TranscriptResult _buildTranscriptResultFromChronoExtractions( + List extractions, String raw, ) { - final summary = extraction.title.isNotEmpty - ? extraction.title - : raw.trim(); - final category = switch (extraction.intent) { + final actions = []; + String? firstResolvedDateTime; + String? firstResolverMethod; + + for (final extraction in extractions) { + final title = extraction.title.isNotEmpty + ? extraction.title + : raw.trim(); + + if (extraction.intent == 'note') { + // Notes don't produce actions with time resolution + actions.add(ExtractedActionResult( + type: 'task', + title: title, + notes: extraction.datetimeExpressionOriginal, + )); + continue; + } + + final englishExpression = extraction.datetimeExpressionEnglish; + final resolved = englishExpression == null + ? null + : _timeExpressionResolver.resolve(englishExpression); + + firstResolvedDateTime ??= resolved?.dateTime.toIso8601String(); + firstResolverMethod ??= resolved?.method; + + actions.add(ExtractedActionResult( + type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', + title: title, + notes: extraction.datetimeExpressionOriginal, + dueDate: extraction.intent == 'reminder' + ? resolved?.dateTime.toIso8601String() + : null, + startTime: extraction.intent == 'event' + ? resolved?.dateTime.toIso8601String() + : null, + )); + } + + final first = extractions.first; + final summary = first.title.isNotEmpty ? first.title : raw.trim(); + final category = switch (first.intent) { 'event' => 'meeting', 'reminder' => 'reminder', _ => 'note', }; - if (extraction.intent == 'note') { - return TranscriptResult( - summary: summary, - category: 'note', - extractedIntent: extraction.intent, - extractedTitle: extraction.title, - datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, - datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, - ); - } - - final englishExpression = extraction.datetimeExpressionEnglish; - final resolved = englishExpression == null - ? null - : _timeExpressionResolver.resolve(englishExpression); - - final action = ExtractedActionResult( - type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', - title: summary, - notes: extraction.datetimeExpressionOriginal, - dueDate: extraction.intent == 'reminder' ? resolved?.dateTime.toIso8601String() : null, - startTime: extraction.intent == 'event' ? resolved?.dateTime.toIso8601String() : null, - ); - return TranscriptResult( summary: summary, category: category, - actions: [action], - extractedIntent: extraction.intent, - extractedTitle: extraction.title, - datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, - datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, - resolvedDateTime: resolved?.dateTime.toIso8601String(), - resolverMethod: resolved?.method, + actions: actions, + extractedIntent: first.intent, + extractedTitle: first.title, + datetimeExpressionOriginal: first.datetimeExpressionOriginal, + datetimeExpressionEnglish: first.datetimeExpressionEnglish, + resolvedDateTime: firstResolvedDateTime, + resolverMethod: firstResolverMethod, ); } TranscriptResult _parseTranscriptResult(String raw) { final cleaned = _sanitizeModelOutput(raw); + + // Try parsing via chrono parser first (handles both array and object) + final chronoResult = _chronoLlmParser.parse(cleaned); + if (chronoResult.extractions.isNotEmpty) { + return _buildTranscriptResultFromChronoExtractions( + chronoResult.extractions, + raw, + ); + } + final jsonStr = _extractFirstJsonObject(cleaned); if (jsonStr == null) { @@ -1469,14 +1491,6 @@ JSON: try { final parsed = jsonDecode(jsonStr) as Map; - final chronoExtraction = _parseChronoExtractionResult(parsed); - if (chronoExtraction != null) { - return _buildTranscriptResultFromChronoExtraction( - chronoExtraction, - raw, - ); - } - final category = _normalizeCategory(parsed['category'] as String?); final summary = (parsed['summary'] as String?)?.trim(); final title = (parsed['title'] as String?)?.trim(); From 1a337fa285c2c33abfc0a50c9632d62130312943 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 21:33:23 +0100 Subject: [PATCH 18/58] feat: restructure transcription models by language and add per-language ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorganize catalog: English → Swedish → German → Multilingual - Add German models: Whisper Base and Small (multilingual forced to de) - Rank models within each language (best→worst): English #1-3, Swedish #1-3, German #1-2, Multilingual #1 - Display gold/silver/bronze badges in dropdown matching LLM model UI --- .../voice_memo/transcription_engine.dart | 129 +++++++++++++++++- .../settings/ai_models_settings_screen.dart | 81 ++++++++++- 2 files changed, 200 insertions(+), 10 deletions(-) diff --git a/zswatch_app/lib/services/voice_memo/transcription_engine.dart b/zswatch_app/lib/services/voice_memo/transcription_engine.dart index 18d53ed..30bc75a 100644 --- a/zswatch_app/lib/services/voice_memo/transcription_engine.dart +++ b/zswatch_app/lib/services/voice_memo/transcription_engine.dart @@ -16,6 +16,13 @@ enum TranscriptionEngineType { /// Tiny English-only Whisper model (~75 MB). Downloaded from whisper.cpp CDN. whisperTinyEn, + /// Base English-only Whisper model (~142 MB). Noticeably better than Tiny. + whisperBaseEn, + + /// Small English-only Whisper model (~466 MB). Highest-fidelity English + /// model at a manageable size. ~800 MB RAM needed. + whisperSmallEn, + /// KB-Whisper Base fine-tuned on 50 k+ hours of Swedish speech (~147 MB, /// q5_0 quantised). Downloaded from HuggingFace on first use. kbWhisperBase, @@ -28,6 +35,14 @@ enum TranscriptionEngineType { /// available from the upstream GGML release. ~1 GB RAM needed. kbWhisperSmallQ8, + /// Whisper Base multilingual model forced to German (~142 MB). + /// Solid German accuracy at a compact size. + whisperBaseMultiDe, + + /// Whisper Small multilingual model forced to German (~244 MB). + /// Higher-fidelity German, noticeably better than Base. ~500 MB RAM. + whisperSmallMultiDe, + /// Whisper Large-v3-Turbo q5_0 quantised (~547 MB). Near cloud-level /// accuracy, optimised for speed. Needs ~1 GB RAM — modern flagships only. whisperLargeV3TurboQ5, @@ -41,6 +56,7 @@ class TranscriptionModelInfo { final String sourceUrl; final String fileName; final int expectedSizeBytes; + final String description; const TranscriptionModelInfo({ required this.type, @@ -49,6 +65,7 @@ class TranscriptionModelInfo { required this.sourceUrl, required this.fileName, required this.expectedSizeBytes, + required this.description, }); } @@ -62,6 +79,32 @@ abstract final class TranscriptionModelCatalog { 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin', fileName: 'ggml-tiny.en.bin', expectedSizeBytes: 75 * 1024 * 1024, + description: + 'Good English (75 MB, fast) — smallest download, lowest accuracy, best for short clear speech.', + ); + + static const _baseEn = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperBaseEn, + name: 'Whisper Base (English)', + language: 'en', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin', + fileName: 'ggml-base.en.bin', + expectedSizeBytes: 142 * 1024 * 1024, + description: + 'Better English (142 MB) — noticeably more accurate than Tiny, still compact.', + ); + + static const _smallEn = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperSmallEn, + name: 'Whisper Small (English)', + language: 'en', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin', + fileName: 'ggml-small.en.bin', + expectedSizeBytes: 466 * 1024 * 1024, + description: + 'Best English (466 MB, slow) — highest-fidelity English, significant accuracy boost over Base. ~800 MB RAM.', ); static const _kbWhisperBase = TranscriptionModelInfo( @@ -72,6 +115,8 @@ abstract final class TranscriptionModelCatalog { 'https://huggingface.co/KBLab/kb-whisper-base/resolve/main/ggml-model-q5_0.bin', fileName: 'ggml-kb-whisper-base-q5_0.bin', expectedSizeBytes: 147 * 1024 * 1024, + description: + 'Good (147 MB) — solid Swedish accuracy, fine-tuned on 50k+ hours of speech.', ); static const _kbWhisperSmallQ5 = TranscriptionModelInfo( @@ -82,33 +127,69 @@ abstract final class TranscriptionModelCatalog { 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main/ggml-model-q5_0.bin', fileName: 'ggml-kb-whisper-small-q5_0.bin', expectedSizeBytes: 175 * 1024 * 1024, + description: + 'Better (175 MB) — noticeably better Swedish than Good at nearly the same size. ~500 MB RAM.', ); static const _kbWhisperSmallQ8 = TranscriptionModelInfo( type: TranscriptionEngineType.kbWhisperSmallQ8, - name: 'KB-Whisper Small · Full GGML (Swedish)', + name: 'KB-Whisper Small · Full (Swedish)', language: 'sv', sourceUrl: 'https://huggingface.co/KBLab/kb-whisper-small/resolve/main/ggml-model.bin', fileName: 'ggml-kb-whisper-small.bin', expectedSizeBytes: 488 * 1024 * 1024, + description: + 'Best (488 MB, slow) — highest-fidelity Swedish, full-precision model. Needs ~1 GB RAM.', + ); + + static const _baseMultiDe = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperBaseMultiDe, + name: 'Whisper Base (German)', + language: 'de', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', + fileName: 'ggml-base-multi-de.bin', + expectedSizeBytes: 142 * 1024 * 1024, + description: + 'Good German (142 MB) — multilingual Whisper forced to German, solid accuracy.', + ); + + static const _smallMultiDe = TranscriptionModelInfo( + type: TranscriptionEngineType.whisperSmallMultiDe, + name: 'Whisper Small (German)', + language: 'de', + sourceUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin', + fileName: 'ggml-small-multi-de.bin', + expectedSizeBytes: 244 * 1024 * 1024, + description: + 'Better German (244 MB) — higher-fidelity German, noticeably better than Base. ~500 MB RAM.', ); static const _whisperLargeV3TurboQ5 = TranscriptionModelInfo( type: TranscriptionEngineType.whisperLargeV3TurboQ5, - name: 'Whisper Large-v3-Turbo · Q5_0', + name: 'Whisper Large-v3-Turbo · Q5_0 (Multilingual)', language: 'auto', sourceUrl: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin', fileName: 'ggml-large-v3-turbo-q5_0.bin', expectedSizeBytes: 547 * 1024 * 1024, + description: + 'Multilingual (547 MB, slow) — near cloud-level accuracy for any language. Needs ~1 GB RAM.', ); + // Models grouped by language: English → Swedish → German → Multilingual, + // ordered best→worst within each group. static const List all = [ + _smallEn, + _baseEn, _tinyEn, - _kbWhisperBase, - _kbWhisperSmallQ5, _kbWhisperSmallQ8, + _kbWhisperSmallQ5, + _kbWhisperBase, + _smallMultiDe, + _baseMultiDe, _whisperLargeV3TurboQ5, ]; @@ -116,12 +197,20 @@ abstract final class TranscriptionModelCatalog { switch (type) { case TranscriptionEngineType.whisperTinyEn: return _tinyEn; + case TranscriptionEngineType.whisperBaseEn: + return _baseEn; + case TranscriptionEngineType.whisperSmallEn: + return _smallEn; case TranscriptionEngineType.kbWhisperBase: return _kbWhisperBase; case TranscriptionEngineType.kbWhisperSmallQ5: return _kbWhisperSmallQ5; case TranscriptionEngineType.kbWhisperSmallQ8: return _kbWhisperSmallQ8; + case TranscriptionEngineType.whisperBaseMultiDe: + return _baseMultiDe; + case TranscriptionEngineType.whisperSmallMultiDe: + return _smallMultiDe; case TranscriptionEngineType.whisperLargeV3TurboQ5: return _whisperLargeV3TurboQ5; } @@ -132,12 +221,44 @@ TranscriptionEngine createTranscriptionEngine(TranscriptionEngineType type) { switch (type) { case TranscriptionEngineType.whisperTinyEn: return WhisperEngine(); + case TranscriptionEngineType.whisperBaseEn: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin', + modelFileName: 'ggml-base.en.bin', + languageCode: 'en', + displayName: 'Whisper Base (English)', + ); + case TranscriptionEngineType.whisperSmallEn: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin', + modelFileName: 'ggml-small.en.bin', + languageCode: 'en', + displayName: 'Whisper Small (English)', + ); case TranscriptionEngineType.kbWhisperBase: return KbWhisperEngines.base(); case TranscriptionEngineType.kbWhisperSmallQ5: return KbWhisperEngines.smallQ5(); case TranscriptionEngineType.kbWhisperSmallQ8: return KbWhisperEngines.smallQ8(); + case TranscriptionEngineType.whisperBaseMultiDe: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', + modelFileName: 'ggml-base-multi-de.bin', + languageCode: 'de', + displayName: 'Whisper Base (German)', + ); + case TranscriptionEngineType.whisperSmallMultiDe: + return CustomGgmlWhisperEngine( + modelUrl: + 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin', + modelFileName: 'ggml-small-multi-de.bin', + languageCode: 'de', + displayName: 'Whisper Small (German)', + ); case TranscriptionEngineType.whisperLargeV3TurboQ5: return KbWhisperEngines.largeV3TurboQ5(); } diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index c03d44a..264fc75 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -292,12 +292,66 @@ class _TranscriptionModelSelectorState border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - items: TranscriptionModelCatalog.all.map((info) { - return DropdownMenuItem( - value: info.type, - child: Text(info.name, overflow: TextOverflow.ellipsis), - ); - }).toList(), + items: () { + const all = TranscriptionModelCatalog.all; + // Rank within each language group (models already ordered + // best→worst, so first occurrence per language = rank 1). + final ranked = {}; + final langCounters = {}; + for (final m in all) { + final lang = m.language; + final rank = (langCounters[lang] ?? 0) + 1; + langCounters[lang] = rank; + ranked[m.type] = rank; + } + return all.map((info) { + final modelRank = ranked[info.type]; + final Color rankColor; + switch (modelRank) { + case 1: + rankColor = const Color(0xFFFFD700); + case 2: + rankColor = const Color(0xFFC0C0C0); + case 3: + rankColor = const Color(0xFFCD7F32); + default: + rankColor = AppTheme.textSecondary; + } + return DropdownMenuItem( + value: info.type, + child: Row( + children: [ + if (modelRank != null) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: rankColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '#$modelRank', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: rankColor, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + child: Text( + info.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(); + }(), onChanged: _isDownloading ? null : (value) { @@ -309,6 +363,21 @@ class _TranscriptionModelSelectorState }, ), ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + 6, + AppTheme.spacingMd, + 0, + ), + child: Text( + TranscriptionModelCatalog.info(selectedType).description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ), // Selected model details card _TranscriptionModelCard( From edd8738ab80a666f0d570f8c62e82ae586082717 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 22:45:05 +0100 Subject: [PATCH 19/58] feat: add phone-recorded AI benchmark flow --- .../android/app/src/main/AndroidManifest.xml | 3 + zswatch_app/ios/Runner/Info.plist | 3 + .../services/ai/model_benchmark_service.dart | 80 ++- .../settings/ai_models_settings_screen.dart | 635 ++++++++++++------ .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + zswatch_app/pubspec.lock | 64 ++ zswatch_app/pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 11 files changed, 553 insertions(+), 246 deletions(-) diff --git a/zswatch_app/android/app/src/main/AndroidManifest.xml b/zswatch_app/android/app/src/main/AndroidManifest.xml index 7120955..86e7519 100644 --- a/zswatch_app/android/app/src/main/AndroidManifest.xml +++ b/zswatch_app/android/app/src/main/AndroidManifest.xml @@ -20,6 +20,9 @@ + + + diff --git a/zswatch_app/ios/Runner/Info.plist b/zswatch_app/ios/Runner/Info.plist index 61770ff..e095a89 100644 --- a/zswatch_app/ios/Runner/Info.plist +++ b/zswatch_app/ios/Runner/Info.plist @@ -43,6 +43,9 @@ ZSWatch needs access to select firmware files from your photo library for watch updates. NSPhotoLibraryAddUsageDescription ZSWatch needs access to save images to your photo library. + + NSMicrophoneUsageDescription + ZSWatch needs microphone access to record audio for testing the voice memo AI pipeline. NSCalendarsUsageDescription ZSWatch needs calendar access to create events from AI-detected voice note actions. diff --git a/zswatch_app/lib/services/ai/model_benchmark_service.dart b/zswatch_app/lib/services/ai/model_benchmark_service.dart index 357d0d3..881a642 100644 --- a/zswatch_app/lib/services/ai/model_benchmark_service.dart +++ b/zswatch_app/lib/services/ai/model_benchmark_service.dart @@ -27,7 +27,8 @@ class BenchmarkProgress { final int attempts; final bool retryEnabled; - /// Current phase: 'loading', 'running', 'done', 'error'. + /// Current phase: 'loading', 'running', 'transcribing', 'correcting', + /// 'classifying', 'done', 'error'. final String phase; final String partialOutput; final int tokens; @@ -40,6 +41,19 @@ class BenchmarkProgress { /// human-readable summary while [rawOutput] keeps the full model response. final String? rawOutput; + /// Corrected transcription (if the correction pass produced one). + final String? correctedTranscription; + + /// Metrics from the correction LLM pass (separate from classify metrics). + final int correctionTokens; + final Duration correctionElapsed; + final double? correctionTokensPerSecond; + + /// Reserved for richer benchmark variants that may include a separate + /// transcription stage. + final String? transcriptionResult; + final Duration? transcriptionElapsed; + const BenchmarkProgress({ required this.testType, required this.modelName, @@ -61,6 +75,12 @@ class BenchmarkProgress { this.tokensPerSecond, this.error, this.rawOutput, + this.correctedTranscription, + this.correctionTokens = 0, + this.correctionElapsed = Duration.zero, + this.correctionTokensPerSecond, + this.transcriptionResult, + this.transcriptionElapsed, }); bool get isComplete => phase == 'done' || phase == 'error'; @@ -287,18 +307,24 @@ class ModelBenchmarkService { String lastRawOutput = ''; final result = await llmService.processTranscript( benchmarkInput, - correctTranscription: false, // skip correction – test classify speed + correctTranscription: true, onProgress: (phase, partial, tokens) { lastRawOutput = partial; final tps = sw.elapsedMilliseconds > 0 ? tokens / (sw.elapsedMilliseconds / 1000.0) : 0.0; + // Map LlmService phases to benchmark phases + final benchPhase = switch (phase) { + 'correcting' => 'correcting', + 'classifying' => 'classifying', + _ => 'running', + }; _emit(BenchmarkProgress( testType: 'ai', modelName: modelName, - promptStrategy: 'shared-chrono-flow', + promptStrategy: 'shared-chrono-flow', retryEnabled: true, - phase: 'running', + phase: benchPhase, partialOutput: partial, rawOutput: partial, tokens: tokens, @@ -313,8 +339,12 @@ class ModelBenchmarkService { final rawResponse = result.classifyMetrics?.rawResponse ?? lastRawOutput; - if (_abortRequested) { - _emit(BenchmarkProgress( + // Helper to extract correction metrics from result + BenchmarkProgress buildAiResult({ + required String phase, + required String partialOutput, + }) { + return BenchmarkProgress( testType: 'ai', modelName: modelName, promptStrategy: result.classifyMetrics?.promptStrategy, @@ -328,42 +358,30 @@ class ModelBenchmarkService { resolverMethod: result.resolverMethod, attempts: result.classifyMetrics?.attempts ?? 1, retryEnabled: result.classifyMetrics?.retryEnabled ?? false, - phase: 'done', - partialOutput: '(aborted)', + phase: phase, + partialOutput: partialOutput, rawOutput: rawResponse, tokens: result.classifyMetrics?.completionTokens ?? 0, elapsed: sw.elapsed, - tokensPerSecond: - result.classifyMetrics?.tokensPerSecond ?? 0.0, - )); - return; + tokensPerSecond: result.classifyMetrics?.tokensPerSecond ?? 0.0, + correctedTranscription: result.correctedTranscription, + correctionTokens: result.correctionMetrics?.completionTokens ?? 0, + correctionElapsed: result.correctionMetrics?.wallTime ?? Duration.zero, + correctionTokensPerSecond: result.correctionMetrics?.tokensPerSecond, + ); } - final tps = result.classifyMetrics?.tokensPerSecond ?? 0.0; + if (_abortRequested) { + _emit(buildAiResult(phase: 'done', partialOutput: '(aborted)')); + return; + } - _emit(BenchmarkProgress( - testType: 'ai', - modelName: modelName, - promptStrategy: result.classifyMetrics?.promptStrategy, - rawPrompt: result.classifyMetrics?.rawPrompt, - parsedJson: result.classifyMetrics?.parsedJson, - extractedIntent: result.extractedIntent, - extractedTitle: result.extractedTitle, - datetimeExpressionOriginal: result.datetimeExpressionOriginal, - datetimeExpressionEnglish: result.datetimeExpressionEnglish, - resolvedDateTime: result.resolvedDateTime, - resolverMethod: result.resolverMethod, - attempts: result.classifyMetrics?.attempts ?? 1, - retryEnabled: result.classifyMetrics?.retryEnabled ?? false, + _emit(buildAiResult( phase: 'done', partialOutput: 'Category: ${result.category}\n' 'Summary: ${result.summary}\n' 'Actions: ${result.actions.length}', - rawOutput: rawResponse, - tokens: result.classifyMetrics?.completionTokens ?? 0, - elapsed: sw.elapsed, - tokensPerSecond: tps, )); } catch (e) { debugPrint('[ModelBenchmark] AI benchmark error: $e'); diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index 264fc75..4221436 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -4,7 +4,9 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:record/record.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../core/theme/app_theme.dart'; @@ -1315,6 +1317,11 @@ class _BenchmarkSection extends ConsumerStatefulWidget { class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { late final TextEditingController _aiInputController; + AudioRecorder? _audioRecorder; + bool _isRecording = false; + Duration _recordingDuration = Duration.zero; + Timer? _recordingTimer; + String? _lastRecordingPath; @override void initState() { @@ -1327,6 +1334,13 @@ class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { @override void dispose() { _aiInputController.dispose(); + _recordingTimer?.cancel(); + _audioRecorder?.dispose(); + // Clean up temp recording file + if (_lastRecordingPath != null) { + final f = File(_lastRecordingPath!); + if (f.existsSync()) f.deleteSync(); + } super.dispose(); } @@ -1337,137 +1351,217 @@ class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { benchState.whenOrNull(data: (s) => s.isRunning) ?? false; final runningType = benchState.whenOrNull(data: (s) => s.runningTestType); + final hasTranscript = _aiInputController.text.trim().isNotEmpty; - return Column( - children: [ - _AiBenchmarkInputEditor( - controller: _aiInputController, - onReset: () { - _aiInputController.text = - 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; - }, + return Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingMd, + 0, + ), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), - - // ---------- Run buttons ---------- - Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: Row( - children: [ - Expanded( - child: FilledButton.icon( - icon: runningType == 'transcription' - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white70, - ), - ) - : const Icon(Icons.mic, size: 18), - label: Text(runningType == 'transcription' - ? 'Running…' - : 'Test Transcription'), - onPressed: isRunning - ? null - : () => _runTranscriptionBenchmark(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _BenchmarkPill( + icon: _isRecording ? Icons.fiber_manual_record : Icons.mic, + label: _isRecording ? 'Recording' : 'Phone input', + color: _isRecording + ? AppTheme.errorColor + : AppTheme.primaryColor, ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton.icon( - icon: runningType == 'ai' - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white70, - ), - ) - : const Icon(Icons.psychology, size: 18), - label: Text( - runningType == 'ai' ? 'Running…' : 'Test AI'), - onPressed: isRunning - ? null - : () => _runAiBenchmark(context), + _BenchmarkPill( + icon: hasTranscript ? Icons.check_circle : Icons.edit_note, + label: hasTranscript ? 'Transcript ready' : 'Type or record', + color: hasTranscript + ? AppTheme.successColor + : AppTheme.textSecondary, ), + ], + ), + const SizedBox(height: 12), + _AiBenchmarkInputEditor( + controller: _aiInputController, + isRecording: _isRecording, + recordingDuration: _recordingDuration, + onReset: () { + _aiInputController.text = + 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; + }, + onRecordToggle: isRunning ? null : () => _toggleRecording(context), + ), + const SizedBox(height: 14), + _BenchmarkHintCard( + message: _isRecording + ? 'Recording in progress. Tap the microphone again to stop and transcribe.' + : 'Record on the phone or paste text, then run the AI benchmark using the same prompt flow as voice memos.', + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: runningType == 'ai' + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ) + : const Icon(Icons.psychology, size: 18), + label: Text(runningType == 'ai' ? 'Running…' : 'Test AI'), + onPressed: isRunning || _isRecording + ? null + : () => _runAiBenchmark(context), ), - ], - ), - ), - - // ---------- Last result summary (shown after completion) ---------- - benchState.when( - data: (state) { - final progress = state.current; - if (progress == null || !progress.isComplete) { - return Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Text( - 'Run a quick test to see how fast the selected models ' - 'perform on your device.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ); - } - // Show a compact last-result row with a "View" button to re-open - return _LastResultTile(progress: progress); - }, - loading: () => const SizedBox.shrink(), - error: (e, _) => Padding( - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Text('Error: $e', - style: const TextStyle(color: AppTheme.errorColor)), - ), + ), + const SizedBox(height: 12), + benchState.when( + data: (state) { + final progress = state.current; + if (progress == null || !progress.isComplete) { + return const SizedBox.shrink(); + } + return _LastResultTile(progress: progress); + }, + loading: () => const SizedBox.shrink(), + error: (e, _) => _BenchmarkHintCard( + message: 'Error: $e', + isError: true, + ), + ), + ], ), - ], + ), ); } - Future _runTranscriptionBenchmark(BuildContext context) async { - // Find a real voice recording to use - final repo = ref.read(voiceMemoRepositoryProvider); - final memos = await repo.getAllMemos(); - final usable = memos.where((m) { - final path = m.convertedFilePath ?? m.localFilePath; - return path != null && File(path).existsSync(); - }).toList(); + // ---- Recording ---- - if (usable.isEmpty) { + Future _toggleRecording(BuildContext context) async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(context); + } + } + + Future _startRecording(BuildContext context) async { + // Request microphone permission + final status = await Permission.microphone.request(); + if (!status.isGranted) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text( - 'No voice recordings found — record one on the watch first.', - ), + content: Text('Microphone permission is required to record audio.'), ), ); } return; } - final memo = usable.first; - final audioPath = memo.convertedFilePath ?? memo.localFilePath!; + _audioRecorder = AudioRecorder(); - // Open debug sheet, then start the benchmark - if (context.mounted) { - _showBenchmarkSheet(context); + // Record as WAV for best Whisper compatibility + final tempDir = await getTemporaryDirectory(); + _lastRecordingPath = + '${tempDir.path}/benchmark_recording_${DateTime.now().millisecondsSinceEpoch}.wav'; + + await _audioRecorder!.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 16000, + numChannels: 1, + ), + path: _lastRecordingPath!, + ); + + setState(() { + _isRecording = true; + _recordingDuration = Duration.zero; + }); + + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + setState(() { + _recordingDuration += const Duration(seconds: 1); + }); + }); + } + + Future _stopRecording() async { + _recordingTimer?.cancel(); + _recordingTimer = null; + + final path = await _audioRecorder?.stop(); + await _audioRecorder?.dispose(); + _audioRecorder = null; + + setState(() { + _isRecording = false; + }); + + if (path == null || !File(path).existsSync()) { + debugPrint('[Benchmark] Recording failed – no file at $path'); + return; } + _lastRecordingPath = path; + + // Transcribe the recording and fill the text field final selectedType = ref.read(transcriptionEngineTypeProvider); - unawaited( - ref - .read(_benchmarkServiceProvider) - .benchmarkTranscription(selectedType, audioPath), - ); + final engine = createTranscriptionEngine(selectedType); + try { + final available = await engine.isAvailable(); + if (!available) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Transcription model not downloaded — download it in settings first.', + ), + ), + ); + } + return; + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Transcribing recording…')), + ); + } + + final transcript = await engine.transcribe(path); + if (transcript.isNotEmpty && mounted) { + _aiInputController.text = transcript; + ScaffoldMessenger.of(context).clearSnackBars(); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No speech detected in recording.')), + ); + } + } catch (e) { + debugPrint('[Benchmark] Transcription error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Transcription failed: $e')), + ); + } + } finally { + engine.dispose(); + } } void _runAiBenchmark(BuildContext context) { @@ -1512,71 +1606,90 @@ class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { class _AiBenchmarkInputEditor extends StatelessWidget { final TextEditingController controller; + final bool isRecording; + final Duration recordingDuration; final VoidCallback onReset; + final VoidCallback? onRecordToggle; const _AiBenchmarkInputEditor({ required this.controller, + required this.isRecording, + required this.recordingDuration, required this.onReset, + required this.onRecordToggle, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final minutes = recordingDuration.inMinutes.remainder(60); + final seconds = recordingDuration.inSeconds.remainder(60); + final durationText = '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - Expanded( - child: Text( - 'AI benchmark input', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - TextButton.icon( - onPressed: onReset, - icon: const Icon(Icons.restart_alt, size: 16), - label: const Text('Reset'), + Expanded( + child: Text( + 'AI benchmark input', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), - ], - ), - Text( - 'Edit only the sample transcript here. The benchmark uses the same fixed AI prompt and chrono flow as the main app.', - style: theme.textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, ), ), - const SizedBox(height: 12), - TextField( - controller: controller, - minLines: 3, - maxLines: 6, - decoration: const InputDecoration( - labelText: 'Test input text', - alignLabelWithHint: true, - border: OutlineInputBorder(), + if (isRecording) ...[ + Text( + durationText, + style: theme.textTheme.labelMedium?.copyWith( + color: AppTheme.errorColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 4), + ], + IconButton( + onPressed: onRecordToggle, + icon: Icon( + isRecording ? Icons.stop_circle : Icons.mic, + color: isRecording ? AppTheme.errorColor : null, + size: 22, ), + tooltip: isRecording ? 'Stop recording' : 'Record audio', + style: IconButton.styleFrom( + backgroundColor: isRecording + ? AppTheme.errorColor.withValues(alpha: 0.15) + : Colors.white.withValues(alpha: 0.04), + ), + ), + const SizedBox(width: 4), + TextButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt, size: 16), + label: const Text('Reset'), ), ], ), - ), + const SizedBox(height: 8), + TextField( + controller: controller, + minLines: 4, + maxLines: 7, + decoration: InputDecoration( + labelText: 'Test input text', + alignLabelWithHint: true, + border: const OutlineInputBorder(), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.02), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + ), + ), + ], ); } } @@ -1590,56 +1703,138 @@ class _LastResultTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final isError = progress.isError; - final icon = progress.testType == 'transcription' - ? Icons.mic - : Icons.psychology; + final icon = switch (progress.testType) { + 'transcription' => Icons.mic, + _ => Icons.psychology, + }; final statusColor = isError ? AppTheme.errorColor : AppTheme.successColor; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: statusColor.withValues(alpha: 0.3)), ), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(AppTheme.radiusMedium), - border: Border.all(color: statusColor.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - Icon(icon, size: 18, color: statusColor), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: Row( + children: [ + Icon(icon, size: 18, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Latest run', + style: theme.textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + progress.modelName, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + if (progress.elapsed > Duration.zero) Text( - progress.modelName, - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, + isError + ? 'Failed' + : '${(progress.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s' + '${progress.tokensPerSecond != null ? ' • ${progress.tokensPerSecond!.toStringAsFixed(1)} t/s' : ''}', + style: theme.textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, ), - overflow: TextOverflow.ellipsis, ), - if (progress.elapsed > Duration.zero) - Text( - isError - ? 'Failed' - : '${(progress.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s' - '${progress.tokensPerSecond != null ? ' • ${progress.tokensPerSecond!.toStringAsFixed(1)} t/s' : ''}', - style: theme.textTheme.labelSmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], + ), + ); + } +} + +class _BenchmarkPill extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + + const _BenchmarkPill({ + required this.icon, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.22)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _BenchmarkHintCard extends StatelessWidget { + final String message; + final bool isError; + + const _BenchmarkHintCard({ + required this.message, + this.isError = false, + }); + + @override + Widget build(BuildContext context) { + final color = isError ? AppTheme.errorColor : AppTheme.textSecondary; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: isError ? 0.08 : 0.05), + borderRadius: BorderRadius.circular(AppTheme.radiusMedium), + border: Border.all(color: color.withValues(alpha: 0.14)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.info_outline, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: color, + ), + ), + ), + ], ), ); } @@ -1690,13 +1885,16 @@ class _BenchmarkDebugSheet extends ConsumerWidget { return [aiDebugNote(context, 'Waiting for benchmark to start…')]; } + final isAiType = progress.testType == 'ai'; + if (!progress.isComplete) { // ---- Live / in-progress view ---- final phaseText = switch (progress.phase) { 'loading' => 'Loading model…', - 'running' => progress.testType == 'transcription' - ? 'Transcribing…' - : 'Generating…', + 'transcribing' => 'Transcribing audio…', + 'correcting' => 'Correcting transcription…', + 'classifying' => 'Classifying & extracting…', + 'running' => isAiType ? 'Generating…' : 'Transcribing…', _ => 'Processing…', }; return [ @@ -1712,14 +1910,12 @@ class _BenchmarkDebugSheet extends ConsumerWidget { const SizedBox(height: 12), aiDebugBlock( context, - title: progress.testType == 'transcription' + title: progress.phase == 'transcribing' ? 'Transcription Status' : 'LLM Output (live)', content: progress.partialOutput, - icon: progress.testType == 'transcription' - ? Icons.mic - : Icons.code, - mono: progress.testType == 'ai', + icon: progress.phase == 'transcribing' ? Icons.mic : Icons.code, + mono: progress.phase != 'transcribing', showCopyButton: true, ), ], @@ -1736,7 +1932,20 @@ class _BenchmarkDebugSheet extends ConsumerWidget { tokensPerSecond: progress.tokensPerSecond, elapsed: progress.elapsed, ), - if (progress.testType == 'ai') ...[ + // Correction result + if (progress.correctedTranscription != null && + progress.correctedTranscription!.isNotEmpty) ...[ + const SizedBox(height: 12), + aiDebugBlock( + context, + title: 'Corrected Transcription' + '${progress.correctionTokensPerSecond != null ? ' (${progress.correctionTokens} tokens, ${progress.correctionTokensPerSecond!.toStringAsFixed(1)} t/s, ${(progress.correctionElapsed.inMilliseconds / 1000).toStringAsFixed(1)}s)' : ''}', + content: progress.correctedTranscription!, + icon: Icons.spellcheck, + showCopyButton: true, + ), + ], + if (isAiType) ...[ const SizedBox(height: 12), aiDebugBlock( context, @@ -1750,7 +1959,7 @@ class _BenchmarkDebugSheet extends ConsumerWidget { showCopyButton: true, ), ], - if (progress.testType == 'ai' && + if (isAiType && aiHasChronoDetails( extractedIntent: progress.extractedIntent, extractedTitle: progress.extractedTitle, @@ -1775,18 +1984,14 @@ class _BenchmarkDebugSheet extends ConsumerWidget { showCopyButton: true, ), ], - // Show parsed summary for AI tests + // Show parsed summary if (progress.partialOutput.isNotEmpty) ...[ const SizedBox(height: 12), aiDebugBlock( context, - title: progress.testType == 'transcription' - ? 'Transcription Result' - : 'Parsed Result', + title: 'Parsed Result', content: progress.partialOutput, - icon: progress.testType == 'transcription' - ? Icons.mic - : Icons.check_circle_outline, + icon: Icons.check_circle_outline, showCopyButton: true, ), ], @@ -1801,7 +2006,7 @@ class _BenchmarkDebugSheet extends ConsumerWidget { showCopyButton: true, ), ], - // Show full raw LLM output for AI tests (preserved after completion) + // Show full raw LLM output (preserved after completion) if (progress.rawOutput != null && progress.rawOutput!.isNotEmpty && progress.rawOutput != progress.partialOutput) ...[ @@ -1956,7 +2161,7 @@ class _ModelMemoryFitBanner extends ConsumerWidget { ); }, loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); } } diff --git a/zswatch_app/linux/flutter/generated_plugin_registrant.cc b/zswatch_app/linux/flutter/generated_plugin_registrant.cc index a35cce6..54d51d9 100644 --- a/zswatch_app/linux/flutter/generated_plugin_registrant.cc +++ b/zswatch_app/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/zswatch_app/linux/flutter/generated_plugins.cmake b/zswatch_app/linux/flutter/generated_plugins.cmake index ca0b2d4..eaa6754 100644 --- a/zswatch_app/linux/flutter/generated_plugins.cmake +++ b/zswatch_app/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + record_linux sqlite3_flutter_libs url_launcher_linux ) diff --git a/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift b/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift index 66750f2..f1a19c4 100644 --- a/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/zswatch_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import geolocator_apple import just_audio import package_info_plus import path_provider_foundation +import record_macos import shared_preferences_foundation import sqlite3_flutter_libs import url_launcher_macos @@ -29,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/zswatch_app/pubspec.lock b/zswatch_app/pubspec.lock index 4d0bea1..8dae7d1 100644 --- a/zswatch_app/pubspec.lock +++ b/zswatch_app/pubspec.lock @@ -1029,6 +1029,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" riverpod: dependency: transitive description: diff --git a/zswatch_app/pubspec.yaml b/zswatch_app/pubspec.yaml index ecd4f78..a95ed01 100644 --- a/zswatch_app/pubspec.yaml +++ b/zswatch_app/pubspec.yaml @@ -64,6 +64,9 @@ dependencies: # Audio Playback (voice memo Ogg/Opus) just_audio: ^0.9.42 + # Audio Recording (debug/benchmark recording on phone) + record: ^6.2.0 + # Speech-to-Text (offline Whisper inference) whisper_ggml_plus: ^1.3.5 whisper_ggml_plus_ffmpeg: ^1.0.0 diff --git a/zswatch_app/windows/flutter/generated_plugin_registrant.cc b/zswatch_app/windows/flutter/generated_plugin_registrant.cc index 766f9d7..457c9ec 100644 --- a/zswatch_app/windows/flutter/generated_plugin_registrant.cc +++ b/zswatch_app/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/zswatch_app/windows/flutter/generated_plugins.cmake b/zswatch_app/windows/flutter/generated_plugins.cmake index 5389ffc..80b4026 100644 --- a/zswatch_app/windows/flutter/generated_plugins.cmake +++ b/zswatch_app/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows geolocator_windows permission_handler_windows + record_windows sqlite3_flutter_libs url_launcher_windows ) From 451b301e1421d2f146fc9935bbd6e2e49bfbbc35 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 22:53:03 +0100 Subject: [PATCH 20/58] feat: default transcription fallback to whisper small english --- zswatch_app/lib/providers/settings_providers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index 07c3f0d..6009309 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -315,7 +315,7 @@ class TranscriptionEngineTypeNotifier for (final type in TranscriptionEngineType.values) { if (value == type.name) return type; } - return TranscriptionEngineType.whisperTinyEn; + return TranscriptionEngineType.whisperSmallEn; } void setType(TranscriptionEngineType type) { From a5b3fcf3a7730f1980301c2322264afb9f295d87 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Fri, 13 Mar 2026 22:56:45 +0100 Subject: [PATCH 21/58] Restructure AI prompt for better item splitting and intent classification - Reorganize prompt with IMPORTANT sections for item splitting and intent - Remove Swedish-specific rules (weekday table, halv X, klockan) - Make prompt language-agnostic with examples teaching patterns implicitly - Add 8 new examples (Swedish, multi-item, negative title-translation) - Add CLI filtering (--case, --case-limit, --model) to benchmark runner - Add Swenglish and conversational test cases (51 total) - Increase nCtx to 4096 for larger prompt - Add benchmark helper scripts (show_failures.py, test_single_prompt.py) - Benchmark result: 46/51 (90%) with zero dropped items --- ai_testbench/benchmark_results/results.json | 1158 +++++++++++++++++ ai_testbench/lib/benchmark_main.dart | 94 +- ai_testbench/lib/main.dart | 2 +- ai_testbench/lib/services/llm_service.dart | 7 +- .../lib/services/model_benchmark_service.dart | 59 +- .../macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + ai_testbench/macos/Podfile | 42 + ai_testbench/macos/Podfile.lock | 35 + .../macos/Runner.xcodeproj/project.pbxproj | 98 +- .../contents.xcworkspacedata | 3 + ai_testbench/run_benchmark.py | 26 +- ai_testbench/show_failures.py | 12 + ai_testbench/test_single_prompt.py | 43 + .../lib/src/prompt_template.dart | 73 +- packages/chrono_ai_flow/pubspec.lock | 397 ++++++ 16 files changed, 2011 insertions(+), 40 deletions(-) create mode 100644 ai_testbench/benchmark_results/results.json create mode 100644 ai_testbench/macos/Podfile create mode 100644 ai_testbench/macos/Podfile.lock create mode 100644 ai_testbench/show_failures.py create mode 100644 ai_testbench/test_single_prompt.py create mode 100644 packages/chrono_ai_flow/pubspec.lock diff --git a/ai_testbench/benchmark_results/results.json b/ai_testbench/benchmark_results/results.json new file mode 100644 index 0000000..07ef33b --- /dev/null +++ b/ai_testbench/benchmark_results/results.json @@ -0,0 +1,1158 @@ +{ + "startedAt": "2026-03-13T21:31:24.883597Z", + "finishedAt": "2026-03-13T21:38:04.722009Z", + "modelCount": 1, + "caseCount": 51, + "results": [ + { + "modelPath": "/Users/jakkra/Documents/ZSWatch-App/ai_testbench/models/Qwen3.5-2B-Q4_K_M.gguf", + "modelName": "Qwen3.5-2B-Q4_K_M.gguf", + "passedCases": 46, + "totalCases": 51, + "avgTokensPerSecond": 6.700685793811264, + "totalElapsedMs": 399367, + "cases": [ + { + "caseName": "en_event_precise_time", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-14 15:30:00.000 OK (via chrono); ", + "intent": "event", + "title": "design review", + "datetimeOriginal": "March 14 at 3:30 PM", + "datetimeEnglish": "March 14th at 3:30 pm", + "elapsedMs": 7653, + "tokensPerSecond": 6.010714752384686, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"design review\",\"datetime_expression_original\":\"March 14 at 3:30 PM\",\"datetime_expression_english\":\"March 14th at 3:30 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_tomorrow", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 07:15:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "take the prototype battery off the charger", + "datetimeOriginal": "tomorrow at 7:15 AM", + "datetimeEnglish": "tomorrow at 7:15 am", + "elapsedMs": 7261, + "tokensPerSecond": 6.335215535050269, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"take the prototype battery off the charger\",\"datetime_expression_original\":\"tomorrow at 7:15 AM\",\"datetime_expression_english\":\"tomorrow at 7:15 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_event_next_tuesday", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-17 14:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "meeting with John", + "datetimeOriginal": "next Tuesday at 2 pm", + "datetimeEnglish": "next Tuesday at 2 pm", + "elapsedMs": 6822, + "tokensPerSecond": 5.277044854881266, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"meeting with John\",\"datetime_expression_original\":\"next Tuesday at 2 pm\",\"datetime_expression_english\":\"next Tuesday at 2 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_next_friday", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-20 17:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "finish PCB layout review", + "datetimeOriginal": "by next Friday at 5 PM", + "datetimeEnglish": "by next Friday at 5 pm", + "elapsedMs": 6968, + "tokensPerSecond": 5.597014925373134, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"finish PCB layout review\",\"datetime_expression_original\":\"by next Friday at 5 PM\",\"datetime_expression_english\":\"by next Friday at 5 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_event_dentist", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-04-22 10:30:00.000 OK (via chrono); ", + "intent": "event", + "title": "dentist appointment", + "datetimeOriginal": "April 22nd at 10:30 AM", + "datetimeEnglish": "April 22nd at 10:30 AM", + "elapsedMs": 7445, + "tokensPerSecond": 6.715916722632639, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"dentist appointment\",\"datetime_expression_original\":\"April 22nd at 10:30 AM\",\"datetime_expression_english\":\"April 22nd at 10:30 AM\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_note_no_time", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "pressure sensor for altitude detection", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6432, + "tokensPerSecond": 4.197761194029851, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"pressure sensor for altitude detection\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_this_afternoon", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-11 15:00:00.000 OK (via regex); ", + "intent": "reminder", + "title": "call the plumber", + "datetimeOriginal": "this afternoon at 3", + "datetimeEnglish": "this afternoon at 3 pm", + "elapsedMs": 6776, + "tokensPerSecond": 5.1652892561983474, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"call the plumber\",\"datetime_expression_original\":\"this afternoon at 3\",\"datetime_expression_english\":\"this afternoon at 3 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_tomorrow", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found ringa, tandläkare in \"ringa tandläkaren\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 08:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "ringa tandläkaren", + "datetimeOriginal": "imorgon klockan 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 7067, + "tokensPerSecond": 5.8016131314560635, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"ringa tandläkaren\",\"datetime_expression_original\":\"imorgon klockan 8\",\"datetime_expression_english\":\"tomorrow at 8 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_meeting", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found möte, projektgrupp in \"möte med projektgruppen\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 14:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "möte med projektgruppen", + "datetimeOriginal": "på torsdag klockan 14", + "datetimeEnglish": "on Thursday at 2 pm", + "elapsedMs": 8184, + "tokensPerSecond": 7.697947214076247, + "outputPreview": "[\n {\n \"intent\": \"event\",\n \"title\": \"möte med projektgruppen\",\n \"datetime_expression_original\": \"på torsdag klockan 14\",\n \"datetime_expression_english\": \"on Thursday at 2 pm\"\n }\n]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_note_no_time", + "passed": false, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found köp, mjölk in \"köp mjölk\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "köp mjölk", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 7556, + "tokensPerSecond": 6.749602964531498, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"köp mjölk\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"köp bröd\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 1, + "countMatch": false, + "itemFailures": [ + "item[1] unexpected extra extraction" + ] + }, + { + "caseName": "sv_note_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found stegräknare, klocka in \"stegräknare i klockan\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "stegräknare i klockan", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6588, + "tokensPerSecond": 4.705525197328476, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"stegräknare i klockan\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_specific_date", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found tandläkare in \"tandläkare\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-15 09:30:00.000 OK (via chrono); ", + "intent": "event", + "title": "tandläkare", + "datetimeOriginal": "den 15 mars klockan halv 10", + "datetimeEnglish": "March 15th at 9:30 am", + "elapsedMs": 7507, + "tokensPerSecond": 6.6604502464366595, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"tandläkare\",\"datetime_expression_original\":\"den 15 mars klockan halv 10\",\"datetime_expression_english\":\"March 15th at 9:30 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "de_event_appointment", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found arzt, termin in \"Arzttermin\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "Arzttermin", + "datetimeOriginal": "am Donnerstag um 9 Uhr", + "datetimeEnglish": "Thursday at 9 am", + "elapsedMs": 6770, + "tokensPerSecond": 5.1698670605613, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"Arzttermin\",\"datetime_expression_original\":\"am Donnerstag um 9 Uhr\",\"datetime_expression_english\":\"Thursday at 9 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "de_reminder_deadline", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found bericht, chef, schicken in \"Bericht an Chef schicken\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-13 17:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "Bericht an Chef schicken", + "datetimeOriginal": "bis Freitag um 17 Uhr", + "datetimeEnglish": "on Friday at 5 pm", + "elapsedMs": 7025, + "tokensPerSecond": 5.551601423487544, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"Bericht an Chef schicken\",\"datetime_expression_original\":\"bis Freitag um 17 Uhr\",\"datetime_expression_english\":\"on Friday at 5 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_reminder_specific_date", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found expense, report in \"expense report\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-20 09:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "expense report", + "datetimeOriginal": "March 20th at 9 AM", + "datetimeEnglish": "March 20th at 9 AM", + "elapsedMs": 7073, + "tokensPerSecond": 5.7966916442810685, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"expense report\",\"datetime_expression_original\":\"March 20th at 9 AM\",\"datetime_expression_english\":\"March 20th at 9 AM\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_event_birthday", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found birthday in \"mom's birthday party\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-04-05 18:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "mom's birthday party", + "datetimeOriginal": "mom's birthday party on April 5th at 6 PM", + "datetimeEnglish": "mom's birthday party on April 5th at 6 pm", + "elapsedMs": 7549, + "tokensPerSecond": 6.755861703536892, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"mom's birthday party\",\"datetime_expression_original\":\"mom's birthday party on April 5th at 6 PM\",\"datetime_expression_english\":\"mom's birthday party on April 5th at 6 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_note_idea_short", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found rust, sensor in \"Try using Rust for the sensor driver\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "Try using Rust for the sensor driver", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6492, + "tokensPerSecond": 4.467036352433765, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"Try using Rust for the sensor driver\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_fika", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found fika, lisa in \"fika med Lisa\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-13 15:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "fika med Lisa", + "datetimeOriginal": "på fredag klockan 15", + "datetimeEnglish": "on Friday at 3 pm", + "elapsedMs": 7016, + "tokensPerSecond": 5.701254275940707, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"fika med Lisa\",\"datetime_expression_original\":\"på fredag klockan 15\",\"datetime_expression_english\":\"on Friday at 3 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_pickup_kids", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found hämta, barn in \"hämta barnen\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 16:30:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "hämta barnen", + "datetimeOriginal": "imorgon klockan halv 5", + "datetimeEnglish": "tomorrow at 4:30 pm", + "elapsedMs": 7219, + "tokensPerSecond": 6.09502701205153, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"hämta barnen\",\"datetime_expression_original\":\"imorgon klockan halv 5\",\"datetime_expression_english\":\"tomorrow at 4:30 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_doctor", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found läkar in \"Läkartid\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-18 10:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "Läkartid", + "datetimeOriginal": "den 18 mars klockan 10", + "datetimeEnglish": "March 18th at 10 am", + "elapsedMs": 7314, + "tokensPerSecond": 6.289308176100629, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"Läkartid\",\"datetime_expression_original\":\"den 18 mars klockan 10\",\"datetime_expression_english\":\"March 18th at 10 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_medicine", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found medicin in \"Ta medicinen varje dag\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "reminder", + "title": "Ta medicinen varje dag", + "datetimeOriginal": "klockan 8 på morgonen", + "datetimeEnglish": "at 8 am every day", + "elapsedMs": 7030, + "tokensPerSecond": 5.689900426742532, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"Ta medicinen varje dag\",\"datetime_expression_original\":\"klockan 8 på morgonen\",\"datetime_expression_english\":\"at 8 am every day\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_event_dinner", + "passed": false, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found middag, mamma in \"middag hos mamma och pappa\"; ", + "timeResolutionCorrect": false, + "timeResolutionDetail": "item[0]: got 2026-03-11 18:00:00.000, expected 2026-03-14 18:00:00.000 (diff 4320min, tolerance 5min); ", + "intent": "event", + "title": "middag hos mamma och pappa", + "datetimeOriginal": "middag klockan 18", + "datetimeEnglish": "lunch at 6 pm", + "elapsedMs": 7095, + "tokensPerSecond": 5.778717406624383, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"middag hos mamma och pappa\",\"datetime_expression_original\":\"middag klockan 18\",\"datetime_expression_english\":\"lunch at 6 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true, + "itemFailures": [ + "item[0] time: got 2026-03-11 18:00:00.000, expected 2026-03-14 18:00:00.000 (diff 4320min, tolerance 5min)" + ] + }, + { + "caseName": "sv_event_car_service", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found bilservice in \"bilservice\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-17 08:00:00.000 OK (via chrono+adjusted); ", + "intent": "event", + "title": "bilservice", + "datetimeOriginal": "på tisdag klockan 8", + "datetimeEnglish": "on Tuesday at 8 am", + "elapsedMs": 6960, + "tokensPerSecond": 5.459770114942529, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"bilservice\",\"datetime_expression_original\":\"på tisdag klockan 8\",\"datetime_expression_english\":\"on Tuesday at 8 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_note_grocery", + "passed": false, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found handla, potatis in \"handla potatis\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "handla potatis", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 8541, + "tokensPerSecond": 8.312843929282286, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"handla potatis\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"lök\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"grädde\",\"datetime_expression_original\":null,\"datetime_ex...", + "error": null, + "extractedCount": 3, + "expectedCount": 1, + "countMatch": false, + "itemFailures": [ + "item[1] unexpected extra extraction", + "item[2] unexpected extra extraction" + ] + }, + { + "caseName": "sv_event_parents_meeting", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found föräldramöte in \"föräldramöte\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-18 18:30:00.000 OK (via chrono+adjusted); ", + "intent": "event", + "title": "föräldramöte", + "datetimeOriginal": "onsdag klockan 18:30", + "datetimeEnglish": "on Wednesday at 6:30 pm", + "elapsedMs": 7450, + "tokensPerSecond": 6.308724832214765, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"föräldramöte\",\"datetime_expression_original\":\"onsdag klockan 18:30\",\"datetime_expression_english\":\"on Wednesday at 6:30 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_reminder_deadline", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found rapport, skicka in \"Skicka in rapporten\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-13 12:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "Skicka in rapporten", + "datetimeOriginal": "senast fredag klockan 12", + "datetimeEnglish": "on Friday at 12 pm", + "elapsedMs": 7158, + "tokensPerSecond": 6.007264599050013, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"Skicka in rapporten\",\"datetime_expression_original\":\"senast fredag klockan 12\",\"datetime_expression_english\":\"on Friday at 12 pm\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_en_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found compass in \"add compass widget\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "add compass widget", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6295, + "tokensPerSecond": 3.971405877680699, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"add compass widget\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_sv_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found e-paper, display in \"testa att använda e-paper display till nästa version\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "testa att använda e-paper display till nästa version", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6682, + "tokensPerSecond": 4.938641125411553, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"testa att använda e-paper display till nästa version\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_en_quick_reminder", + "passed": false, + "validJson": true, + "intentMatch": false, + "timePresenceMatch": false, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found water, plant in \"water the plants\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "water the plants", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6302, + "tokensPerSecond": 3.9669946048873377, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"water the plants\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true, + "itemFailures": [ + "item[0] intent: got \"note\", expected \"reminder\"", + "item[0] time presence: got false, expected true" + ] + }, + { + "caseName": "voice_short_sv_quick_reminder", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found ring, försäkring in \"ring försäkringsbolaget\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "reminder", + "title": "ring försäkringsbolaget", + "datetimeOriginal": "imorgon", + "datetimeEnglish": "tomorrow", + "elapsedMs": 6685, + "tokensPerSecond": 4.93642483171279, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"ring försäkringsbolaget\",\"datetime_expression_original\":\"imorgon\",\"datetime_expression_english\":\"tomorrow\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_en_fragment", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found batter in \"buy batteries\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "buy batteries", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6305, + "tokensPerSecond": 3.8065027755749408, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"buy batteries\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_short_sv_fragment", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found boka, frisör in \"boka tid hos frisören\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "boka tid hos frisören", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6440, + "tokensPerSecond": 4.3478260869565215, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"boka tid hos frisören\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_en_rambling_reminder", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found dry clean in \"pick up the dry cleaning\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "reminder", + "title": "pick up the dry cleaning", + "datetimeOriginal": "tomorrow before noon", + "datetimeEnglish": "tomorrow before noon", + "elapsedMs": 6796, + "tokensPerSecond": 5.002942907592701, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"pick up the dry cleaning\",\"datetime_expression_original\":\"tomorrow before noon\",\"datetime_expression_english\":\"tomorrow before noon\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_sv_rambling_event", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found budget in \"budget meeting\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 10:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "budget meeting", + "datetimeOriginal": "på torsdag klockan 10", + "datetimeEnglish": "Thursday at 10 am", + "elapsedMs": 6991, + "tokensPerSecond": 5.435560005721642, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"budget meeting\",\"datetime_expression_original\":\"på torsdag klockan 10\",\"datetime_expression_english\":\"Thursday at 10 am\"}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_en_idea_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found light, sensor in \"integrate ambient light sensor\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "integrate ambient light sensor", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6545, + "tokensPerSecond": 4.125286478227655, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"integrate ambient light sensor\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "voice_long_sv_idea_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found sömnspårning in \"lägg till sömnspårning\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "lägg till sömnspårning", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6691, + "tokensPerSecond": 4.334180242116275, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"lägg till sömnspårning\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "sv_multi_fika_and_errand", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found fika, anna in \"fika med Anna\"; item[1]: found paket in \"lämna in paketet\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 10:00:00.000 OK (via chrono); item[1]: no time check; ", + "intent": "event", + "title": "fika med Anna", + "datetimeOriginal": "imorgon klockan 10", + "datetimeEnglish": "tomorrow at 10 am", + "elapsedMs": 8277, + "tokensPerSecond": 7.973903588256616, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"fika med Anna\",\"datetime_expression_original\":\"imorgon klockan 10\",\"datetime_expression_english\":\"tomorrow at 10 am\"},{\"intent\":\"note\",\"title\":\"lämna in paketet\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_multi_three_items", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found tandläkare, ring in \"ring tandläkaren\"; item[1]: found present in \"köp presenter\"; item[2]: found möte, chef in \"möte med chefen\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:00:00.000 OK (via chrono); item[1]: no time check; item[2]: 2026-03-13 14:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "ring tandläkaren", + "datetimeOriginal": "imorgon klockan 9", + "datetimeEnglish": "tomorrow at 9 am", + "elapsedMs": 13035, + "tokensPerSecond": 12.58151131568853, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"ring tandläkaren\",\n \"datetime_expression_original\": \"imorgon klockan 9\",\n \"datetime_expression_english\": \"tomorrow at 9 am\"\n },\n {\n \"intent\": \"note\",\n \"title\": \"köp presenter\",\n \"datetime_expression_original\": null,\n \"datetime_express...", + "error": null, + "extractedCount": 3, + "expectedCount": 3, + "countMatch": true + }, + { + "caseName": "en_multi_event_and_idea", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found standup in \"team standup\"; item[1]: found notification, prototype in \"prototype notification system\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:15:00.000 OK (via chrono); item[1]: no time check; ", + "intent": "event", + "title": "team standup", + "datetimeOriginal": "tomorrow at 9:15", + "datetimeEnglish": "tomorrow at 9:15 am", + "elapsedMs": 10205, + "tokensPerSecond": 10.289073983341499, + "outputPreview": "[\n {\n \"intent\": \"event\",\n \"title\": \"team standup\",\n \"datetime_expression_original\": \"tomorrow at 9:15\",\n \"datetime_expression_english\": \"tomorrow at 9:15 am\"\n },\n {\n \"intent\": \"note\",\n \"title\": \"prototype notification system\",\n \"datetime_expression_original\": null,\n \"datet...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "voice_long_multi_sv", + "passed": false, + "validJson": true, + "intentMatch": false, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found gym in \"gå till gymmet\"; item[1]: found projekt in \"prata om projektet\"; item[2]: found kattsand in \"köpa kattsand\"; ", + "timeResolutionCorrect": false, + "timeResolutionDetail": "item[0]: 2026-03-12 08:00:00.000 OK (via chrono); item[1]: got 2026-03-11 15:00:00.000, expected 2026-03-12 15:00:00.000 (diff 1440min, tolerance 5min); item[2]: no time check; ", + "intent": "reminder", + "title": "gå till gymmet", + "datetimeOriginal": "imorgon klockan 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 13049, + "tokensPerSecond": 12.568012874549774, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"gå till gymmet\",\n \"datetime_expression_original\": \"imorgon klockan 8\",\n \"datetime_expression_english\": \"tomorrow at 8 am\"\n },\n {\n \"intent\": \"event\",\n \"title\": \"prata om projektet\",\n \"datetime_expression_original\": \"på eftermiddagen typ k...", + "error": null, + "extractedCount": 3, + "expectedCount": 3, + "countMatch": true, + "itemFailures": [ + "item[1] time: got 2026-03-11 15:00:00.000, expected 2026-03-12 15:00:00.000 (diff 1440min, tolerance 5min)", + "item[2] intent: got \"reminder\", expected \"note\"" + ] + }, + { + "caseName": "sv_multi_dev_tasks_swenglish", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found clean, kod in \"cleana upp klockans kod\"; item[1]: found testa in \"testa att avbryta en kalender tilläggning\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "note", + "title": "cleana upp klockans kod", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 10023, + "tokensPerSecond": 10.07682330639529, + "outputPreview": "[\n {\n \"intent\": \"note\",\n \"title\": \"cleana upp klockans kod\",\n \"datetime_expression_original\": null,\n \"datetime_expression_english\": null\n },\n {\n \"intent\": \"note\",\n \"title\": \"testa att avbryta en kalender tilläggning\",\n \"datetime_expression_original\": null,\n \"datetime_expre...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_multi_casual_planning", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found bugg, bluetooth in \"fixa buggen med Bluetooth-anslutningen\"; item[1]: found refaktor, sensor in \"refaktorera sensor-koden\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "note", + "title": "fixa buggen med Bluetooth-anslutningen", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 7934, + "tokensPerSecond": 7.436349886564154, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"fixa buggen med Bluetooth-anslutningen\",\"datetime_expression_original\":null,\"datetime_expression_english\":null},{\"intent\":\"note\",\"title\":\"refaktorera sensor-koden\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_note_swenglish_long", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found sync, notification, feature in \"add feature to sync notifications via BLE\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; ", + "intent": "note", + "title": "add feature to sync notifications via BLE", + "datetimeOriginal": null, + "datetimeEnglish": null, + "elapsedMs": 6552, + "tokensPerSecond": 4.426129426129426, + "outputPreview": "[{\"intent\":\"note\",\"title\":\"add feature to sync notifications via BLE\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 1, + "expectedCount": 1, + "countMatch": true + }, + { + "caseName": "en_multi_two_reminders", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found dog, pick in \"pick up the dog\"; item[1]: found light in \"turn off all lights\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "reminder", + "title": "pick up the dog", + "datetimeOriginal": "tomorrow at 5 pm", + "datetimeEnglish": "tomorrow at 5 pm", + "elapsedMs": 8378, + "tokensPerSecond": 8.11649558367152, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"pick up the dog\",\"datetime_expression_original\":\"tomorrow at 5 pm\",\"datetime_expression_english\":\"tomorrow at 5 pm\"},{\"intent\":\"reminder\",\"title\":\"turn off all lights\",\"datetime_expression_original\":\"at 9\",\"datetime_expression_english\":\"tomorrow at 9 pm\"}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_reminder_and_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found plumber in \"call the plumber\"; item[1]: found light, bulb in \"buy new light bulbs\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; ", + "intent": "reminder", + "title": "call the plumber", + "datetimeOriginal": "call the plumber at 3 pm tomorrow", + "datetimeEnglish": "call the plumber at 3 pm tomorrow", + "elapsedMs": 8096, + "tokensPerSecond": 7.781620553359684, + "outputPreview": "[{\"intent\":\"reminder\",\"title\":\"call the plumber\",\"datetime_expression_original\":\"call the plumber at 3 pm tomorrow\",\"datetime_expression_english\":\"call the plumber at 3 pm tomorrow\"},{\"intent\":\"note\",\"title\":\"buy new light bulbs\",\"datetime_expression_original\":null,\"datetime_expression_english\":null...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_two_events", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found sarah in \"Meeting with Sarah\"; item[1]: found lunch in \"lunch with the team\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-16 10:00:00.000 OK (via chrono+adjusted); item[1]: 2026-03-18 12:00:00.000 OK (via chrono+adjusted); ", + "intent": "event", + "title": "Meeting with Sarah", + "datetimeOriginal": "on Monday at 10 am", + "datetimeEnglish": "on Monday at 10 am", + "elapsedMs": 8572, + "tokensPerSecond": 8.399440037330846, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"Meeting with Sarah\",\"datetime_expression_original\":\"on Monday at 10 am\",\"datetime_expression_english\":\"on Monday at 10 am\"},{\"intent\":\"event\",\"title\":\"lunch with the team\",\"datetime_expression_original\":\"on Wednesday at noon\",\"datetime_expression_english\":\"on Wednesday at...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_three_mixed", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: no keyword check; item[1]: found lunch in \"have lunch with Mike\"; item[2]: found grocer in \"buy groceries\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: no time check; item[1]: no time check; item[2]: no time check; ", + "intent": "reminder", + "title": "go for a run", + "datetimeOriginal": "tomorrow at 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 12174, + "tokensPerSecond": 12.074913750616068, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"go for a run\",\n \"datetime_expression_original\": \"tomorrow at 8\",\n \"datetime_expression_english\": \"tomorrow at 8 am\"\n },\n {\n \"intent\": \"event\",\n \"title\": \"have lunch with Mike\",\n \"datetime_expression_original\": \"at noon\",\n \"datetime_ex...", + "error": null, + "extractedCount": 3, + "expectedCount": 3, + "countMatch": true + }, + { + "caseName": "sv_multi_event_and_note", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found tandläkare in \"tandläkare\"; item[1]: found handla in \"handla mat\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-15 09:30:00.000 OK (via chrono); item[1]: no time check; ", + "intent": "event", + "title": "tandläkare", + "datetimeOriginal": "den 15 mars klockan halv 10", + "datetimeEnglish": "March 15th at 9:30 am", + "elapsedMs": 8567, + "tokensPerSecond": 8.40434224349247, + "outputPreview": "[{\"intent\":\"event\",\"title\":\"tandläkare\",\"datetime_expression_original\":\"den 15 mars klockan halv 10\",\"datetime_expression_english\":\"March 15th at 9:30 am\"},{\"intent\":\"note\",\"title\":\"handla mat\",\"datetime_expression_original\":null,\"datetime_expression_english\":null}]", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "de_multi_two_events", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found arzt in \"Arzttermin\"; item[1]: found zahnarzt in \"Zahnarzt\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 09:00:00.000 OK (via chrono); item[1]: 2026-03-13 14:00:00.000 OK (via chrono); ", + "intent": "event", + "title": "Arzttermin", + "datetimeOriginal": "am Donnerstag um 9 Uhr", + "datetimeEnglish": "Thursday at 9 am", + "elapsedMs": 10491, + "tokensPerSecond": 10.675817367267182, + "outputPreview": "[\n {\n \"intent\": \"event\",\n \"title\": \"Arzttermin\",\n \"datetime_expression_original\": \"am Donnerstag um 9 Uhr\",\n \"datetime_expression_english\": \"Thursday at 9 am\"\n },\n {\n \"intent\": \"event\",\n \"title\": \"Zahnarzt\",\n \"datetime_expression_original\": \"am Freitag um 14 Uhr\",\n \"dateti...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "en_multi_same_day_reminders", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found electrician in \"call the electrician\"; item[1]: found kids in \"pick up the kids from school\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-11 15:00:00.000 OK (via chrono); item[1]: 2026-03-11 18:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "call the electrician", + "datetimeOriginal": "Today at 3 pm", + "datetimeEnglish": "Today at 3 pm", + "elapsedMs": 10440, + "tokensPerSecond": 10.632183908045977, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"call the electrician\",\n \"datetime_expression_original\": \"Today at 3 pm\",\n \"datetime_expression_english\": \"Today at 3 pm\"\n },\n {\n \"intent\": \"reminder\",\n \"title\": \"pick up the kids from school\",\n \"datetime_expression_original\": \"at 6 pm\",\n...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + }, + { + "caseName": "sv_multi_two_reminders", + "passed": true, + "validJson": true, + "intentMatch": true, + "timePresenceMatch": true, + "titleLanguageMatch": true, + "titleLanguageDetail": "item[0]: found gym in \"gå till gymmet\"; item[1]: found paket in \"hämta paketet på posten\"; ", + "timeResolutionCorrect": true, + "timeResolutionDetail": "item[0]: 2026-03-12 08:00:00.000 OK (via chrono); item[1]: 2026-03-12 15:00:00.000 OK (via chrono); ", + "intent": "reminder", + "title": "gå till gymmet", + "datetimeOriginal": "imorgon klockan 8", + "datetimeEnglish": "tomorrow at 8 am", + "elapsedMs": 10867, + "tokensPerSecond": 11.134627772154227, + "outputPreview": "[\n {\n \"intent\": \"reminder\",\n \"title\": \"gå till gymmet\",\n \"datetime_expression_original\": \"imorgon klockan 8\",\n \"datetime_expression_english\": \"tomorrow at 8 am\"\n },\n {\n \"intent\": \"reminder\",\n \"title\": \"hämta paketet på posten\",\n \"datetime_expression_original\": \"klockan 15\",\n ...", + "error": null, + "extractedCount": 2, + "expectedCount": 2, + "countMatch": true + } + ] + } + ] +} \ No newline at end of file diff --git a/ai_testbench/lib/benchmark_main.dart b/ai_testbench/lib/benchmark_main.dart index ee44c9e..ba49133 100644 --- a/ai_testbench/lib/benchmark_main.dart +++ b/ai_testbench/lib/benchmark_main.dart @@ -30,9 +30,38 @@ Future main(List args) async { return; } + final filteredModelPaths = _filterModelPaths( + modelPaths, + config.modelFilter, + ); + if (filteredModelPaths.isEmpty) { + stdout.writeln('[BenchmarkRunner] No matching .gguf files found'); + if (config.modelFilter != null) { + stdout.writeln('[BenchmarkRunner] Model filter: ${config.modelFilter}'); + } + exitCode = 1; + return; + } + + final selectedCases = _selectBenchmarkCases( + caseFilter: config.caseFilter, + caseLimit: config.caseLimit, + ); + if (selectedCases.isEmpty) { + stdout.writeln('[BenchmarkRunner] No benchmark cases matched the request'); + if (config.caseFilter != null) { + stdout.writeln('[BenchmarkRunner] Case filter: ${config.caseFilter}'); + } + exitCode = 1; + return; + } + await _runHeadlessBenchmark( - modelPaths: modelPaths, + modelPaths: filteredModelPaths, outputPath: config.outputPath, + selectedCases: selectedCases, + modelFilter: config.modelFilter, + caseFilter: config.caseFilter, ); return; } @@ -60,11 +89,17 @@ class _RunnerConfig { required this.headless, required this.modelDir, required this.outputPath, + required this.modelFilter, + required this.caseFilter, + required this.caseLimit, }); final bool headless; final String modelDir; final String? outputPath; + final String? modelFilter; + final String? caseFilter; + final int? caseLimit; } _RunnerConfig _parseConfig(List args) { @@ -80,12 +115,19 @@ _RunnerConfig _parseConfig(List args) { final modelDir = readValue('--model-dir') ?? Directory('models').absolute.path; final outputPath = readValue('--output'); + final modelFilter = readValue('--model'); + final caseFilter = readValue('--case'); + final caseLimitValue = readValue('--case-limit'); + final caseLimit = caseLimitValue == null ? null : int.tryParse(caseLimitValue); final headless = hasFlag('--headless') || Platform.environment['AI_BENCH_HEADLESS'] == '1'; return _RunnerConfig( headless: headless, modelDir: modelDir, outputPath: outputPath, + modelFilter: modelFilter, + caseFilter: caseFilter, + caseLimit: caseLimit, ); } @@ -99,9 +141,44 @@ List _discoverModelPaths(String modelDir) { ..sort(); } +List _filterModelPaths(List modelPaths, String? modelFilter) { + if (modelFilter == null || modelFilter.isEmpty) { + return modelPaths; + } + + final filter = modelFilter.toLowerCase(); + return modelPaths + .where((path) => path.toLowerCase().contains(filter)) + .toList(growable: false); +} + +List _selectBenchmarkCases({ + String? caseFilter, + int? caseLimit, +}) { + Iterable selected = ModelBenchmarkService.benchmarkCases; + + if (caseFilter != null && caseFilter.isNotEmpty) { + final filter = caseFilter.toLowerCase(); + selected = selected.where((benchmarkCase) { + return benchmarkCase.name.toLowerCase().contains(filter) || + benchmarkCase.transcript.toLowerCase().contains(filter); + }); + } + + if (caseLimit != null && caseLimit > 0) { + selected = selected.take(caseLimit); + } + + return selected.toList(growable: false); +} + Future _runHeadlessBenchmark({ required List modelPaths, required String? outputPath, + required List selectedCases, + required String? modelFilter, + required String? caseFilter, }) async { final service = ModelBenchmarkService(); @@ -110,10 +187,21 @@ Future _runHeadlessBenchmark({ for (final modelPath in modelPaths) { stdout.writeln(' - ${modelPath.split(Platform.pathSeparator).last}'); } + if (modelFilter != null && modelFilter.isNotEmpty) { + stdout.writeln('[BenchmarkRunner] Model filter: $modelFilter'); + } + stdout.writeln('[BenchmarkRunner] Case count: ${selectedCases.length}'); + if (caseFilter != null && caseFilter.isNotEmpty) { + stdout.writeln('[BenchmarkRunner] Case filter: $caseFilter'); + } + for (final benchmarkCase in selectedCases) { + stdout.writeln(' * ${benchmarkCase.name}'); + } final startedAt = DateTime.now().toUtc(); final results = await service.runForModels( modelPaths, + selectedCases: selectedCases, onProgress: (progress) { final completed = progress.completedRuns; final total = progress.totalRuns; @@ -130,7 +218,9 @@ Future _runHeadlessBenchmark({ 'startedAt': startedAt.toIso8601String(), 'finishedAt': finishedAt.toIso8601String(), 'modelCount': results.length, - 'caseCount': ModelBenchmarkService.benchmarkCases.length, + 'caseCount': selectedCases.length, + if (modelFilter != null && modelFilter.isNotEmpty) 'modelFilter': modelFilter, + if (caseFilter != null && caseFilter.isNotEmpty) 'caseFilter': caseFilter, 'results': results.map(_serializeModelResult).toList(growable: false), }; diff --git a/ai_testbench/lib/main.dart b/ai_testbench/lib/main.dart index fd25e51..4845f98 100644 --- a/ai_testbench/lib/main.dart +++ b/ai_testbench/lib/main.dart @@ -26,7 +26,7 @@ void main(List args) async { if (args.contains('--headless') || Platform.environment['AI_BENCH_HEADLESS'] == '1') { await model_bench.main(args); - return; + exit(exitCode); } WidgetsFlutterBinding.ensureInitialized(); diff --git a/ai_testbench/lib/services/llm_service.dart b/ai_testbench/lib/services/llm_service.dart index 633543b..5abcff6 100644 --- a/ai_testbench/lib/services/llm_service.dart +++ b/ai_testbench/lib/services/llm_service.dart @@ -57,7 +57,10 @@ class LlmService { static void _logFilter(String log) { if (log.contains('loaded') || log.contains('error') || log.contains('Error') || log.contains('token') || log.contains('speed') || log.contains('FAILED') || - log.contains('Model loaded') || log.contains('Initialized')) { + log.contains('Model loaded') || log.contains('Initialized') || + log.contains('Backend initialized') || log.contains('Available backends') || + log.contains('GPU offload') || log.contains('Number of GPU layers requested') || + log.contains('Metal') || log.contains('Vulkan') || log.contains('CUDA')) { debugPrint('[llama.cpp] $log'); } } @@ -100,6 +103,7 @@ class LlmService { modelPath: _modelPath!, maxTokens: overrideMaxTokens ?? maxTokens, numGpuLayers: numGpuLayers, + numThreads: nThreads, temperature: temperature, topP: topP, frequencyPenalty: 0.0, @@ -176,6 +180,7 @@ class LlmService { modelPath: _modelPath!, maxTokens: overrideMaxTokens ?? maxTokens, numGpuLayers: numGpuLayers, + numThreads: nThreads, temperature: temperature, topP: topP, frequencyPenalty: 0.0, diff --git a/ai_testbench/lib/services/model_benchmark_service.dart b/ai_testbench/lib/services/model_benchmark_service.dart index 0928f19..0848620 100644 --- a/ai_testbench/lib/services/model_benchmark_service.dart +++ b/ai_testbench/lib/services/model_benchmark_service.dart @@ -597,6 +597,55 @@ class ModelBenchmarkService { ], ), + // ── Conversational Swedish / Swenglish voice notes ─────────────── + + BenchmarkCase( + name: 'sv_multi_dev_tasks_swenglish', + transcript: + 'Vi ska cleana upp klockans kod för voice memons sen ska vi testa att ' + 'det går avbryta en calender tilläggning genom att klicka cancel på popup på klockan', + expectedItems: [ + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['clean', 'kod', 'voice'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['testa', 'calender', 'cancel'], + ), + ], + ), + BenchmarkCase( + name: 'sv_multi_casual_planning', + transcript: + 'Vi behöver fixa buggen med Bluetooth-anslutningen och sen ska vi ' + 'refaktorera sensor-koden lite grann.', + expectedItems: [ + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['bugg', 'bluetooth'], + ), + ExpectedItem( + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['refaktor', 'sensor'], + ), + ], + ), + BenchmarkCase.single( + name: 'sv_note_swenglish_long', + transcript: + 'Jag tänkte att vi kanske borde adda en feature för att synca ' + 'notifications mellan telefonen och klockan typ via BLE, ' + 'kolla om det finns nåt library för det.', + expectedIntent: 'note', + expectTime: false, + titleLanguageKeywords: ['sync', 'notification', 'feature'], + ), + // ── Multi-item cases ───────────────────────────────────────────── BenchmarkCase( @@ -753,9 +802,11 @@ class ModelBenchmarkService { Future> runForModels( List modelPaths, { void Function(BenchmarkProgress progress)? onProgress, + List? selectedCases, }) async { final results = []; - final totalCases = benchmarkCases.length; + final casesToRun = selectedCases ?? benchmarkCases; + final totalCases = casesToRun.length; final totalRuns = modelPaths.length * totalCases; var completedRuns = 0; final resolver = TimeExpressionResolver(); @@ -764,7 +815,7 @@ class ModelBenchmarkService { final modelPath = modelPaths[modelIndex]; final llm = LlmService() ..setModel(modelPath) - ..nCtx = 2048 + ..nCtx = 4096 ..nThreads = Platform.numberOfProcessors ..maxTokens = 384 ..temperature = 0.3 @@ -775,9 +826,9 @@ class ModelBenchmarkService { final caseResults = []; try { for (var caseIndex = 0; - caseIndex < benchmarkCases.length; + caseIndex < casesToRun.length; caseIndex++) { - final testCase = benchmarkCases[caseIndex]; + final testCase = casesToRun[caseIndex]; onProgress?.call( BenchmarkProgress( totalModels: modelPaths.length, diff --git a/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig b/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig +++ b/ai_testbench/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/ai_testbench/macos/Flutter/Flutter-Release.xcconfig b/ai_testbench/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/ai_testbench/macos/Flutter/Flutter-Release.xcconfig +++ b/ai_testbench/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/ai_testbench/macos/Podfile b/ai_testbench/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/ai_testbench/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/ai_testbench/macos/Podfile.lock b/ai_testbench/macos/Podfile.lock new file mode 100644 index 0000000..5c7e944 --- /dev/null +++ b/ai_testbench/macos/Podfile.lock @@ -0,0 +1,35 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - fllama (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - fllama (from `Flutter/ephemeral/.symlinks/plugins/fllama/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + fllama: + :path: Flutter/ephemeral/.symlinks/plugins/fllama/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + +SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + fllama: 54acd3605cfd830c0cffea6b297eef964de58be1 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/ai_testbench/macos/Runner.xcodeproj/project.pbxproj b/ai_testbench/macos/Runner.xcodeproj/project.pbxproj index ec67ab5..b6a9431 100644 --- a/ai_testbench/macos/Runner.xcodeproj/project.pbxproj +++ b/ai_testbench/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 1F7F7EC55BCE2F1F8553C53F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AF401A6101B52E69F23D4EA /* Pods_Runner.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5BACA1072283716D69E57AB4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9751294267F5454B2C8B7CDC /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 21D07557C61F52EFEEB29AAB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* ai_testbench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ai_testbench.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* ai_testbench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ai_testbench.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 6367D1B332C4EC45B4AD77AE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8164D56A44957E05F530EF8B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 8D37F365B0B5A4385FDE89F7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 8E913363B4CD69F5CA30DF09 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9751294267F5454B2C8B7CDC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9AF401A6101B52E69F23D4EA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E0A254A1AD2CE3B3E6224374 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5BACA1072283716D69E57AB4 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1F7F7EC55BCE2F1F8553C53F /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 5C2190EC137385104FC591FA /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 5C2190EC137385104FC591FA /* Pods */ = { + isa = PBXGroup; + children = ( + 8164D56A44957E05F530EF8B /* Pods-Runner.debug.xcconfig */, + 6367D1B332C4EC45B4AD77AE /* Pods-Runner.release.xcconfig */, + 8E913363B4CD69F5CA30DF09 /* Pods-Runner.profile.xcconfig */, + 21D07557C61F52EFEEB29AAB /* Pods-RunnerTests.debug.xcconfig */, + 8D37F365B0B5A4385FDE89F7 /* Pods-RunnerTests.release.xcconfig */, + E0A254A1AD2CE3B3E6224374 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 9AF401A6101B52E69F23D4EA /* Pods_Runner.framework */, + 9751294267F5454B2C8B7CDC /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 405C8E04DCD64439122D9136 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + DB8E0CF7AF0D76C764C51B99 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 0F0D8415CC687AEA57058C2C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0F0D8415CC687AEA57058C2C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +378,50 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 405C8E04DCD64439122D9136 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DB8E0CF7AF0D76C764C51B99 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 21D07557C61F52EFEEB29AAB /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8D37F365B0B5A4385FDE89F7 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E0A254A1AD2CE3B3E6224374 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata b/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/ai_testbench/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ai_testbench/run_benchmark.py b/ai_testbench/run_benchmark.py index 217d872..7434392 100644 --- a/ai_testbench/run_benchmark.py +++ b/ai_testbench/run_benchmark.py @@ -5,6 +5,7 @@ import sys import json import os +import argparse APP_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), @@ -17,18 +18,33 @@ def main(): + parser = argparse.ArgumentParser(description="Run ai_testbench headless benchmark and capture output.") + parser.add_argument("--model", help="Substring filter for model filename") + parser.add_argument("--case", dest="case_filter", help="Substring filter for benchmark case name or transcript") + parser.add_argument("--case-limit", type=int, help="Maximum number of benchmark cases to run after filtering") + parser.add_argument("--output", help="Override output JSON path") + args = parser.parse_args() + os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) + output_file = args.output or OUTPUT_FILE cmd = [ APP_PATH, "--headless", "--model-dir", MODEL_DIR, - "--output", OUTPUT_FILE, + "--output", output_file, ] + if args.model: + cmd.extend(["--model", args.model]) + if args.case_filter: + cmd.extend(["--case", args.case_filter]) + if args.case_limit is not None: + cmd.extend(["--case-limit", str(args.case_limit)]) + print(f"Running: {' '.join(cmd)}") print(f"Model dir: {MODEL_DIR}") - print(f"Output: {OUTPUT_FILE}") + print(f"Output: {output_file}") print("-" * 60) proc = subprocess.Popen( @@ -50,8 +66,8 @@ def main(): print(f"Exit code: {proc.returncode}") # Try to load and pretty-print results - if os.path.exists(OUTPUT_FILE): - with open(OUTPUT_FILE) as f: + if os.path.exists(output_file): + with open(output_file) as f: results = json.load(f) print("\n" + "=" * 60) @@ -100,7 +116,7 @@ def main(): if case.get("error"): print(f" ERROR: {case['error']}") else: - print(f"No output file found at {OUTPUT_FILE}") + print(f"No output file found at {output_file}") return proc.returncode diff --git a/ai_testbench/show_failures.py b/ai_testbench/show_failures.py new file mode 100644 index 0000000..9f4ec0c --- /dev/null +++ b/ai_testbench/show_failures.py @@ -0,0 +1,12 @@ +import json +with open('benchmark_results/results.json') as f: + data = json.load(f) +for c in data['results'][0]['cases']: + if not c['passed']: + fails = c.get('itemFailures', []) + out = c.get('outputPreview','')[:200] + print(f"FAIL: {c['caseName']}") + print(f" output: {out}") + for f2 in fails: + print(f" - {f2}") + print() diff --git a/ai_testbench/test_single_prompt.py b/ai_testbench/test_single_prompt.py new file mode 100644 index 0000000..8d947f7 --- /dev/null +++ b/ai_testbench/test_single_prompt.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Quick test: run a single transcript through the model and print the raw output.""" +import subprocess, os, sys, json + +APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "build/macos/Build/Products/Release/ai_testbench.app/Contents/MacOS/ai_testbench") + +# The transcript to test - pass as arg or use default +transcript = sys.argv[1] if len(sys.argv) > 1 else \ + "Vi ska cleana upp klockans kod för voice memons sen ska vi testa att det går avbryta en calender tilläggning genom att klicka cancel på popup på klockan" + +MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models/Qwen3.5-2B-Q4_K_M.gguf") +OUTPUT = "/tmp/single_test_result.json" + +cmd = [APP, "--headless", "--model-dir", os.path.dirname(MODEL), "--output", OUTPUT] +print(f"Transcript: {transcript}") +print(f"Running benchmark (all cases)...") +print("(We'll grep the output for our specific case)") +print("-" * 60) + +proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) + +# Read results +if os.path.exists(OUTPUT): + with open(OUTPUT) as f: + data = json.load(f) + # Print summary for all cases + for model in data.get('results', []): + print(f"Model: {model['modelName']} — {model['passedCases']}/{model['totalCases']} passed") + for c in model['cases']: + s = 'PASS' if c['passed'] else 'FAIL' + cnt = f"{c.get('extractedCount',1)}/{c.get('expectedCount',1)}" + fails = [] + for f_item in c.get('itemFailures', []): + fails.append(f_item) + fail_str = f" [{', '.join(fails)}]" if fails else "" + print(f" [{s}] {c['caseName']} cnt={cnt}{fail_str}") + if 'swenglish' in c['caseName'] or 'casual_planning' in c['caseName'] or 'swenglish_long' in c['caseName']: + print(f" OUTPUT: {c.get('outputPreview','')}") +else: + print("No output file found") + print("STDOUT:", proc.stdout[-2000:] if proc.stdout else "") + print("STDERR:", proc.stderr[-2000:] if proc.stderr else "") diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index 7ad4a82..e1228f4 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -25,30 +25,30 @@ Your tasks per item: 3. Translate that time/date phrase into natural English. If already English, copy it. 4. Extract a short title (the task or event, NOT the time part). -Rules: +IMPORTANT — item splitting: - ALWAYS return a JSON array, even for a single item. -- Each distinct task, event, or note in the memo becomes its own object in the array. -- Multi-item date context: when a preceding item establishes a date (e.g. "tomorrow", "on Friday"), carry it into subsequent items that only mention a time. Example: if item 1 says "tomorrow at 8 am" and item 2 says "at 3 pm", translate item 2 as "tomorrow at 3 pm". -- The title MUST stay in the SAME language as the voice memo. DO NOT translate the title to English. -- NEVER compute or resolve dates. NEVER output ISO timestamps. -- Keep time expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". -- Copy the original time phrase exactly from the memo. -- You MUST fill "datetime_expression_english" whenever "datetime_expression_original" is not null. -- If the memo is in English, copy the same English time phrase to both fields. -- If no time/date is mentioned for an item, set both datetime fields to null and intent to "note". -- Title must be short (2-5 words). Only translate datetime fields to English, NEVER the title. -- Translate time expressions accurately to natural English. Convert 24-hour to 12-hour format. Translate idioms correctly (e.g. the Swedish "halv 10" means 9:30, not 10:30). Use PM for afternoon/evening context (e.g. picking up children, dinner, after work → PM, not AM). -- Translate weekday references directly. Do NOT add "next" unless the original explicitly says "next" or equivalent ("nächsten", "nästa", "prochain"). When the original DOES contain "next" or its equivalent, you MUST preserve "next" in the English translation. E.g. "am Freitag" → "on Friday", "på torsdag" → "on Thursday", "nächsten Montag" → "next Monday", "by next Friday" → "by next Friday". -- Intent rules: - - "event" = scheduled meetings, appointments, social plans, bookings (dentist, conference, meeting with someone, lunch with a person) - - "reminder" = personal tasks/actions with a specific time that are NOT meetings/appointments (call someone at 3 pm, pick up package at 5) - - "note" = no time/date mentioned at all (buy bread, good idea about sensors) - - When a task has NO time but appears alongside timed tasks, it is a "note" — NOT a "reminder" -- Deadlines ARE time expressions: "by Friday", "bis Freitag", "senast fredag", "until Monday" → extract the deadline date/time. -- NOT time expressions (never extract these as datetime): - - Locations: "on the way home", "at work", "at the store" - - Vague conditions: "when I get home", "after lunch", "later" - - These make the intent "note", not "reminder" +- NEVER drop items. Every distinct action in the memo MUST appear as a separate object. +- Connectors that introduce a NEW item: "and", "and then", "also", "och", "och sen", "sen", "und", commas between clauses. +- A single idea with elaboration/details stays as ONE item. + +IMPORTANT — intent classification: +- "event" = meetings, appointments, social plans, bookings, standups — things you ATTEND with others or at a place (dentist, fika with someone, team standup, conference, lunch with a person) +- "reminder" = personal tasks/actions WITH a specific time — things you DO alone (call someone at 3 pm, pick up package at 5, go to gym at 8, take medicine at 8) +- "note" = NO time/date mentioned — ideas, shopping lists, tasks without a deadline (buy bread, good idea about sensors, prototype a feature) +- A task with NO time appearing alongside timed tasks is still a "note" + +Rules: +- Multi-item date context: when a preceding item establishes a date (e.g. "tomorrow"), carry it into subsequent items that only mention a time. Example: "tomorrow at 8 am ... at 3 pm" → item 2 is "tomorrow at 3 pm". +- The title MUST stay in the SAME language as the voice memo. DO NOT translate the title. +- NEVER compute or resolve dates. Keep time expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". +- Copy the original time phrase exactly. Fill "datetime_expression_english" whenever "datetime_expression_original" is not null. +- If the memo is in English, copy the same phrase to both datetime fields. +- If no time/date for an item, set both datetime fields to null. +- Title must be short (2-5 words). +- Translate time expressions to natural English. Convert 24-hour to 12-hour. Use PM for afternoon/evening context. +- Do NOT add "next" to weekday translations unless the original explicitly says "next" / "nästa" / "nächsten". +- Deadlines ARE time expressions: "by Friday", "senast fredag", "bis Freitag" → extract them. +- NOT time expressions: locations ("at the store"), vague conditions ("when I get home", "after lunch"), words that look like time but aren't ("boka tid" = book appointment) Examples: @@ -85,10 +85,31 @@ Memo: "Arzttermin am Donnerstag um 9 Uhr und Zahnarzt am Freitag um 14 Uhr" Memo: "Den Bericht bis Freitag um 17 Uhr an den Chef schicken" [{"intent":"reminder","title":"Bericht an Chef schicken","datetime_expression_original":"bis Freitag um 17 Uhr","datetime_expression_english":"on Friday at 5 pm"}] +Memo: "Boka tid hos frisören" +[{"intent":"note","title":"boka tid hos frisören","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Fika med Anna imorgon klockan 10 och sen lämna in paketet på posten" +[{"intent":"event","title":"fika med Anna","datetime_expression_original":"imorgon klockan 10","datetime_expression_english":"tomorrow at 10 am"},{"intent":"note","title":"lämna in paketet","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Vi behöver fixa buggen och sen refaktorera koden" +[{"intent":"note","title":"fixa buggen","datetime_expression_original":null,"datetime_expression_english":null},{"intent":"note","title":"refaktorera koden","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Bilservice på tisdag klockan 8" +[{"intent":"event","title":"bilservice","datetime_expression_original":"på tisdag klockan 8","datetime_expression_english":"on Tuesday at 8 am"}] + +Memo: "Hämta barnen imorgon klockan halv 5" +[{"intent":"reminder","title":"hämta barnen","datetime_expression_original":"imorgon klockan halv 5","datetime_expression_english":"tomorrow at 4:30 pm"}] + +Memo: "Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och möte med chefen på fredag klockan 14" +[{"intent":"reminder","title":"ring tandläkaren","datetime_expression_original":"imorgon klockan 9","datetime_expression_english":"tomorrow at 9 am"},{"intent":"note","title":"köp presenter","datetime_expression_original":null,"datetime_expression_english":null},{"intent":"event","title":"möte med chefen","datetime_expression_original":"på fredag klockan 14","datetime_expression_english":"on Friday at 2 pm"}] + +Memo: "Team standup tomorrow at 9:15 and I should prototype the new notification system" +[{"intent":"event","title":"team standup","datetime_expression_original":"tomorrow at 9:15","datetime_expression_english":"tomorrow at 9:15 am"},{"intent":"note","title":"prototype notification system","datetime_expression_original":null,"datetime_expression_english":null}] + WRONG — never translate the title, not even for notes: -Memo: "möte med projektgruppen på torsdag klockan 14" -WRONG: [{"intent":"event","title":"meeting with project group",...}] -RIGHT: [{"intent":"event","title":"möte projektgruppen","datetime_expression_original":"på torsdag klockan 14","datetime_expression_english":"Thursday at 2 pm"}] +Memo: "Bra idé om att lägga till stegräknare i klockan" +WRONG: [{"intent":"note","title":"add step counter to watch",...}] +RIGHT: [{"intent":"note","title":"stegräknare i klockan","datetime_expression_original":null,"datetime_expression_english":null}] Output JSON schema (always an array): [ diff --git a/packages/chrono_ai_flow/pubspec.lock b/packages/chrono_ai_flow/pubspec.lock new file mode 100644 index 0000000..d715f04 --- /dev/null +++ b/packages/chrono_ai_flow/pubspec.lock @@ -0,0 +1,397 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" + url: "https://pub.dev" + source: hosted + version: "96.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + chrono_dart: + dependency: "direct main" + description: + name: chrono_dart + sha256: ac121aeec8c8ea22765d6eff5bf5bc8caae3fda1473d996bb5ee915e1b4b8a9d + url: "https://pub.dev" + source: hosted + version: "2.0.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + day: + dependency: transitive + description: + name: day + sha256: "1e7068deb2f825a8b705d01d1116cc485ddc32531b43dc8c4bf58c5a1b87cd48" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + meta: + dependency: transitive + description: + name: meta + sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f" + url: "https://pub.dev" + source: hosted + version: "1.18.1" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" From 4c7b30791d55eb9f4c212b9e4aed571f1c7bc9d6 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 10:58:32 +0100 Subject: [PATCH 22/58] refactor: unify AI debug info with multi-action chrono display - Extract shared AiDebugInfo class into ai_debug_info.dart - Add ActionChronoDebug for per-action chrono extraction details - Forward actionChronoDetails through processTranscript/BrainDump paths - Shared ai_debug_widgets.dart: aiFormatJson, aiMemoryInfoBlock, multi-action aiFormatChronoDetails with Action N headers - Both debug sheets (benchmark + voice memos) use unified widgets --- zswatch_app/lib/providers/ai_providers.dart | 3 +- .../lib/services/ai/ai_debug_info.dart | 131 +++++++++++++ zswatch_app/lib/services/ai/llm_service.dart | 23 +++ .../services/ai/model_benchmark_service.dart | 131 ++++--------- .../services/ai/voice_note_ai_pipeline.dart | 179 ++++-------------- .../settings/ai_models_settings_screen.dart | 19 +- .../voice_memos/voice_memos_screen.dart | 163 +++++----------- .../lib/ui/widgets/ai_debug_widgets.dart | 131 ++++++++++++- 8 files changed, 411 insertions(+), 369 deletions(-) create mode 100644 zswatch_app/lib/services/ai/ai_debug_info.dart diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart index 663da18..7270a66 100644 --- a/zswatch_app/lib/providers/ai_providers.dart +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/extracted_action.dart'; import '../data/repositories/extracted_action_repository.dart'; import '../data/repositories/voice_memo_repository.dart'; +import '../services/ai/ai_debug_info.dart'; import '../services/ai/extracted_action_creation_service.dart'; import '../services/ai/llm_service.dart'; import '../services/ai/voice_note_ai_pipeline.dart'; @@ -138,7 +139,7 @@ final voiceNoteAiPipelineProvider = Provider((ref) { /// Stream of debug info from the most recent AI processing run. final aiProcessingDebugInfoProvider = - StreamProvider((ref) { + StreamProvider((ref) { final pipeline = ref.watch(voiceNoteAiPipelineProvider); return pipeline.debugInfoStream; }); diff --git a/zswatch_app/lib/services/ai/ai_debug_info.dart b/zswatch_app/lib/services/ai/ai_debug_info.dart new file mode 100644 index 0000000..e188ffb --- /dev/null +++ b/zswatch_app/lib/services/ai/ai_debug_info.dart @@ -0,0 +1,131 @@ +/// Unified debug info class shared by both the voice-memo AI pipeline and the +/// benchmark harness. Change field definitions here — both flows use the same +/// data model so the debug sheets stay in sync automatically. +class AiDebugInfo { + /// 'transcription', 'ai', or 'full-flow'. + final String testType; + final String modelName; + + // ---- Prompt / extraction details ---- + final String? promptStrategy; + final String? rawPrompt; + final String? parsedJson; + final String? extractedIntent; + final String? extractedTitle; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; + final int attempts; + final bool retryEnabled; + + // ---- Phase / live progress ---- + + /// Current phase: 'loading', 'running', 'transcribing', 'correcting', + /// 'classifying', 'done', 'error'. + final String phase; + + /// Partial or summary output. During live streaming this accumulates + /// token-by-token; on completion it holds a human-readable summary. + final String partialOutput; + final int tokens; + final Duration elapsed; + final double? tokensPerSecond; + final String? error; + + /// Full raw LLM output preserved across completion. + final String? rawOutput; + + // ---- Correction pass ---- + final String? correctedTranscription; + final int correctionTokens; + final Duration correctionElapsed; + final double? correctionTokensPerSecond; + + // ---- Transcription stage ---- + final String? transcriptionResult; + final Duration? transcriptionElapsed; + + // ---- Voice-memo context ---- + final String? filename; + final String? summary; + final String? category; + final int actionCount; + final DateTime? timestamp; + + // ---- Memory & inference parameter debug info ---- + final int? deviceMemoryMB; + final int? availableMemoryMB; + final int? modelSizeMB; + final int? memoryHeadroomMB; + final int? inferenceContextSize; + final int? inferenceGpuLayers; + final int? inferenceMaxTokensCap; + + // ---- Per-action chrono extraction details (multi-action support) ---- + final List extractedActions; + + const AiDebugInfo({ + this.testType = 'ai', + required this.modelName, + this.promptStrategy, + this.rawPrompt, + this.parsedJson, + this.extractedIntent, + this.extractedTitle, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, + this.attempts = 1, + this.retryEnabled = false, + this.phase = 'loading', + this.partialOutput = '', + this.tokens = 0, + this.elapsed = Duration.zero, + this.tokensPerSecond, + this.error, + this.rawOutput, + this.correctedTranscription, + this.correctionTokens = 0, + this.correctionElapsed = Duration.zero, + this.correctionTokensPerSecond, + this.transcriptionResult, + this.transcriptionElapsed, + this.filename, + this.summary, + this.category, + this.actionCount = 0, + this.timestamp, + this.deviceMemoryMB, + this.availableMemoryMB, + this.modelSizeMB, + this.memoryHeadroomMB, + this.inferenceContextSize, + this.inferenceGpuLayers, + this.inferenceMaxTokensCap, + this.extractedActions = const [], + }); + + bool get isComplete => phase == 'done' || phase == 'error'; + bool get isError => phase == 'error'; +} + +/// Per-action debug data for the chrono extraction / resolution display. +class ActionChronoDebug { + final String? intent; + final String? title; + final String? datetimeExpressionOriginal; + final String? datetimeExpressionEnglish; + final String? resolvedDateTime; + final String? resolverMethod; + + const ActionChronoDebug({ + this.intent, + this.title, + this.datetimeExpressionOriginal, + this.datetimeExpressionEnglish, + this.resolvedDateTime, + this.resolverMethod, + }); +} diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 7802324..5b229b9 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import '../voice_memo/whisper_lifecycle_manager.dart'; +import 'ai_debug_info.dart'; // --------------------------------------------------------------------------- // Types @@ -215,6 +216,9 @@ class TranscriptResult { final LlmInferenceMetrics? correctionMetrics; final LlmInferenceMetrics? classifyMetrics; + /// Per-action chrono extraction details for debug display. + final List actionChronoDetails; + const TranscriptResult({ required this.summary, required this.category, @@ -229,6 +233,7 @@ class TranscriptResult { this.correctedTranscription, this.correctionMetrics, this.classifyMetrics, + this.actionChronoDetails = const [], }); } @@ -989,6 +994,7 @@ class LlmService { summary: result.summary, category: result.category, actions: result.actions, + actionChronoDetails: result.actionChronoDetails, extractedIntent: result.extractedIntent, extractedTitle: result.extractedTitle, datetimeExpressionOriginal: result.datetimeExpressionOriginal, @@ -1235,6 +1241,7 @@ JSON: summary: richSummary, category: 'brain_dump', actions: result.actions, + actionChronoDetails: result.actionChronoDetails, extractedIntent: result.extractedIntent, extractedTitle: result.extractedTitle, datetimeExpressionOriginal: result.datetimeExpressionOriginal, @@ -1405,6 +1412,7 @@ JSON: String raw, ) { final actions = []; + final chronoDetails = []; String? firstResolvedDateTime; String? firstResolverMethod; @@ -1420,6 +1428,12 @@ JSON: title: title, notes: extraction.datetimeExpressionOriginal, )); + chronoDetails.add(ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + )); continue; } @@ -1442,6 +1456,14 @@ JSON: ? resolved?.dateTime.toIso8601String() : null, )); + chronoDetails.add(ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + resolvedDateTime: resolved?.dateTime.toIso8601String(), + resolverMethod: resolved?.method, + )); } final first = extractions.first; @@ -1456,6 +1478,7 @@ JSON: summary: summary, category: category, actions: actions, + actionChronoDetails: chronoDetails, extractedIntent: first.intent, extractedTitle: first.title, datetimeExpressionOriginal: first.datetimeExpressionOriginal, diff --git a/zswatch_app/lib/services/ai/model_benchmark_service.dart b/zswatch_app/lib/services/ai/model_benchmark_service.dart index 881a642..ce7252c 100644 --- a/zswatch_app/lib/services/ai/model_benchmark_service.dart +++ b/zswatch_app/lib/services/ai/model_benchmark_service.dart @@ -4,96 +4,20 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; import '../voice_memo/transcription_engine.dart'; +import 'ai_debug_info.dart'; import 'llm_service.dart'; // --------------------------------------------------------------------------- // State model // --------------------------------------------------------------------------- -/// Live-updating benchmark run (mirrors the AiProcessingDebugInfo pattern from -/// the voice-memo debug sheet so the UI can reuse the same visual style). -class BenchmarkProgress { - final String testType; // 'transcription' or 'ai' - final String modelName; - final String? promptStrategy; - final String? rawPrompt; - final String? parsedJson; - final String? extractedIntent; - final String? extractedTitle; - final String? datetimeExpressionOriginal; - final String? datetimeExpressionEnglish; - final String? resolvedDateTime; - final String? resolverMethod; - final int attempts; - final bool retryEnabled; - - /// Current phase: 'loading', 'running', 'transcribing', 'correcting', - /// 'classifying', 'done', 'error'. - final String phase; - final String partialOutput; - final int tokens; - final Duration elapsed; - final double? tokensPerSecond; - final String? error; - - /// Full raw LLM output preserved across completion. During live streaming - /// this mirrors [partialOutput]; on completion [partialOutput] is set to a - /// human-readable summary while [rawOutput] keeps the full model response. - final String? rawOutput; - - /// Corrected transcription (if the correction pass produced one). - final String? correctedTranscription; - - /// Metrics from the correction LLM pass (separate from classify metrics). - final int correctionTokens; - final Duration correctionElapsed; - final double? correctionTokensPerSecond; - - /// Reserved for richer benchmark variants that may include a separate - /// transcription stage. - final String? transcriptionResult; - final Duration? transcriptionElapsed; - - const BenchmarkProgress({ - required this.testType, - required this.modelName, - this.promptStrategy, - this.rawPrompt, - this.parsedJson, - this.extractedIntent, - this.extractedTitle, - this.datetimeExpressionOriginal, - this.datetimeExpressionEnglish, - this.resolvedDateTime, - this.resolverMethod, - this.attempts = 1, - this.retryEnabled = false, - this.phase = 'loading', - this.partialOutput = '', - this.tokens = 0, - this.elapsed = Duration.zero, - this.tokensPerSecond, - this.error, - this.rawOutput, - this.correctedTranscription, - this.correctionTokens = 0, - this.correctionElapsed = Duration.zero, - this.correctionTokensPerSecond, - this.transcriptionResult, - this.transcriptionElapsed, - }); - - bool get isComplete => phase == 'done' || phase == 'error'; - bool get isError => phase == 'error'; -} - /// Top-level state for the benchmark section. class BenchmarkState { final bool isRunning; /// Which test is currently running ('transcription' or 'ai'), null if idle. final String? runningTestType; - final BenchmarkProgress? current; + final AiDebugInfo? current; const BenchmarkState({ this.isRunning = false, @@ -104,7 +28,7 @@ class BenchmarkState { BenchmarkState copyWith({ bool? isRunning, String? runningTestType, - BenchmarkProgress? current, + AiDebugInfo? current, }) => BenchmarkState( isRunning: isRunning ?? this.isRunning, @@ -140,7 +64,7 @@ class ModelBenchmarkService { _abortRequested = true; final current = currentState.current; if (current != null) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: current.testType, modelName: current.modelName, phase: 'running', @@ -167,7 +91,7 @@ class ModelBenchmarkService { final engine = createTranscriptionEngine(type); StreamSubscription? engineSub; - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'loading', @@ -177,7 +101,7 @@ class ModelBenchmarkService { try { // Verify the audio file exists if (!File(audioFilePath).existsSync()) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'error', @@ -188,7 +112,7 @@ class ModelBenchmarkService { final available = await engine.isAvailable(); if (!available) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'error', @@ -210,7 +134,7 @@ class ModelBenchmarkService { }; // Only emit running-phase status updates while we're still running if (!currentState.current!.isComplete) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'running', @@ -219,7 +143,7 @@ class ModelBenchmarkService { } }); - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'running', @@ -231,7 +155,7 @@ class ModelBenchmarkService { sw.stop(); if (_abortRequested) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'done', @@ -241,7 +165,7 @@ class ModelBenchmarkService { return; } - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'done', @@ -249,7 +173,7 @@ class ModelBenchmarkService { elapsed: sw.elapsed, )); } catch (e) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'error', @@ -278,7 +202,7 @@ class ModelBenchmarkService { _abortRequested = false; final modelName = llmService.modelName; - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'ai', modelName: modelName, phase: 'loading', @@ -288,7 +212,7 @@ class ModelBenchmarkService { try { final isDownloaded = await llmService.isModelDownloaded(); if (!isDownloaded) { - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'ai', modelName: modelName, phase: 'error', @@ -319,7 +243,8 @@ class ModelBenchmarkService { 'classifying' => 'classifying', _ => 'running', }; - _emit(BenchmarkProgress( + final mem = llmService.lastInferenceMemoryInfo; + _emit(AiDebugInfo( testType: 'ai', modelName: modelName, promptStrategy: 'shared-chrono-flow', @@ -330,6 +255,13 @@ class ModelBenchmarkService { tokens: tokens, elapsed: sw.elapsed, tokensPerSecond: tps, + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + inferenceContextSize: mem?.contextSize, + inferenceGpuLayers: mem?.gpuLayers, + inferenceMaxTokensCap: mem?.maxTokensCap, )); }, ); @@ -340,11 +272,12 @@ class ModelBenchmarkService { result.classifyMetrics?.rawResponse ?? lastRawOutput; // Helper to extract correction metrics from result - BenchmarkProgress buildAiResult({ + final mem = llmService.lastInferenceMemoryInfo; + AiDebugInfo buildAiResult({ required String phase, required String partialOutput, }) { - return BenchmarkProgress( + return AiDebugInfo( testType: 'ai', modelName: modelName, promptStrategy: result.classifyMetrics?.promptStrategy, @@ -356,6 +289,7 @@ class ModelBenchmarkService { datetimeExpressionEnglish: result.datetimeExpressionEnglish, resolvedDateTime: result.resolvedDateTime, resolverMethod: result.resolverMethod, + extractedActions: result.actionChronoDetails, attempts: result.classifyMetrics?.attempts ?? 1, retryEnabled: result.classifyMetrics?.retryEnabled ?? false, phase: phase, @@ -368,6 +302,13 @@ class ModelBenchmarkService { correctionTokens: result.correctionMetrics?.completionTokens ?? 0, correctionElapsed: result.correctionMetrics?.wallTime ?? Duration.zero, correctionTokensPerSecond: result.correctionMetrics?.tokensPerSecond, + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + inferenceContextSize: mem?.contextSize, + inferenceGpuLayers: mem?.gpuLayers, + inferenceMaxTokensCap: mem?.maxTokensCap, ); } @@ -385,7 +326,7 @@ class ModelBenchmarkService { )); } catch (e) { debugPrint('[ModelBenchmark] AI benchmark error: $e'); - _emit(BenchmarkProgress( + _emit(AiDebugInfo( testType: 'ai', modelName: modelName, phase: 'error', @@ -411,7 +352,7 @@ class ModelBenchmarkService { // ---- Helpers ---- - void _emit(BenchmarkProgress progress) { + void _emit(AiDebugInfo progress) { _stateSubject.add(BenchmarkState( isRunning: !progress.isComplete, runningTestType: progress.isComplete ? null : progress.testType, diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart index 1f9e381..b27e000 100644 --- a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -4,122 +4,9 @@ import 'package:rxdart/rxdart.dart'; import '../../data/models/extracted_action.dart'; import '../../data/repositories/extracted_action_repository.dart'; import '../../data/repositories/voice_memo_repository.dart'; +import 'ai_debug_info.dart'; import 'llm_service.dart'; -/// Debug info from the last AI processing run. -class AiProcessingDebugInfo { - final String filename; - final String modelName; - final String? classifyPrompt; - final String? classifyPromptStrategy; - final int? classifyAttempts; - final bool retryEnabled; - final String? originalTranscription; - final String? correctedTranscription; - final String? rawLlmResponse; - final String? parsedJson; - final String? extractedIntent; - final String? extractedTitle; - final String? datetimeExpressionOriginal; - final String? datetimeExpressionEnglish; - final String? resolvedDateTime; - final String? resolverMethod; - final String? summary; - final String? category; - final int actionCount; - final Duration? correctionTime; - final double? correctionTokensPerSec; - final Duration? classifyTime; - final double? classifyTokensPerSec; - final int? correctionTokens; - final int? classifyTokens; - final DateTime timestamp; - - /// Current processing phase: 'correcting', 'classifying', 'done', or null - /// when viewing a completed result. - final String? currentPhase; - - /// Partial LLM output that builds up token-by-token during generation. - final String partialResponse; - - /// Current token count for the active generation phase. - final int liveTokenCount; - - /// Elapsed wall-clock time since inference started (live updates). - final Duration? liveElapsed; - - /// Live tokens-per-second during the current generation phase. - final double? liveTokensPerSecond; - - /// Whether processing has finished (final snapshot vs live update). - final bool isComplete; - - // --- Memory & inference parameter debug info --- - - /// Device total physical RAM in MB. - final int? deviceMemoryMB; - - /// Available (free) RAM in MB at inference time. - final int? availableMemoryMB; - - /// Model file size in MB. - final int? modelSizeMB; - - /// Headroom = availableMemoryMB - modelSizeMB. - final int? memoryHeadroomMB; - - /// Context size actually used for this inference. - final int? inferenceContextSize; - - /// GPU layers actually used for this inference. - final int? inferenceGpuLayers; - - /// Max tokens cap applied due to memory pressure (null = no cap). - final int? inferenceMaxTokensCap; - - const AiProcessingDebugInfo({ - required this.filename, - required this.modelName, - this.classifyPrompt, - this.classifyPromptStrategy, - this.classifyAttempts, - this.retryEnabled = false, - this.originalTranscription, - this.correctedTranscription, - this.rawLlmResponse, - this.parsedJson, - this.extractedIntent, - this.extractedTitle, - this.datetimeExpressionOriginal, - this.datetimeExpressionEnglish, - this.resolvedDateTime, - this.resolverMethod, - this.summary, - this.category, - this.actionCount = 0, - this.correctionTime, - this.correctionTokensPerSec, - this.classifyTime, - this.classifyTokensPerSec, - this.correctionTokens, - this.classifyTokens, - required this.timestamp, - this.currentPhase, - this.partialResponse = '', - this.liveTokenCount = 0, - this.liveElapsed, - this.liveTokensPerSecond, - this.isComplete = true, - this.deviceMemoryMB, - this.availableMemoryMB, - this.modelSizeMB, - this.memoryHeadroomMB, - this.inferenceContextSize, - this.inferenceGpuLayers, - this.inferenceMaxTokensCap, - }); -} - /// Orchestrates AI processing of voice memo transcripts. /// /// After a transcript is available, this pipeline: @@ -137,16 +24,16 @@ class VoiceNoteAiPipeline { void Function(String filename, String title)? onProcessingComplete; /// Stream of debug info from the most recent AI processing runs. - final _debugInfoSubject = BehaviorSubject.seeded(null); - Stream get debugInfoStream => _debugInfoSubject.stream; - AiProcessingDebugInfo? get lastDebugInfo => _debugInfoSubject.value; + final _debugInfoSubject = BehaviorSubject.seeded(null); + Stream get debugInfoStream => _debugInfoSubject.stream; + AiDebugInfo? get lastDebugInfo => _debugInfoSubject.value; /// Completed debug info stored per filename so the UI can retrieve results /// for a specific voice note rather than only the latest global run. - final Map _debugInfoByFile = {}; + final Map _debugInfoByFile = {}; /// Get the most recent completed debug info for [filename], or null. - AiProcessingDebugInfo? getDebugInfoForFile(String filename) => + AiDebugInfo? getDebugInfoForFile(String filename) => _debugInfoByFile[filename]; VoiceNoteAiPipeline({ @@ -180,14 +67,13 @@ class VoiceNoteAiPipeline { // Publish initial loading state so the debug sheet shows something // immediately (before the model finishes loading / first token arrives). - _debugInfoSubject.add(AiProcessingDebugInfo( + _debugInfoSubject.add(AiDebugInfo( filename: filename, modelName: _llmService.modelName, - originalTranscription: transcript, - currentPhase: 'loading', - partialResponse: '', - liveTokenCount: 0, - isComplete: false, + transcriptionResult: transcript, + phase: 'loading', + partialOutput: '', + tokens: 0, timestamp: DateTime.now(), )); @@ -206,16 +92,15 @@ class VoiceNoteAiPipeline { final elapsedMs = sw.elapsedMilliseconds; final tps = elapsedMs > 0 ? tokens / (elapsedMs / 1000.0) : 0.0; final mem = _llmService.lastInferenceMemoryInfo; - _debugInfoSubject.add(AiProcessingDebugInfo( + _debugInfoSubject.add(AiDebugInfo( filename: filename, modelName: _llmService.modelName, - originalTranscription: transcript, - currentPhase: phase, - partialResponse: partial, - liveTokenCount: tokens, - liveElapsed: sw.elapsed, - liveTokensPerSecond: tps, - isComplete: false, + transcriptionResult: transcript, + phase: phase, + partialOutput: partial, + tokens: tokens, + elapsed: sw.elapsed, + tokensPerSecond: tps, timestamp: DateTime.now(), deviceMemoryMB: mem?.deviceMB, availableMemoryMB: mem?.availableMB, @@ -288,16 +173,16 @@ class VoiceNoteAiPipeline { // Publish final debug info and store per-file final mem = _llmService.lastInferenceMemoryInfo; - final finalDebug = AiProcessingDebugInfo( + final finalDebug = AiDebugInfo( filename: filename, modelName: _llmService.modelName, - classifyPrompt: result.classifyMetrics?.rawPrompt, - classifyPromptStrategy: result.classifyMetrics?.promptStrategy, - classifyAttempts: result.classifyMetrics?.attempts, + rawPrompt: result.classifyMetrics?.rawPrompt, + promptStrategy: result.classifyMetrics?.promptStrategy, + attempts: result.classifyMetrics?.attempts ?? 1, retryEnabled: result.classifyMetrics?.retryEnabled ?? false, - originalTranscription: result.originalTranscription, + transcriptionResult: result.originalTranscription, correctedTranscription: result.correctedTranscription, - rawLlmResponse: result.classifyMetrics?.rawResponse, + rawOutput: result.classifyMetrics?.rawResponse, parsedJson: result.classifyMetrics?.parsedJson, extractedIntent: result.extractedIntent, extractedTitle: result.extractedTitle, @@ -305,17 +190,17 @@ class VoiceNoteAiPipeline { datetimeExpressionEnglish: result.datetimeExpressionEnglish, resolvedDateTime: result.resolvedDateTime, resolverMethod: result.resolverMethod, + extractedActions: result.actionChronoDetails, summary: result.summary, category: result.category, actionCount: result.actions.length, - correctionTime: result.correctionMetrics?.wallTime, - correctionTokensPerSec: result.correctionMetrics?.tokensPerSecond, - classifyTime: result.classifyMetrics?.wallTime, - classifyTokensPerSec: result.classifyMetrics?.tokensPerSecond, - correctionTokens: result.correctionMetrics?.completionTokens, - classifyTokens: result.classifyMetrics?.completionTokens, - currentPhase: 'done', - isComplete: true, + correctionElapsed: result.correctionMetrics?.wallTime ?? Duration.zero, + correctionTokensPerSecond: result.correctionMetrics?.tokensPerSecond, + elapsed: result.classifyMetrics?.wallTime ?? Duration.zero, + tokensPerSecond: result.classifyMetrics?.tokensPerSecond, + correctionTokens: result.correctionMetrics?.completionTokens ?? 0, + tokens: result.classifyMetrics?.completionTokens ?? 0, + phase: 'done', timestamp: DateTime.now(), deviceMemoryMB: mem?.deviceMB, availableMemoryMB: mem?.availableMB, diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index 4221436..e01beaa 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -13,6 +13,7 @@ import '../../../core/theme/app_theme.dart'; import '../../../providers/ai_providers.dart'; import '../../../providers/settings_providers.dart'; import '../../../providers/voice_memo_providers.dart'; +import '../../../services/ai/ai_debug_info.dart'; import '../../../services/ai/extracted_action_creation_service.dart'; import '../../../services/ai/llm_service.dart'; import '../../../services/ai/model_benchmark_service.dart'; @@ -1696,7 +1697,7 @@ class _AiBenchmarkInputEditor extends StatelessWidget { /// Compact tile shown in the benchmark section after a completed run. class _LastResultTile extends StatelessWidget { - final BenchmarkProgress progress; + final AiDebugInfo progress; const _LastResultTile({required this.progress}); @override @@ -1880,7 +1881,7 @@ class _BenchmarkDebugSheet extends ConsumerWidget { ); } - List _buildBody(BuildContext context, BenchmarkProgress? progress) { + List _buildBody(BuildContext context, AiDebugInfo? progress) { if (progress == null) { return [aiDebugNote(context, 'Waiting for benchmark to start…')]; } @@ -1919,6 +1920,11 @@ class _BenchmarkDebugSheet extends ConsumerWidget { showCopyButton: true, ), ], + // Memory & inference info + if (aiMemoryInfoBlock(context, progress) != null) ...[ + const SizedBox(height: 12), + aiMemoryInfoBlock(context, progress)!, + ], ]; } @@ -1967,6 +1973,7 @@ class _BenchmarkDebugSheet extends ConsumerWidget { datetimeExpressionEnglish: progress.datetimeExpressionEnglish, resolvedDateTime: progress.resolvedDateTime, resolverMethod: progress.resolverMethod, + extractedActions: progress.extractedActions, )) ...[ const SizedBox(height: 12), aiDebugBlock( @@ -1979,6 +1986,7 @@ class _BenchmarkDebugSheet extends ConsumerWidget { datetimeExpressionEnglish: progress.datetimeExpressionEnglish, resolvedDateTime: progress.resolvedDateTime, resolverMethod: progress.resolverMethod, + extractedActions: progress.extractedActions, ), icon: Icons.schedule, showCopyButton: true, @@ -2000,7 +2008,7 @@ class _BenchmarkDebugSheet extends ConsumerWidget { aiDebugBlock( context, title: 'Parsed JSON', - content: progress.parsedJson!, + content: aiFormatJson(progress.parsedJson!), icon: Icons.data_object, mono: true, showCopyButton: true, @@ -2030,6 +2038,11 @@ class _BenchmarkDebugSheet extends ConsumerWidget { showCopyButton: true, ), ], + // Memory & inference info + if (aiMemoryInfoBlock(context, progress) != null) ...[ + const SizedBox(height: 12), + aiMemoryInfoBlock(context, progress)!, + ], ]; } } diff --git a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart index 5cf3674..61283be 100644 --- a/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart +++ b/zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart @@ -16,7 +16,7 @@ import '../../../services/ai/extracted_action_creation_service.dart'; import '../../widgets/ai_debug_widgets.dart'; import '../../../providers/voice_memo_providers.dart'; import '../../../providers/watch_service_provider.dart'; -import '../../../services/ai/voice_note_ai_pipeline.dart'; +import '../../../services/ai/ai_debug_info.dart'; import '../../../services/voice_memo/transcription_engine.dart'; import '../../../services/voice_memo/voice_memo_sync_service.dart'; import '../../navigation/app_router.dart'; @@ -764,66 +764,65 @@ class _AiDebugSheet extends ConsumerWidget { // --- Live / in-progress view --- _livePhaseHeader(context, debugInfo), const SizedBox(height: 12), - if (debugInfo.originalTranscription != null) ...[ + if (debugInfo.transcriptionResult != null) ...[ _debugBlock( context, title: 'Original Transcription', - content: debugInfo.originalTranscription!, + content: debugInfo.transcriptionResult!, icon: Icons.mic, ), const SizedBox(height: 12), ], // Only show the partial-response block once tokens are flowing - if (debugInfo.currentPhase != 'loading') + if (debugInfo.phase != 'loading') _debugBlock( context, - title: '${_phaseLabel(debugInfo.currentPhase)} (live)', - content: debugInfo.partialResponse.isEmpty + title: '${_phaseLabel(debugInfo.phase)} (live)', + content: debugInfo.partialOutput.isEmpty ? '...' - : debugInfo.partialResponse, - icon: debugInfo.currentPhase == 'correcting' + : debugInfo.partialOutput, + icon: debugInfo.phase == 'correcting' ? Icons.auto_fix_high : Icons.code, - mono: debugInfo.currentPhase == 'classifying', + mono: debugInfo.phase == 'classifying', ), ] else ...[ // --- Completed view --- _metricsRow(context, debugInfo), const SizedBox(height: 16), - if (debugInfo.originalTranscription != null && + if (debugInfo.transcriptionResult != null && debugInfo.correctedTranscription != null && debugInfo.correctedTranscription != - debugInfo.originalTranscription) ...[ + debugInfo.transcriptionResult) ...[ _transcriptionDiffBlock( context, - original: debugInfo.originalTranscription!, + original: debugInfo.transcriptionResult!, corrected: debugInfo.correctedTranscription!, ), const SizedBox(height: 12), - ] else if (debugInfo.originalTranscription != null) ...[ + ] else if (debugInfo.transcriptionResult != null) ...[ _debugBlock( context, title: 'Transcription', - content: debugInfo.originalTranscription!, + content: debugInfo.transcriptionResult!, icon: Icons.mic, ), const SizedBox(height: 12), ], - if (debugInfo.rawLlmResponse != null) ...[ + if (debugInfo.rawOutput != null) ...[ _debugBlock( context, title: 'Raw LLM Response', - content: debugInfo.rawLlmResponse!, + content: debugInfo.rawOutput!, icon: Icons.code, mono: true, ), const SizedBox(height: 12), ], - if (debugInfo.parsedJson != null) ...[ - _debugBlock( + if (debugInfo.parsedJson != null) ...[ _debugBlock( context, title: 'Parsed JSON', - content: debugInfo.parsedJson!, + content: aiFormatJson(debugInfo.parsedJson!), icon: Icons.data_object, mono: true, ), @@ -836,6 +835,7 @@ class _AiDebugSheet extends ConsumerWidget { datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, resolvedDateTime: debugInfo.resolvedDateTime, resolverMethod: debugInfo.resolverMethod, + extractedActions: debugInfo.extractedActions, )) ...[ aiDebugBlock( context, @@ -847,6 +847,7 @@ class _AiDebugSheet extends ConsumerWidget { datetimeExpressionEnglish: debugInfo.datetimeExpressionEnglish, resolvedDateTime: debugInfo.resolvedDateTime, resolverMethod: debugInfo.resolverMethod, + extractedActions: debugInfo.extractedActions, ), icon: Icons.schedule, showCopyButton: true, @@ -873,9 +874,9 @@ class _AiDebugSheet extends ConsumerWidget { } } - Widget _livePhaseHeader(BuildContext context, AiProcessingDebugInfo info) { + Widget _livePhaseHeader(BuildContext context, AiDebugInfo info) { final theme = Theme.of(context); - final phaseText = switch (info.currentPhase) { + final phaseText = switch (info.phase) { 'loading' => 'Loading model...', 'correcting' => 'Correcting transcription...', 'classifying' => 'Classifying & summarizing...', @@ -925,13 +926,13 @@ class _AiDebugSheet extends ConsumerWidget { _metricChip( context, 'Tokens', - '${info.liveTokenCount}', + '${info.tokens}', Icons.token, ), ], ), - if (info.availableMemoryMB != null) ...[ const SizedBox(height: 8), - _memoryInfoRow(context, info), + if (aiMemoryInfoBlock(context, info) != null) ...[ const SizedBox(height: 8), + aiMemoryInfoBlock(context, info)!, ], ], ), @@ -986,7 +987,7 @@ class _AiDebugSheet extends ConsumerWidget { ); } - Widget _metricsRow(BuildContext context, AiProcessingDebugInfo info) { + Widget _metricsRow(BuildContext context, AiDebugInfo info) { final theme = Theme.of(context); return Container( @@ -1009,132 +1010,59 @@ class _AiDebugSheet extends ConsumerWidget { spacing: 16, runSpacing: 8, children: [ - if (info.correctionTime != null) + if (info.correctionElapsed > Duration.zero) _metricChip( context, 'Correction', - '${info.correctionTime!.inMilliseconds}ms', + '${info.correctionElapsed.inMilliseconds}ms', Icons.timer_outlined, ), - if (info.correctionTokensPerSec != null) + if (info.correctionTokensPerSecond != null) _metricChip( context, 'Correction tok/s', - info.correctionTokensPerSec!.toStringAsFixed(1), + info.correctionTokensPerSecond!.toStringAsFixed(1), Icons.speed, ), - if (info.correctionTokens != null) + if (info.correctionTokens > 0) _metricChip( context, 'Correction tokens', '${info.correctionTokens}', Icons.token, ), - if (info.classifyTime != null) + if (info.elapsed > Duration.zero) _metricChip( context, 'Classify', - '${info.classifyTime!.inMilliseconds}ms', + '${info.elapsed.inMilliseconds}ms', Icons.timer_outlined, ), - if (info.classifyTokensPerSec != null) + if (info.tokensPerSecond != null) _metricChip( context, 'Classify tok/s', - info.classifyTokensPerSec!.toStringAsFixed(1), + info.tokensPerSecond!.toStringAsFixed(1), Icons.speed, ), - if (info.classifyTokens != null) + if (info.tokens > 0) _metricChip( context, 'Classify tokens', - '${info.classifyTokens}', + '${info.tokens}', Icons.token, ), ], ), - if (info.availableMemoryMB != null) ...[ + if (aiMemoryInfoBlock(context, info) != null) ...[ const SizedBox(height: 8), - _memoryInfoRow(context, info), + aiMemoryInfoBlock(context, info)!, ], ], ), ); } - Widget _memoryInfoRow(BuildContext context, AiProcessingDebugInfo info) { - final theme = Theme.of(context); - final isLowMemory = (info.memoryHeadroomMB ?? 999) < 100; - final statusColor = isLowMemory ? Colors.orange : AppTheme.textSecondary; - - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isLowMemory - ? Colors.orange.withValues(alpha: 0.08) - : AppTheme.textSecondary.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: isLowMemory - ? Border.all(color: Colors.orange.withValues(alpha: 0.3)) - : null, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.memory, size: 14, color: statusColor), - const SizedBox(width: 4), - Text( - 'Memory & Inference', - style: theme.textTheme.labelSmall?.copyWith( - color: statusColor, - fontWeight: FontWeight.w600, - ), - ), - if (isLowMemory) ...[ - const SizedBox(width: 6), - Icon(Icons.warning_amber_rounded, - size: 13, color: Colors.orange), - ], - ], - ), - const SizedBox(height: 6), - Wrap( - spacing: 16, - runSpacing: 4, - children: [ - if (info.availableMemoryMB != null) - _metricChip(context, 'Available RAM', - '${info.availableMemoryMB}MB', Icons.memory), - if (info.deviceMemoryMB != null) - _metricChip(context, 'Total RAM', - '${info.deviceMemoryMB}MB', Icons.phone_android), - if (info.modelSizeMB != null) - _metricChip(context, 'Model', - '${info.modelSizeMB}MB', Icons.smart_toy_outlined), - if (info.memoryHeadroomMB != null) - _metricChip(context, 'Headroom', - '${info.memoryHeadroomMB}MB', Icons.expand), - if (info.inferenceContextSize != null) - _metricChip(context, 'nCtx', - '${info.inferenceContextSize}', Icons.tune), - if (info.inferenceGpuLayers != null) - _metricChip(context, 'GPU layers', - info.inferenceGpuLayers == 0 - ? 'CPU only' - : '${info.inferenceGpuLayers}', - Icons.developer_board), - if (info.inferenceMaxTokensCap != null) - _metricChip(context, 'Max tokens cap', - '${info.inferenceMaxTokensCap}', Icons.compress), - ], - ), - ], - ), - ); - } - Widget _metricChip( BuildContext context, String label, @@ -1369,7 +1297,7 @@ class _AiDebugSheet extends ConsumerWidget { return spans; } - Widget _resultRow(BuildContext context, AiProcessingDebugInfo info) { + Widget _resultRow(BuildContext context, AiDebugInfo info) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1389,11 +1317,12 @@ class _AiDebugSheet extends ConsumerWidget { if (info.category != null) _kvRow(context, 'Category', info.category!), if (info.summary != null) _kvRow(context, 'Summary', info.summary!), _kvRow(context, 'Actions', '${info.actionCount}'), - _kvRow( - context, - 'Processed', - DateFormat('HH:mm:ss.SSS').format(info.timestamp), - ), + if (info.timestamp != null) + _kvRow( + context, + 'Processed', + DateFormat('HH:mm:ss.SSS').format(info.timestamp!), + ), ], ); } diff --git a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart index ef2e020..7a458eb 100644 --- a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart +++ b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart @@ -1,7 +1,21 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../core/theme/app_theme.dart'; +import '../../services/ai/ai_debug_info.dart'; + +/// Try to pretty-print a JSON string. Returns the original string unchanged +/// when it isn't valid JSON. +String aiFormatJson(String raw) { + try { + final decoded = jsonDecode(raw); + return const JsonEncoder.withIndent(' ').convert(decoded); + } catch (_) { + return raw; + } +} // --------------------------------------------------------------------------- // Shared UI primitives for AI / benchmark debug bottom sheets. @@ -182,7 +196,9 @@ bool aiHasChronoDetails({ String? datetimeExpressionEnglish, String? resolvedDateTime, String? resolverMethod, + List extractedActions = const [], }) { + if (extractedActions.isNotEmpty) return true; return (extractedIntent?.isNotEmpty ?? false) || (extractedTitle?.isNotEmpty ?? false) || (datetimeExpressionOriginal?.isNotEmpty ?? false) || @@ -198,16 +214,36 @@ String aiFormatChronoDetails({ String? datetimeExpressionEnglish, String? resolvedDateTime, String? resolverMethod, + List extractedActions = const [], }) { String show(String? value) => (value != null && value.trim().isNotEmpty) ? value.trim() : 'null'; - return 'Intent: ${show(extractedIntent)}\n' - 'Title: ${show(extractedTitle)}\n' - 'Original time phrase: ${show(datetimeExpressionOriginal)}\n' - 'English time phrase: ${show(datetimeExpressionEnglish)}\n' - 'Resolved datetime: ${show(resolvedDateTime)}\n' - 'Resolver: ${show(resolverMethod)}'; + // When multiple actions are available, show all of them. + if (extractedActions.length > 1) { + final buf = StringBuffer(); + for (var i = 0; i < extractedActions.length; i++) { + final a = extractedActions[i]; + if (i > 0) buf.writeln(); + buf.writeln('--- Action ${i + 1} ---'); + buf.writeln('Intent: ${show(a.intent)}'); + buf.writeln('Title: ${show(a.title)}'); + buf.writeln('Original time phrase: ${show(a.datetimeExpressionOriginal)}'); + buf.writeln('English time phrase: ${show(a.datetimeExpressionEnglish)}'); + buf.writeln('Resolved datetime: ${show(a.resolvedDateTime)}'); + buf.write('Resolver: ${show(a.resolverMethod)}'); + } + return buf.toString(); + } + + // Single action — use the direct fields (or the single extractedAction). + final a = extractedActions.isNotEmpty ? extractedActions.first : null; + return 'Intent: ${show(a?.intent ?? extractedIntent)}\n' + 'Title: ${show(a?.title ?? extractedTitle)}\n' + 'Original time phrase: ${show(a?.datetimeExpressionOriginal ?? datetimeExpressionOriginal)}\n' + 'English time phrase: ${show(a?.datetimeExpressionEnglish ?? datetimeExpressionEnglish)}\n' + 'Resolved datetime: ${show(a?.resolvedDateTime ?? resolvedDateTime)}\n' + 'Resolver: ${show(a?.resolverMethod ?? resolverMethod)}'; } /// Small label + value chip used inside metric rows. @@ -422,3 +458,86 @@ Widget aiCompletedMetricsHeader( ), ); } + +/// Memory & inference parameter info block. +/// +/// Returns `null` when no memory data is available so callers can skip it with +/// a simple null check: +/// ```dart +/// final memBlock = aiMemoryInfoBlock(context, info); +/// if (memBlock != null) ...[const SizedBox(height: 12), memBlock], +/// ``` +Widget? aiMemoryInfoBlock(BuildContext context, AiDebugInfo info) { + if (info.availableMemoryMB == null) return null; + + final theme = Theme.of(context); + final isLowMemory = (info.memoryHeadroomMB ?? 999) < 100; + final statusColor = isLowMemory ? Colors.orange : AppTheme.textSecondary; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isLowMemory + ? Colors.orange.withValues(alpha: 0.08) + : AppTheme.textSecondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: isLowMemory + ? Border.all(color: Colors.orange.withValues(alpha: 0.3)) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.memory, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + 'Memory & Inference', + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + if (isLowMemory) ...[ + const SizedBox(width: 6), + Icon(Icons.warning_amber_rounded, + size: 13, color: Colors.orange), + ], + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 16, + runSpacing: 4, + children: [ + if (info.availableMemoryMB != null) + aiMetricChip(context, 'Available RAM', + '${info.availableMemoryMB}MB', Icons.memory), + if (info.deviceMemoryMB != null) + aiMetricChip(context, 'Total RAM', + '${info.deviceMemoryMB}MB', Icons.phone_android), + if (info.modelSizeMB != null) + aiMetricChip(context, 'Model', + '${info.modelSizeMB}MB', Icons.smart_toy_outlined), + if (info.memoryHeadroomMB != null) + aiMetricChip(context, 'Headroom', + '${info.memoryHeadroomMB}MB', Icons.expand), + if (info.inferenceContextSize != null) + aiMetricChip(context, 'nCtx', + '${info.inferenceContextSize}', Icons.tune), + if (info.inferenceGpuLayers != null) + aiMetricChip(context, 'GPU layers', + info.inferenceGpuLayers == 0 + ? 'CPU only' + : '${info.inferenceGpuLayers}', + Icons.developer_board), + if (info.inferenceMaxTokensCap != null) + aiMetricChip(context, 'Max tokens cap', + '${info.inferenceMaxTokensCap}', Icons.compress), + ], + ), + ], + ), + ); +} From 51fdf4e26b02c58d602fcd0b23885b2dc702ac2f Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 11:00:32 +0100 Subject: [PATCH 23/58] Improve AI extraction prompt and add correction toggle setting - Apply selective improvements from external AI review: - Add JSON constraint (start with '[', end with ']') - Add comma-list rule to prevent over-splitting - Move schema before examples for better model comprehension - Strengthen title extraction rule (copy words from memo) - Clarify date propagation for multi-item memos - Restore Swedish/German connectors (och, och sen, sen, und) - Add AI transcript correction toggle (default OFF): - New setting in SharedPreferences (ai_correction_enabled) - Provider + notifier following existing toggle pattern - UI toggle in AI settings, gated by Local AI enabled - Wired through VoiceNoteAiPipeline to LlmService Benchmark: 47-48/51 (92-94%) across multiple runs --- .../lib/src/prompt_template.dart | 35 ++++++++++--------- zswatch_app/lib/providers/ai_providers.dart | 2 ++ .../lib/providers/settings_providers.dart | 20 +++++++++++ .../services/ai/voice_note_ai_pipeline.dart | 6 ++++ .../settings/ai_models_settings_screen.dart | 24 +++++++++++++ 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index e1228f4..1734d65 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -17,7 +17,7 @@ A memo may contain ONE or MULTIPLE items. Return a JSON array with one object pe The memo may be in ANY language. -Return JSON only. No explanation. +Return JSON only. Output MUST start with '[' and end with ']'. No text before or after. Your tasks per item: 1. Detect intent: "reminder", "event", or "note". @@ -28,8 +28,9 @@ Your tasks per item: IMPORTANT — item splitting: - ALWAYS return a JSON array, even for a single item. - NEVER drop items. Every distinct action in the memo MUST appear as a separate object. -- Connectors that introduce a NEW item: "and", "and then", "also", "och", "och sen", "sen", "und", commas between clauses. +- Connectors that introduce a NEW item: "and", "and then", "also", "och", "och sen", "sen", "und", commas between clauses, sentence boundaries. - A single idea with elaboration/details stays as ONE item. +- Comma-separated lists (shopping items, ingredients, supplies) are ONE item. IMPORTANT — intent classification: - "event" = meetings, appointments, social plans, bookings, standups — things you ATTEND with others or at a place (dentist, fika with someone, team standup, conference, lunch with a person) @@ -38,18 +39,28 @@ IMPORTANT — intent classification: - A task with NO time appearing alongside timed tasks is still a "note" Rules: -- Multi-item date context: when a preceding item establishes a date (e.g. "tomorrow"), carry it into subsequent items that only mention a time. Example: "tomorrow at 8 am ... at 3 pm" → item 2 is "tomorrow at 3 pm". -- The title MUST stay in the SAME language as the voice memo. DO NOT translate the title. -- NEVER compute or resolve dates. Keep time expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". +- Multi-item date context: if a later item only mentions a time (e.g. "at 3 pm"), reuse the most recent date from a previous item. Example: "tomorrow at 8 am ... at 3 pm" → item 2 is "tomorrow at 3 pm". +- Copy title words directly from the memo. Never translate the title. Keep titles short (2-5 words). +- Title must NOT contain time or date words. +- Do not resolve relative dates to absolute dates or ISO timestamps. Keep expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". - Copy the original time phrase exactly. Fill "datetime_expression_english" whenever "datetime_expression_original" is not null. - If the memo is in English, copy the same phrase to both datetime fields. - If no time/date for an item, set both datetime fields to null. -- Title must be short (2-5 words). - Translate time expressions to natural English. Convert 24-hour to 12-hour. Use PM for afternoon/evening context. - Do NOT add "next" to weekday translations unless the original explicitly says "next" / "nästa" / "nächsten". - Deadlines ARE time expressions: "by Friday", "senast fredag", "bis Freitag" → extract them. - NOT time expressions: locations ("at the store"), vague conditions ("when I get home", "after lunch"), words that look like time but aren't ("boka tid" = book appointment) +Output JSON schema (always an array): +[ + { + "intent": "reminder" | "event" | "note", + "title": "short task description in original language", + "datetime_expression_original": "original time phrase" | null, + "datetime_expression_english": "english translation of time phrase" | null + } +] + Examples: Memo: "Remind me tomorrow at 10 am to buy milk" @@ -106,21 +117,11 @@ Memo: "Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och möt Memo: "Team standup tomorrow at 9:15 and I should prototype the new notification system" [{"intent":"event","title":"team standup","datetime_expression_original":"tomorrow at 9:15","datetime_expression_english":"tomorrow at 9:15 am"},{"intent":"note","title":"prototype notification system","datetime_expression_original":null,"datetime_expression_english":null}] -WRONG — never translate the title, not even for notes: +WRONG — never translate the title: Memo: "Bra idé om att lägga till stegräknare i klockan" WRONG: [{"intent":"note","title":"add step counter to watch",...}] RIGHT: [{"intent":"note","title":"stegräknare i klockan","datetime_expression_original":null,"datetime_expression_english":null}] -Output JSON schema (always an array): -[ - { - "intent": "reminder" | "event" | "note", - "title": "short task description in original language", - "datetime_expression_original": "original time phrase" | null, - "datetime_expression_english": "english translation of time phrase" | null - } -] - Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact Timezone: UTC$promptPlaceholderTimezoneOffset diff --git a/zswatch_app/lib/providers/ai_providers.dart b/zswatch_app/lib/providers/ai_providers.dart index 7270a66..39024d0 100644 --- a/zswatch_app/lib/providers/ai_providers.dart +++ b/zswatch_app/lib/providers/ai_providers.dart @@ -130,10 +130,12 @@ final voiceNoteAiPipelineProvider = Provider((ref) { final llm = ref.watch(llmServiceProvider); final memoRepo = ref.watch(voiceMemoRepositoryProvider); final actionRepo = ref.watch(extractedActionRepositoryProvider); + final correctionEnabled = ref.watch(aiCorrectionEnabledProvider); return VoiceNoteAiPipeline( llmService: llm, memoRepository: memoRepo, actionRepository: actionRepo, + correctTranscription: correctionEnabled, ); }); diff --git a/zswatch_app/lib/providers/settings_providers.dart b/zswatch_app/lib/providers/settings_providers.dart index 6009309..f3823d6 100644 --- a/zswatch_app/lib/providers/settings_providers.dart +++ b/zswatch_app/lib/providers/settings_providers.dart @@ -24,6 +24,7 @@ abstract final class SettingsKeys { 'selected_productivity_calendar_id'; static const String gpuInferenceMode = 'gpu_inference_mode'; static const String autoCreateActions = 'auto_create_actions'; + static const String aiCorrectionEnabled = 'ai_correction_enabled'; } /// Provider for SharedPreferences instance @@ -396,6 +397,25 @@ class AutoCreateActionsNotifier extends StateNotifier { } } +/// Whether AI transcript correction is enabled before classification. +final aiCorrectionEnabledProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return AiCorrectionEnabledNotifier(prefs.valueOrNull); +}); + +class AiCorrectionEnabledNotifier extends StateNotifier { + final SharedPreferences? _prefs; + + AiCorrectionEnabledNotifier(this._prefs) + : super(_prefs?.getBool(SettingsKeys.aiCorrectionEnabled) ?? false); + + void setEnabled(bool enabled) { + state = enabled; + _prefs?.setBool(SettingsKeys.aiCorrectionEnabled, enabled); + } +} + /// Currently selected local AI model id. final selectedAiModelIdProvider = StateNotifierProvider((ref) { diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart index b27e000..4b484dc 100644 --- a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -19,6 +19,9 @@ class VoiceNoteAiPipeline { final VoiceMemoRepository _memoRepository; final ExtractedActionRepository _actionRepository; + /// Whether to run the LLM correction step before classification. + bool correctTranscription; + /// Called after successful AI processing with (filename, summary). /// Used to send the result toast back to the watch. void Function(String filename, String title)? onProcessingComplete; @@ -40,6 +43,7 @@ class VoiceNoteAiPipeline { required LlmService llmService, required VoiceMemoRepository memoRepository, required ExtractedActionRepository actionRepository, + this.correctTranscription = false, }) : _llmService = llmService, _memoRepository = memoRepository, _actionRepository = actionRepository; @@ -116,12 +120,14 @@ class VoiceNoteAiPipeline { final result = useBrainDump ? await _llmService.processTranscriptBrainDump( transcript, + correctTranscription: correctTranscription, onProgress: (phase, partial, tokens) { emitLive(phase, partial, tokens); }, ) : await _llmService.processTranscript( transcript, + correctTranscription: correctTranscription, onProgress: (phase, partial, tokens) { emitLive(phase, partial, tokens); }, diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index e01beaa..b0c39fb 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -664,6 +664,7 @@ class _AiTogglesTile extends ConsumerWidget { final localAiEnabled = ref.watch(localAiEnabledProvider); final autoProcess = ref.watch(autoProcessVoiceNotesProvider); final autoCreate = ref.watch(autoCreateActionsProvider); + final correctionEnabled = ref.watch(aiCorrectionEnabledProvider); final bothEnabled = localAiEnabled && autoProcess; return Column( @@ -726,6 +727,29 @@ class _AiTogglesTile extends ConsumerWidget { : null, ), ), + Opacity( + opacity: localAiEnabled ? 1.0 : 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.spellcheck, + color: correctionEnabled && localAiEnabled + ? AppTheme.primaryColor + : AppTheme.textSecondary, + ), + title: const Text('AI transcript correction'), + subtitle: Text( + localAiEnabled + ? 'Fix transcription errors before classification' + : 'Enable Local AI first', + ), + value: correctionEnabled, + onChanged: localAiEnabled + ? (value) { + ref.read(aiCorrectionEnabledProvider.notifier).setEnabled(value); + } + : null, + ), + ), ], ); } From 1ef58283ad00abe677c365068801d9afd6019797 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 11:49:09 +0100 Subject: [PATCH 24/58] Adapt AI prompt to runtime context limits - raise default nCtx to 4096 for production and testbench - add compact chrono prompt for reduced-context devices - choose full vs compact prompt based on effective context size - surface prompt mode in debug UI strategy output - retune memory tiers for safer mobile inference --- ai_testbench/lib/services/llm_service.dart | 2 +- .../lib/src/prompt_template.dart | 85 +++++++++++++++++++ zswatch_app/lib/services/ai/llm_service.dart | 64 ++++++++------ 3 files changed, 124 insertions(+), 27 deletions(-) diff --git a/ai_testbench/lib/services/llm_service.dart b/ai_testbench/lib/services/llm_service.dart index 5abcff6..b4949ae 100644 --- a/ai_testbench/lib/services/llm_service.dart +++ b/ai_testbench/lib/services/llm_service.dart @@ -44,7 +44,7 @@ class LlmService { bool _requestInFlight = false; // Configuration - int nCtx = 2048; + int nCtx = 4096; int nThreads = 2; int maxTokens = 512; double temperature = 0.3; diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index 1734d65..dc0cb15 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -132,6 +132,91 @@ $promptPlaceholderTranscript /no_think JSON:'''; + /// Compact prompt with fewer examples for low-memory devices (nCtx < 4096). + /// Same rules, 5 examples instead of 18. + static const String compactTemplate = ''' +You extract structured information from a voice memo. + +A memo may contain ONE or MULTIPLE items. Return a JSON array with one object per item. + +The memo may be in ANY language. + +Return JSON only. Output MUST start with '[' and end with ']'. No text before or after. + +Your tasks per item: +1. Detect intent: "reminder", "event", or "note". +2. Extract the time/date phrase exactly as it appears in the memo. +3. Translate that time/date phrase into natural English. If already English, copy it. +4. Extract a short title (the task or event, NOT the time part). + +IMPORTANT — item splitting: +- ALWAYS return a JSON array, even for a single item. +- NEVER drop items. Every distinct action in the memo MUST appear as a separate object. +- Connectors that introduce a NEW item: "and", "and then", "also", "och", "och sen", "sen", "und", commas between clauses, sentence boundaries. +- A single idea with elaboration/details stays as ONE item. +- Comma-separated lists (shopping items, ingredients, supplies) are ONE item. + +IMPORTANT — intent classification: +- "event" = meetings, appointments, social plans, bookings, standups — things you ATTEND with others or at a place +- "reminder" = personal tasks/actions WITH a specific time — things you DO alone +- "note" = NO time/date mentioned — ideas, shopping lists, tasks without a deadline +- A task with NO time appearing alongside timed tasks is still a "note" + +Rules: +- Multi-item date context: if a later item only mentions a time (e.g. "at 3 pm"), reuse the most recent date from a previous item. +- Copy title words directly from the memo. Never translate the title. Keep titles short (2-5 words). +- Title must NOT contain time or date words. +- Keep expressions relative: "tomorrow at 10 am", NOT "2026-03-10T10:00:00". +- Copy the original time phrase exactly. Fill "datetime_expression_english" whenever "datetime_expression_original" is not null. +- If the memo is in English, copy the same phrase to both datetime fields. +- If no time/date for an item, set both datetime fields to null. +- Translate time expressions to natural English. Convert 24-hour to 12-hour. Use PM for afternoon/evening context. +- Do NOT add "next" to weekday translations unless the original explicitly says "next" / "nästa" / "nächsten". +- Deadlines ARE time expressions: "by Friday", "senast fredag", "bis Freitag" → extract them. +- NOT time expressions: locations ("at the store"), vague conditions ("when I get home", "after lunch"), words that look like time but aren't ("boka tid" = book appointment) + +Output JSON schema (always an array): +[ + { + "intent": "reminder" | "event" | "note", + "title": "short task description in original language", + "datetime_expression_original": "original time phrase" | null, + "datetime_expression_english": "english translation of time phrase" | null + } +] + +Examples: + +Memo: "Remind me tomorrow at 10 am to buy milk" +[{"intent":"reminder","title":"buy milk","datetime_expression_original":"tomorrow at 10 am","datetime_expression_english":"tomorrow at 10 am"}] + +Memo: "tandläkare den 15 mars klockan halv 10 och sen handla mat på vägen hem" +[{"intent":"event","title":"tandläkare","datetime_expression_original":"den 15 mars klockan halv 10","datetime_expression_english":"March 15th at 9:30 am"},{"intent":"note","title":"handla mat","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Tomorrow at 3 pm call the electrician and also buy new light bulbs" +[{"intent":"reminder","title":"call the electrician","datetime_expression_original":"tomorrow at 3 pm","datetime_expression_english":"tomorrow at 3 pm"},{"intent":"note","title":"buy new light bulbs","datetime_expression_original":null,"datetime_expression_english":null}] + +Memo: "Ring tandläkaren imorgon klockan 9, köp presenter till kalaset och möte med chefen på fredag klockan 14" +[{"intent":"reminder","title":"ring tandläkaren","datetime_expression_original":"imorgon klockan 9","datetime_expression_english":"tomorrow at 9 am"},{"intent":"note","title":"köp presenter","datetime_expression_original":null,"datetime_expression_english":null},{"intent":"event","title":"möte med chefen","datetime_expression_original":"på fredag klockan 14","datetime_expression_english":"on Friday at 2 pm"}] + +Memo: "köp bröd på vägen hem" +[{"intent":"note","title":"köp bröd","datetime_expression_original":null,"datetime_expression_english":null}] + +Current datetime: $promptPlaceholderCurrentLocalDateTimeCompact +Timezone: UTC$promptPlaceholderTimezoneOffset + +Voice memo: + +$promptPlaceholderTranscript + +/no_think +JSON:'''; + + /// Returns the appropriate template for the given context size. + static String templateForContextSize(int nCtx) { + return nCtx >= 4096 ? defaultTemplate : compactTemplate; + } + static String render( String template, { required String transcript, diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 5b229b9..a24976c 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -330,7 +330,7 @@ class LlmService { String get selectedModelId => _selectedModelId; // ---- Tunables ---- - int nCtx = 2048; + int nCtx = 4096; int nThreads = 2; int maxTokens = 512; // Qwen3.5 recommended sampling for non-thinking text tasks. @@ -682,16 +682,16 @@ class LlmService { /// The available memory IS the headroom for KV cache + compute buffers. /// /// Metal (GPU) pre-allocates the FULL KV cache for nCtx upfront, so - /// nCtx=2048 on GPU needs ~1–1.5 GB of GPU-accessible memory. On 4 GB - /// iPhones with ~1 GB free after model load, GPU+2048 causes a page-fault + /// nCtx=4096 on GPU needs ~2–3 GB of GPU-accessible memory. On 4 GB + /// iPhones with ~1 GB free after model load, GPU+4096 causes a page-fault /// crash. The thresholds below prefer CPU with full context over GPU with /// a crash: /// - /// available ≥ 1200 MB → nCtx 2048, full GPU, maxTokens unchanged - /// available ≥ 600 MB → nCtx 2048, CPU-only, maxTokens unchanged - /// available ≥ 300 MB → nCtx 1024, CPU-only, maxTokens unchanged - /// available ≥ 100 MB → nCtx 512, CPU-only, maxTokens unchanged - /// available < 100 MB → nCtx 512, CPU-only, maxTokens capped at 256 + /// available ≥ 1500 MB → nCtx 4096, full GPU, maxTokens unchanged + /// available ≥ 800 MB → nCtx 4096, CPU-only, maxTokens unchanged + /// available ≥ 400 MB → nCtx 2048, CPU-only, maxTokens unchanged (compact prompt) + /// available ≥ 100 MB → nCtx 1024, CPU-only, maxTokens unchanged (compact prompt) + /// available < 100 MB → nCtx 1024, CPU-only, maxTokens capped at 256 (compact prompt) Future<({int contextSize, int gpuLayers, int? maxTokensCap})> _computeInferenceParams( LlmModelInfo modelInfo, @@ -727,46 +727,47 @@ class LlmService { int gpu; int? tokensCap; // null = use default maxTokens - if (headroomMB >= 1200) { - // Plenty of room: full context on GPU. Metal needs ~1–1.5GB for KV cache - // + compute scratch buffers at nCtx=2048 on top of the model weights. - ctx = nCtx; // full context (default 2048) + if (headroomMB >= 1500) { + // Plenty of room: full context on GPU. Metal needs ~2–3GB for KV cache + // + compute scratch buffers at nCtx=4096 on top of the model weights. + ctx = nCtx; // full context (default 4096) gpu = numGpuLayers; - } else if (headroomMB >= 600) { + } else if (headroomMB >= 800) { // Moderate room: full context but on CPU. This avoids Metal page-fault // crashes (GPU pre-allocates the full nCtx KV cache upfront). CPU is // slower (~5–7 tok/s vs ~20 tok/s) but doesn't crash and handles long - // prompts (e.g. classify prompt at ~1100 tokens). + // prompts (classify prompt at ~1600 tokens + transcript + output). ctx = nCtx; gpu = 0; debugPrint( '[LlmService] Moderate memory (${headroomMB}MB). ' 'Using CPU with full nCtx=$nCtx to avoid GPU memory pressure.', ); - } else if (headroomMB >= 300) { - ctx = 1024; + } else if (headroomMB >= 400) { + // Low — halved context, uses compact prompt automatically. + ctx = 2048; gpu = 0; debugPrint( '[LlmService] Low memory (${headroomMB}MB). ' - 'Using CPU with nCtx=1024.', + 'Using CPU with nCtx=2048 (compact prompt).', ); } else if (headroomMB >= 100) { - ctx = 512; + ctx = 1024; gpu = 0; debugPrint( '[LlmService] WARNING: Very low memory (${headroomMB}MB). ' - 'Using CPU with nCtx=512. ' + 'Using CPU with nCtx=1024 (compact prompt). ' 'Model ${modelInfo.id} ($modelMB MB), ' 'available=${availableMB}MB.', ); } else { - // Critically low — still run but with absolute minimum settings. - ctx = 512; + // Critically low — minimum settings with compact prompt. + ctx = 1024; gpu = 0; tokensCap = 256; debugPrint( '[LlmService] CRITICAL: Extremely low memory (${headroomMB}MB). ' - 'Using minimum settings: nCtx=512, CPU-only, maxTokens=256. ' + 'Using minimum settings: nCtx=1024, CPU-only, maxTokens=256. ' 'Model ${modelInfo.id} ($modelMB MB), ' 'available=${availableMB}MB, device=${deviceMB}MB.', ); @@ -922,6 +923,12 @@ class LlmService { '[LlmService] Processing transcript (${transcript.length} chars)', ); + // Pre-compute effective context size so we can select the right prompt. + await _ensureModel(); + final modelInfo = await currentModelInfo(); + final preParams = await _computeInferenceParams(modelInfo); + final effectiveCtx = preParams.contextSize; + String effectiveTranscript = transcript; LlmInferenceMetrics? correctionMetrics; String? correctedTranscription; @@ -969,12 +976,14 @@ class LlmService { promptTemplate, transcript: effectiveTranscript, ) - : _buildClassifyPrompt(effectiveTranscript); + : _buildClassifyPrompt(effectiveTranscript, effectiveCtx: effectiveCtx); final structuredResult = await _generateStructuredJsonWithRetry( prompt, promptStrategy: (promptTemplate != null && promptTemplate.isNotEmpty) ? (promptStrategyOverride ?? 'custom-template') - : 'full+/no_think', + : effectiveCtx >= 4096 + ? 'full+/no_think' + : 'compact+/no_think (nCtx=$effectiveCtx)', phase: 'classifying', onProgress: onProgress, ); @@ -1040,9 +1049,12 @@ class LlmService { ); } - String _buildClassifyPrompt(String transcript) { + String _buildClassifyPrompt(String transcript, {int? effectiveCtx}) { + final template = ChronoPromptTemplate.templateForContextSize( + effectiveCtx ?? nCtx, + ); return _renderClassifyPromptTemplate( - defaultClassifyPromptTemplate, + template, transcript: transcript, ); } From a9e38e215d65a6e84014c7601b4c154c4a510c46 Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 11:52:15 +0100 Subject: [PATCH 25/58] fix: honor AI correction toggle in benchmark --- zswatch_app/lib/services/ai/llm_service.dart | 4 ++++ zswatch_app/lib/services/ai/model_benchmark_service.dart | 3 ++- .../lib/ui/screens/settings/ai_models_settings_screen.dart | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index a24976c..3896367 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -829,6 +829,10 @@ class LlmService { int? overrideMaxTokens, void Function(String partial, int tokens)? onPartialResponse, }) async { + // Cancel any still-running inference (e.g. leftover from hot-restart or + // a previous phase) before starting a new one. This reduces the window + // for the "Callback invoked after it has been deleted" crash. + cancelInference(); await _ensureModel(); final completer = Completer(); diff --git a/zswatch_app/lib/services/ai/model_benchmark_service.dart b/zswatch_app/lib/services/ai/model_benchmark_service.dart index ce7252c..07681a7 100644 --- a/zswatch_app/lib/services/ai/model_benchmark_service.dart +++ b/zswatch_app/lib/services/ai/model_benchmark_service.dart @@ -198,6 +198,7 @@ class ModelBenchmarkService { Future benchmarkAiModel( LlmService llmService, { String? testInput, + bool correctTranscription = false, }) async { _abortRequested = false; final modelName = llmService.modelName; @@ -231,7 +232,7 @@ class ModelBenchmarkService { String lastRawOutput = ''; final result = await llmService.processTranscript( benchmarkInput, - correctTranscription: true, + correctTranscription: correctTranscription, onProgress: (phase, partial, tokens) { lastRawOutput = partial; final tps = sw.elapsedMilliseconds > 0 diff --git a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart index b0c39fb..446a591 100644 --- a/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart +++ b/zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart @@ -1604,6 +1604,7 @@ class _BenchmarkSectionState extends ConsumerState<_BenchmarkSection> { ref.read(_benchmarkServiceProvider).benchmarkAiModel( llm, testInput: benchmarkInput, + correctTranscription: ref.read(aiCorrectionEnabledProvider), ), ); } From fc162d1361b90d45f55e183d0ef4d422c90a0d2d Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 14:05:58 +0100 Subject: [PATCH 26/58] Update fllama submodule for numThreads support Points third_party/fllama to 39d7702, which adds numThreads to OpenAiRequest and forwards it through fllamaChat for the AI testbench. --- third_party/fllama | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/fllama b/third_party/fllama index 9866100..39d7702 160000 --- a/third_party/fllama +++ b/third_party/fllama @@ -1 +1 @@ -Subproject commit 98661007472606a526a670a0626359582edd9845 +Subproject commit 39d7702a2b207dc7cb537afd344c64c813e8d475 From 72037d37fd4442554b7630eb5b9fbe5e4a82b61e Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 14:32:00 +0100 Subject: [PATCH 27/58] feat: clarify local AI prompt and memory status --- .../lib/src/prompt_template.dart | 22 +- .../lib/services/ai/ai_debug_info.dart | 2 + zswatch_app/lib/services/ai/llm_service.dart | 412 ++++++++++-------- .../services/ai/model_benchmark_service.dart | 289 ++++++------ .../services/ai/voice_note_ai_pipeline.dart | 89 ++-- .../lib/ui/widgets/ai_debug_widgets.dart | 172 +++++--- 6 files changed, 579 insertions(+), 407 deletions(-) diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index dc0cb15..6384c1f 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -6,11 +6,11 @@ class ChronoPromptTemplate { static const String promptPlaceholderCurrentLocalDateTimeCompact = '{{current_local_datetime_compact}}'; static const String promptPlaceholderWeekday = '{{weekday}}'; - static const String promptPlaceholderTimezoneOffset = - '{{timezone_offset}}'; + static const String promptPlaceholderTimezoneOffset = '{{timezone_offset}}'; static const String promptPlaceholderTranscript = '{{transcript}}'; - static const String defaultTemplate = ''' + static const String defaultTemplate = + ''' You extract structured information from a voice memo. A memo may contain ONE or MULTIPLE items. Return a JSON array with one object per item. @@ -134,7 +134,8 @@ JSON:'''; /// Compact prompt with fewer examples for low-memory devices (nCtx < 4096). /// Same rules, 5 examples instead of 18. - static const String compactTemplate = ''' + static const String compactTemplate = + ''' You extract structured information from a voice memo. A memo may contain ONE or MULTIPLE items. Return a JSON array with one object per item. @@ -214,7 +215,7 @@ JSON:'''; /// Returns the appropriate template for the given context size. static String templateForContextSize(int nCtx) { - return nCtx >= 4096 ? defaultTemplate : compactTemplate; + return nCtx >= 3072 ? defaultTemplate : compactTemplate; } static String render( @@ -236,8 +237,10 @@ JSON:'''; final tzOffset = localNow.timeZoneOffset; final tzSign = tzOffset.isNegative ? '-' : '+'; final tzHours = tzOffset.inHours.abs().toString().padLeft(2, '0'); - final tzMinutes = - (tzOffset.inMinutes.abs() % 60).toString().padLeft(2, '0'); + final tzMinutes = (tzOffset.inMinutes.abs() % 60).toString().padLeft( + 2, + '0', + ); final tz = '$tzSign$tzHours:$tzMinutes'; final compactDateTime = '${localNow.year}-${localNow.month.toString().padLeft(2, '0')}-${localNow.day.toString().padLeft(2, '0')} ' @@ -245,7 +248,10 @@ JSON:'''; return template .replaceAll(promptPlaceholderCurrentLocalDateTime, iso) - .replaceAll(promptPlaceholderCurrentLocalDateTimeCompact, compactDateTime) + .replaceAll( + promptPlaceholderCurrentLocalDateTimeCompact, + compactDateTime, + ) .replaceAll(promptPlaceholderWeekday, weekday) .replaceAll(promptPlaceholderTimezoneOffset, tz) .replaceAll(promptPlaceholderTranscript, transcript); diff --git a/zswatch_app/lib/services/ai/ai_debug_info.dart b/zswatch_app/lib/services/ai/ai_debug_info.dart index e188ffb..66b5c87 100644 --- a/zswatch_app/lib/services/ai/ai_debug_info.dart +++ b/zswatch_app/lib/services/ai/ai_debug_info.dart @@ -58,6 +58,7 @@ class AiDebugInfo { final int? availableMemoryMB; final int? modelSizeMB; final int? memoryHeadroomMB; + final int? requestedContextSize; final int? inferenceContextSize; final int? inferenceGpuLayers; final int? inferenceMaxTokensCap; @@ -101,6 +102,7 @@ class AiDebugInfo { this.availableMemoryMB, this.modelSizeMB, this.memoryHeadroomMB, + this.requestedContextSize, this.inferenceContextSize, this.inferenceGpuLayers, this.inferenceMaxTokensCap, diff --git a/zswatch_app/lib/services/ai/llm_service.dart b/zswatch_app/lib/services/ai/llm_service.dart index 3896367..94dd700 100644 --- a/zswatch_app/lib/services/ai/llm_service.dart +++ b/zswatch_app/lib/services/ai/llm_service.dart @@ -62,13 +62,7 @@ class LlmModelInfo { } /// Status of the LLM service. -enum LlmServiceStatus { - idle, - downloading, - processing, - ready, - error, -} +enum LlmServiceStatus { idle, downloading, processing, ready, error } /// How well a model fits into available device memory. enum ModelMemoryFit { @@ -104,11 +98,11 @@ class ModelFitResult { String get summary { switch (fit) { case ModelMemoryFit.comfortable: - return 'Fits well — full performance'; + return 'Fits well — full prompt should be available'; case ModelMemoryFit.reduced: - return 'Tight fit — context reduced to $contextSize tokens'; + return 'Tight fit — may switch to a shorter prompt'; case ModelMemoryFit.cpuFallback: - return 'Low memory — CPU-only mode (slower)'; + return 'Low memory — may fall back to the smallest prompt'; } } } @@ -129,12 +123,11 @@ class LlmServiceState { LlmServiceStatus? status, double? downloadProgress, String? error, - }) => - LlmServiceState( - status: status ?? this.status, - downloadProgress: downloadProgress ?? this.downloadProgress, - error: error ?? this.error, - ); + }) => LlmServiceState( + status: status ?? this.status, + downloadProgress: downloadProgress ?? this.downloadProgress, + error: error ?? this.error, + ); } /// One extracted action from the LLM output. @@ -184,20 +177,19 @@ class LlmInferenceMetrics { this.retryEnabled = false, }); - LlmInferenceMetrics copyWithParsedJson(String? json) => - LlmInferenceMetrics( - modelName: modelName, - rawPrompt: rawPrompt, - rawResponse: rawResponse, - parsedJson: json ?? parsedJson, - wallTime: wallTime, - promptTokens: promptTokens, - completionTokens: completionTokens, - tokensPerSecond: tokensPerSecond, - attempts: attempts, - promptStrategy: promptStrategy, - retryEnabled: retryEnabled, - ); + LlmInferenceMetrics copyWithParsedJson(String? json) => LlmInferenceMetrics( + modelName: modelName, + rawPrompt: rawPrompt, + rawResponse: rawResponse, + parsedJson: json ?? parsedJson, + wallTime: wallTime, + promptTokens: promptTokens, + completionTokens: completionTokens, + tokensPerSecond: tokensPerSecond, + attempts: attempts, + promptStrategy: promptStrategy, + retryEnabled: retryEnabled, + ); } /// Result of processTranscript(). @@ -249,6 +241,10 @@ class TranscriptResult { /// /// The model loads lazily on first inference and stays cached in-process. class LlmService { + static const int defaultTargetContextSize = 3072; + static const int reducedContextSize = 2048; + static const int minimumContextSize = 1024; + static const int _maxStructuredOutputAttempts = 2; static const String promptPlaceholderCurrentLocalDateTime = ChronoPromptTemplate.promptPlaceholderCurrentLocalDateTime; @@ -265,12 +261,11 @@ class LlmService { ChronoPromptTemplate.defaultTemplate; static String get defaultClassifyPromptTemplate => - defaultBenchmarkPromptTemplate; + defaultBenchmarkPromptTemplate; final TimeExpressionResolver _timeExpressionResolver = - TimeExpressionResolver(); - final ChronoLlmParser _chronoLlmParser = const ChronoLlmParser(); - + TimeExpressionResolver(); + final ChronoLlmParser _chronoLlmParser = const ChronoLlmParser(); static const String defaultModelId = 'qwen25_1_5b_q4_k_m'; // Models ordered by benchmark score (best first). @@ -329,8 +324,14 @@ class LlmService { String get modelName => _selectedModelName; String get selectedModelId => _selectedModelId; + static bool usesFullPrompt(int contextSize) => + contextSize >= defaultTargetContextSize; + + static bool usesEmergencyCompactPrompt(int contextSize) => + contextSize <= minimumContextSize; + // ---- Tunables ---- - int nCtx = 4096; + int nCtx = defaultTargetContextSize; int nThreads = 2; int maxTokens = 512; // Qwen3.5 recommended sampling for non-thinking text tasks. @@ -400,7 +401,8 @@ class LlmService { return dir.path; } - static String customModelIdForFilename(String filename) => 'custom::$filename'; + static String customModelIdForFilename(String filename) => + 'custom::$filename'; static bool _isCustomModelId(String id) => id.startsWith('custom::'); @@ -435,7 +437,8 @@ class LlmService { void selectModel(String modelId) { _selectedModelId = modelId; final builtIn = catalogModels.where((m) => m.id == modelId).firstOrNull; - _selectedModelName = builtIn?.displayName ?? + _selectedModelName = + builtIn?.displayName ?? (_isCustomModelId(modelId) ? modelId.replaceFirst('custom::', '') : catalogModels.first.displayName); @@ -473,14 +476,16 @@ class LlmService { /// Whether the model file is present on disk. Future isModelDownloaded({String? modelId}) async { - final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? catalogModels.first; return File(await _modelFilePathFor(model)).existsSync(); } /// Size of the local model file in bytes, or null if not downloaded. Future modelFileSize({String? modelId}) async { - final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? catalogModels.first; final f = File(await _modelFilePathFor(model)); return f.existsSync() ? f.lengthSync() : null; @@ -490,7 +495,8 @@ class LlmService { /// Download the GGUF model from HuggingFace. Future downloadModel({String? modelId}) async { - final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? catalogModels.first; if (!model.isDownloadable) { @@ -502,10 +508,12 @@ class LlmService { return; } - _stateSubject.add(_stateSubject.value.copyWith( - status: LlmServiceStatus.downloading, - downloadProgress: 0.0, - )); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.downloading, + downloadProgress: 0.0, + ), + ); try { final destPath = await _modelFilePathFor(model); @@ -526,9 +534,11 @@ class LlmService { sink.add(chunk); received += chunk.length; if (contentLength > 0) { - _stateSubject.add(_stateSubject.value.copyWith( - downloadProgress: received / contentLength, - )); + _stateSubject.add( + _stateSubject.value.copyWith( + downloadProgress: received / contentLength, + ), + ); } } @@ -538,25 +548,30 @@ class LlmService { // Atomic rename File(tmpPath).renameSync(destPath); - _stateSubject.add(_stateSubject.value.copyWith( - status: LlmServiceStatus.ready, - downloadProgress: 1.0, - )); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.ready, + downloadProgress: 1.0, + ), + ); _selectedModelName = model.displayName; debugPrint('[LlmService] Model downloaded to $destPath'); } catch (e) { - _stateSubject.add(_stateSubject.value.copyWith( - status: LlmServiceStatus.error, - error: e.toString(), - )); + _stateSubject.add( + _stateSubject.value.copyWith( + status: LlmServiceStatus.error, + error: e.toString(), + ), + ); rethrow; } } /// Delete the local model file. Future deleteModel({String? modelId}) async { - final model = await _resolveModelById(modelId ?? _selectedModelId) ?? + final model = + await _resolveModelById(modelId ?? _selectedModelId) ?? catalogModels.first; final f = File(await _modelFilePathFor(model)); if (f.existsSync()) { @@ -623,18 +638,35 @@ class LlmService { // ---- Memory-aware tunables ---- - static const MethodChannel _deviceChannel = - MethodChannel('dev.zswatch.app/productivity'); + static const MethodChannel _deviceChannel = MethodChannel( + 'dev.zswatch.app/productivity', + ); /// Snapshot of memory state from the most recent `_computeInferenceParams` /// call. Exposed so callers (e.g. the AI pipeline) can surface this in the /// debug UI. - ({int deviceMB, int availableMB, int modelMB, int headroomMB, - int contextSize, int gpuLayers, int? maxTokensCap})? - get lastInferenceMemoryInfo => _lastInferenceMemoryInfo; - ({int deviceMB, int availableMB, int modelMB, int headroomMB, - int contextSize, int gpuLayers, int? maxTokensCap})? - _lastInferenceMemoryInfo; + ({ + int deviceMB, + int availableMB, + int modelMB, + int headroomMB, + int requestedContextSize, + int contextSize, + int gpuLayers, + int? maxTokensCap, + })? + get lastInferenceMemoryInfo => _lastInferenceMemoryInfo; + ({ + int deviceMB, + int availableMB, + int modelMB, + int headroomMB, + int requestedContextSize, + int contextSize, + int gpuLayers, + int? maxTokensCap, + })? + _lastInferenceMemoryInfo; /// Query device physical RAM (MB), cached after first call. Future _queryDeviceMemoryMB() async { @@ -681,21 +713,17 @@ class LlmService { /// We do NOT subtract model size again — that would double-count. /// The available memory IS the headroom for KV cache + compute buffers. /// - /// Metal (GPU) pre-allocates the FULL KV cache for nCtx upfront, so - /// nCtx=4096 on GPU needs ~2–3 GB of GPU-accessible memory. On 4 GB - /// iPhones with ~1 GB free after model load, GPU+4096 causes a page-fault - /// crash. The thresholds below prefer CPU with full context over GPU with - /// a crash: + /// Larger context windows need a larger KV cache and more scratch space. + /// The thresholds below prefer a smaller or CPU-only configuration over a + /// crash when free RAM is tight: /// - /// available ≥ 1500 MB → nCtx 4096, full GPU, maxTokens unchanged - /// available ≥ 800 MB → nCtx 4096, CPU-only, maxTokens unchanged - /// available ≥ 400 MB → nCtx 2048, CPU-only, maxTokens unchanged (compact prompt) + /// available ≥ 1500 MB → target nCtx, full GPU, maxTokens unchanged + /// available ≥ 800 MB → target nCtx, CPU-only, maxTokens unchanged + /// available ≥ 400 MB → nCtx 2048, CPU-only, maxTokens unchanged (shorter prompt) /// available ≥ 100 MB → nCtx 1024, CPU-only, maxTokens unchanged (compact prompt) /// available < 100 MB → nCtx 1024, CPU-only, maxTokens capped at 256 (compact prompt) Future<({int contextSize, int gpuLayers, int? maxTokensCap})> - _computeInferenceParams( - LlmModelInfo modelInfo, - ) async { + _computeInferenceParams(LlmModelInfo modelInfo) async { // Explicit per-model overrides win. final explicitCtx = modelInfo.contextSize; final explicitGpu = modelInfo.maxGpuLayers; @@ -728,46 +756,42 @@ class LlmService { int? tokensCap; // null = use default maxTokens if (headroomMB >= 1500) { - // Plenty of room: full context on GPU. Metal needs ~2–3GB for KV cache - // + compute scratch buffers at nCtx=4096 on top of the model weights. - ctx = nCtx; // full context (default 4096) + // Plenty of room: use the full target context on GPU. + ctx = nCtx; gpu = numGpuLayers; } else if (headroomMB >= 800) { - // Moderate room: full context but on CPU. This avoids Metal page-fault - // crashes (GPU pre-allocates the full nCtx KV cache upfront). CPU is - // slower (~5–7 tok/s vs ~20 tok/s) but doesn't crash and handles long - // prompts (classify prompt at ~1600 tokens + transcript + output). + // Moderate room: keep the full target context, but on CPU. ctx = nCtx; gpu = 0; debugPrint( '[LlmService] Moderate memory (${headroomMB}MB). ' - 'Using CPU with full nCtx=$nCtx to avoid GPU memory pressure.', + 'Using CPU with full target context ($nCtx) to avoid GPU memory pressure.', ); } else if (headroomMB >= 400) { - // Low — halved context, uses compact prompt automatically. - ctx = 2048; + // Low — shorter prompt and reduced context. + ctx = reducedContextSize; gpu = 0; debugPrint( '[LlmService] Low memory (${headroomMB}MB). ' - 'Using CPU with nCtx=2048 (compact prompt).', + 'Using CPU with shorter prompt context ($ctx).', ); } else if (headroomMB >= 100) { - ctx = 1024; + ctx = minimumContextSize; gpu = 0; debugPrint( '[LlmService] WARNING: Very low memory (${headroomMB}MB). ' - 'Using CPU with nCtx=1024 (compact prompt). ' + 'Using CPU with emergency compact prompt ($ctx). ' 'Model ${modelInfo.id} ($modelMB MB), ' 'available=${availableMB}MB.', ); } else { // Critically low — minimum settings with compact prompt. - ctx = 1024; + ctx = minimumContextSize; gpu = 0; tokensCap = 256; debugPrint( '[LlmService] CRITICAL: Extremely low memory (${headroomMB}MB). ' - 'Using minimum settings: nCtx=1024, CPU-only, maxTokens=256. ' + 'Using minimum settings: compact prompt ($ctx), CPU-only, maxTokens=256. ' 'Model ${modelInfo.id} ($modelMB MB), ' 'available=${availableMB}MB, device=${deviceMB}MB.', ); @@ -794,6 +818,7 @@ class LlmService { availableMB: availableMB, modelMB: modelMB, headroomMB: headroomMB, + requestedContextSize: nCtx, contextSize: resolvedCtx, gpuLayers: resolvedGpu, maxTokensCap: tokensCap, @@ -875,16 +900,17 @@ class LlmService { enableThinking: false, ); - _runningRequestId = await fllamaChat( - request, - (String response, String responseJson, bool done) { - tokenCount++; - onPartialResponse?.call(response, tokenCount); - if (done && !completer.isCompleted) { - completer.complete(response); - } - }, - ); + _runningRequestId = await fllamaChat(request, ( + String response, + String responseJson, + bool done, + ) { + tokenCount++; + onPartialResponse?.call(response, tokenCount); + if (done && !completer.isCompleted) { + completer.complete(response); + } + }); final text = (await completer.future).trim(); stopwatch.stop(); @@ -940,8 +966,9 @@ class LlmService { // --- Step 1: Correct transcription errors if enabled --- if (correctTranscription) { final correctionPrompt = _buildCorrectionPrompt(transcript); - final correctionMaxTokens = - CorrectionPromptTemplate.estimateMaxTokens(transcript); + final correctionMaxTokens = CorrectionPromptTemplate.estimateMaxTokens( + transcript, + ); final correctionResult = await _generate( correctionPrompt, overrideMaxTokens: correctionMaxTokens, @@ -962,7 +989,8 @@ class LlmService { debugPrint('[LlmService] Corrected transcription: $corrected'); } else { debugPrint( - '[LlmService] Correction output not usable, using original'); + '[LlmService] Correction output not usable, using original', + ); } } @@ -980,14 +1008,19 @@ class LlmService { promptTemplate, transcript: effectiveTranscript, ) - : _buildClassifyPrompt(effectiveTranscript, effectiveCtx: effectiveCtx); + : _buildClassifyPrompt( + effectiveTranscript, + effectiveCtx: effectiveCtx, + ); final structuredResult = await _generateStructuredJsonWithRetry( prompt, promptStrategy: (promptTemplate != null && promptTemplate.isNotEmpty) ? (promptStrategyOverride ?? 'custom-template') - : effectiveCtx >= 4096 - ? 'full+/no_think' - : 'compact+/no_think (nCtx=$effectiveCtx)', + : usesFullPrompt(effectiveCtx) + ? 'full+/no_think' + : usesEmergencyCompactPrompt(effectiveCtx) + ? 'emergency-compact/no_think (nCtx=$effectiveCtx)' + : 'shortened/no_think (nCtx=$effectiveCtx)', phase: 'classifying', onProgress: onProgress, ); @@ -1057,20 +1090,14 @@ class LlmService { final template = ChronoPromptTemplate.templateForContextSize( effectiveCtx ?? nCtx, ); - return _renderClassifyPromptTemplate( - template, - transcript: transcript, - ); + return _renderClassifyPromptTemplate(template, transcript: transcript); } String _renderClassifyPromptTemplate( String template, { required String transcript, }) { - return ChronoPromptTemplate.render( - template, - transcript: transcript, - ); + return ChronoPromptTemplate.render(template, transcript: transcript); } /// Word-count threshold for brain dump mode. Transcripts with more @@ -1081,14 +1108,22 @@ class LlmService { String _buildBrainDumpPrompt(String transcript) { final localNow = DateTime.now(); final weekday = const [ - 'Monday', 'Tuesday', 'Wednesday', 'Thursday', - 'Friday', 'Saturday', 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', ][localNow.weekday - 1]; final iso = localNow.toIso8601String(); final tzOffset = localNow.timeZoneOffset; final tzSign = tzOffset.isNegative ? '-' : '+'; final tzHours = tzOffset.inHours.abs().toString().padLeft(2, '0'); - final tzMinutes = (tzOffset.inMinutes.abs() % 60).toString().padLeft(2, '0'); + final tzMinutes = (tzOffset.inMinutes.abs() % 60).toString().padLeft( + 2, + '0', + ); final tz = '$tzSign$tzHours:$tzMinutes'; return ''' @@ -1179,8 +1214,9 @@ JSON: // --- Step 1: Correct transcription errors if enabled --- if (correctTranscription) { final correctionPrompt = _buildCorrectionPrompt(transcript); - final correctionMaxTokens = - CorrectionPromptTemplate.estimateMaxTokens(transcript); + final correctionMaxTokens = CorrectionPromptTemplate.estimateMaxTokens( + transcript, + ); final correctionResult = await _generate( correctionPrompt, overrideMaxTokens: correctionMaxTokens, @@ -1235,7 +1271,8 @@ JSON: buf.writeln(); for (final section in sections.whereType>()) { final header = section['header'] as String?; - final bullets = (section['bullets'] as List?) + final bullets = + (section['bullets'] as List?) ?.whereType() .toList() ?? []; @@ -1283,12 +1320,15 @@ JSON: // ---- Output parsing ---- - Future<({ - String raw, - TranscriptResult result, - LlmInferenceMetrics metrics, - int attempts, - })> _generateStructuredJsonWithRetry( + Future< + ({ + String raw, + TranscriptResult result, + LlmInferenceMetrics metrics, + int attempts, + }) + > + _generateStructuredJsonWithRetry( String prompt, { int? overrideMaxTokens, required String promptStrategy, @@ -1296,7 +1336,10 @@ JSON: void Function(String phase, String partialResponse, int tokens)? onProgress, }) async { String raw = ''; - TranscriptResult parsed = const TranscriptResult(summary: '', category: 'note'); + TranscriptResult parsed = const TranscriptResult( + summary: '', + category: 'note', + ); LlmInferenceMetrics? lastMetrics; Duration totalWallTime = Duration.zero; var totalCompletionTokens = 0; @@ -1331,7 +1374,8 @@ JSON: await Future.delayed(const Duration(milliseconds: 300)); } - final parsedJson = _chronoLlmParser.extractFirstJsonArray(raw) ?? + final parsedJson = + _chronoLlmParser.extractFirstJsonArray(raw) ?? _extractFirstJsonObject(raw); final metrics = LlmInferenceMetrics( modelName: _selectedModelName, @@ -1346,12 +1390,7 @@ JSON: retryEnabled: _maxStructuredOutputAttempts > 1, ); - return ( - raw: raw, - result: parsed, - metrics: metrics, - attempts: attempts, - ); + return (raw: raw, result: parsed, metrics: metrics, attempts: attempts); } String _sanitizeModelOutput(String raw) { @@ -1360,7 +1399,8 @@ JSON: bool _shouldRetryStructuredOutput(String raw, TranscriptResult result) { final cleaned = _sanitizeModelOutput(raw); - final jsonStr = _chronoLlmParser.extractFirstJsonArray(cleaned) ?? + final jsonStr = + _chronoLlmParser.extractFirstJsonArray(cleaned) ?? _extractFirstJsonObject(cleaned); if (jsonStr == null) { @@ -1377,7 +1417,8 @@ JSON: if (result.category == 'note' && result.actions.isEmpty && - (result.summary.trim() == cleaned || result.summary.trim() == jsonStr.trim())) { + (result.summary.trim() == cleaned || + result.summary.trim() == jsonStr.trim())) { return true; } @@ -1433,23 +1474,25 @@ JSON: String? firstResolverMethod; for (final extraction in extractions) { - final title = extraction.title.isNotEmpty - ? extraction.title - : raw.trim(); + final title = extraction.title.isNotEmpty ? extraction.title : raw.trim(); if (extraction.intent == 'note') { // Notes don't produce actions with time resolution - actions.add(ExtractedActionResult( - type: 'task', - title: title, - notes: extraction.datetimeExpressionOriginal, - )); - chronoDetails.add(ActionChronoDebug( - intent: extraction.intent, - title: title, - datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, - datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, - )); + actions.add( + ExtractedActionResult( + type: 'task', + title: title, + notes: extraction.datetimeExpressionOriginal, + ), + ); + chronoDetails.add( + ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + ), + ); continue; } @@ -1461,25 +1504,29 @@ JSON: firstResolvedDateTime ??= resolved?.dateTime.toIso8601String(); firstResolverMethod ??= resolved?.method; - actions.add(ExtractedActionResult( - type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', - title: title, - notes: extraction.datetimeExpressionOriginal, - dueDate: extraction.intent == 'reminder' - ? resolved?.dateTime.toIso8601String() - : null, - startTime: extraction.intent == 'event' - ? resolved?.dateTime.toIso8601String() - : null, - )); - chronoDetails.add(ActionChronoDebug( - intent: extraction.intent, - title: title, - datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, - datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, - resolvedDateTime: resolved?.dateTime.toIso8601String(), - resolverMethod: resolved?.method, - )); + actions.add( + ExtractedActionResult( + type: extraction.intent == 'event' ? 'calendar_event' : 'reminder', + title: title, + notes: extraction.datetimeExpressionOriginal, + dueDate: extraction.intent == 'reminder' + ? resolved?.dateTime.toIso8601String() + : null, + startTime: extraction.intent == 'event' + ? resolved?.dateTime.toIso8601String() + : null, + ), + ); + chronoDetails.add( + ActionChronoDebug( + intent: extraction.intent, + title: title, + datetimeExpressionOriginal: extraction.datetimeExpressionOriginal, + datetimeExpressionEnglish: extraction.datetimeExpressionEnglish, + resolvedDateTime: resolved?.dateTime.toIso8601String(), + resolverMethod: resolved?.method, + ), + ); } final first = extractions.first; @@ -1519,12 +1566,11 @@ JSON: final jsonStr = _extractFirstJsonObject(cleaned); if (jsonStr == null) { - debugPrint('[LlmService] Failed to parse AI response: ' - 'FormatException: No JSON object found'); - return TranscriptResult( - summary: cleaned.trim(), - category: 'note', + debugPrint( + '[LlmService] Failed to parse AI response: ' + 'FormatException: No JSON object found', ); + return TranscriptResult(summary: cleaned.trim(), category: 'note'); } try { @@ -1551,7 +1597,8 @@ JSON: title: actionTitle, notes: ((action['notes'] ?? action['body']) as String?)?.trim(), dueDate: (action['due_date'] ?? action['dueDate']) as String?, - startTime: (action['start_time'] ?? action['startTime']) as String?, + startTime: + (action['start_time'] ?? action['startTime']) as String?, location: (action['location'] as String?)?.trim(), ), ); @@ -1559,7 +1606,8 @@ JSON: } if (actions.isEmpty) { - final actionItems = (parsed['action_items'] as List?) + final actionItems = + (parsed['action_items'] as List?) ?.whereType() .map((item) => item.trim()) .where((item) => item.isNotEmpty) @@ -1576,8 +1624,9 @@ JSON: } } - final resolvedSummary = - (summary != null && summary.isNotEmpty) ? summary : (title ?? '').trim(); + final resolvedSummary = (summary != null && summary.isNotEmpty) + ? summary + : (title ?? '').trim(); return TranscriptResult( summary: resolvedSummary.isEmpty ? raw.trim() : resolvedSummary, @@ -1586,10 +1635,7 @@ JSON: ); } catch (e) { debugPrint('[LlmService] Failed to parse AI response: $e'); - return TranscriptResult( - summary: jsonStr, - category: 'note', - ); + return TranscriptResult(summary: jsonStr, category: 'note'); } } } diff --git a/zswatch_app/lib/services/ai/model_benchmark_service.dart b/zswatch_app/lib/services/ai/model_benchmark_service.dart index 07681a7..a0f19c4 100644 --- a/zswatch_app/lib/services/ai/model_benchmark_service.dart +++ b/zswatch_app/lib/services/ai/model_benchmark_service.dart @@ -29,12 +29,11 @@ class BenchmarkState { bool? isRunning, String? runningTestType, AiDebugInfo? current, - }) => - BenchmarkState( - isRunning: isRunning ?? this.isRunning, - runningTestType: runningTestType ?? this.runningTestType, - current: current ?? this.current, - ); + }) => BenchmarkState( + isRunning: isRunning ?? this.isRunning, + runningTestType: runningTestType ?? this.runningTestType, + current: current ?? this.current, + ); } // --------------------------------------------------------------------------- @@ -64,15 +63,17 @@ class ModelBenchmarkService { _abortRequested = true; final current = currentState.current; if (current != null) { - _emit(AiDebugInfo( - testType: current.testType, - modelName: current.modelName, - phase: 'running', - partialOutput: 'Aborting after current operation…', - tokens: current.tokens, - elapsed: current.elapsed, - tokensPerSecond: current.tokensPerSecond, - )); + _emit( + AiDebugInfo( + testType: current.testType, + modelName: current.modelName, + phase: 'running', + partialOutput: 'Aborting after current operation…', + tokens: current.tokens, + elapsed: current.elapsed, + tokensPerSecond: current.tokensPerSecond, + ), + ); } } @@ -91,33 +92,39 @@ class ModelBenchmarkService { final engine = createTranscriptionEngine(type); StreamSubscription? engineSub; - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'loading', - partialOutput: 'Checking model availability…', - )); + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'loading', + partialOutput: 'Checking model availability…', + ), + ); try { // Verify the audio file exists if (!File(audioFilePath).existsSync()) { - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'error', - error: 'Audio file not found: $audioFilePath', - )); + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: 'Audio file not found: $audioFilePath', + ), + ); return; } final available = await engine.isAvailable(); if (!available) { - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'error', - error: 'Model not downloaded – download it first.', - )); + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: 'Model not downloaded – download it first.', + ), + ); return; } @@ -134,59 +141,71 @@ class ModelBenchmarkService { }; // Only emit running-phase status updates while we're still running if (!currentState.current!.isComplete) { - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'running', - partialOutput: statusText, - )); + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'running', + partialOutput: statusText, + ), + ); } }); - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'running', - partialOutput: 'Starting transcription…', - )); + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'running', + partialOutput: 'Starting transcription…', + ), + ); final sw = Stopwatch()..start(); final output = await engine.transcribe(audioFilePath); sw.stop(); if (_abortRequested) { - _emit(AiDebugInfo( + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'done', + partialOutput: '(aborted)\n${output.isEmpty ? '' : output}', + elapsed: sw.elapsed, + ), + ); + return; + } + + _emit( + AiDebugInfo( testType: 'transcription', modelName: info.name, phase: 'done', - partialOutput: '(aborted)\n${output.isEmpty ? '' : output}', + partialOutput: output.isEmpty ? '(no speech detected)' : output, elapsed: sw.elapsed, - )); - return; - } - - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'done', - partialOutput: output.isEmpty ? '(no speech detected)' : output, - elapsed: sw.elapsed, - )); + ), + ); } catch (e) { - _emit(AiDebugInfo( - testType: 'transcription', - modelName: info.name, - phase: 'error', - error: e.toString(), - )); + _emit( + AiDebugInfo( + testType: 'transcription', + modelName: info.name, + phase: 'error', + error: e.toString(), + ), + ); } finally { await engineSub?.cancel(); engine.dispose(); - _stateSubject.add(BenchmarkState( - isRunning: false, - runningTestType: null, - current: currentState.current, - )); + _stateSubject.add( + BenchmarkState( + isRunning: false, + runningTestType: null, + current: currentState.current, + ), + ); } } @@ -203,26 +222,30 @@ class ModelBenchmarkService { _abortRequested = false; final modelName = llmService.modelName; - _emit(AiDebugInfo( - testType: 'ai', - modelName: modelName, - phase: 'loading', - partialOutput: 'Loading model…', - )); + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + phase: 'loading', + partialOutput: 'Loading model…', + ), + ); try { final isDownloaded = await llmService.isModelDownloaded(); if (!isDownloaded) { - _emit(AiDebugInfo( - testType: 'ai', - modelName: modelName, - phase: 'error', - error: 'Model not downloaded – download it first.', - )); + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + phase: 'error', + error: 'Model not downloaded – download it first.', + ), + ); return; } - final benchmarkInput = (testInput != null && testInput.trim().isNotEmpty) + final benchmarkInput = (testInput != null && testInput.trim().isNotEmpty) ? testInput.trim() : 'Remind me to buy groceries tomorrow and call the dentist at 2 PM.'; @@ -245,32 +268,34 @@ class ModelBenchmarkService { _ => 'running', }; final mem = llmService.lastInferenceMemoryInfo; - _emit(AiDebugInfo( - testType: 'ai', - modelName: modelName, - promptStrategy: 'shared-chrono-flow', - retryEnabled: true, - phase: benchPhase, - partialOutput: partial, - rawOutput: partial, - tokens: tokens, - elapsed: sw.elapsed, - tokensPerSecond: tps, - deviceMemoryMB: mem?.deviceMB, - availableMemoryMB: mem?.availableMB, - modelSizeMB: mem?.modelMB, - memoryHeadroomMB: mem?.headroomMB, - inferenceContextSize: mem?.contextSize, - inferenceGpuLayers: mem?.gpuLayers, - inferenceMaxTokensCap: mem?.maxTokensCap, - )); + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + promptStrategy: 'shared-chrono-flow', + retryEnabled: true, + phase: benchPhase, + partialOutput: partial, + rawOutput: partial, + tokens: tokens, + elapsed: sw.elapsed, + tokensPerSecond: tps, + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, + inferenceContextSize: mem?.contextSize, + inferenceGpuLayers: mem?.gpuLayers, + inferenceMaxTokensCap: mem?.maxTokensCap, + ), + ); }, ); sw.stop(); // Use the raw classify response when available - final rawResponse = - result.classifyMetrics?.rawResponse ?? lastRawOutput; + final rawResponse = result.classifyMetrics?.rawResponse ?? lastRawOutput; // Helper to extract correction metrics from result final mem = llmService.lastInferenceMemoryInfo; @@ -301,12 +326,14 @@ class ModelBenchmarkService { tokensPerSecond: result.classifyMetrics?.tokensPerSecond ?? 0.0, correctedTranscription: result.correctedTranscription, correctionTokens: result.correctionMetrics?.completionTokens ?? 0, - correctionElapsed: result.correctionMetrics?.wallTime ?? Duration.zero, + correctionElapsed: + result.correctionMetrics?.wallTime ?? Duration.zero, correctionTokensPerSecond: result.correctionMetrics?.tokensPerSecond, deviceMemoryMB: mem?.deviceMB, availableMemoryMB: mem?.availableMB, modelSizeMB: mem?.modelMB, memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, inferenceContextSize: mem?.contextSize, inferenceGpuLayers: mem?.gpuLayers, inferenceMaxTokensCap: mem?.maxTokensCap, @@ -318,27 +345,33 @@ class ModelBenchmarkService { return; } - _emit(buildAiResult( - phase: 'done', - partialOutput: - 'Category: ${result.category}\n' - 'Summary: ${result.summary}\n' - 'Actions: ${result.actions.length}', - )); + _emit( + buildAiResult( + phase: 'done', + partialOutput: + 'Category: ${result.category}\n' + 'Summary: ${result.summary}\n' + 'Actions: ${result.actions.length}', + ), + ); } catch (e) { debugPrint('[ModelBenchmark] AI benchmark error: $e'); - _emit(AiDebugInfo( - testType: 'ai', - modelName: modelName, - phase: 'error', - error: e.toString(), - )); + _emit( + AiDebugInfo( + testType: 'ai', + modelName: modelName, + phase: 'error', + error: e.toString(), + ), + ); } finally { - _stateSubject.add(BenchmarkState( - isRunning: false, - runningTestType: null, - current: currentState.current, - )); + _stateSubject.add( + BenchmarkState( + isRunning: false, + runningTestType: null, + current: currentState.current, + ), + ); } } @@ -354,10 +387,12 @@ class ModelBenchmarkService { // ---- Helpers ---- void _emit(AiDebugInfo progress) { - _stateSubject.add(BenchmarkState( - isRunning: !progress.isComplete, - runningTestType: progress.isComplete ? null : progress.testType, - current: progress, - )); + _stateSubject.add( + BenchmarkState( + isRunning: !progress.isComplete, + runningTestType: progress.isComplete ? null : progress.testType, + current: progress, + ), + ); } } diff --git a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart index 4b484dc..cb32f74 100644 --- a/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart +++ b/zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart @@ -44,9 +44,9 @@ class VoiceNoteAiPipeline { required VoiceMemoRepository memoRepository, required ExtractedActionRepository actionRepository, this.correctTranscription = false, - }) : _llmService = llmService, - _memoRepository = memoRepository, - _actionRepository = actionRepository; + }) : _llmService = llmService, + _memoRepository = memoRepository, + _actionRepository = actionRepository; /// Process a single voice memo's transcript with the local LLM. /// @@ -58,7 +58,9 @@ class VoiceNoteAiPipeline { required String transcript, }) async { if (transcript.trim().isEmpty) { - debugPrint('[VoiceNoteAiPipeline] Skipping empty transcript for $filename'); + debugPrint( + '[VoiceNoteAiPipeline] Skipping empty transcript for $filename', + ); return false; } @@ -71,15 +73,17 @@ class VoiceNoteAiPipeline { // Publish initial loading state so the debug sheet shows something // immediately (before the model finishes loading / first token arrives). - _debugInfoSubject.add(AiDebugInfo( - filename: filename, - modelName: _llmService.modelName, - transcriptionResult: transcript, - phase: 'loading', - partialOutput: '', - tokens: 0, - timestamp: DateTime.now(), - )); + _debugInfoSubject.add( + AiDebugInfo( + filename: filename, + modelName: _llmService.modelName, + transcriptionResult: transcript, + phase: 'loading', + partialOutput: '', + tokens: 0, + timestamp: DateTime.now(), + ), + ); // Route to brain dump prompt for long transcripts (Feature 6) final useBrainDump = _llmService.isBrainDump(transcript); @@ -96,24 +100,27 @@ class VoiceNoteAiPipeline { final elapsedMs = sw.elapsedMilliseconds; final tps = elapsedMs > 0 ? tokens / (elapsedMs / 1000.0) : 0.0; final mem = _llmService.lastInferenceMemoryInfo; - _debugInfoSubject.add(AiDebugInfo( - filename: filename, - modelName: _llmService.modelName, - transcriptionResult: transcript, - phase: phase, - partialOutput: partial, - tokens: tokens, - elapsed: sw.elapsed, - tokensPerSecond: tps, - timestamp: DateTime.now(), - deviceMemoryMB: mem?.deviceMB, - availableMemoryMB: mem?.availableMB, - modelSizeMB: mem?.modelMB, - memoryHeadroomMB: mem?.headroomMB, - inferenceContextSize: mem?.contextSize, - inferenceGpuLayers: mem?.gpuLayers, - inferenceMaxTokensCap: mem?.maxTokensCap, - )); + _debugInfoSubject.add( + AiDebugInfo( + filename: filename, + modelName: _llmService.modelName, + transcriptionResult: transcript, + phase: phase, + partialOutput: partial, + tokens: tokens, + elapsed: sw.elapsed, + tokensPerSecond: tps, + timestamp: DateTime.now(), + deviceMemoryMB: mem?.deviceMB, + availableMemoryMB: mem?.availableMB, + modelSizeMB: mem?.modelMB, + memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, + inferenceContextSize: mem?.contextSize, + inferenceGpuLayers: mem?.gpuLayers, + inferenceMaxTokensCap: mem?.maxTokensCap, + ), + ); } // Run the LLM processing with live progress updates @@ -126,18 +133,19 @@ class VoiceNoteAiPipeline { }, ) : await _llmService.processTranscript( - transcript, - correctTranscription: correctTranscription, - onProgress: (phase, partial, tokens) { - emitLive(phase, partial, tokens); - }, - ); + transcript, + correctTranscription: correctTranscription, + onProgress: (phase, partial, tokens) { + emitLive(phase, partial, tokens); + }, + ); sw.stop(); debugPrint( - '[VoiceNoteAiPipeline] Processed $filename: ' - 'summary="${result.summary}", category=${result.category}, ' - '${result.actions.length} actions'); + '[VoiceNoteAiPipeline] Processed $filename: ' + 'summary="${result.summary}", category=${result.category}, ' + '${result.actions.length} actions', + ); // If the LLM corrected the transcription, update the transcript as well if (result.correctedTranscription != null && @@ -212,6 +220,7 @@ class VoiceNoteAiPipeline { availableMemoryMB: mem?.availableMB, modelSizeMB: mem?.modelMB, memoryHeadroomMB: mem?.headroomMB, + requestedContextSize: mem?.requestedContextSize, inferenceContextSize: mem?.contextSize, inferenceGpuLayers: mem?.gpuLayers, inferenceMaxTokensCap: mem?.maxTokensCap, diff --git a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart index 7a458eb..2bb0169 100644 --- a/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart +++ b/zswatch_app/lib/ui/widgets/ai_debug_widgets.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import '../../core/theme/app_theme.dart'; import '../../services/ai/ai_debug_info.dart'; +import '../../services/ai/llm_service.dart'; /// Try to pretty-print a JSON string. Returns the original string unchanged /// when it isn't valid JSON. @@ -80,10 +81,7 @@ Widget aiDebugSheetHeader( label: const Text('Stop'), onPressed: onStop, ), - IconButton( - icon: const Icon(Icons.close), - onPressed: onClose, - ), + IconButton(icon: const Icon(Icons.close), onPressed: onClose), ], ), ); @@ -104,9 +102,9 @@ Widget aiDebugNote(BuildContext context, String text) { Expanded( child: Text( text, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), ], @@ -132,9 +130,9 @@ Widget aiDebugBlock( const SizedBox(width: 6), Text( title, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), ), if (showCopyButton) ...[ const Spacer(), @@ -169,10 +167,10 @@ Widget aiDebugBlock( child: SelectableText( content, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: mono ? 'monospace' : null, - fontSize: mono ? 11 : null, - height: 1.5, - ), + fontFamily: mono ? 'monospace' : null, + fontSize: mono ? 11 : null, + height: 1.5, + ), ), ), ], @@ -228,7 +226,9 @@ String aiFormatChronoDetails({ buf.writeln('--- Action ${i + 1} ---'); buf.writeln('Intent: ${show(a.intent)}'); buf.writeln('Title: ${show(a.title)}'); - buf.writeln('Original time phrase: ${show(a.datetimeExpressionOriginal)}'); + buf.writeln( + 'Original time phrase: ${show(a.datetimeExpressionOriginal)}', + ); buf.writeln('English time phrase: ${show(a.datetimeExpressionEnglish)}'); buf.writeln('Resolved datetime: ${show(a.resolvedDateTime)}'); buf.write('Resolver: ${show(a.resolverMethod)}'); @@ -261,16 +261,16 @@ Widget aiMetricChip( Text( '$label: ', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - fontSize: 11, - ), + color: AppTheme.textSecondary, + fontSize: 11, + ), ), Text( value, style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 11, - ), + fontWeight: FontWeight.w600, + fontSize: 11, + ), ), ], ); @@ -375,8 +375,7 @@ Widget aiCompletedHeader( Duration? elapsed, }) { final theme = Theme.of(context); - final statusColor = - isError ? AppTheme.errorColor : AppTheme.successColor; + final statusColor = isError ? AppTheme.errorColor : AppTheme.successColor; final chips = aiMetricChips( context, tokens: tokens, @@ -472,16 +471,50 @@ Widget? aiMemoryInfoBlock(BuildContext context, AiDebugInfo info) { final theme = Theme.of(context); final isLowMemory = (info.memoryHeadroomMB ?? 999) < 100; - final statusColor = isLowMemory ? Colors.orange : AppTheme.textSecondary; + final requestedContextSize = + info.requestedContextSize ?? info.inferenceContextSize; + final actualContextSize = info.inferenceContextSize; + final availableMemoryMB = info.availableMemoryMB; + final headroomMB = info.memoryHeadroomMB; + final showSeparateHeadroom = + headroomMB != null && + availableMemoryMB != null && + headroomMB != availableMemoryMB; + final isFullPrompt = requestedContextSize != null && actualContextSize != null + ? actualContextSize >= requestedContextSize + : false; + final isEmergencyCompact = actualContextSize != null + ? LlmService.usesEmergencyCompactPrompt(actualContextSize) + : false; + + final String promptLabel; + final String promptExplanation; + final Color statusColor; + + if (isEmergencyCompact) { + promptLabel = 'Emergency compact'; + promptExplanation = + 'Very low free RAM forced the smallest prompt so the model could still run.'; + statusColor = Colors.orange; + } else if (isFullPrompt) { + promptLabel = 'Full prompt'; + promptExplanation = 'The full prompt fit in memory for this run.'; + statusColor = AppTheme.textSecondary; + } else { + promptLabel = 'Shorter prompt'; + promptExplanation = + 'Free RAM was tight, so the app shortened the prompt for this run.'; + statusColor = AppTheme.warningColor; + } return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: isLowMemory + color: (isLowMemory || !isFullPrompt) ? Colors.orange.withValues(alpha: 0.08) : AppTheme.textSecondary.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), - border: isLowMemory + border: (isLowMemory || !isFullPrompt) ? Border.all(color: Colors.orange.withValues(alpha: 0.3)) : null, ), @@ -499,42 +532,83 @@ Widget? aiMemoryInfoBlock(BuildContext context, AiDebugInfo info) { fontWeight: FontWeight.w600, ), ), - if (isLowMemory) ...[ + if (isLowMemory || !isFullPrompt) ...[ const SizedBox(width: 6), - Icon(Icons.warning_amber_rounded, - size: 13, color: Colors.orange), + Icon(Icons.warning_amber_rounded, size: 13, color: Colors.orange), ], ], ), const SizedBox(height: 6), + Text( + promptExplanation, + style: theme.textTheme.bodySmall?.copyWith(color: statusColor), + ), + const SizedBox(height: 6), + Text( + showSeparateHeadroom + ? 'RAM values are measured after the model is loaded. Headroom is the free memory left for the prompt and KV cache.' + : 'Free RAM is measured after the model is loaded, so headroom would be the same number and is hidden here.', + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 6), Wrap( spacing: 16, runSpacing: 4, children: [ - if (info.availableMemoryMB != null) - aiMetricChip(context, 'Available RAM', - '${info.availableMemoryMB}MB', Icons.memory), + aiMetricChip(context, 'Prompt', promptLabel, Icons.short_text), + if (availableMemoryMB != null) + aiMetricChip( + context, + 'Free RAM after load', + '${availableMemoryMB}MB', + Icons.memory, + ), if (info.deviceMemoryMB != null) - aiMetricChip(context, 'Total RAM', - '${info.deviceMemoryMB}MB', Icons.phone_android), + aiMetricChip( + context, + 'Total RAM', + '${info.deviceMemoryMB}MB', + Icons.phone_android, + ), if (info.modelSizeMB != null) - aiMetricChip(context, 'Model', - '${info.modelSizeMB}MB', Icons.smart_toy_outlined), - if (info.memoryHeadroomMB != null) - aiMetricChip(context, 'Headroom', - '${info.memoryHeadroomMB}MB', Icons.expand), - if (info.inferenceContextSize != null) - aiMetricChip(context, 'nCtx', - '${info.inferenceContextSize}', Icons.tune), + aiMetricChip( + context, + 'Model', + '${info.modelSizeMB}MB', + Icons.smart_toy_outlined, + ), + if (showSeparateHeadroom) + aiMetricChip( + context, + 'Headroom', + '${headroomMB}MB', + Icons.expand, + ), + if (actualContextSize != null && requestedContextSize != null) + aiMetricChip( + context, + 'Prompt window', + '$actualContextSize of $requestedContextSize', + Icons.tune, + ), if (info.inferenceGpuLayers != null) - aiMetricChip(context, 'GPU layers', - info.inferenceGpuLayers == 0 - ? 'CPU only' - : '${info.inferenceGpuLayers}', - Icons.developer_board), + aiMetricChip( + context, + 'GPU layers', + info.inferenceGpuLayers == 0 + ? 'CPU only' + : '${info.inferenceGpuLayers}', + Icons.developer_board, + ), if (info.inferenceMaxTokensCap != null) - aiMetricChip(context, 'Max tokens cap', - '${info.inferenceMaxTokensCap}', Icons.compress), + aiMetricChip( + context, + 'Max tokens cap', + '${info.inferenceMaxTokensCap}', + Icons.compress, + ), ], ), ], From 2e698424574756186b8075db78cd389c9b96f11a Mon Sep 17 00:00:00 2001 From: Jakob Krantz Date: Sat, 14 Mar 2026 22:21:47 +0100 Subject: [PATCH 28/58] feat: Android LLM optimizations (foreground service, thread priority, big-core affinity, backend loading) --- .../lib/src/prompt_template.dart | 6 +- third_party/fllama | 2 +- .../android/app/src/main/AndroidManifest.xml | 7 + .../dev/zswatch/app/LlmComputeService.kt | 163 ++++++++++++++++++ .../kotlin/dev/zswatch/app/MainActivity.kt | 24 ++- .../lib/services/ai/llm_compute_service.dart | 49 ++++++ zswatch_app/lib/services/ai/llm_service.dart | 24 ++- 7 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/LlmComputeService.kt create mode 100644 zswatch_app/lib/services/ai/llm_compute_service.dart diff --git a/packages/chrono_ai_flow/lib/src/prompt_template.dart b/packages/chrono_ai_flow/lib/src/prompt_template.dart index 6384c1f..cbdaa39 100644 --- a/packages/chrono_ai_flow/lib/src/prompt_template.dart +++ b/packages/chrono_ai_flow/lib/src/prompt_template.dart @@ -215,7 +215,11 @@ JSON:'''; /// Returns the appropriate template for the given context size. static String templateForContextSize(int nCtx) { - return nCtx >= 3072 ? defaultTemplate : compactTemplate; + // Always use compactTemplate (5 examples, ~1030 tokens) — the full + // template with 19 examples produces ~2150 tokens which dominates + // inference time due to O(n²) attention. The compact template is + // sufficient for structured extraction quality. + return compactTemplate; } static String render( diff --git a/third_party/fllama b/third_party/fllama index 39d7702..1d8fdf2 160000 --- a/third_party/fllama +++ b/third_party/fllama @@ -1 +1 @@ -Subproject commit 39d7702a2b207dc7cb537afd344c64c813e8d475 +Subproject commit 1d8fdf26c465bbab4fb911baff6ca62bbe0af873 diff --git a/zswatch_app/android/app/src/main/AndroidManifest.xml b/zswatch_app/android/app/src/main/AndroidManifest.xml index 86e7519..a63e506 100644 --- a/zswatch_app/android/app/src/main/AndroidManifest.xml +++ b/zswatch_app/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + @@ -86,6 +87,12 @@ android:name=".BleConnectionForegroundService" android:exported="false" android:foregroundServiceType="connectedDevice" /> + + + - - - - diff --git a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/LlmComputeService.kt b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/LlmComputeService.kt index e469303..4ace203 100644 --- a/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/LlmComputeService.kt +++ b/zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/LlmComputeService.kt @@ -108,6 +108,8 @@ class LlmComputeService : Service() { private fun startForegroundWithNotification() { val openAppIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } ?: Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } val pendingIntent = PendingIntent.getActivity( this, 0, openAppIntent, diff --git a/zswatch_app/ios/Runner/AppDelegate.swift b/zswatch_app/ios/Runner/AppDelegate.swift index e9e7a38..b528259 100644 --- a/zswatch_app/ios/Runner/AppDelegate.swift +++ b/zswatch_app/ios/Runner/AppDelegate.swift @@ -110,17 +110,17 @@ import UIKit guard let self else { return } if let error { - result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) } return } guard granted else { - result(FlutterError(code: "PERMISSION_DENIED", message: "Calendar access was denied.", details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "PERMISSION_DENIED", message: "Calendar access was denied.", details: nil)) } return } guard let calendar = self.eventStore.defaultCalendarForNewEvents else { - result(FlutterError(code: "NO_CALENDAR", message: "No writable calendar was found.", details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "NO_CALENDAR", message: "No writable calendar was found.", details: nil)) } return } @@ -144,12 +144,14 @@ import UIKit do { try self.eventStore.save(event, span: .thisEvent, commit: true) - result([ - "platformId": event.calendarItemIdentifier, - "targetType": "calendar_event", - ]) + DispatchQueue.main.async { + result([ + "platformId": event.calendarItemIdentifier, + "targetType": "calendar_event", + ]) + } } catch { - result(FlutterError(code: "CREATE_ACTION_FAILED", message: error.localizedDescription, details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "CREATE_ACTION_FAILED", message: error.localizedDescription, details: nil)) } } } } @@ -165,17 +167,17 @@ import UIKit guard let self else { return } if let error { - result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) } return } guard granted else { - result(FlutterError(code: "PERMISSION_DENIED", message: "Reminder access was denied.", details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "PERMISSION_DENIED", message: "Reminder access was denied.", details: nil)) } return } guard let calendar = self.eventStore.defaultCalendarForNewReminders() else { - result(FlutterError(code: "NO_CALENDAR", message: "No reminders list was found.", details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "NO_CALENDAR", message: "No reminders list was found.", details: nil)) } return } @@ -198,12 +200,14 @@ import UIKit do { try self.eventStore.save(reminder, commit: true) - result([ - "platformId": reminder.calendarItemIdentifier, - "targetType": "reminder", - ]) + DispatchQueue.main.async { + result([ + "platformId": reminder.calendarItemIdentifier, + "targetType": "reminder", + ]) + } } catch { - result(FlutterError(code: "CREATE_ACTION_FAILED", message: error.localizedDescription, details: nil)) + DispatchQueue.main.async { result(FlutterError(code: "CREATE_ACTION_FAILED", message: error.localizedDescription, details: nil)) } } } }