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