CoolFace
Apppublic

MMo4/csit-ned-chatbot

sourceHugging Faceupdated 10mo agoView on Hugging Face
0likes
fuzzy_matcher.py188 linesDownload Raw Back to utils
1"""
2Fuzzy matching utilities for faculty names and other entities.
3"""
4
5import logging
6from typing import List, Dict, Tuple, Optional
7from rapidfuzz import fuzz, process
8
9logger = logging.getLogger(__name__)
10
11class FuzzyMatcher:
12    """Fuzzy matcher for names and entities."""
13
14    def __init__(self, threshold: float = 70.0):
15        """
16        Initialize fuzzy matcher.
17
18        Args:
19            threshold: Minimum similarity score (0-100) to consider a match
20        """
21        self.threshold = threshold
22
23    def match_faculty_name(self, query_name: str, faculty_list: List[str]) -> List[Tuple[str, float]]:
24        """
25        Match a query name against a list of faculty names using fuzzy matching.
26
27        Args:
28            query_name: The name to search for (can be partial)
29            faculty_list: List of full faculty names
30
31        Returns:
32            List of (matched_name, score) tuples, sorted by score descending
33        """
34        if not query_name or not faculty_list:
35            return []
36
37        # Clean query name
38        query_clean = self._clean_name(query_name)
39
40        matches = []
41
42        for faculty_name in faculty_list:
43            # Calculate similarity scores using multiple methods
44            scores = []
45
46            # Method 1: Token set ratio (good for partial names)
47            score1 = fuzz.token_set_ratio(query_clean, faculty_name.lower())
48            scores.append(score1)
49
50            # Method 2: Partial ratio (good for substring matching)
51            score2 = fuzz.partial_ratio(query_clean, faculty_name.lower())
52            scores.append(score2)
53
54            # Method 3: Token sort ratio (good for reordered names)
55            score3 = fuzz.token_sort_ratio(query_clean, faculty_name.lower())
56            scores.append(score3)
57
58            # Use the maximum score
59            max_score = max(scores)
60
61            if max_score >= self.threshold:
62                matches.append((faculty_name, max_score))
63
64        # Sort by score descending
65        matches.sort(key=lambda x: x[1], reverse=True)
66
67        logger.info(f"Fuzzy matched '{query_name}' -> {len(matches)} results")
68        return matches
69
70    def expand_query_with_matches(self, query: str, faculty_names: List[str]) -> str:
71        """
72        Expand query by adding full names of detected faculty members.
73
74        Args:
75            query: Original query
76            faculty_names: List of all faculty names to match against
77
78        Returns:
79            Expanded query with full faculty names added
80        """
81        # Extract potential name mentions from query
82        potential_names = self._extract_potential_names(query)
83
84        if not potential_names:
85            return query
86
87        # Match each potential name
88        expansions = []
89        for potential_name in potential_names:
90            matches = self.match_faculty_name(potential_name, faculty_names)
91            if matches:
92                # Add top match's full name
93                full_name = matches[0][0]
94                expansions.append(full_name)
95                logger.info(f"Expanding '{potential_name}' -> '{full_name}'")
96
97        if expansions:
98            # Add expansions to query
99            expanded = f"{query} {' '.join(expansions)}"
100            return expanded
101
102        return query
103
104    def _clean_name(self, name: str) -> str:
105        """Clean and normalize a name for matching."""
106        # Remove titles
107        name = name.lower()
108        titles = ['dr', 'dr.', 'prof', 'prof.', 'engr', 'engr.', 'mr', 'mr.', 'ms', 'ms.', 'mrs', 'mrs.']
109        for title in titles:
110            name = name.replace(title, '')
111
112        # Remove extra whitespace
113        name = ' '.join(name.split())
114
115        return name.strip()
116
117    def _extract_potential_names(self, query: str) -> List[str]:
118        """
119        Extract potential faculty names from query.
120        Looks for patterns like "Dr. [Name]" or capitalized words.
121        """
122        import re
123
124        potential_names = []
125
126        # Pattern 1: Title + Name (e.g., "Dr. Mubashir", "Prof. Khan")
127        title_pattern = r'(?:dr\.?|prof\.?|engr\.?)\s+([a-z]+(?:\s+[a-z]+)*)'
128        matches = re.findall(title_pattern, query, re.IGNORECASE)
129        potential_names.extend(matches)
130
131        # Pattern 2: Capitalized words (likely names)
132        # Look for 2-3 consecutive capitalized words
133        cap_pattern = r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b'
134        matches = re.findall(cap_pattern, query)
135        potential_names.extend(matches)
136
137        # Pattern 3: Common name keywords
138        name_keywords = ['mubashir', 'najeed', 'shariq', 'raheela', 'murk', 'maria',
139                         'usman', 'khan', 'asif', 'siddiqui', 'marvi', 'ahmed']
140        query_lower = query.lower()
141        for keyword in name_keywords:
142            if keyword in query_lower:
143                potential_names.append(keyword)
144
145        # Remove duplicates and clean
146        potential_names = list(set(potential_names))
147        potential_names = [self._clean_name(n) for n in potential_names if len(n) > 2]
148
149        return potential_names
150
151    def match_best(self, query: str, candidates: List[str]) -> Optional[Tuple[str, float]]:
152        """
153        Find the best match for a query from a list of candidates.
154
155        Args:
156            query: Query string
157            candidates: List of candidate strings
158
159        Returns:
160            (best_match, score) tuple or None if no match above threshold
161        """
162        if not query or not candidates:
163            return None
164
165        result = process.extractOne(
166            query.lower(),
167            [c.lower() for c in candidates],
168            scorer=fuzz.token_set_ratio
169        )
170
171        if result and result[1] >= self.threshold:
172            # Return original candidate (not lowercased)
173            best_match = candidates[result[2]]
174            return (best_match, result[1])
175
176        return None
177
178
179# Global instance
180_fuzzy_matcher = None
181
182def get_fuzzy_matcher(threshold: float = 70.0) -> FuzzyMatcher:
183    """Get or create global fuzzy matcher instance."""
184    global _fuzzy_matcher
185    if _fuzzy_matcher is None:
186        _fuzzy_matcher = FuzzyMatcher(threshold)
187    return _fuzzy_matcher
188