| """
|
| 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
|
|
|
| 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
|
|
|
|
|
| _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
|
|
|
| matched = staff_name_tokens & question_token_set
|
| if len(matched) >= 2:
|
| return staff
|
|
|
| sole = next(iter(matched))
|
| if len(sole) >= 6 and sole not in _WEAK_TOKENS:
|
| return staff
|
|
|
|
|
| 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()
|
|
|
| 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"<strong>{staff['full_name']}</strong> is the <strong>{staff['role']}</strong>.<br><br>"
|
| f"They can help with: {staff['expertise']}.<br><br>"
|
| f"π§ <a href=\"mailto:{staff['email']}\">{staff['email']}</a><br>"
|
| f"π <a href=\"tel:{staff['phone'].replace(' ', '')}\">{staff['phone']}</a>"
|
| )
|
|
|
|
|
| def staff_role_answer(staff: Dict, question: str) -> str:
|
| return (
|
| f"For help with that, the best person to contact is <strong>{staff['full_name']}</strong> β "
|
| f"<strong>{staff['role']}</strong>.<br><br>"
|
| f"They can help with: {staff['expertise']}.<br><br>"
|
| f"π§ <a href=\"mailto:{staff['email']}\">{staff['email']}</a><br>"
|
| f"π <a href=\"tel:{staff['phone'].replace(' ', '')}\">{staff['phone']}</a>"
|
| )
|
|
|
|
|
|
|
|
|