"""
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.