nikeshn commited on
Commit
92f2b4f
·
verified ·
1 Parent(s): bb49f85

Upload 7 files

Browse files
Files changed (4) hide show
  1. README.md +3 -1
  2. app.py +31 -4
  3. pipeline.py +81 -10
  4. pure_client.py +3 -0
README.md CHANGED
@@ -29,6 +29,8 @@ Turns a natural-language question into keyword searches on **Khazna** (KU's Else
29
 
30
  **Concept expansion:** for topic, thesis, expert, project and collaboration searches, one Pure thesaurus concept from `/concepts/search` that shares a word with the query is added as an extra search variant. Set `USE_CONCEPTS=false` to switch it off.
31
 
 
 
32
  Expert finding combines three signals: Pure's profile search, the KU authors of the top-ranked publications for the topic, and the supervisors of matching student theses. Profile search alone misses active researchers with sparse profiles. The publications also become the "matching outputs" evidence on each profile card.
33
 
34
  ## Pipeline
@@ -114,7 +116,7 @@ Not used, so they can be removed from the key: `/users/*`, `/roles/*`, `/changes
114
  | GET | `/` | redirects to `FRONTEND_URL` (https://library.ku.ac.ae/khaznabot) |
115
  | GET | `/test` | the Space's own test chat (widget) |
116
  | GET | `/widget.js` | embeddable widget |
117
- | POST | `/agent` | `{question, history, model: "gpt"\|"claude", scope, top_k}` → answer, `request_id` (also an `X-Request-ID` header), `search_results` (each has `kind`: publication / thesis / person / organisation / project / equipment / collaborator / dataset), `person`, `trace`, `plan`, `follow_up_suggestions` |
118
  | POST | `/feedback` | `{request_id, feedback: "up"\|"down", session_id}` (LibGuides block) or `{question, rating}` (widget) |
119
  | GET | `/config` | public frontend config: announcement, maintenance mode |
120
  | GET | `/health` | backend status |
 
29
 
30
  **Concept expansion:** for topic, thesis, expert, project and collaboration searches, one Pure thesaurus concept from `/concepts/search` that shares a word with the query is added as an extra search variant. Set `USE_CONCEPTS=false` to switch it off.
31
 
32
+ **Name disambiguation:** for a named researcher, `/persons/search` returns up to 50 people, kept only if every typed name word equals one of their name words (first, middle or last; prefix matches only when nothing matches exactly). One match, or one exact full-name match, goes straight to that profile. Two or more return `intent: "author_clarify"` with up to `MAX_NAME_CANDIDATES` (default 12) cards marked `choose: true`. The frontend then resends the original question with `person_uuid`, which bypasses the name search entirely.
33
+
34
  Expert finding combines three signals: Pure's profile search, the KU authors of the top-ranked publications for the topic, and the supervisors of matching student theses. Profile search alone misses active researchers with sparse profiles. The publications also become the "matching outputs" evidence on each profile card.
35
 
36
  ## Pipeline
 
116
  | GET | `/` | redirects to `FRONTEND_URL` (https://library.ku.ac.ae/khaznabot) |
117
  | GET | `/test` | the Space's own test chat (widget) |
118
  | GET | `/widget.js` | embeddable widget |
119
+ | POST | `/agent` | `{question, history, model: "gpt"\|"claude", scope, top_k, person_uuid?}` → answer, `request_id` (also an `X-Request-ID` header), `search_results` (each has `kind`: publication / thesis / person / organisation / project / equipment / collaborator / dataset), `person`, `trace`, `plan`, `follow_up_suggestions` |
120
  | POST | `/feedback` | `{request_id, feedback: "up"\|"down", session_id}` (LibGuides block) or `{question, rating}` (widget) |
121
  | GET | `/config` | public frontend config: announcement, maintenance mode |
122
  | GET | `/health` | backend status |
app.py CHANGED
@@ -143,7 +143,8 @@ class AgentRequest(BaseModel):
143
  model: str = "gpt" # "gpt" | "claude" (same convention as LibBee)
144
  history: list[ChatMessage] = []
145
  top_k: int | None = None
146
- scope: str = "all" # all | publications | persons | organisations | datasets
 
147
 
148
 
149
  class FeedbackRequest(BaseModel):
@@ -165,7 +166,7 @@ def _public_result(o: dict, n: int) -> dict:
165
  kind = o.get("kind", "publication")
166
  if kind == "person":
167
  keep = ("kind", "uuid", "name", "job_titles", "organisations", "keywords", "orcid", "portal_url", "evidence", "matching_outputs",
168
- "supervised", "supervised_theses")
169
  elif kind == "organisation":
170
  keep = ("kind", "uuid", "name", "type", "parents", "portal_url")
171
  elif kind == "collaborator":
@@ -199,6 +200,13 @@ def _reply(t0: float, **kw) -> dict:
199
  _SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
200
 
201
 
 
 
 
 
 
 
 
202
  def _followups(plan, retrieval: dict | None) -> list[dict]:
203
  """Next-step suggestions the frontend renders as chips: {label, question, scope}."""
204
  kw = (plan.keyword_queries[0] if plan.keyword_queries else "").strip()
@@ -326,8 +334,9 @@ async def agent(req: AgentRequest, request: Request, response: Response):
326
  log_query(q, plan.intent, [], 0, model, time.time() - t0, **logx)
327
  return _reply(t0, **base, answer=ans)
328
 
 
329
  try:
330
- retrieval = await pipeline.retrieve(pure, plan)
331
  except PureError as e:
332
  log_query(q, plan.intent, plan.keyword_queries, 0, model, time.time() - t0, str(e), **logx)
333
  if e.status == 403 and plan.intent == "dataset_search":
@@ -339,6 +348,22 @@ async def agent(req: AgentRequest, request: Request, response: Response):
339
  return _reply(t0, **base, answer=msg, error=str(e))
340
 
341
  results = retrieval["results"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  if not results:
343
  filt = bool(plan.year_from or plan.year_to or plan.output_types or plan.open_access_only)
344
  answer = ("I couldn't find Khazna records matching that" + (" with the filters you asked for" if filt else "")
@@ -365,7 +390,9 @@ async def agent(req: AgentRequest, request: Request, response: Response):
365
  person=_public_result(person, 0) if person else None,
366
  database_query=" | ".join(t.get("query", "") for t in retrieval["trace"] if t.get("query")),
367
  trace=[{k: v for k, v in t.items() if k != "error"} for t in retrieval["trace"]],
368
- portal_search_url=f"{PURE_PORTAL_URL}/en/{portal_path}/?search={quote_plus(search_term)}",
 
 
369
  follow_up_suggestions=_followups(plan, retrieval) if results else [],
370
  )
371
 
 
143
  model: str = "gpt" # "gpt" | "claude" (same convention as LibBee)
144
  history: list[ChatMessage] = []
145
  top_k: int | None = None
146
+ scope: str = "all" # all | publications | theses | persons | organisations | projects | equipment | collaborations
147
+ person_uuid: str | None = None # set when the user picks one researcher from a disambiguation list
148
 
149
 
150
  class FeedbackRequest(BaseModel):
 
166
  kind = o.get("kind", "publication")
167
  if kind == "person":
168
  keep = ("kind", "uuid", "name", "job_titles", "organisations", "keywords", "orcid", "portal_url", "evidence", "matching_outputs",
169
+ "supervised", "supervised_theses", "choose")
170
  elif kind == "organisation":
171
  keep = ("kind", "uuid", "name", "type", "parents", "portal_url")
172
  elif kind == "collaborator":
 
200
  _SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
201
 
202
 
203
+ def _portal_url(path: str, term: str, search_by: str | None = None) -> str:
204
+ """Plain Khazna portal search link, e.g. https://khazna.ku.ac.ae/en/persons/?search=kumar.
205
+ The portal's default people search already lists everyone whose name matches, so no
206
+ searchBy mode is added (search_by is accepted but intentionally unused)."""
207
+ return f"{PURE_PORTAL_URL}/en/{path}/?search={quote_plus(term)}"
208
+
209
+
210
  def _followups(plan, retrieval: dict | None) -> list[dict]:
211
  """Next-step suggestions the frontend renders as chips: {label, question, scope}."""
212
  kw = (plan.keyword_queries[0] if plan.keyword_queries else "").strip()
 
334
  log_query(q, plan.intent, [], 0, model, time.time() - t0, **logx)
335
  return _reply(t0, **base, answer=ans)
336
 
337
+ person_uuid = req.person_uuid if req.person_uuid and re.fullmatch(r"[A-Za-z0-9-]{1,64}", req.person_uuid) else None
338
  try:
339
+ retrieval = await pipeline.retrieve(pure, plan, person_uuid=person_uuid)
340
  except PureError as e:
341
  log_query(q, plan.intent, plan.keyword_queries, 0, model, time.time() - t0, str(e), **logx)
342
  if e.status == 403 and plan.intent == "dataset_search":
 
348
  return _reply(t0, **base, answer=msg, error=str(e))
349
 
350
  results = retrieval["results"]
351
+ if retrieval.get("clarify"):
352
+ c = retrieval["clarify"]
353
+ more = (f" I'm showing the first {c['shown']}; add a first name, surname or department to narrow it down."
354
+ if c["count"] > c["shown"] else "")
355
+ answer = (f"I found **{c['count']} KU researchers** whose name matches “{c['name']}”. Which one do you mean?\n"
356
+ f"Choose **Explore this researcher** on the right person below, or add their full name or department to your question.{more}")
357
+ log_query(q, "author_clarify", [c["name"]], len(results), model, time.time() - t0, **logx)
358
+ return _reply(
359
+ t0, **{**base, "intent": "author_clarify"},
360
+ answer=answer, clarify=True, original_question=q,
361
+ search_results=[_public_result(o, i) for i, o in enumerate(results, 1)],
362
+ total_found=c["count"], after_filters=len(results),
363
+ trace=[{k: v for k, v in t.items() if k != "error"} for t in retrieval["trace"]],
364
+ portal_search_url=_portal_url("persons", c["name"], "PartOfNameOrTitle"),
365
+ follow_up_suggestions=[],
366
+ )
367
  if not results:
368
  filt = bool(plan.year_from or plan.year_to or plan.output_types or plan.open_access_only)
369
  answer = ("I couldn't find Khazna records matching that" + (" with the filters you asked for" if filt else "")
 
390
  person=_public_result(person, 0) if person else None,
391
  database_query=" | ".join(t.get("query", "") for t in retrieval["trace"] if t.get("query")),
392
  trace=[{k: v for k, v in t.items() if k != "error"} for t in retrieval["trace"]],
393
+ portal_search_url=_portal_url(portal_path, search_term,
394
+ "PartOfNameOrTitle" if plan.intent == "author_search" and plan.author_name
395
+ else "RelatedConcepts" if plan.intent == "expert_search" else None),
396
  follow_up_suggestions=_followups(plan, retrieval) if results else [],
397
  )
398
 
pipeline.py CHANGED
@@ -15,6 +15,7 @@ from __future__ import annotations
15
 
16
  import asyncio
17
  import json
 
18
  from datetime import datetime
19
  import math
20
  import os
@@ -86,7 +87,8 @@ Rules:
86
  - Research projects, grants, funded research => project_search. Instruments, equipment, facilities, labs with a device => equipment_search.
87
  - "Who does KU collaborate with", partner universities/institutions, international collaboration => collab_search.
88
  - Follow-ups ("only 2024 ones", "more by her", "what about desalination?") must be resolved using the conversation history.
89
- - For author_search put the name in author_name; put any topic words in keyword_queries (may be empty).
 
90
  - Today's year is {year}.
91
 
92
  Examples:
@@ -103,6 +105,8 @@ Examples:
103
  "Who at KU works on robotics for agriculture?" => expert_search, ["agricultural robotics","robotics agriculture"], broad_query="robotics"
104
  "Find a supervisor for battery research" => expert_search, ["lithium-ion batteries","battery materials"], broad_query="batteries"
105
  "Show me Jane Smith's profile" => author_search, author_name="Jane Smith", keyword_queries=[]
 
 
106
  "Which KU research centres work on 6G?" => org_search, ["6G"], broad_query="wireless"
107
  "Department of Mechanical Engineering" => org_search, ["Mechanical Engineering"]
108
  "Datasets on air quality in the UAE" => dataset_search, ["air quality UAE","air pollution"], broad_query="air quality"
@@ -128,7 +132,11 @@ def _heuristic_plan(question: str, year: int) -> QueryPlan:
128
  yrs = [int(y) for y in re.findall(r"\b(19[89]\d|20[0-4]\d)\b", question)]
129
  recent = bool(re.search(r"\b(recent|latest|new)\b", question, re.I))
130
  intent = "topic_search"
131
- named = re.search(r"\b(?i:profile|publications|papers|work|research)\b[^?!]*?\b(?i:of|by)\s+((?:Dr\.?\s+|Prof\.?\s+)?[A-Z][\w'.-]+(?:\s+[A-Z][\w'.-]+){1,3})", question)
 
 
 
 
132
  if named:
133
  name = re.sub(r"^(Dr|Prof)\.?\s+", "", named.group(1))
134
  return QueryPlan(intent="author_search", standalone_question=question, author_name=name, keyword_queries=[])
@@ -293,17 +301,76 @@ def rank(variant_hits: list[list[dict]], plan: QueryPlan) -> list[dict]:
293
  RETRIEVE_SIZE = int(os.environ.get("PURE_RETRIEVE_SIZE", "25"))
294
 
295
 
296
- async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  search = search or pure.search_research_outputs
298
  trace: list[dict] = []
299
  person = None
300
  total = 0
301
 
302
- if plan.intent == "author_search" and plan.author_name:
303
- people = await pure.search_persons(plan.author_name, size=5)
304
- trace.append({"endpoint": "persons/search", "query": plan.author_name, "hits": len(people)})
305
- if people:
306
- person = people[0]
 
 
 
 
307
  await pure.fill_org_names([person])
308
  outs = await pure.person_research_outputs(person, size=100)
309
  trace.append({"endpoint": f"persons/{person['uuid']}/research-outputs", "hits": len(outs)})
@@ -314,7 +381,8 @@ async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None) -> d
314
  variant_hits = [outs]
315
  total = len(outs)
316
  else: # no person record: treat the name as a free-text query
317
- plan.keyword_queries = [f"{plan.author_name} {q}".strip() for q in (plan.keyword_queries or [""])]
 
318
  plan.intent = "topic_search"
319
 
320
  if plan.intent != "author_search" or person is None:
@@ -513,7 +581,10 @@ async def _retrieve_datasets(pure: PureClient, plan: QueryPlan) -> dict:
513
  return r
514
 
515
 
516
- async def retrieve(pure: PureClient, plan: QueryPlan) -> dict:
 
 
 
517
  concept = None
518
  if plan.intent in ("topic_search", "thesis_search", "expert_search", "project_search", "collab_search", "count"):
519
  concept = await _expand_with_concepts(pure, plan)
 
15
 
16
  import asyncio
17
  import json
18
+ import unicodedata
19
  from datetime import datetime
20
  import math
21
  import os
 
87
  - Research projects, grants, funded research => project_search. Instruments, equipment, facilities, labs with a device => equipment_search.
88
  - "Who does KU collaborate with", partner universities/institutions, international collaboration => collab_search.
89
  - Follow-ups ("only 2024 ones", "more by her", "what about desalination?") must be resolved using the conversation history.
90
+ - For author_search put the name in author_name exactly as the user wrote it (one word like "Kumar" is fine); put any topic words in keyword_queries (may be empty).
91
+ - A message that is only a person's name (e.g. "Kumar", "KUMAR Padmanabhan", "Dr. Fatima") is author_search.
92
  - Today's year is {year}.
93
 
94
  Examples:
 
105
  "Who at KU works on robotics for agriculture?" => expert_search, ["agricultural robotics","robotics agriculture"], broad_query="robotics"
106
  "Find a supervisor for battery research" => expert_search, ["lithium-ion batteries","battery materials"], broad_query="batteries"
107
  "Show me Jane Smith's profile" => author_search, author_name="Jane Smith", keyword_queries=[]
108
+ "Kumar" => author_search, author_name="Kumar", keyword_queries=[]
109
+ "Publications by Kumar Padmanabhan on machine learning" => author_search, author_name="Kumar Padmanabhan", keyword_queries=["machine learning"]
110
  "Which KU research centres work on 6G?" => org_search, ["6G"], broad_query="wireless"
111
  "Department of Mechanical Engineering" => org_search, ["Mechanical Engineering"]
112
  "Datasets on air quality in the UAE" => dataset_search, ["air quality UAE","air pollution"], broad_query="air quality"
 
132
  yrs = [int(y) for y in re.findall(r"\b(19[89]\d|20[0-4]\d)\b", question)]
133
  recent = bool(re.search(r"\b(recent|latest|new)\b", question, re.I))
134
  intent = "topic_search"
135
+ if (re.fullmatch(r"\s*(?:(?:Dr|Prof)\.?\s+)?[A-Z][A-Za-z'.-]+(?:\s+[A-Z][A-Za-z'.-]+){0,3}\s*", question)
136
+ and question.strip().lower().strip("!.") not in {"hi", "hello", "hey", "thanks", "thank you", "ok", "okay", "help"}):
137
+ return QueryPlan(intent="author_search", standalone_question=question,
138
+ author_name=re.sub(r"^\s*(Dr|Prof)\.?\s+", "", question).strip(), keyword_queries=[])
139
+ named = re.search(r"\b(?i:profile|publications|papers|work|research)\b[^?!]*?\b(?i:of|by)\s+((?:Dr\.?\s+|Prof\.?\s+)?[A-Z][\w'.-]+(?:\s+[A-Z][\w'.-]+){0,3})\s*[?.!]?\s*$", question)
140
  if named:
141
  name = re.sub(r"^(Dr|Prof)\.?\s+", "", named.group(1))
142
  return QueryPlan(intent="author_search", standalone_question=question, author_name=name, keyword_queries=[])
 
301
  RETRIEVE_SIZE = int(os.environ.get("PURE_RETRIEVE_SIZE", "25"))
302
 
303
 
304
+ _TITLES = {"dr", "prof", "professor", "mr", "mrs", "ms", "miss", "eng", "engr"}
305
+ MAX_CANDIDATES = int(os.environ.get("MAX_NAME_CANDIDATES", "12"))
306
+
307
+
308
+ def _name_tokens(s: str) -> list[str]:
309
+ s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode().lower()
310
+ return [t for t in re.split(r"[^a-z]+", s) if len(t) > 1 and t not in _TITLES]
311
+
312
+
313
+ def match_people(query: str, people: list[dict]) -> tuple[list[dict], list[dict]]:
314
+ """Return (matches, exact_full_name_matches) for a typed name.
315
+
316
+ A person matches when EVERY word the user typed equals one of their name words,
317
+ in any position (first, middle or last). Only if nobody matches exactly do we
318
+ accept prefix matches ('Kumar' → 'Kumaran'). Matches are ordered surname-first.
319
+ """
320
+ q = _name_tokens(query)
321
+ if not q:
322
+ return [], []
323
+ exact, prefix = [], []
324
+ for p in people:
325
+ tokens = _name_tokens(p.get("name", ""))
326
+ if all(t in tokens for t in q):
327
+ exact.append(p)
328
+ elif all(any(n.startswith(t) for n in tokens) for t in q):
329
+ prefix.append(p)
330
+ matches = exact or prefix
331
+ full = [p for p in matches if set(_name_tokens(p.get("name", ""))) == set(q)]
332
+
333
+ def order(p):
334
+ last = set(_name_tokens(p.get("last_name", "")))
335
+ return (0 if set(q) & last else 1, p.get("name", ""))
336
+ return sorted(matches, key=order), full
337
+
338
+
339
+ async def _resolve_person(pure: PureClient, plan: QueryPlan, person_uuid: str | None, trace: list[dict]):
340
+ """→ (person, candidates). candidates is non-empty only when the name is ambiguous."""
341
+ if person_uuid:
342
+ person = await pure.get_person(person_uuid)
343
+ trace.append({"endpoint": f"persons/{person_uuid}", "hits": 1 if person else 0})
344
+ return person, []
345
+ people = await pure.search_persons(plan.author_name, size=50)
346
+ trace.append({"endpoint": "persons/search", "query": plan.author_name, "hits": len(people)})
347
+ matches, full = match_people(plan.author_name, people)
348
+ if not matches and len(people) == 1:
349
+ matches = people
350
+ if len(full) == 1:
351
+ return full[0], []
352
+ if len(matches) == 1:
353
+ return matches[0], []
354
+ if len(matches) >= 2:
355
+ return None, matches
356
+ return None, []
357
+
358
+
359
+ async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None, person_uuid: str | None = None) -> dict:
360
  search = search or pure.search_research_outputs
361
  trace: list[dict] = []
362
  person = None
363
  total = 0
364
 
365
+ if plan.intent == "author_search" and (plan.author_name or person_uuid):
366
+ person, candidates = await _resolve_person(pure, plan, person_uuid, trace)
367
+ if candidates: # ambiguous name: ask the user to choose instead of guessing
368
+ shown = candidates[:MAX_CANDIDATES]
369
+ await pure.fill_org_names(shown)
370
+ return {"results": [{**c, "choose": True} for c in shown], "unfiltered": len(candidates),
371
+ "total": len(candidates), "person": None, "trace": trace,
372
+ "clarify": {"name": plan.author_name, "count": len(candidates), "shown": len(shown)}}
373
+ if person:
374
  await pure.fill_org_names([person])
375
  outs = await pure.person_research_outputs(person, size=100)
376
  trace.append({"endpoint": f"persons/{person['uuid']}/research-outputs", "hits": len(outs)})
 
381
  variant_hits = [outs]
382
  total = len(outs)
383
  else: # no person record: treat the name as a free-text query
384
+ plan.keyword_queries = [f"{plan.author_name or ''} {q}".strip() for q in (plan.keyword_queries or [""])]
385
+ plan.keyword_queries = [q for q in plan.keyword_queries if q] or [plan.standalone_question]
386
  plan.intent = "topic_search"
387
 
388
  if plan.intent != "author_search" or person is None:
 
581
  return r
582
 
583
 
584
+ async def retrieve(pure: PureClient, plan: QueryPlan, person_uuid: str | None = None) -> dict:
585
+ if person_uuid: # the user picked a specific researcher from a list
586
+ plan.intent = "author_search"
587
+ return await _retrieve_outputs(pure, plan, person_uuid=person_uuid)
588
  concept = None
589
  if plan.intent in ("topic_search", "thesis_search", "expert_search", "project_search", "collab_search", "count"):
590
  concept = await _expand_with_concepts(pure, plan)
pure_client.py CHANGED
@@ -209,9 +209,12 @@ def normalize_person(item: dict, portal_url: str) -> dict:
209
  if org.get("uuid"):
210
  orgs.append({"uuid": org["uuid"], "name": _loc(org.get("name"))})
211
  # NB: email / phone are intentionally never copied out of the record.
 
212
  return {
213
  "kind": "person",
214
  "uuid": uuid,
 
 
215
  "name": _person_name(item.get("name")),
216
  "title": _person_name(item.get("name")),
217
  "job_titles": list(dict.fromkeys(titles))[:3],
 
209
  if org.get("uuid"):
210
  orgs.append({"uuid": org["uuid"], "name": _loc(org.get("name"))})
211
  # NB: email / phone are intentionally never copied out of the record.
212
+ nm = item.get("name") if isinstance(item.get("name"), dict) else {}
213
  return {
214
  "kind": "person",
215
  "uuid": uuid,
216
+ "first_name": (nm.get("firstName") or "").strip(),
217
+ "last_name": (nm.get("lastName") or "").strip(),
218
  "name": _person_name(item.get("name")),
219
  "title": _person_name(item.get("name")),
220
  "job_titles": list(dict.fromkeys(titles))[:3],