diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 6c62959..e464f94 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -57,8 +57,7 @@ jobs: - name: Build APK run: | - flutter build apk --release \ - --dart-define=HUME_API_KEY=${{ secrets.HUME_API_KEY }} + flutter build apk --debug --target-platform android-arm64 - name: Build iOS if: runner.os == 'macOS' diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..89f73bc --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,26 @@ +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: anomalyco/opencode/github@latest + with: + model: opencode/big-pickle + share: false + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index dc78650..c0442ed 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -24,7 +24,7 @@ android { applicationId = "com.example.evi_example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 + minSdkVersion = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/lib/data/api/chat_events_spec.yaml b/lib/data/api/chat_events_spec.yaml new file mode 100644 index 0000000..bbdb40f --- /dev/null +++ b/lib/data/api/chat_events_spec.yaml @@ -0,0 +1,283 @@ +openapi: 3.1.0 +info: + title: empathic-voice-interface + version: 1.0.0 +paths: + /v0/evi/chats/{id}: + get: + operationId: list-chat-events + summary: List chat events + description: Fetches a paginated list of **Chat** events. + tags: + - subpackage_chats + parameters: + - name: id + in: path + description: Identifier for a Chat. Formatted as a UUID. + required: true + schema: + type: string + format: uuid + - name: page_size + in: query + description: >- + Specifies the maximum number of results to include per page, + enabling pagination. The value must be between 1 and 100, inclusive. + + + For example, if `page_size` is set to 10, each page will include `up + to 10 items. Defaults to 10. + required: false + schema: + type: integer + - name: page_number + in: query + description: >- + Specifies the page number to retrieve, enabling pagination. + + + This parameter uses zero-based indexing. For example, setting + `page_number` to 0 retrieves the first page of results (items 0-9 if + `page_size` is 10), setting `page_number` to 1 retrieves the second + page (items 10-19), and so on. Defaults to 0, which retrieves the first + page. + required: false + schema: + type: integer + default: 0 + - name: ascending_order + in: query + description: >- + Specifies the sorting order of the results based on their creation + date. Set to true for ascending order (chronological, with the + oldest records first) and false for descending order + (reverse-chronological, with the newest records first). Defaults to + true. + required: false + schema: + type: boolean + - name: X-Hume-Api-Key + in: header + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/return_chat_paged_events' +servers: + - url: https://api.hume.ai +components: + schemas: + return_config_spec: + type: object + properties: + id: + type: string + description: Identifier for a Config. Formatted as a UUID. + version: + type: + - integer + - 'null' + description: >- + Version number for a Config. + + + Configs, Prompts, Custom Voices, and Tools are versioned. This + versioning system supports iterative development, allowing you to + progressively refine configurations and revert to previous versions + if needed. + + + Version numbers are integer values representing different iterations + of the Config. Each update to the Config increments its version + number. + required: + - id + description: The Config associated with this Chat. + title: return_config_spec + ReturnChatEventRole: + type: string + enum: + - USER + - AGENT + - SYSTEM + - TOOL + title: ReturnChatEventRole + ReturnChatEventType: + type: string + enum: + - AGENT_MESSAGE + - ASSISTANT_PROSODY + - CHAT_START_MESSAGE + - CHAT_END_MESSAGE + - FUNCTION_CALL + - FUNCTION_CALL_RESPONSE + - PAUSE_ONSET + - RESUME_ONSET + - SESSION_SETTINGS + - SYSTEM_PROMPT + - USER_INTERRUPTION + - USER_MESSAGE + - USER_RECORDING_START_MESSAGE + title: ReturnChatEventType + return_chat_event: + type: object + properties: + chat_id: + type: string + description: Identifier for the Chat this event occurred in. Formatted as a UUID. + emotion_features: + type: + - string + - 'null' + description: >- + Stringified JSON containing the prosody model inference results. + + + EVI uses the prosody model to measure 48 expressions related to + speech and vocal characteristics. These results contain a detailed + emotional and tonal analysis of the audio. Scores typically range + from 0 to 1, with higher values indicating a stronger confidence + level in the measured attribute. + id: + type: string + description: Identifier for a Chat Event. Formatted as a UUID. + message_text: + type: + - string + - 'null' + description: >- + The text of the Chat Event. This field contains the message content + for each event type listed in the `type` field. + metadata: + type: + - string + - 'null' + description: Stringified JSON with additional metadata about the chat event. + related_event_id: + type: + - string + - 'null' + description: >- + Identifier for a related chat event. Currently only seen on + ASSISTANT_PROSODY events, to point back to the ASSISTANT_MESSAGE + that generated these prosody scores + role: + $ref: '#/components/schemas/ReturnChatEventRole' + timestamp: + type: integer + format: int64 + description: >- + Time at which the Chat Event occurred. Measured in seconds since the + Unix epoch. + type: + $ref: '#/components/schemas/ReturnChatEventType' + required: + - chat_id + - id + - role + - timestamp + - type + description: A description of a single event in a chat returned from the server + title: return_chat_event + ReturnChatPagedEventsPaginationDirection: + type: string + enum: + - ASC + - DESC + title: ReturnChatPagedEventsPaginationDirection + ReturnChatPagedEventsStatus: + type: string + enum: + - ACTIVE + - USER_ENDED + - USER_TIMEOUT + - MAX_DURATION_TIMEOUT + - INACTIVITY_TIMEOUT + - ERROR + title: ReturnChatPagedEventsStatus + return_chat_paged_events: + type: object + properties: + chat_group_id: + type: string + description: >- + Identifier for the Chat Group. Any chat resumed from this Chat will + have the same `chat_group_id`. Formatted as a UUID. + config: + $ref: '#/components/schemas/return_config_spec' + end_timestamp: + type: + - integer + - 'null' + format: int64 + description: >- + Time at which the Chat ended. Measured in seconds since the Unix + epoch. + events_page: + type: array + items: + $ref: '#/components/schemas/return_chat_event' + description: List of Chat Events for the specified `page_number` and `page_size`. + id: + type: string + description: Identifier for a Chat. Formatted as a UUID. + metadata: + type: + - string + - 'null' + description: Stringified JSON with additional metadata about the chat. + page_number: + type: integer + description: >- + The page number of the returned list. + + + This value corresponds to the `page_number` parameter specified in + the request. Pagination uses zero-based indexing. + page_size: + type: integer + description: >- + The maximum number of items returned per page. + + + This value corresponds to the `page_size` parameter specified in + the request. + pagination_direction: + $ref: '#/components/schemas/ReturnChatPagedEventsPaginationDirection' + start_timestamp: + type: + - integer + - 'null' + format: int64 + description: >- + Time at which the Chat started. Measured in seconds since the Unix + epoch. + status: + $ref: '#/components/schemas/ReturnChatPagedEventsStatus' + total_pages: + type: integer + description: The total number of pages in the collection. + required: + - chat_group_id + - events_page + - id + - page_number + - page_size + - pagination_direction + - start_timestamp + - status + - total_pages + description: >- + A description of chat status with a paginated list of chat events + returned from the server + title: return_chat_paged_events + securitySchemes: + bearerAuth: + type: apiKey + in: header + name: X-Hume-Api-Key diff --git a/lib/data/api/generated/export.dart b/lib/data/api/generated/export.dart new file mode 100644 index 0000000..c282a83 --- /dev/null +++ b/lib/data/api/generated/export.dart @@ -0,0 +1,17 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +// Clients +export 'subpackage_chats/subpackage_chats_client.dart'; +// Data classes +export 'models/return_config_spec.dart'; +export 'models/return_chat_event_role.dart'; +export 'models/return_chat_event_type.dart'; +export 'models/return_chat_event.dart'; +export 'models/return_chat_paged_events_pagination_direction.dart'; +export 'models/return_chat_paged_events_status.dart'; +export 'models/return_chat_paged_events.dart'; +// Root client +export 'rest_client.dart'; + diff --git a/lib/data/api/generated/models/return_chat_event.dart b/lib/data/api/generated/models/return_chat_event.dart new file mode 100644 index 0000000..726de18 --- /dev/null +++ b/lib/data/api/generated/models/return_chat_event.dart @@ -0,0 +1,59 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'return_chat_event_role.dart'; +import 'return_chat_event_type.dart'; + +part 'return_chat_event.g.dart'; + +/// A description of a single event in a chat returned from the server +@JsonSerializable() +class ReturnChatEvent { + const ReturnChatEvent({ + required this.chatId, + required this.emotionFeatures, + required this.id, + required this.messageText, + required this.metadata, + required this.relatedEventId, + required this.role, + required this.timestamp, + required this.type, + }); + + factory ReturnChatEvent.fromJson(Map json) => _$ReturnChatEventFromJson(json); + + /// Identifier for the Chat this event occurred in. Formatted as a UUID. + @JsonKey(name: 'chat_id') + final String chatId; + + /// Stringified JSON containing the prosody model inference results. + /// + /// EVI uses the prosody model to measure 48 expressions related to speech and vocal characteristics. These results contain a detailed emotional and tonal analysis of the audio. Scores typically range from 0 to 1, with higher values indicating a stronger confidence level in the measured attribute. + @JsonKey(name: 'emotion_features') + final String? emotionFeatures; + + /// Identifier for a Chat Event. Formatted as a UUID. + final String id; + + /// The text of the Chat Event. This field contains the message content for each event type listed in the `type` field. + @JsonKey(name: 'message_text') + final String? messageText; + + /// Stringified JSON with additional metadata about the chat event. + final String? metadata; + + /// Identifier for a related chat event. Currently only seen on ASSISTANT_PROSODY events, to point back to the ASSISTANT_MESSAGE that generated these prosody scores + @JsonKey(name: 'related_event_id') + final String? relatedEventId; + final ReturnChatEventRole role; + + /// Time at which the Chat Event occurred. Measured in seconds since the Unix epoch. + final int timestamp; + final ReturnChatEventType type; + + Map toJson() => _$ReturnChatEventToJson(this); +} diff --git a/lib/data/api/generated/models/return_chat_event.g.dart b/lib/data/api/generated/models/return_chat_event.g.dart new file mode 100644 index 0000000..2139a60 --- /dev/null +++ b/lib/data/api/generated/models/return_chat_event.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'return_chat_event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReturnChatEvent _$ReturnChatEventFromJson(Map json) => + ReturnChatEvent( + chatId: json['chat_id'] as String, + emotionFeatures: json['emotion_features'] as String?, + id: json['id'] as String, + messageText: json['message_text'] as String?, + metadata: json['metadata'] as String?, + relatedEventId: json['related_event_id'] as String?, + role: ReturnChatEventRole.fromJson(json['role'] as String), + timestamp: (json['timestamp'] as num).toInt(), + type: ReturnChatEventType.fromJson(json['type'] as String), + ); + +Map _$ReturnChatEventToJson(ReturnChatEvent instance) => + { + 'chat_id': instance.chatId, + 'emotion_features': instance.emotionFeatures, + 'id': instance.id, + 'message_text': instance.messageText, + 'metadata': instance.metadata, + 'related_event_id': instance.relatedEventId, + 'role': _$ReturnChatEventRoleEnumMap[instance.role]!, + 'timestamp': instance.timestamp, + 'type': _$ReturnChatEventTypeEnumMap[instance.type]!, + }; + +const _$ReturnChatEventRoleEnumMap = { + ReturnChatEventRole.user: 'USER', + ReturnChatEventRole.agent: 'AGENT', + ReturnChatEventRole.system: 'SYSTEM', + ReturnChatEventRole.tool: 'TOOL', + ReturnChatEventRole.$unknown: r'$unknown', +}; + +const _$ReturnChatEventTypeEnumMap = { + ReturnChatEventType.agentMessage: 'AGENT_MESSAGE', + ReturnChatEventType.assistantProsody: 'ASSISTANT_PROSODY', + ReturnChatEventType.chatStartMessage: 'CHAT_START_MESSAGE', + ReturnChatEventType.chatEndMessage: 'CHAT_END_MESSAGE', + ReturnChatEventType.functionCall: 'FUNCTION_CALL', + ReturnChatEventType.functionCallResponse: 'FUNCTION_CALL_RESPONSE', + ReturnChatEventType.pauseOnset: 'PAUSE_ONSET', + ReturnChatEventType.resumeOnset: 'RESUME_ONSET', + ReturnChatEventType.sessionSettings: 'SESSION_SETTINGS', + ReturnChatEventType.systemPrompt: 'SYSTEM_PROMPT', + ReturnChatEventType.userInterruption: 'USER_INTERRUPTION', + ReturnChatEventType.userMessage: 'USER_MESSAGE', + ReturnChatEventType.userRecordingStartMessage: 'USER_RECORDING_START_MESSAGE', + ReturnChatEventType.$unknown: r'$unknown', +}; diff --git a/lib/data/api/generated/models/return_chat_event_role.dart b/lib/data/api/generated/models/return_chat_event_role.dart new file mode 100644 index 0000000..63468be --- /dev/null +++ b/lib/data/api/generated/models/return_chat_event_role.dart @@ -0,0 +1,28 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum() +enum ReturnChatEventRole { + @JsonValue('USER') + user('USER'), + @JsonValue('AGENT') + agent('AGENT'), + @JsonValue('SYSTEM') + system('SYSTEM'), + @JsonValue('TOOL') + tool('TOOL'), + /// Default value for all unparsed values, allows backward compatibility when adding new values on the backend. + $unknown(null); + + const ReturnChatEventRole(this.json); + + factory ReturnChatEventRole.fromJson(String json) => values.firstWhere( + (e) => e.json == json, + orElse: () => $unknown, + ); + + final String? json; +} diff --git a/lib/data/api/generated/models/return_chat_event_type.dart b/lib/data/api/generated/models/return_chat_event_type.dart new file mode 100644 index 0000000..327dbf0 --- /dev/null +++ b/lib/data/api/generated/models/return_chat_event_type.dart @@ -0,0 +1,46 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum() +enum ReturnChatEventType { + @JsonValue('AGENT_MESSAGE') + agentMessage('AGENT_MESSAGE'), + @JsonValue('ASSISTANT_PROSODY') + assistantProsody('ASSISTANT_PROSODY'), + @JsonValue('CHAT_START_MESSAGE') + chatStartMessage('CHAT_START_MESSAGE'), + @JsonValue('CHAT_END_MESSAGE') + chatEndMessage('CHAT_END_MESSAGE'), + @JsonValue('FUNCTION_CALL') + functionCall('FUNCTION_CALL'), + @JsonValue('FUNCTION_CALL_RESPONSE') + functionCallResponse('FUNCTION_CALL_RESPONSE'), + @JsonValue('PAUSE_ONSET') + pauseOnset('PAUSE_ONSET'), + @JsonValue('RESUME_ONSET') + resumeOnset('RESUME_ONSET'), + @JsonValue('SESSION_SETTINGS') + sessionSettings('SESSION_SETTINGS'), + @JsonValue('SYSTEM_PROMPT') + systemPrompt('SYSTEM_PROMPT'), + @JsonValue('USER_INTERRUPTION') + userInterruption('USER_INTERRUPTION'), + @JsonValue('USER_MESSAGE') + userMessage('USER_MESSAGE'), + @JsonValue('USER_RECORDING_START_MESSAGE') + userRecordingStartMessage('USER_RECORDING_START_MESSAGE'), + /// Default value for all unparsed values, allows backward compatibility when adding new values on the backend. + $unknown(null); + + const ReturnChatEventType(this.json); + + factory ReturnChatEventType.fromJson(String json) => values.firstWhere( + (e) => e.json == json, + orElse: () => $unknown, + ); + + final String? json; +} diff --git a/lib/data/api/generated/models/return_chat_paged_events.dart b/lib/data/api/generated/models/return_chat_paged_events.dart new file mode 100644 index 0000000..e3e6bc7 --- /dev/null +++ b/lib/data/api/generated/models/return_chat_paged_events.dart @@ -0,0 +1,77 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'return_chat_event.dart'; +import 'return_chat_paged_events_pagination_direction.dart'; +import 'return_chat_paged_events_status.dart'; +import 'return_config_spec.dart'; + +part 'return_chat_paged_events.g.dart'; + +/// A description of chat status with a paginated list of chat events returned from the server +@JsonSerializable() +class ReturnChatPagedEvents { + const ReturnChatPagedEvents({ + required this.chatGroupId, + required this.config, + required this.endTimestamp, + required this.eventsPage, + required this.id, + required this.metadata, + required this.pageNumber, + required this.pageSize, + required this.paginationDirection, + required this.startTimestamp, + required this.status, + required this.totalPages, + }); + + factory ReturnChatPagedEvents.fromJson(Map json) => _$ReturnChatPagedEventsFromJson(json); + + /// Identifier for the Chat Group. Any chat resumed from this Chat will have the same `chat_group_id`. Formatted as a UUID. + @JsonKey(name: 'chat_group_id') + final String chatGroupId; + final ReturnConfigSpec config; + + /// Time at which the Chat ended. Measured in seconds since the Unix epoch. + @JsonKey(name: 'end_timestamp') + final int? endTimestamp; + + /// List of Chat Events for the specified `page_number` and `page_size`. + @JsonKey(name: 'events_page') + final List eventsPage; + + /// Identifier for a Chat. Formatted as a UUID. + final String id; + + /// Stringified JSON with additional metadata about the chat. + final String? metadata; + + /// The page number of the returned list. + /// + /// This value corresponds to the `page_number` parameter specified in the request. Pagination uses zero-based indexing. + @JsonKey(name: 'page_number') + final int pageNumber; + + /// The maximum number of items returned per page. + /// + /// This value corresponds to the `page_size` parameter specified in the request. + @JsonKey(name: 'page_size') + final int pageSize; + @JsonKey(name: 'pagination_direction') + final ReturnChatPagedEventsPaginationDirection paginationDirection; + + /// Time at which the Chat started. Measured in seconds since the Unix epoch. + @JsonKey(name: 'start_timestamp') + final int? startTimestamp; + final ReturnChatPagedEventsStatus status; + + /// The total number of pages in the collection. + @JsonKey(name: 'total_pages') + final int totalPages; + + Map toJson() => _$ReturnChatPagedEventsToJson(this); +} diff --git a/lib/data/api/generated/models/return_chat_paged_events.g.dart b/lib/data/api/generated/models/return_chat_paged_events.g.dart new file mode 100644 index 0000000..59bc565 --- /dev/null +++ b/lib/data/api/generated/models/return_chat_paged_events.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'return_chat_paged_events.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReturnChatPagedEvents _$ReturnChatPagedEventsFromJson( + Map json) => + ReturnChatPagedEvents( + chatGroupId: json['chat_group_id'] as String, + config: ReturnConfigSpec.fromJson(json['config'] as Map), + endTimestamp: (json['end_timestamp'] as num?)?.toInt(), + eventsPage: (json['events_page'] as List) + .map((e) => ReturnChatEvent.fromJson(e as Map)) + .toList(), + id: json['id'] as String, + metadata: json['metadata'] as String?, + pageNumber: (json['page_number'] as num).toInt(), + pageSize: (json['page_size'] as num).toInt(), + paginationDirection: ReturnChatPagedEventsPaginationDirection.fromJson( + json['pagination_direction'] as String), + startTimestamp: (json['start_timestamp'] as num?)?.toInt(), + status: ReturnChatPagedEventsStatus.fromJson(json['status'] as String), + totalPages: (json['total_pages'] as num).toInt(), + ); + +Map _$ReturnChatPagedEventsToJson( + ReturnChatPagedEvents instance) => + { + 'chat_group_id': instance.chatGroupId, + 'config': instance.config, + 'end_timestamp': instance.endTimestamp, + 'events_page': instance.eventsPage, + 'id': instance.id, + 'metadata': instance.metadata, + 'page_number': instance.pageNumber, + 'page_size': instance.pageSize, + 'pagination_direction': _$ReturnChatPagedEventsPaginationDirectionEnumMap[ + instance.paginationDirection]!, + 'start_timestamp': instance.startTimestamp, + 'status': _$ReturnChatPagedEventsStatusEnumMap[instance.status]!, + 'total_pages': instance.totalPages, + }; + +const _$ReturnChatPagedEventsPaginationDirectionEnumMap = { + ReturnChatPagedEventsPaginationDirection.asc: 'ASC', + ReturnChatPagedEventsPaginationDirection.desc: 'DESC', + ReturnChatPagedEventsPaginationDirection.$unknown: r'$unknown', +}; + +const _$ReturnChatPagedEventsStatusEnumMap = { + ReturnChatPagedEventsStatus.active: 'ACTIVE', + ReturnChatPagedEventsStatus.userEnded: 'USER_ENDED', + ReturnChatPagedEventsStatus.userTimeout: 'USER_TIMEOUT', + ReturnChatPagedEventsStatus.maxDurationTimeout: 'MAX_DURATION_TIMEOUT', + ReturnChatPagedEventsStatus.inactivityTimeout: 'INACTIVITY_TIMEOUT', + ReturnChatPagedEventsStatus.error: 'ERROR', + ReturnChatPagedEventsStatus.$unknown: r'$unknown', +}; diff --git a/lib/data/api/generated/models/return_chat_paged_events_pagination_direction.dart b/lib/data/api/generated/models/return_chat_paged_events_pagination_direction.dart new file mode 100644 index 0000000..890f53d --- /dev/null +++ b/lib/data/api/generated/models/return_chat_paged_events_pagination_direction.dart @@ -0,0 +1,24 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum() +enum ReturnChatPagedEventsPaginationDirection { + @JsonValue('ASC') + asc('ASC'), + @JsonValue('DESC') + desc('DESC'), + /// Default value for all unparsed values, allows backward compatibility when adding new values on the backend. + $unknown(null); + + const ReturnChatPagedEventsPaginationDirection(this.json); + + factory ReturnChatPagedEventsPaginationDirection.fromJson(String json) => values.firstWhere( + (e) => e.json == json, + orElse: () => $unknown, + ); + + final String? json; +} diff --git a/lib/data/api/generated/models/return_chat_paged_events_status.dart b/lib/data/api/generated/models/return_chat_paged_events_status.dart new file mode 100644 index 0000000..4413eeb --- /dev/null +++ b/lib/data/api/generated/models/return_chat_paged_events_status.dart @@ -0,0 +1,32 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum() +enum ReturnChatPagedEventsStatus { + @JsonValue('ACTIVE') + active('ACTIVE'), + @JsonValue('USER_ENDED') + userEnded('USER_ENDED'), + @JsonValue('USER_TIMEOUT') + userTimeout('USER_TIMEOUT'), + @JsonValue('MAX_DURATION_TIMEOUT') + maxDurationTimeout('MAX_DURATION_TIMEOUT'), + @JsonValue('INACTIVITY_TIMEOUT') + inactivityTimeout('INACTIVITY_TIMEOUT'), + @JsonValue('ERROR') + error('ERROR'), + /// Default value for all unparsed values, allows backward compatibility when adding new values on the backend. + $unknown(null); + + const ReturnChatPagedEventsStatus(this.json); + + factory ReturnChatPagedEventsStatus.fromJson(String json) => values.firstWhere( + (e) => e.json == json, + orElse: () => $unknown, + ); + + final String? json; +} diff --git a/lib/data/api/generated/models/return_config_spec.dart b/lib/data/api/generated/models/return_config_spec.dart new file mode 100644 index 0000000..1913e2e --- /dev/null +++ b/lib/data/api/generated/models/return_config_spec.dart @@ -0,0 +1,30 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'return_config_spec.g.dart'; + +/// The Config associated with this Chat. +@JsonSerializable() +class ReturnConfigSpec { + const ReturnConfigSpec({ + required this.id, + required this.version, + }); + + factory ReturnConfigSpec.fromJson(Map json) => _$ReturnConfigSpecFromJson(json); + + /// Identifier for a Config. Formatted as a UUID. + final String id; + + /// Version number for a Config. + /// + /// Configs, Prompts, Custom Voices, and Tools are versioned. This versioning system supports iterative development, allowing you to progressively refine configurations and revert to previous versions if needed. + /// + /// Version numbers are integer values representing different iterations of the Config. Each update to the Config increments its version number. + final int? version; + + Map toJson() => _$ReturnConfigSpecToJson(this); +} diff --git a/lib/data/api/generated/models/return_config_spec.g.dart b/lib/data/api/generated/models/return_config_spec.g.dart new file mode 100644 index 0000000..c3f4870 --- /dev/null +++ b/lib/data/api/generated/models/return_config_spec.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'return_config_spec.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReturnConfigSpec _$ReturnConfigSpecFromJson(Map json) => + ReturnConfigSpec( + id: json['id'] as String, + version: (json['version'] as num?)?.toInt(), + ); + +Map _$ReturnConfigSpecToJson(ReturnConfigSpec instance) => + { + 'id': instance.id, + 'version': instance.version, + }; diff --git a/lib/data/api/generated/rest_client.dart b/lib/data/api/generated/rest_client.dart new file mode 100644 index 0000000..6ef5b31 --- /dev/null +++ b/lib/data/api/generated/rest_client.dart @@ -0,0 +1,25 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:dio/dio.dart'; + +import 'subpackage_chats/subpackage_chats_client.dart'; + +/// empathic-voice-interface `v1.0.0` +class RestClient { + RestClient( + Dio dio, { + String? baseUrl, + }) : _dio = dio, + _baseUrl = baseUrl; + + final Dio _dio; + final String? _baseUrl; + + static String get version => '1.0.0'; + + SubpackageChatsClient? _subpackageChats; + + SubpackageChatsClient get subpackageChats => _subpackageChats ??= SubpackageChatsClient(_dio, baseUrl: _baseUrl); +} diff --git a/lib/data/api/generated/subpackage_chats/subpackage_chats_client.dart b/lib/data/api/generated/subpackage_chats/subpackage_chats_client.dart new file mode 100644 index 0000000..66d7716 --- /dev/null +++ b/lib/data/api/generated/subpackage_chats/subpackage_chats_client.dart @@ -0,0 +1,39 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import + +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../models/return_chat_paged_events.dart'; + +part 'subpackage_chats_client.g.dart'; + +@RestApi() +abstract class SubpackageChatsClient { + factory SubpackageChatsClient(Dio dio, {String? baseUrl}) = _SubpackageChatsClient; + + /// List chat events. + /// + /// Fetches a paginated list of **Chat** events. + /// + /// [id] - Identifier for a Chat. Formatted as a UUID. + /// + /// [pageSize] - Specifies the maximum number of results to include per page, enabling pagination. The value must be between 1 and 100, inclusive. + /// + /// For example, if `page_size` is set to 10, each page will include `up to 10 items. Defaults to 10. + /// + /// [pageNumber] - Specifies the page number to retrieve, enabling pagination. + /// + /// This parameter uses zero-based indexing. For example, setting `page_number` to 0 retrieves the first page of results (items 0-9 if `page_size` is 10), setting `page_number` to 1 retrieves the second page (items 10-19), and so on. Defaults to 0, which retrieves the first page. + /// + /// [ascendingOrder] - Specifies the sorting order of the results based on their creation date. Set to true for ascending order (chronological, with the oldest records first) and false for descending order (reverse-chronological, with the newest records first). Defaults to true. + @GET('/v0/evi/chats/{id}') + Future listChatEvents({ + @Path('id') required String id, + @Header('X-Hume-Api-Key') required String xHumeApiKey, + @Query('page_number') int pageNumber = 0, + @Query('page_size') int? pageSize, + @Query('ascending_order') bool? ascendingOrder, + }); +} diff --git a/lib/data/api/generated/subpackage_chats/subpackage_chats_client.g.dart b/lib/data/api/generated/subpackage_chats/subpackage_chats_client.g.dart new file mode 100644 index 0000000..2e27703 --- /dev/null +++ b/lib/data/api/generated/subpackage_chats/subpackage_chats_client.g.dart @@ -0,0 +1,98 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'subpackage_chats_client.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations + +class _SubpackageChatsClient implements SubpackageChatsClient { + _SubpackageChatsClient( + this._dio, { + this.baseUrl, + this.errorLogger, + }); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future listChatEvents({ + required String id, + required String xHumeApiKey, + int pageNumber = 0, + int? pageSize, + bool? ascendingOrder, + }) async { + final _extra = {}; + final queryParameters = { + r'page_number': pageNumber, + r'page_size': pageSize, + r'ascending_order': ascendingOrder, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {r'X-Hume-Api-Key': xHumeApiKey}; + _headers.removeWhere((k, v) => v == null); + const Map? _data = null; + final _options = _setStreamType(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/v0/evi/chats/${id}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + final _result = await _dio.fetch>(_options); + late ReturnChatPagedEvents _value; + try { + _value = ReturnChatPagedEvents.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls( + String dioBaseUrl, + String? baseUrl, + ) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/lib/evi_message.dart b/lib/evi_message.dart index 44c5571..c830ebc 100644 --- a/lib/evi_message.dart +++ b/lib/evi_message.dart @@ -73,7 +73,7 @@ class ProsodyInference { class Inference { final ProsodyInference? prosody; - Inference(json) : prosody = ProsodyInference(json['prosody']); + Inference(json) : prosody = json['prosody'] != null ? ProsodyInference(json['prosody']) : null; } class AssistantMessage extends EviMessage { diff --git a/lib/pages/my_home_page.dart b/lib/pages/my_home_page.dart index dc25fd9..960a931 100644 --- a/lib/pages/my_home_page.dart +++ b/lib/pages/my_home_page.dart @@ -174,7 +174,7 @@ class _MyHomePageState extends State { uri += '?access_token=${ConfigManager.instance.humeAccessToken}'; } else if (ConfigManager.instance.humeApiKey.isNotEmpty) { uri += - '?api_key=REPLACE_THIS_WITH_ACTUAL_HUME_API_KEY&config_id=REPLACE_THIS_WITH_ACTUAL_HUME_CONFIG_ID'; + '?api_key=${ConfigManager.instance.humeApiKey}'; } else { throw Exception('Please set your Hume API credentials in main.dart'); } diff --git a/lib/provider/chat_provider.dart b/lib/provider/chat_provider.dart index 98ea7b8..e12cd19 100644 --- a/lib/provider/chat_provider.dart +++ b/lib/provider/chat_provider.dart @@ -1,10 +1,11 @@ import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:evi_example/data/api/generated/export.dart'; +import 'package:evi_example/utils.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; - import 'package:shared_preferences/shared_preferences.dart'; - import '../pages/emotion_page.dart'; import './initial_sessions.dart'; // Add this import @@ -68,46 +69,45 @@ class ChatProvider extends ChangeNotifier { _context = context; } - Future>> _fetchChatMessages(String chatId) async { - final url = Uri.parse('https://api.hume.ai/v0/evi/chats/$chatId') - .replace(queryParameters: { - 'page_number': '0', - 'page_size': '100', - 'ascending_order': 'false', - }); - - final response = await http.get( - url, - headers: { - 'X-Hume-Api-Key': 'REPLACE_THIS_WITH_ACTUAL_HUME_API_KEY', - }, - ); - - if (response.statusCode != 200) { - throw Exception('Failed to fetch messages: ${response.statusCode}'); + Future _fetchChatMessages(String chatId) async { + try { + var baseUrl = 'https://api.hume.ai/'; // TODO extract duplicate + final dio = Dio(BaseOptions(baseUrl: baseUrl)); + final client = RestClient(dio); + final events = await client.subpackageChats.listChatEvents( + id: chatId, + xHumeApiKey: ConfigManager.instance.humeApiKey, + pageNumber: 0, + pageSize: 100, + ascendingOrder: false, + ); + return events; + } catch (e) { + throw Exception('Failed to fetch messages: $e'); } + } - final data = json.decode(response.body); - final allMessages = data['events_page'] + static List> filterMessages(ReturnChatPagedEvents events) { + final allMessages = events.eventsPage .map((message) => { - 'role': message['role'], - 'text': message['message_text'], - 'emotion_features': message['emotion_features'], + 'role': message.role, + 'text': message.messageText, + 'emotion_features': message.emotionFeatures, }) .toList() .cast>(); print(allMessages); // Remove messages from the start until finding one less than 200 characters while (allMessages.isNotEmpty && - allMessages.first['role'] != 'USER' && + allMessages.first['role'] != ReturnChatEventRole.user && + allMessages.first['text'] != null && allMessages.first['text'].length > 200) { allMessages.removeAt(0); } - return allMessages; } - Map _processEmotions(List> messages) { + static Map processEmotions(List> messages) { Map allEmotions = {}; int messageCount = 0; @@ -240,20 +240,18 @@ class ChatProvider extends ChangeNotifier { if (_chats.isEmpty) return false; try { - final chatId = _chats.last['chat_id']; - - // Fetch messages - final messages = await _fetchChatMessages(chatId); - _chatMessages = messages; + var chatId = _chats.last['chat_id']; + final body = await _fetchChatMessages(chatId); + _chatMessages = filterMessages(body); // Process emotions for user messages - final userMessages = messages.where((m) => m['role'] == 'USER').toList(); + final userMessages = _chatMessages.where((m) => m['role'] == 'USER').toList(); if (userMessages.isEmpty) { print('No user messages found.'); return false; } - emotions = _processEmotions(userMessages); + emotions = processEmotions(userMessages); whatWentWell = await _processWhatWentWell(userMessages); challenges = await _processChallenges(userMessages); diff --git a/pubspec.yaml b/pubspec.yaml index c8aaa07..0006edf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: http: ^1.2.2 freezed_annotation: ^2.4.4 json_annotation: ^4.9.0 + dio: ^5.7.0 + retrofit: ^4.4.1 provider: ^6.1.1 flutter_svg: ^2.0.0 fl_chart: ^0.69.2 @@ -66,6 +68,8 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^5.0.0 build_runner: ^2.4.13 + swagger_parser: ^1.23.2 + retrofit_generator: ^9.1.2 freezed: ^2.5.7 json_serializable: ^6.9.0 diff --git a/swagger_parser.yaml b/swagger_parser.yaml new file mode 100644 index 0000000..523af5d --- /dev/null +++ b/swagger_parser.yaml @@ -0,0 +1,5 @@ +swagger_parser: + schema_path: lib/data/api/chat_events_spec.yaml + output_directory: lib/data/api/generated + json_serializer: json_serializable + root_client: true diff --git a/test/data/api/models/return_chat_event_test.dart b/test/data/api/models/return_chat_event_test.dart new file mode 100644 index 0000000..73b0131 --- /dev/null +++ b/test/data/api/models/return_chat_event_test.dart @@ -0,0 +1,61 @@ +import 'package:evi_example/data/api/generated/export.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'dart:convert'; + +void main() { + group('ReturnChatEvent Tests', () { + test('should parse a valid JSON string for return_chat_event', () { + final jsonString = ''' + { + "chat_id": "550e8400-e29b-41d4-a716-446655440000", + "emotion_features": "{\\"happiness\\": 0.8, \\"sadness\\": 0.1}", + "id": "660e8400-e29b-41d4-a716-446655440000", + "message_text": "Hello world", + "metadata": null, + "related_event_id": null, + "role": "USER", + "timestamp": 1672531200, + "type": "USER_MESSAGE" + } + '''; + + final Map jsonMap = jsonDecode(jsonString); + final event = ReturnChatEvent.fromJson(jsonMap); + + expect(event, isNotNull); + expect(event.chatId, '550e8400-e29b-41d4-a716-446655440000'); + expect(event.id, '660e8400-e29b-41d4-a716-446655440000'); + expect(event.role, ReturnChatEventRole.user); + expect(event.type, ReturnChatEventType.userMessage); + expect(event.messageText, 'Hello world'); + expect(event.timestamp, 1672531200); + // Double-parsing check for emotion_features + expect(event.emotionFeatures, isNotNull); + final emotionMap = jsonDecode(event.emotionFeatures!); + expect(emotionMap['happiness'], 0.8); + expect(emotionMap['sadness'], 0.1); + }); + + test('should handle null emotion_features', () { + final jsonString = ''' + { + "chat_id": "550e8400-e29b-41d4-a716-446655440000", + "emotion_features": null, + "id": "660e8400-e29b-41d4-a716-446655440000", + "message_text": "Hello world", + "metadata": null, + "related_event_id": null, + "role": "USER", + "timestamp": 1672531200, + "type": "USER_MESSAGE" + } + '''; + + final Map jsonMap = jsonDecode(jsonString); + final event = ReturnChatEvent.fromJson(jsonMap); + + expect(event, isNotNull); + expect(event.emotionFeatures, isNull); + }); + }); +} diff --git a/test/data/api/models/return_chat_paged_events_test.dart b/test/data/api/models/return_chat_paged_events_test.dart new file mode 100644 index 0000000..0b3b518 --- /dev/null +++ b/test/data/api/models/return_chat_paged_events_test.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:evi_example/data/api/generated/export.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ReturnChatPagedEvents', () { + test('should parse return_chat_paged_events correctly', () {}); + + test('errors for empty JSON`', () async { + expect(() => ReturnChatPagedEvents.fromJson({}), throwsA(anything)); + }); + + test('to do basic mapping of required keys', () async { + final jsonString = ''' + { + "chat_group_id": "770e8400-e29b-41d4-a716-446655440000", + "events_page": [ + { + "chat_id": "550e8400-e29b-41d4-a716", + "emotion_features": "{\\"happiness\\": 0.5}", + "id": "660e8400-e29b-41d4-a716-446655440000", + "message_text": "Event 1", + "role": "USER", + "timestamp": 1672531200, + "type": "USER_MESSAGE" + } + ], + "id": "990e8400-e29b-41d4-a716-446655440000", + "page_number": 0, + "page_size": 10, + "pagination_direction": "ASC", + "start_timestamp": 1672531200, + "status": "ACTIVE", + "total_pages": 1, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + + final result = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + expect(result, isNotNull); + expect(result.chatGroupId, "770e8400-e29b-41d4-a716-446655440000"); + expect(result.eventsPage[0].chatId, "550e8400-e29b-41d4-a716"); + expect(result.eventsPage[0].emotionFeatures, "{\"happiness\": 0.5}"); + expect(result.eventsPage[0].id, "660e8400-e29b-41d4-a716-446655440000"); + expect(result.eventsPage[0].messageText, "Event 1"); + expect(result.eventsPage[0].role, ReturnChatEventRole.user); + expect(result.eventsPage[0].timestamp, 1672531200); + expect(result.eventsPage[0].type, ReturnChatEventType.userMessage); + expect(result.eventsPage.length, 1); + expect(result.id, "990e8400-e29b-41d4-a716-446655440000"); + expect(result.pageNumber, 0); + expect(result.pageSize, 10); + expect(result.paginationDirection, ReturnChatPagedEventsPaginationDirection.asc); + expect(result.startTimestamp, 1672531200); + expect(result.status, ReturnChatPagedEventsStatus.active); + expect(result.totalPages, 1); + }); + }); +} diff --git a/test/dummy_test.dart b/test/dummy_test.dart deleted file mode 100644 index cdc3313..0000000 --- a/test/dummy_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('dummy test', () { - expect(true, isTrue); - }); -} diff --git a/test/evi_message_test.dart b/test/evi_message_test.dart new file mode 100644 index 0000000..8052ad9 --- /dev/null +++ b/test/evi_message_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:evi_example/evi_message.dart'; + +void main() { + test('AssistantMessage with empty models has null prosody', () { + final jsonString = ''' + { + "type": "assistant_message", + "message": {"role": "user", "content": "hello"}, + "models": {} + } + '''; + + final message = EviMessage.decode(jsonString) as AssistantMessage; + + expect(message.models.prosody, isNull); + }); + + test('AssistantMessage with prosody in models parses scores', () { + var emotion1 = 'Admiration'; + var emotion1score = 0.00838470458984375; + var emotion2 = 'Amusement'; + var emotion2score = 0.04827880859375; + final jsonString = ''' + { + "type": "assistant_message", + "message": {"role": "user", "content": "hello"}, + "models": { + "prosody": { + "scores": { + "$emotion1": $emotion1score, + "$emotion2": $emotion2score + } + } + } + } + '''; + + final msg = EviMessage.decode(jsonString) as AssistantMessage; + + expect(msg.models.prosody, isNotNull); + expect(msg.models.prosody!.scores[emotion1], emotion1score); + expect(msg.models.prosody!.scores[emotion2], emotion2score); + }); +} \ No newline at end of file diff --git a/test/provider/chat_provider_test.dart b/test/provider/chat_provider_test.dart new file mode 100644 index 0000000..7d330d5 --- /dev/null +++ b/test/provider/chat_provider_test.dart @@ -0,0 +1,481 @@ +import 'dart:convert'; +import 'package:evi_example/data/api/generated/export.dart'; +import 'package:evi_example/provider/chat_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ChatProvider Tests', () { + group('filterMessages', () { + test( + 'removes messages from the start until finding one less than 200 characters', + () { + final jsonString = ''' + { + "chat_group_id": "", + "events_page": [ + { + "role": "SYSTEM", + "message_text": "this is more than 200 characters long this is more than 200 characters long this is more than 200 characters long this is more than 200 characters long this is more than 200 characters long this is long", + "emotion_features": null, + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "this is less than 200 characters", + "emotion_features": null, + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + expect(result[0]['text'], "this is less than 200 characters"); + expect(result.length, 1); + }); + + test('handles null message_text gracefully', () { + final jsonString = ''' + { + "events_page": [ + { + "role": "SYSTEM", + "message_text": null, + "emotion_features": null, + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "USER", + "message_text": null, + "emotion_features": null, + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "this is more than 200 characters long this is more than 200 characters long this is more than 200 characters long this is more than 200 characters long this is more than 200 characters long this is long", + "emotion_features": null, + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "this is less than 200 characters", + "emotion_features": null, + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "chat_group_id": "", + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + expect(result[0]["role"], ReturnChatEventRole.system); + expect(result[1]["role"], ReturnChatEventRole.user); + expect(result[2]["role"], ReturnChatEventRole.system); + expect(result[3]["role"], ReturnChatEventRole.system); + expect(result.length, 4); + }); + + test('handles empty events_page list', () { + final jsonString = ''' + { + "chat_group_id": "", + "events_page": [ + ], + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + expect(result, isEmpty); + }); + + test('no trimming when first message is USER', () { + final jsonString = ''' + { + "chat_group_id": "", + "events_page": [ + { + "role": "USER", + "message_text": "Hello!", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + expect(result.length, 1); + expect(result.first['role'], ReturnChatEventRole.user); + }); + + test('stops trimming when non-USER message is short (<= 200 chars)', () { + final jsonString = ''' + { + "events_page": [ + { + "role": "SYSTEM", + "message_text": "${"a" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "Short message", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "USER", + "message_text": "User hello", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "chat_group_id": "", + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + // It should stop at the "Short message" because it's <= 200 chars, even though it's not USER. + expect(result, isNotEmpty); + expect(result[0]['text'], 'Short message'); + expect(result[1]['text'], 'User hello'); + expect(result.length, 2); + }); + + test('trims multiple long non-USER messages', () { + final jsonString = ''' + { + "events_page": [ + { + "role": "SYSTEM", + "message_text": "${"a" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "${"b" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "USER", + "message_text": "User text", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "chat_group_id": "", + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + expect(result, isNotEmpty); + expect(result.first['role'], ReturnChatEventRole.user); + expect(result.length, 1); + }); + + test('trims until specific condition is met in mixed sequence', () { + final jsonString = ''' + { + "events_page": [ + { + "role": "SYSTEM", + "message_text": "${"a" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "${"b" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "SYSTEM", + "message_text": "Short one", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "USER", + "message_text": "User text", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "chat_group_id": "", + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + expect(result, isNotEmpty); + expect(result.first['text'], 'Short one'); + expect(result.length, 2); + }); + + test('stops trimming if role changes to USER', () { + final jsonString = ''' + { + "events_page": [ + { + "role": "SYSTEM", + "message_text": "${"a" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + }, + { + "role": "USER", + "message_text": "${"b" * 250}", + "emotion_features": "{}", + "chat_id": "", + "id": "", + "timestamp": 0, + "type": "USER_MESSAGE" + } + ], + "chat_group_id": "", + "id": "", + "page_number": 0, + "page_size": 0, + "pagination_direction": "ASC", + "start_timestamp": 0, + "status": "ACTIVE", + "total_pages": 0, + "config": { + "id": "config1d", + "version": 0 + } + } + '''; + final events = ReturnChatPagedEvents.fromJson(jsonDecode(jsonString)); + + final result = ChatProvider.filterMessages(events); + + // Even if the USER message is long, it should stop because role == 'USER'. + expect(result, isNotEmpty); + expect(result.first['role'], ReturnChatEventRole.user); + expect(result.length, 1); + }); + }); + + group('processEmotions', () { + test('calculates averages', () { + final messages = [ + {'emotion_features': '{"Admiration": 0.4, "Adoration": 0.2}'}, + { + 'emotion_features': + '{"Admiration": 0.2, "Adoration": 0.6, "Awkwardness": 1.0}' + }, + {'emotion_features': null} + ]; + + final result = ChatProvider.processEmotions(messages); + + expect(result['Awkwardness'], 0.5); + expect(result['Adoration'], 0.4); + expect(result['Admiration'], (0.4 + 0.2) / 2); + }); + + test('sorts averages', () { + final messages = [ + {'emotion_features': '{"Admiration": 0.4, "Adoration": 0.2}'}, + { + 'emotion_features': + '{"Admiration": 0.2, "Adoration": 0.6, "Awkwardness": 1.0}' + }, + {'emotion_features': null} + ]; + + final result = ChatProvider.processEmotions(messages); + + expect(result.keys.toString(), "(Awkwardness, Adoration, Admiration)"); + expect(result.values.toString(), "(0.5, 0.4, ${(0.4 + 0.2) / 2})"); + }); + + test('ignores empty emotion_features', () { + final messages = [ + {'emotion_features': '{"Admiration": 0.8}'}, + {'emotion_features': null} + ]; + + final result = ChatProvider.processEmotions(messages); + + expect(result['Admiration'], 0.8); + expect(result.length, 1); + }); + + test('handles empty emotion_features object {}', () { + final messages = [ + {'emotion_features': '{"Admiration": 0.8}'}, + {'emotion_features': '{}'} + ]; + + final result = ChatProvider.processEmotions(messages); + + final expected = {}; + expected['Admiration'] = 0.4; + expect(result, expected); + }); + + test('skips emotion_features that decodes to non-Map type (e.g. JSON array)', () { + final messages = [ + {'emotion_features': '[]'}, + {'emotion_features': '{"Admiration": 0.8}'}, + {'emotion_features': '"just_a_string"'} + ]; + + final result = ChatProvider.processEmotions(messages); + + expect(result['Admiration'], 0.8); + expect(result.length, 1); + }); + }); + }); +}