MMo4/csit-ned-chatbot
0
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 