"""Deterministic staff matching using the supplied KU directory snapshot. Live roles and contact details still require checking against the official staff page; these helpers do not perform a live directory lookup. """ import difflib import re from typing import Dict, List, Optional STAFF_DIRECTORY = [ { "full_name": "Dr. Abdulla Al Hefeiti", "role": "Library Director / Assistant Provost, Libraries", "email": "abdulla.alhefeiti@ku.ac.ae", "phone": "+971 2 312 3331", "expertise": "strategic and institutional matters, library leadership, and partnerships", "tokens": ["abdulla", "hefeiti", "abdulla al hefeiti", "al hefeiti"], "aliases": ["library director", "director", "assistant provost"], }, { "full_name": "Nikesh Narayanan", "role": "Research & Access Services Librarian", "email": "nikesh.narayanan@ku.ac.ae", "phone": "+971 2 312 3980", "expertise": "research support, Open Access publishing, Khazna repository, ORCID, Scopus, research impact, AI tools for research, bibliometrics, and scholarly communication", "tokens": ["nikesh", "narayanan", "nikesh narayanan"], "aliases": ["research librarian", "research support librarian"], }, { "full_name": "Rani Anand", "role": "E-Resources Librarian", "email": "rani.anand@ku.ac.ae", "phone": "+971 2 312 3935", "expertise": "database access problems, e-resources troubleshooting, remote access, vendor issues, patents, e-books, and bibliometrics", "tokens": ["rani", "anand", "rani anand"], "aliases": ["e-resources librarian", "eresources librarian", "database librarian"], }, { "full_name": "Jason Fetty", "role": "Medical Librarian", "email": "jason.fetty@ku.ac.ae", "phone": "+971 2 312 4722", "expertise": "medical and health sciences research, systematic reviews, PubMed, Embase, CINAHL, UpToDate, and clinical databases", "tokens": ["jason", "fetty", "jason fetty"], "aliases": ["medical librarian", "medical library", "health sciences librarian"], }, { "full_name": "Walter Brian Hall", "role": "Digital & Technology Services Librarian / Systems Librarian", "email": "walter.hall@ku.ac.ae", "phone": "+971 2 312 3163", "expertise": "library website, systems, technology, digital infrastructure, ORCID, STEAM, coding, and Open Access support", "tokens": ["walter", "brian", "hall", "walter brian hall", "walter hall", "brian hall"], "aliases": ["systems librarian", "technology librarian", "digital librarian"], }, { "full_name": "Alia Al-Harrasi", "role": "Manager, Technical Services", "email": "alia.alharrasi@ku.ac.ae", "phone": "+971 2 312 3180", "expertise": "cataloguing, metadata, acquisitions processing, and technical services", "tokens": ["alia", "alia al-harrasi", "alia alharrasi", "al harrasi", "alharrasi"], "aliases": ["technical services", "acquisitions processing", "cataloguing"], }, { "full_name": "Zainab Al Ali", "role": "Library Information Senior Specialist (Interlibrary Loan)", "email": "zainab.alali@ku.ac.ae", "phone": "+971 2 312 4321", "expertise": "Interlibrary Loan (ILL) requests and status, document delivery, and obtaining books or articles that KU does not hold (ILL mailbox: ill@ku.ac.ae)", "tokens": ["zainab", "zainab al ali", "zainab alali", "alali"], "aliases": [ "interlibrary loan", "inter library loan", "inter-library loan", "ill request", "ill requests", "document delivery", ], }, ] _STOP_WORDS = { "who", "is", "are", "the", "a", "an", "can", "help", "me", "tell", "about", "find", "contact", "email", "phone", "number", "i", "need", "to", "speak", "with", "please", "get", "in", "touch", "reach", "how", "do", "what", "which", "where", "for", "of", "best", "person", "librarian", } _CONTACT_INTENT_RE = re.compile( r"\b(contact|email|phone|number|who is|who's|who handles|who can help|best person|best librarian|" r"which librarian|talk to|speak to|reach|appointment|book an appointment|schedule)", re.IGNORECASE, ) _ROLE_INDEX: dict[str, Dict] = {} def build_staff_index() -> None: """Build role alias → staff dict. Called once from app.py lifespan.""" global _ROLE_INDEX _ROLE_INDEX = {} for staff in STAFF_DIRECTORY: for alias in [staff["full_name"], *staff.get("aliases", [])]: key = re.sub(r"[^a-z0-9]+", " ", alias.lower()).strip() _ROLE_INDEX[key] = staff def _normalize(text: str) -> List[str]: tokens = re.sub(r"[^a-z0-9 ]+", " ", (text or "").lower()).split() return [t for t in tokens if t not in _STOP_WORDS] def should_attempt_staff_lookup(question: str) -> bool: q = (question or "").strip().lower() if not q: return False if not _CONTACT_INTENT_RE.search(q): return False # Don't trigger on article/research queries that happen to contain contact words if re.search( r"\b(article|articles|paper|papers|study|studies|research article|research articles|literature)\b", q ) and not re.search( r"\b(who|contact|email|phone|which librarian|best person|who can help)\b", q ): return False return True def match_staff_name(question: str) -> Optional[Dict]: """Match alternative spellings independently, without combining aliases. A contact request can use a first name, surname or full-name alias. Common words such as "hall" cannot identify a person on their own. Ambiguous matches fall through to the KB instead of choosing the first staff entry. """ if not should_attempt_staff_lookup(question): return None question_tokens = set(_normalize(question)) weak = {"al", "dr", "hall", "brian"} candidates = [] for staff in STAFF_DIRECTORY: strength = 0 for alias in [staff["full_name"], *staff.get("tokens", [])]: tokens = set(_normalize(alias)) - {"dr"} if tokens and tokens.issubset(question_tokens): if len(tokens) >= 2 or next(iter(tokens)) not in weak: strength = max(strength, len(tokens)) if strength: candidates.append(staff) if len(candidates) == 1: return candidates[0] if candidates: return None return _fuzzy_staff_match(question) def _fuzzy_staff_match(question: str) -> Optional[Dict]: """Compare probable name words with name words, not full sentences.""" candidates = {} for token in _normalize(question): if len(token) < 6: continue for staff in STAFF_DIRECTORY: distinctive = { word for alias in staff.get("tokens", []) for word in _normalize(alias) if len(word) >= 6 and word not in {"librarian", "library"} } if difflib.get_close_matches(token, distinctive, n=1, cutoff=0.88): candidates[staff["email"]] = staff return next(iter(candidates.values())) if len(candidates) == 1 else None def match_staff_role(question: str) -> Optional[Dict]: """Match by role alias. Auto-builds index if empty (defensive guard).""" if not should_attempt_staff_lookup(question): return None if not _ROLE_INDEX: build_staff_index() q = re.sub(r"[^a-z0-9]+", " ", question.lower()).strip() # Longest alias first — prevents "librarian" matching before "medical librarian" for alias, staff in sorted(_ROLE_INDEX.items(), key=lambda item: len(item[0]), reverse=True): if len(alias) > 3 and re.search(r"(? str: return ( f"{staff['full_name']} is the {staff['role']}.

" f"They can help with: {staff['expertise']}.

" f"📧 {staff['email']}
" f"📞 {staff['phone']}" '

Contact details are from the supplied directory. ' 'Check the current staff directory.' ) def staff_role_answer(staff: Dict, question: str) -> str: return ( f"For help with that, the best person to contact is {staff['full_name']} — " f"{staff['role']}.

" f"They can help with: {staff['expertise']}.

" f"📧 {staff['email']}
" f"📞 {staff['phone']}" '

Contact details are from the supplied directory. ' 'Check the current staff directory.' ) # NOTE: build_staff_index() is NOT called here at module level. # It is called once from app.py lifespan to avoid double-build.