File size: 9,559 Bytes
37ae25d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""

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

# NOTE: build_staff_index() is NOT called here at module level.
# It is called once from app.py lifespan to avoid double-build.