diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index 151cfc33..6870c2cf 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:ell_ena/services/supabase_service.dart'; import 'package:ell_ena/services/meeting_formatter.dart'; +import 'package:ell_ena/services/context_pruning_service.dart'; class AIService { static final AIService _instance = AIService._internal(); @@ -67,17 +68,25 @@ class AIService { bool isMeetingQuery = _isMeetingRelatedQuery(userMessage); String meetingContext = ""; - // If it's a meeting query, retrieve relevant meeting summaries - if (isMeetingQuery) { - final meetingSummaries = await getRelevantMeetingSummaries(userMessage); - - if (meetingSummaries.isNotEmpty) { - meetingContext = "\nRelevant meeting information:\n\n"; - meetingContext += MeetingFormatter.formatMeetingSummaries(meetingSummaries); - } - } - try { + // If it's a meeting query, retrieve relevant meeting summaries + if (isMeetingQuery) { + final meetingSummaries = await getRelevantMeetingSummaries(userMessage); + + // Apply Context-Window Pruning and Token Truncation Middleware + final contextPruner = ContextPruningService(); + final optimizedMeetingSummaries = contextPruner.prune( + query: userMessage, + meetings: meetingSummaries, + maxTokens: 1500 // Strict budget for meeting context + ); + + if (optimizedMeetingSummaries.isNotEmpty) { + meetingContext = "\nRelevant meeting information:\n\n"; + meetingContext += MeetingFormatter.formatMeetingSummaries(optimizedMeetingSummaries); + } + } + // Define function declarations for the model final List> functionDeclarations = [ { diff --git a/lib/services/context_pruning_service.dart b/lib/services/context_pruning_service.dart new file mode 100644 index 00000000..5b029894 --- /dev/null +++ b/lib/services/context_pruning_service.dart @@ -0,0 +1,124 @@ +import 'dart:core'; +import 'package:flutter/foundation.dart'; +import 'package:ell_ena/services/meeting_formatter.dart'; + +class ContextPruningService { + /// Estimates the number of tokens based on a character heuristic (approx. 4 chars per token). + int _estimateTokens(String text) { + return (text.length / 4).ceil(); + } + + /// Calculates a lightweight keyword overlap score as a fallback + /// if the vector search similarity score is unavailable. + double _calculateKeywordOverlap(String query, String text) { + final punctuationRegExp = RegExp(r'[^\w\s]+'); + final queryWords = query.toLowerCase().replaceAll(punctuationRegExp, '').split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toSet(); + final textWords = text.toLowerCase().replaceAll(punctuationRegExp, '').split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toSet(); + + if (queryWords.isEmpty || textWords.isEmpty) return 0.0; + + final intersection = queryWords.intersection(textWords); + return intersection.length / queryWords.length; + } + + /// Prunes the list of meeting contexts based on relevance and a token budget. + List> prune({ + required String query, + required List> meetings, + required int maxTokens, + }) { + final stopwatch = Stopwatch()..start(); + + // Create deep copies to avoid modifying cached references + List> chunks = meetings.map((m) => Map.from(m)).toList(); + + int originalTokens = 0; + int chunksRemoved = 0; + + // 1. Scoring & Initial Token Counting + for (var chunk in chunks) { + final summaryStr = chunk['summary']?.toString() ?? ''; + + // Calculate token budget based on fully rendered meeting context + final renderedChunk = MeetingFormatter.formatMeetingSummaries([chunk]); + final tokenCount = _estimateTokens(renderedChunk); + + originalTokens += tokenCount; + chunk['_token_count'] = tokenCount; + + // Use existing similarity if provided by vector search, else fallback to keyword overlap + if (chunk.containsKey('similarity') && chunk['similarity'] != null) { + chunk['_relevance_score'] = (chunk['similarity'] as num).toDouble(); + } else { + chunk['_relevance_score'] = _calculateKeywordOverlap(query, summaryStr); + } + } + + // 2. Rank contexts from highest relevance to lowest + chunks.sort((a, b) { + final scoreA = a['_relevance_score'] as double; + final scoreB = b['_relevance_score'] as double; + return scoreB.compareTo(scoreA); // Descending order + }); + + // 3. Sliding-Window Pruning (Remove lowest-ranked chunks until under budget) + List> optimizedChunks = []; + // Reserve overhead for prepended content: "\nRelevant meeting information:\n\n" + int currentTokens = _estimateTokens("\nRelevant meeting information:\n\n"); + + for (var chunk in chunks) { + final tokenCount = chunk['_token_count'] as int; + + if (currentTokens + tokenCount <= maxTokens) { + optimizedChunks.add(chunk); + currentTokens += tokenCount; + } else { + // Enforce strict truncation for the last partially fitting chunk + int remainingTokens = maxTokens - currentTokens; + + if (remainingTokens > 100 && chunk['summary'] != null) { + var partialSummary = Map.from(chunk['summary']); + + if (partialSummary['overall_summary'] != null) { + String overall = partialSummary['overall_summary'].toString(); + int allowedChars = remainingTokens * 4; + + if (overall.length > allowedChars) { + partialSummary['overall_summary'] = + overall.substring(0, allowedChars) + '\n... [TRUNCATED DUE TO TOKEN BUDGET]'; + } + } + chunk['summary'] = partialSummary; + optimizedChunks.add(chunk); + currentTokens += remainingTokens; + } + } + } + + // Accumulate skipped chunks in the count + chunksRemoved = chunks.length - optimizedChunks.length; + + stopwatch.stop(); + + // 4. Debug Logging & Reduction Metrics + final double reductionPct = originalTokens > 0 + ? ((originalTokens - currentTokens) / originalTokens) * 100 + : 0.0; + + debugPrint('--- Context-Window Pruning Metrics ---'); + debugPrint('Original Tokens: $originalTokens'); + debugPrint('Final Tokens: $currentTokens'); + debugPrint('Reduction: ${reductionPct.toStringAsFixed(2)}%'); + debugPrint('Chunks Removed: $chunksRemoved'); + debugPrint('Latency: ${stopwatch.elapsedMilliseconds} ms'); + debugPrint('--------------------------------------'); + + // Clean up internal metric keys before returning + for (var chunk in optimizedChunks) { + chunk.remove('_token_count'); + chunk.remove('_relevance_score'); + } + + return optimizedChunks; + } +} diff --git a/test/services/meeting_formatter_test.dart b/test/services/meeting_formatter_test.dart new file mode 100644 index 00000000..be3100e7 --- /dev/null +++ b/test/services/meeting_formatter_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ell_ena/services/meeting_formatter.dart'; + +void main() { + group('MeetingFormatter', () { + group('formatMeetingSummary', () { + test('formats complete summary correctly (Normal/valid input)', () { + final Map summary = { + 'key_discussion_points': ['Point 1', 'Point 2'], + 'important_decisions': ['Decision 1'], + 'action_items': >[ + {'item': 'Task 1', 'owner': 'Alice', 'deadline': 'Tomorrow'}, + {'item': 'Task 2'} // Missing owner and deadline to test fallbacks + ], + 'follow_up_tasks': ['Follow up 1'], + 'overall_summary': 'Great meeting' + }; + + final String result = MeetingFormatter.formatMeetingSummary( + title: 'Team Sync', + date: '2026-07-25 at 10:00', + summary: summary, + ); + + final String expected = '📅 *Team Sync*\n' + '🕒 2026-07-25 at 10:00\n' + '\n' + 'Key Points:\n' + '-> Point 1\n' + '-> Point 2\n' + '\n' + 'Decisions:\n' + '-> Decision 1\n' + '\n' + 'Action Items:\n' + '-> Task 1 (Owner: Alice, Deadline: Tomorrow)\n' + '-> Task 2 (Owner: Unassigned, Deadline: No deadline)\n' + '\n' + 'Follow-Up Tasks:\n' + '-> Follow up 1\n' + '\n' + 'Summary:\n' + 'Great meeting\n'; + + expect(result, expected); + }); + + test('formats partial input correctly (Partial input)', () { + final Map summary = { + 'key_discussion_points': ['Point 1'], + }; + + final String result = MeetingFormatter.formatMeetingSummary( + title: 'Partial Sync', + date: '2026-07-25 at 10:00', + summary: summary, + ); + + final String expected = '📅 *Partial Sync*\n' + '🕒 2026-07-25 at 10:00\n' + '\n' + 'Key Points:\n' + '-> Point 1\n' + '\n'; + + expect(result, expected); + }); + + test('formats empty summary map correctly (Empty input)', () { + final String result = MeetingFormatter.formatMeetingSummary( + title: 'Empty Sync', + date: '2026-07-25 at 10:00', + summary: {}, + ); + + final String expected = '📅 *Empty Sync*\n' + '🕒 2026-07-25 at 10:00\n' + '\n'; + + expect(result, expected); + }); + + test('formats null summary correctly (Null values)', () { + final String result = MeetingFormatter.formatMeetingSummary( + title: 'Null Sync', + date: '2026-07-25 at 10:00', + summary: null, + ); + + final String expected = '📅 *Null Sync*\n' + '🕒 2026-07-25 at 10:00\n' + '\n'; + + expect(result, expected); + }); + + test('formats empty lists correctly (Empty lists/maps)', () { + final Map summary = { + 'key_discussion_points': [], + 'important_decisions': [], + 'action_items': >[], + 'follow_up_tasks': [], + }; + + final String result = MeetingFormatter.formatMeetingSummary( + title: 'Empty Lists Sync', + date: '2026-07-25 at 10:00', + summary: summary, + ); + + // Based on current implementation, empty key points and decisions generate headers, + // but action items and follow up tasks are skipped due to .isNotEmpty check. + final String expected = '📅 *Empty Lists Sync*\n' + '🕒 2026-07-25 at 10:00\n' + '\n' + 'Key Points:\n' + '\n' + 'Decisions:\n' + '\n'; + + expect(result, expected); + }); + + test('throws Error when action items list contains null (Malformed input)', () { + final Map summary = { + 'action_items': [null], + }; + + expect( + () => MeetingFormatter.formatMeetingSummary( + title: 'Malformed Sync', + date: '2026-07-25 at 10:00', + summary: summary, + ), + throwsA(isA()), // Catches NoSuchMethodError or TypeError depending on Dart version + ); + }); + }); + + group('formatMeetingSummaries', () { + test('returns default message for empty list (Empty list)', () { + final String result = MeetingFormatter.formatMeetingSummaries(>[]); + expect(result, 'No relevant meetings found.'); + }); + + test('formats multiple meetings correctly (Multiple items, Date/String formatting)', () { + final List> meetings = >[ + { + 'title': 'Meeting 1', + 'meeting_date': '2026-07-25 14:30:00', // Use local string without Z to be completely safe from timezone parsing bugs + 'summary': { + 'overall_summary': 'Summary 1', + }, + }, + { + // Missing title and date + 'summary': null, + } + ]; + + final String result = MeetingFormatter.formatMeetingSummaries(meetings); + + final String expected = '📅 *Meeting 1*\n' + '🕒 2026-07-25 at 14:30\n' + '\n' + 'Summary:\n' + 'Summary 1\n' + '\n' + '----------------------------------------\n' + '\n' + '📅 *Untitled Meeting*\n' + '🕒 Unknown date\n' + '\n'; + + expect(result, expected); + }); + + test('throws FormatException for malformed date string (Edge cases)', () { + final List> meetings = >[ + { + 'title': 'Meeting 1', + 'meeting_date': 'not-a-valid-date', // Will fail DateTime.parse() + } + ]; + + expect( + () => MeetingFormatter.formatMeetingSummaries(meetings), + throwsFormatException, + ); + }); + }); + }); +}