Upload 7 files
Browse files- README.md +3 -1
- app.py +31 -4
- pipeline.py +81 -10
- 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 |
|
|
|
|
| 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=
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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],
|