LibBee / src /services /staff_service.py
nikeshn's picture
Upload staff_service.py
614727d verified
Raw History Blame Contribute Delete
9.25 kB
"""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"(?<!\w)" + re.escape(alias) + r"(?!\w)", 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>"
'<br><br>Contact details are from the supplied directory. '
'<a href="https://library.ku.ac.ae/librarystaff" target="_blank">Check the current staff directory</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>"
'<br><br>Contact details are from the supplied directory. '
'<a href="https://library.ku.ac.ae/librarystaff" target="_blank">Check the current staff directory</a>.'
)
# NOTE: build_staff_index() is NOT called here at module level.
# It is called once from app.py lifespan to avoid double-build.