import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/conversation.dart'; import '../../../core/models/chat_message.dart'; /// Advanced conversation search service with multiple search strategies class ConversationSearchService { static const int maxResults = 50; static const int contextLines = 2; // Lines before/after match for context /// Search through conversations with various criteria Future searchConversations({ required List conversations, required String query, ConversationSearchOptions options = const ConversationSearchOptions(), }) async { if (query.trim().isEmpty) { return ConversationSearchResults.empty(); } final normalizedQuery = query.toLowerCase().trim(); final results = []; // Search through each conversation for (final conversation in conversations) { final matches = await _searchInConversation( conversation: conversation, query: normalizedQuery, options: options, ); results.addAll(matches); } // Sort results by relevance and date results.sort((a, b) { // First by relevance score (higher is better) final relevanceCompare = b.relevanceScore.compareTo(a.relevanceScore); if (relevanceCompare != 0) return relevanceCompare; // Then by date (newer first) return b.timestamp.compareTo(a.timestamp); }); // Limit results final limitedResults = results.take(maxResults).toList(); return ConversationSearchResults( query: query, results: limitedResults, totalMatches: results.length, searchDuration: DateTime.now().difference(DateTime.now()), ); } /// Search within a single conversation Future> _searchInConversation({ required Conversation conversation, required String query, required ConversationSearchOptions options, }) async { final matches = []; // Search in conversation title if (options.searchTitles && _containsQuery(conversation.title, query)) { matches.add( ConversationSearchMatch( conversationId: conversation.id, conversationTitle: conversation.title, matchType: SearchMatchType.title, snippet: conversation.title, highlightedSnippet: _highlightQuery(conversation.title, query), relevanceScore: _calculateTitleRelevance(conversation.title, query), timestamp: conversation.updatedAt, ), ); } // Search in messages if (options.searchMessages) { final messageMatches = await _searchInMessages( conversation: conversation, query: query, options: options, ); matches.addAll(messageMatches); } // Search in tags if (options.searchTags) { for (final tag in conversation.tags) { if (_containsQuery(tag, query)) { matches.add( ConversationSearchMatch( conversationId: conversation.id, conversationTitle: conversation.title, matchType: SearchMatchType.tag, snippet: tag, highlightedSnippet: _highlightQuery(tag, query), relevanceScore: _calculateTagRelevance(tag, query), timestamp: conversation.updatedAt, additionalInfo: {'tag': tag}, ), ); } } } return matches; } /// Search within messages of a conversation Future> _searchInMessages({ required Conversation conversation, required String query, required ConversationSearchOptions options, }) async { final matches = []; for (int i = 0; i < conversation.messages.length; i++) { final message = conversation.messages[i]; // Skip system messages if not enabled if (!options.includeSystemMessages && message.role == 'system') { continue; } // Filter by role if specified if (options.roleFilter != null && message.role != options.roleFilter) { continue; } // Check if message contains query if (_containsQuery(message.content, query)) { final snippet = _extractSnippet(message.content, query); final contextMessages = _getContextMessages(conversation.messages, i); matches.add( ConversationSearchMatch( conversationId: conversation.id, conversationTitle: conversation.title, messageId: message.id, matchType: SearchMatchType.message, snippet: snippet, highlightedSnippet: _highlightQuery(snippet, query), relevanceScore: _calculateMessageRelevance(message.content, query), timestamp: message.timestamp, messageRole: message.role, messageIndex: i, contextMessages: contextMessages, ), ); } } return matches; } /// Extract relevant snippet around the query match String _extractSnippet(String content, String query) { const maxSnippetLength = 200; final queryIndex = content.toLowerCase().indexOf(query); if (queryIndex == -1) { return content.substring(0, maxSnippetLength.clamp(0, content.length)); } // Calculate snippet bounds final start = (queryIndex - 50).clamp(0, content.length); final end = (queryIndex + query.length + 50).clamp(0, content.length); String snippet = content.substring(start, end); // Add ellipsis if needed if (start > 0) snippet = '...$snippet'; if (end < content.length) snippet = '$snippet...'; return snippet; } /// Get context messages around a matched message List _getContextMessages(List messages, int index) { final start = (index - contextLines).clamp(0, messages.length); final end = (index + contextLines + 1).clamp(0, messages.length); return messages.sublist(start, end); } /// Highlight query matches in text String _highlightQuery(String text, String query) { if (query.isEmpty) return text; final regex = RegExp(RegExp.escape(query), caseSensitive: false); return text.replaceAllMapped(regex, (match) { return '${match.group(0)}'; }); } /// Check if text contains the query bool _containsQuery(String text, String query) { return text.toLowerCase().contains(query); } /// Calculate relevance score for title matches double _calculateTitleRelevance(String title, String query) { final titleLower = title.toLowerCase(); final queryLower = query.toLowerCase(); // Exact match gets highest score if (titleLower == queryLower) return 100.0; // Title starts with query gets high score if (titleLower.startsWith(queryLower)) return 90.0; // Title contains query as whole word gets medium score if (RegExp( r'\b' + RegExp.escape(queryLower) + r'\b', ).hasMatch(titleLower)) { return 70.0; } // Partial match gets lower score return 50.0; } /// Calculate relevance score for message matches double _calculateMessageRelevance(String content, String query) { final contentLower = content.toLowerCase(); final queryLower = query.toLowerCase(); // Count occurrences final occurrences = queryLower.allMatches(contentLower).length; // Base score for containing the query double score = 30.0; // Bonus for multiple occurrences score += (occurrences - 1) * 10.0; // Bonus for whole word matches if (RegExp( r'\b' + RegExp.escape(queryLower) + r'\b', ).hasMatch(contentLower)) { score += 20.0; } // Penalty for very long messages (relevance dilution) if (content.length > 1000) { score *= 0.8; } return score.clamp(0.0, 100.0); } /// Calculate relevance score for tag matches double _calculateTagRelevance(String tag, String query) { final tagLower = tag.toLowerCase(); final queryLower = query.toLowerCase(); // Exact match gets highest score if (tagLower == queryLower) return 80.0; // Tag starts with query gets high score if (tagLower.startsWith(queryLower)) return 70.0; // Partial match gets medium score return 50.0; } } /// Search options for conversation search @immutable class ConversationSearchOptions { final bool searchTitles; final bool searchMessages; final bool searchTags; final bool includeSystemMessages; final String? roleFilter; // 'user', 'assistant', 'system' final DateTime? dateFrom; final DateTime? dateTo; final bool caseSensitive; const ConversationSearchOptions({ this.searchTitles = true, this.searchMessages = true, this.searchTags = true, this.includeSystemMessages = false, this.roleFilter, this.dateFrom, this.dateTo, this.caseSensitive = false, }); ConversationSearchOptions copyWith({ bool? searchTitles, bool? searchMessages, bool? searchTags, bool? includeSystemMessages, String? roleFilter, DateTime? dateFrom, DateTime? dateTo, bool? caseSensitive, }) { return ConversationSearchOptions( searchTitles: searchTitles ?? this.searchTitles, searchMessages: searchMessages ?? this.searchMessages, searchTags: searchTags ?? this.searchTags, includeSystemMessages: includeSystemMessages ?? this.includeSystemMessages, roleFilter: roleFilter ?? this.roleFilter, dateFrom: dateFrom ?? this.dateFrom, dateTo: dateTo ?? this.dateTo, caseSensitive: caseSensitive ?? this.caseSensitive, ); } } /// Search results container @immutable class ConversationSearchResults { final String query; final List results; final int totalMatches; final Duration searchDuration; const ConversationSearchResults({ required this.query, required this.results, required this.totalMatches, required this.searchDuration, }); factory ConversationSearchResults.empty() { return ConversationSearchResults( query: '', results: const [], totalMatches: 0, searchDuration: Duration.zero, ); } bool get isEmpty => results.isEmpty; bool get isNotEmpty => results.isNotEmpty; int get length => results.length; } /// Individual search match @immutable class ConversationSearchMatch { final String conversationId; final String conversationTitle; final String? messageId; final SearchMatchType matchType; final String snippet; final String highlightedSnippet; final double relevanceScore; final DateTime timestamp; final String? messageRole; final int? messageIndex; final List? contextMessages; final Map? additionalInfo; const ConversationSearchMatch({ required this.conversationId, required this.conversationTitle, this.messageId, required this.matchType, required this.snippet, required this.highlightedSnippet, required this.relevanceScore, required this.timestamp, this.messageRole, this.messageIndex, this.contextMessages, this.additionalInfo, }); } /// Types of search matches enum SearchMatchType { title, message, tag } /// Provider for conversation search service final conversationSearchServiceProvider = Provider(( ref, ) { return ConversationSearchService(); }); /// Provider for search results final conversationSearchResultsProvider = StateProvider((ref) { return null; }); /// Provider for search options final searchOptionsProvider = StateProvider((ref) { return const ConversationSearchOptions(); });