""" staff_service.py — LibBee v3.1 Fixes applied: 1. match_staff_name token logic corrected: previously checked if every question token was in the staff token set (inverted). Now checks if the staff member's own name tokens are all present in the question token set (correct direction). 2. Module-level build_staff_index() call removed — was running at import time, causing the index to be built twice (once at import, once in lifespan). app.py lifespan remains the single build trigger. 3. Fuzzy name matching added as a fallback in match_staff_name using difflib.get_close_matches (cutoff=0.75) to handle typos like "Nikkesh". 4. _ROLE_INDEX guard added: match_staff_role auto-builds if index is empty, defensive against out-of-order call scenarios. """ 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"], }, ] _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 by name tokens. ALL of a staff member's name tokens must appear in the question. Single-token matches are only accepted if the token is distinctive (≥6 chars and not a common word). This prevents short or common tokens like 'hall', 'rani', 'brian', 'jason' from matching on unrelated queries. Multi-token matches (e.g. 'nikesh narayanan', 'rani anand') always accepted. """ if not should_attempt_staff_lookup(question): return None question_token_set = set(_normalize(question)) if not question_token_set: return None # Tokens that are too common/short to be used as sole match criteria _WEAK_TOKENS = { "hall", "rani", "brian", "alia", "jason", "walter", "anand", "fetty", } for staff in STAFF_DIRECTORY: staff_name_tokens: set[str] = set() for tok in staff.get("tokens", []): staff_name_tokens.update(_normalize(tok)) if not staff_name_tokens: continue if not staff_name_tokens.issubset(question_token_set): continue # All tokens matched — now check if match is strong enough matched = staff_name_tokens & question_token_set if len(matched) >= 2: return staff # Single token match — only accept if distinctive sole = next(iter(matched)) if len(sole) >= 6 and sole not in _WEAK_TOKENS: return staff # Fuzzy fallback for typos (e.g. "Nikkesh", "Al Harrasi") return _fuzzy_staff_match(question) def _fuzzy_staff_match(question: str) -> Optional[Dict]: """ Fuzzy match individual question tokens against staff full names using difflib. Whole-question comparison is skipped — it never matches because a full sentence has near-zero similarity to a short name string. """ all_names = [s["full_name"].lower() for s in STAFF_DIRECTORY] for token in _normalize(question): if len(token) < 4: continue token_matches = difflib.get_close_matches( token, all_names, n=1, cutoff=0.8 ) if token_matches: return next( (s for s in STAFF_DIRECTORY if s["full_name"].lower() == token_matches[0]), None, ) return 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 alias in q: return staff return None def staff_name_answer(staff: Dict) -> str: return ( f"{staff['full_name']} is the {staff['role']}.

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

" f"📧 {staff['email']}
" f"📞 {staff['phone']}" ) 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']}" ) # NOTE: build_staff_index() is NOT called here at module level. # It is called once from app.py lifespan to avoid double-build.