-
-
Notifications
You must be signed in to change notification settings - Fork 110
feat: implement context-window pruning middleware for semantic workspaces #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// Prunes the list of meeting contexts based on relevance and a token budget. | ||
| List<Map<String, dynamic>> prune({ | ||
| required String query, | ||
| required List<Map<String, dynamic>> meetings, | ||
| required int maxTokens, | ||
| }) { | ||
| final stopwatch = Stopwatch()..start(); | ||
|
|
||
| // Create deep copies to avoid modifying cached references | ||
| List<Map<String, dynamic>> chunks = meetings.map((m) => Map<String, dynamic>.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<Map<String, dynamic>> 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<String, dynamic>.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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, dynamic> summary = <String, dynamic>{ | ||
| 'key_discussion_points': <String>['Point 1', 'Point 2'], | ||
| 'important_decisions': <String>['Decision 1'], | ||
| 'action_items': <Map<String, dynamic>>[ | ||
| <String, dynamic>{'item': 'Task 1', 'owner': 'Alice', 'deadline': 'Tomorrow'}, | ||
| <String, dynamic>{'item': 'Task 2'} // Missing owner and deadline to test fallbacks | ||
| ], | ||
| 'follow_up_tasks': <String>['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<String, dynamic> summary = <String, dynamic>{ | ||
| 'key_discussion_points': <String>['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: <String, dynamic>{}, | ||
| ); | ||
|
|
||
| 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<String, dynamic> summary = <String, dynamic>{ | ||
| 'key_discussion_points': <String>[], | ||
| 'important_decisions': <String>[], | ||
| 'action_items': <Map<String, dynamic>>[], | ||
| 'follow_up_tasks': <String>[], | ||
| }; | ||
|
|
||
| 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<String, dynamic> summary = <String, dynamic>{ | ||
| 'action_items': <dynamic>[null], | ||
| }; | ||
|
|
||
| expect( | ||
| () => MeetingFormatter.formatMeetingSummary( | ||
| title: 'Malformed Sync', | ||
| date: '2026-07-25 at 10:00', | ||
| summary: summary, | ||
| ), | ||
| throwsA(isA<Error>()), // Catches NoSuchMethodError or TypeError depending on Dart version | ||
| ); | ||
|
Comment on lines
+124
to
+136
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Do not codify a crash for malformed action items. This accepts a runtime failure when externally supplied summary data contains a null/non-map item. As per path instructions, 🤖 Prompt for AI AgentsSource: Path instructions |
||
| }); | ||
| }); | ||
|
|
||
| group('formatMeetingSummaries', () { | ||
| test('returns default message for empty list (Empty list)', () { | ||
| final String result = MeetingFormatter.formatMeetingSummaries(<Map<String, dynamic>>[]); | ||
| expect(result, 'No relevant meetings found.'); | ||
| }); | ||
|
|
||
| test('formats multiple meetings correctly (Multiple items, Date/String formatting)', () { | ||
| final List<Map<String, dynamic>> meetings = <Map<String, dynamic>>[ | ||
| <String, dynamic>{ | ||
| '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': <String, dynamic>{ | ||
| 'overall_summary': 'Summary 1', | ||
| }, | ||
| }, | ||
| <String, dynamic>{ | ||
| // 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<Map<String, dynamic>> meetings = <Map<String, dynamic>>[ | ||
| <String, dynamic>{ | ||
| 'title': 'Meeting 1', | ||
| 'meeting_date': 'not-a-valid-date', // Will fail DateTime.parse() | ||
| } | ||
| ]; | ||
|
|
||
| expect( | ||
| () => MeetingFormatter.formatMeetingSummaries(meetings), | ||
| throwsFormatException, | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.