From b04918ad6bb73467d83dc454d24239b5f3ce5a34 Mon Sep 17 00:00:00 2001 From: Mrunmayee Kokitkar Date: Tue, 7 Jul 2026 13:39:34 +0530 Subject: [PATCH] Enhance AI Search with Filters, Ranking & Contextual Results (#76) - Enhanced backend search with filter support (resultType, date range, organization, tags) - Added configurable topK limit for search results - Implemented semantic ranking with similarity score sorting - Created modular AI search components: SearchBar, SearchFilters, SearchResultCard, SearchSkeleton, SearchEmptyState - Updated AiSearch page with new components and features - Added result type badges with color coding - Implemented similarity score display with percentage - Added quick actions: View Details, Open Meeting, Copy Summary - Improved loading states with skeleton UI - Enhanced empty states for better UX - Added clear search button functionality - Implemented date range and sort filters - Maintained existing AI Search endpoint compatibility - Responsive design across desktop, tablet, and mobile - All linting and build checks passed --- client/src/components/ai-search/SearchBar.jsx | 43 ++++ .../components/ai-search/SearchEmptyState.jsx | 33 +++ .../components/ai-search/SearchFilters.jsx | 68 +++++ .../components/ai-search/SearchResultCard.jsx | 132 ++++++++++ .../components/ai-search/SearchSkeleton.jsx | 31 +++ client/src/pages/AiSearch.jsx | 242 +++++++++--------- server/routes/aiRoutes.js | 31 ++- server/utils/embeddingUtils.js | 70 ++++- 8 files changed, 503 insertions(+), 147 deletions(-) create mode 100644 client/src/components/ai-search/SearchBar.jsx create mode 100644 client/src/components/ai-search/SearchEmptyState.jsx create mode 100644 client/src/components/ai-search/SearchFilters.jsx create mode 100644 client/src/components/ai-search/SearchResultCard.jsx create mode 100644 client/src/components/ai-search/SearchSkeleton.jsx diff --git a/client/src/components/ai-search/SearchBar.jsx b/client/src/components/ai-search/SearchBar.jsx new file mode 100644 index 0000000..df55a86 --- /dev/null +++ b/client/src/components/ai-search/SearchBar.jsx @@ -0,0 +1,43 @@ +import React from "react"; + +const SearchBar = ({ query, setQuery, onSearch, loading, onClear }) => { + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSearch()} + /> + {query && ( + + )} + +
+
+ ); +}; + +export default SearchBar; diff --git a/client/src/components/ai-search/SearchEmptyState.jsx b/client/src/components/ai-search/SearchEmptyState.jsx new file mode 100644 index 0000000..4a7070d --- /dev/null +++ b/client/src/components/ai-search/SearchEmptyState.jsx @@ -0,0 +1,33 @@ +import React from "react"; + +const SearchEmptyState = ({ hasSearched }) => { + if (hasSearched) { + return ( +
+
🔍
+

+ No results found +

+

+ We couldn't find any matches for your search. Try different keywords + or adjust your filters. +

+
+ ); + } + + return ( +
+
💬
+

+ Start searching +

+

+ Type your question above to explore your meetings, policies, and AI + summaries using natural language. +

+
+ ); +}; + +export default SearchEmptyState; diff --git a/client/src/components/ai-search/SearchFilters.jsx b/client/src/components/ai-search/SearchFilters.jsx new file mode 100644 index 0000000..d19aa57 --- /dev/null +++ b/client/src/components/ai-search/SearchFilters.jsx @@ -0,0 +1,68 @@ +import React from "react"; + +const SearchFilters = ({ filters, setFilters, resultCount }) => { + const handleFilterChange = (key, value) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + return ( +
+
+
+ + +
+ +
+ + handleFilterChange("dateFrom", e.target.value)} + className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" + /> +
+ +
+ + handleFilterChange("dateTo", e.target.value)} + className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" + /> +
+ +
+ + +
+ + {resultCount > 0 && ( +
+ {resultCount} result{resultCount !== 1 ? "s" : ""} +
+ )} +
+
+ ); +}; + +export default SearchFilters; diff --git a/client/src/components/ai-search/SearchResultCard.jsx b/client/src/components/ai-search/SearchResultCard.jsx new file mode 100644 index 0000000..311bba1 --- /dev/null +++ b/client/src/components/ai-search/SearchResultCard.jsx @@ -0,0 +1,132 @@ +import React from "react"; + +const SearchResultCard = ({ + result, + onViewDetails, + onOpenMeeting, + onCopySummary, +}) => { + const getResultTypeLabel = (type) => { + switch (type) { + case "meeting": + return "Meeting"; + case "policy": + return "Policy"; + case "summary": + return "AI Summary"; + default: + return "Meeting"; + } + }; + + const getResultTypeColor = (type) => { + switch (type) { + case "meeting": + return "bg-blue-100 text-blue-700"; + case "policy": + return "bg-purple-100 text-purple-700"; + case "summary": + return "bg-green-100 text-green-700"; + default: + return "bg-blue-100 text-blue-700"; + } + }; + + const formatDate = (date) => { + if (!date) return "Unknown date"; + return new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + const getSimilarityColor = (score) => { + if (score >= 0.8) return "text-green-600"; + if (score >= 0.6) return "text-blue-600"; + if (score >= 0.4) return "text-yellow-600"; + return "text-gray-600"; + }; + + return ( +
+
+
+
+ + {getResultTypeLabel(result.resultType)} + + {result.similarityScore && ( + + {Math.round(result.similarityScore * 100)}% match + + )} +
+

onViewDetails(result)} + > + {result.title || "Untitled Meeting"} +

+
+
+ +

+ {result.summary || + result.transcript?.slice(0, 200) || + "No summary available."} +

+ +
+ {result.tags && + result.tags.length > 0 && + result.tags.slice(0, 3).map((tag) => ( + + #{tag} + + ))} +
+ +
+
+ 📅 {formatDate(result.createdAt)} + {result.organization && ( + 🏢 {result.organization} + )} +
+
+ + {result.resultType === "meeting" && ( + + )} + +
+
+
+ ); +}; + +export default SearchResultCard; diff --git a/client/src/components/ai-search/SearchSkeleton.jsx b/client/src/components/ai-search/SearchSkeleton.jsx new file mode 100644 index 0000000..fd160ac --- /dev/null +++ b/client/src/components/ai-search/SearchSkeleton.jsx @@ -0,0 +1,31 @@ +import React from "react"; + +const SearchSkeleton = () => { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +}; + +export default SearchSkeleton; diff --git a/client/src/pages/AiSearch.jsx b/client/src/pages/AiSearch.jsx index d81bc3e..e3fb426 100644 --- a/client/src/pages/AiSearch.jsx +++ b/client/src/pages/AiSearch.jsx @@ -1,6 +1,11 @@ import React, { useState, useContext } from "react"; import Navbar from "../components/Navbar.jsx"; -import AppContent from "../context/AppContent.js"; // ✅ +import AppContent from "../context/AppContent.js"; +import SearchBar from "../components/ai-search/SearchBar.jsx"; +import SearchFilters from "../components/ai-search/SearchFilters.jsx"; +import SearchResultCard from "../components/ai-search/SearchResultCard.jsx"; +import SearchSkeleton from "../components/ai-search/SearchSkeleton.jsx"; +import SearchEmptyState from "../components/ai-search/SearchEmptyState.jsx"; // Modal Component for showing full details const ResultModal = ({ result, onClose }) => { @@ -10,18 +15,20 @@ const ResultModal = ({ result, onClose }) => {
-

{result.title || "Untitled Meeting"}

+

+ {result.title || "Untitled Meeting"} +

+ >
-

Summary

+

+ Summary +

{result.summary || result.transcript || "No summary available."}

@@ -29,7 +36,9 @@ const ResultModal = ({ result, onClose }) => { {result.transcript && (
-

Transcript

+

+ Transcript +

{result.transcript}

@@ -59,10 +68,15 @@ const ResultModal = ({ result, onClose }) => { {result.tags && result.tags.length > 0 && (
-

Tags

+

+ Tags +

{result.tags.map((tag) => ( - + #{tag} ))} @@ -89,6 +103,13 @@ const AiSearch = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [selectedResult, setSelectedResult] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + const [filters, setFilters] = useState({ + resultType: "all", + dateFrom: "", + dateTo: "", + sortBy: "relevance", + }); const handleSearch = async () => { if (!query.trim()) { @@ -99,12 +120,13 @@ const AiSearch = () => { setLoading(true); setError(""); setResults([]); + setHasSearched(true); try { const res = await fetch(`${backendUrl}/api/ai-search`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), + body: JSON.stringify({ query, filters }), }); if (!res.ok) { @@ -113,18 +135,32 @@ const AiSearch = () => { } const data = await res.json(); - // console.log("🔍 Search results from backend:", data); - setResults(data.results || []); - if (data.results?.length === 0) { - setError("No matching meetings found. Try a different query."); + let sortedResults = data.results || []; + + if (filters.sortBy === "date-desc") { + sortedResults.sort( + (a, b) => new Date(b.createdAt) - new Date(a.createdAt), + ); + } else if (filters.sortBy === "date-asc") { + sortedResults.sort( + (a, b) => new Date(a.createdAt) - new Date(b.createdAt), + ); + } + + setResults(sortedResults); + if (sortedResults.length === 0) { + setError( + "No matching results found. Try a different query or adjust your filters.", + ); } } catch (err) { console.error("❌ Search error:", err); - - // Network error handling + if (err.message === "Failed to fetch") { - setError("Unable to connect to the server. Please check your internet connection and try again."); + setError( + "Unable to connect to the server. Please check your internet connection and try again.", + ); } else if (err.message.includes("500")) { setError("Server error. Please try again later."); } else if (err.message.includes("404")) { @@ -137,6 +173,33 @@ const AiSearch = () => { } }; + const handleClear = () => { + setQuery(""); + setResults([]); + setError(""); + setHasSearched(false); + }; + + const handleViewDetails = (result) => { + setSelectedResult(result); + }; + + const handleOpenMeeting = (result) => { + window.open(`/meetings/${result.meetingId}`, "_blank"); + }; + + const handleCopySummary = async (result) => { + const textToCopy = result.summary || result.transcript || ""; + if (textToCopy) { + try { + await navigator.clipboard.writeText(textToCopy); + alert("Summary copied to clipboard!"); + } catch (err) { + console.error("Failed to copy:", err); + } + } + }; + return (
@@ -148,120 +211,59 @@ const AiSearch = () => {

Search across your meeting transcripts, policies, and{" "} - AI summaries using natural language — powered by your intelligent semantic engine. + AI summaries using natural language — powered by your + intelligent semantic engine.

{/* Search Input */} -
-
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> - -
+ - {/* Error Message */} - {error && ( -
- ⚠️ {error.includes("Unable to connect") ? "Connection Error" : "Error"} -

{error}

-
- )} -
+ {/* Error Message */} + {error && ( +
+ + ⚠️{" "} + {error.includes("Unable to connect") + ? "Connection Error" + : "Error"} + +

{error}

+
+ )} {/* Search Results Section */}
- {loading && ( -
-
-

Searching your meetings...

-

This may take a few seconds

-
- )} + {loading && } {!loading && results.length > 0 && ( -
-
-

- Found {results.length} relevant meeting - {results.length > 1 ? "s" : ""}: -

- + <> + +
+ {results.map((result, index) => ( + + ))}
- - {results.map((result, index) => ( -
setSelectedResult(result)} - > - {/* Title */} -
-

- {result.title || "Untitled Meeting"} -

- - Score: {result.similarityScore || "N/A"} - -
- - {/* Summary */} -

- {result.summary || - result.transcript?.slice(0, 200) || - "No summary available."} -

- - {/* Metadata */} -
- - 📅{" "} - {result.createdAt - ? new Date(result.createdAt).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }) - : "Unknown date"} - - -
-
- ))} -
+ )} - {!loading && results.length === 0 && !error && ( -
-

- 💬 Type your question above to start exploring with AI -

-
+ {!loading && results.length === 0 && ( + )}
@@ -277,4 +279,4 @@ const AiSearch = () => { ); }; -export default AiSearch; \ No newline at end of file +export default AiSearch; diff --git a/server/routes/aiRoutes.js b/server/routes/aiRoutes.js index 550a8ed..fb1f8b2 100644 --- a/server/routes/aiRoutes.js +++ b/server/routes/aiRoutes.js @@ -7,38 +7,37 @@ const router = express.Router(); // POST /api/ai-search router.post("/", async (req, res) => { try { - const { query } = req.body; - + const { query, filters } = req.body; + // ✅ Validate input if (!query || query.trim().length === 0) { - return res.status(400).json({ + return res.status(400).json({ error: "Query text is required", - results: [] + results: [], }); } - console.log("🔍 Received search query:", query); + console.log("🔍 Received search query:", query, "with filters:", filters); + + // ✅ Call vector search with filters + const results = await searchVectorStore(query, filters || {}); - // ✅ Call vector search - const results = await searchVectorStore(query); - // ✅ Debug log console.log(`📤 Returning ${results.length} results to frontend`); - + // ✅ Send response - res.json({ - query, + res.json({ + query, results, - count: results.length + count: results.length, }); - } catch (error) { console.error("❌ AI Search Error:", error); - res.status(500).json({ + res.status(500).json({ error: error.message || "Search failed", - results: [] + results: [], }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/utils/embeddingUtils.js b/server/utils/embeddingUtils.js index 0a29344..38b7c91 100644 --- a/server/utils/embeddingUtils.js +++ b/server/utils/embeddingUtils.js @@ -72,7 +72,6 @@ export const embedText = async (text) => { } }; - // =================================================== // 💾 4️⃣ Index Meeting in Pinecone (FINAL v3-compatible) // =================================================== @@ -126,14 +125,13 @@ export const indexMeeting = async (meeting) => { } }; - // =================================================== // 🔍 5️⃣ Perform Semantic Search via Pinecone // =================================================== // =================================================== // 🔍 5️⃣ Perform Semantic Search via Pinecone (FIXED) // =================================================== -export const searchVectorStore = async (query) => { +export const searchVectorStore = async (query, filters = {}) => { try { if (!query || query.trim().length === 0) { throw new Error("Empty query received for vector search"); @@ -141,13 +139,20 @@ export const searchVectorStore = async (query) => { const indexInstance = await initVectorStore(); - console.log("🔍 Performing Pinecone vector search for:", query); + console.log( + "🔍 Performing Pinecone vector search for:", + query, + "with filters:", + filters, + ); const queryEmbedding = await embedText(query); + const topK = filters.limit || 10; + const results = await indexInstance.query({ vector: queryEmbedding, - topK: 5, + topK: topK, includeMetadata: true, }); @@ -156,22 +161,63 @@ export const searchVectorStore = async (query) => { return []; } - // ✅ FIXED: Added transcript + better formatting + // ✅ FIXED: Added transcript + better formatting + result type const formattedResults = results.matches.map((match) => { const metadata = match.metadata || {}; - + return { meetingId: metadata.meetingId || match.id, title: metadata.title || "Untitled Meeting", summary: metadata.summary || "No summary available.", - transcript: metadata.transcript || "", // ✅ Added transcript + transcript: metadata.transcript || "", createdAt: metadata.createdAt || null, similarityScore: parseFloat(match.score?.toFixed(3)) || 0, + resultType: metadata.resultType || "meeting", + organization: metadata.organization || null, + tags: metadata.tags || [], }; }); - console.log("✅ Pinecone vector search results:", formattedResults); - return formattedResults; + // Apply filters if provided + let filteredResults = formattedResults; + + if (filters.resultType && filters.resultType !== "all") { + filteredResults = filteredResults.filter( + (r) => r.resultType === filters.resultType, + ); + } + + if (filters.organization) { + filteredResults = filteredResults.filter( + (r) => r.organization === filters.organization, + ); + } + + if (filters.dateFrom) { + filteredResults = filteredResults.filter((r) => { + if (!r.createdAt) return false; + return new Date(r.createdAt) >= new Date(filters.dateFrom); + }); + } + + if (filters.dateTo) { + filteredResults = filteredResults.filter((r) => { + if (!r.createdAt) return false; + return new Date(r.createdAt) <= new Date(filters.dateTo); + }); + } + + if (filters.tags && filters.tags.length > 0) { + filteredResults = filteredResults.filter( + (r) => r.tags && r.tags.some((tag) => filters.tags.includes(tag)), + ); + } + + // Sort by similarity score (already ranked by Pinecone, but ensure consistency) + filteredResults.sort((a, b) => b.similarityScore - a.similarityScore); + + console.log("✅ Pinecone vector search results:", filteredResults); + return filteredResults; } catch (error) { console.error("❌ Pinecone vector search error:", error); throw new Error("Vector search failed"); @@ -189,7 +235,9 @@ export const reindexAllMeetings = async () => { transcript: { $exists: true, $ne: "" }, }); - console.log(`🔁 Reindexing ${allMeetings.length} meetings into Pinecone...`); + console.log( + `🔁 Reindexing ${allMeetings.length} meetings into Pinecone...`, + ); for (const m of allMeetings) { await indexMeeting(m); }