Upload 7 files
Browse files- README.md +3 -1
- app.py +19 -8
- pipeline.py +68 -16
- pure_client.py +49 -9
README.md
CHANGED
|
@@ -29,7 +29,9 @@ 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 |
-
**
|
|
|
|
|
|
|
| 33 |
|
| 34 |
**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.
|
| 35 |
|
|
|
|
| 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 |
+
**Who is listed comes from the Khazna portal:** for a named researcher, the bot reads the portal's own "Find Profiles" search (`/en/persons/?search=<name>β¦&community=false`). That gives current, public, KU-only profiles, the same list people see on Khazna. The API adds each person's uuid, role and unit. Someone the portal lists but the API can't match is still shown and explored by name (`person_name`). If the portal can't be reached, the bot falls back to `/persons/search`. Set `USE_PORTAL_PEOPLE=false` to always use the API. Tests use a real saved portal page (`tests/fixtures/portal_persons_kumar.html`, 8 Kumars).
|
| 33 |
+
|
| 34 |
+
**Current KU staff only (API fallback):** `/persons` never includes external persons. By default the bot also drops anyone without a current staff appointment, meaning a `staffOrganizationAssociations` entry with no `period.endDate` or one in the future. That leaves out former staff and student-only records. KU's Pure does not return appointment data for every current staff member, so a person with no appointment data is kept. Only an ended appointment drops someone. When a named researcher has no current KU profile, the bot only returns publications that have a **KU** author of that name, never ones that match an external co-author. `GET /debug/persons?q=Kumar` (admin) lists every person Pure returned, their appointments, and why each was shown or dropped. Set `PURE_ACTIVE_PERSONS_ONLY=false` to list everyone. A researcher the user explicitly picks is always shown. Khazna people-search links add `community=false`, so external Pure Community profiles are excluded.
|
| 35 |
|
| 36 |
**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.
|
| 37 |
|
app.py
CHANGED
|
@@ -146,6 +146,7 @@ class AgentRequest(BaseModel):
|
|
| 146 |
top_k: int | None = None
|
| 147 |
scope: str = "all" # all | publications | theses | persons | organisations | projects | equipment | collaborations
|
| 148 |
person_uuid: str | None = None # set when the user picks one researcher from a disambiguation list
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
class FeedbackRequest(BaseModel):
|
|
@@ -201,8 +202,7 @@ def _reply(t0: float, **kw) -> dict:
|
|
| 201 |
_SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
| 202 |
|
| 203 |
|
| 204 |
-
|
| 205 |
-
"&inferConcepts=true&searchBy=RelatedConcepts&community=false&direct2experts=false")
|
| 206 |
|
| 207 |
|
| 208 |
def _portal_url(path: str, term: str, search_by: str | None = None) -> str:
|
|
@@ -341,7 +341,8 @@ async def agent(req: AgentRequest, request: Request, response: Response):
|
|
| 341 |
|
| 342 |
person_uuid = req.person_uuid if req.person_uuid and re.fullmatch(r"[A-Za-z0-9-]{1,64}", req.person_uuid) else None
|
| 343 |
try:
|
| 344 |
-
|
|
|
|
| 345 |
except PureError as e:
|
| 346 |
log_query(q, plan.intent, plan.keyword_queries, 0, model, time.time() - t0, str(e), **logx)
|
| 347 |
if e.status == 403 and plan.intent == "dataset_search":
|
|
@@ -376,7 +377,12 @@ async def agent(req: AgentRequest, request: Request, response: Response):
|
|
| 376 |
log_query(q, "author_not_found", [name], 0, model, time.time() - t0, **logx)
|
| 377 |
return _reply(t0, **base, answer=answer, trace=[{k: v for k, v in t.items() if k != "error"} for t in retrieval["trace"]],
|
| 378 |
portal_search_url=_portal_url("persons", name), follow_up_suggestions=[])
|
| 379 |
-
if not results:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
filt = bool(plan.year_from or plan.year_to or plan.output_types or plan.open_access_only)
|
| 381 |
answer = ("I couldn't find Khazna records matching that" + (" with the filters you asked for" if filt else "")
|
| 382 |
+ ". Try broader or different keywords" + (", or remove the year/type filter." if filt else "."))
|
|
@@ -478,15 +484,20 @@ async def debug_persons(q: str = "Kumar", x_admin_password: str | None = Header(
|
|
| 478 |
"current": pure_client._association_active(a, today)})
|
| 479 |
reason = ("not public" if not pure_client.is_public(i) else
|
| 480 |
"no current staff appointment" if status is False else
|
| 481 |
-
"
|
| 482 |
-
"name does not match" if i.get("uuid") not in matched_uuids else "shown")
|
| 483 |
rows.append({"name": pure_client._person_name(i.get("name")), "uuid": i.get("uuid"),
|
| 484 |
"visibility": vis.get("key") if isinstance(vis, dict) else vis,
|
| 485 |
"staff_appointments": assocs,
|
| 486 |
"other_association_fields": [k for k in i if k.endswith("Associations") and k != "staffOrganizationAssociations" and i.get(k)],
|
| 487 |
"kept_by_filter": i.get("uuid") in kept_uuids, "result": reason})
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
|
| 491 |
|
| 492 |
@app.get("/debug/pure")
|
|
|
|
| 146 |
top_k: int | None = None
|
| 147 |
scope: str = "all" # all | publications | theses | persons | organisations | projects | equipment | collaborations
|
| 148 |
person_uuid: str | None = None # set when the user picks one researcher from a disambiguation list
|
| 149 |
+
person_name: str | None = None # same, for a portal-listed researcher without an API uuid
|
| 150 |
|
| 151 |
|
| 152 |
class FeedbackRequest(BaseModel):
|
|
|
|
| 202 |
_SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
| 203 |
|
| 204 |
|
| 205 |
+
from pure_client import PERSONS_PORTAL_PARAMS
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
def _portal_url(path: str, term: str, search_by: str | None = None) -> str:
|
|
|
|
| 341 |
|
| 342 |
person_uuid = req.person_uuid if req.person_uuid and re.fullmatch(r"[A-Za-z0-9-]{1,64}", req.person_uuid) else None
|
| 343 |
try:
|
| 344 |
+
person_name = (req.person_name or "").strip()[:120] or None
|
| 345 |
+
retrieval = await pipeline.retrieve(pure, plan, person_uuid=person_uuid, person_name=person_name)
|
| 346 |
except PureError as e:
|
| 347 |
log_query(q, plan.intent, plan.keyword_queries, 0, model, time.time() - t0, str(e), **logx)
|
| 348 |
if e.status == 403 and plan.intent == "dataset_search":
|
|
|
|
| 377 |
log_query(q, "author_not_found", [name], 0, model, time.time() - t0, **logx)
|
| 378 |
return _reply(t0, **base, answer=answer, trace=[{k: v for k, v in t.items() if k != "error"} for t in retrieval["trace"]],
|
| 379 |
portal_search_url=_portal_url("persons", name), follow_up_suggestions=[])
|
| 380 |
+
if not results and retrieval.get("person"):
|
| 381 |
+
pname = retrieval["person"].get("name", "this researcher")
|
| 382 |
+
answer = (f"Here is **{pname}**'s Khazna profile. I couldn't find matching publications through search"
|
| 383 |
+
+ (" for that topic or filter" if (plan.keyword_queries and plan.intent == "author_search") else "")
|
| 384 |
+
+ "; open the profile for their full list of outputs.")
|
| 385 |
+
elif not results:
|
| 386 |
filt = bool(plan.year_from or plan.year_to or plan.output_types or plan.open_access_only)
|
| 387 |
answer = ("I couldn't find Khazna records matching that" + (" with the filters you asked for" if filt else "")
|
| 388 |
+ ". Try broader or different keywords" + (", or remove the year/type filter." if filt else "."))
|
|
|
|
| 484 |
"current": pure_client._association_active(a, today)})
|
| 485 |
reason = ("not public" if not pure_client.is_public(i) else
|
| 486 |
"no current staff appointment" if status is False else
|
| 487 |
+
"name does not match" if i.get("uuid") not in matched_uuids else "kept")
|
|
|
|
| 488 |
rows.append({"name": pure_client._person_name(i.get("name")), "uuid": i.get("uuid"),
|
| 489 |
"visibility": vis.get("key") if isinstance(vis, dict) else vis,
|
| 490 |
"staff_appointments": assocs,
|
| 491 |
"other_association_fields": [k for k in i if k.endswith("Associations") and k != "staffOrganizationAssociations" and i.get(k)],
|
| 492 |
"kept_by_filter": i.get("uuid") in kept_uuids, "result": reason})
|
| 493 |
+
portal = await pure.portal_person_search(q)
|
| 494 |
+
merged = pipeline.merge_portal_people(portal or [], await pure.search_persons(q, size=50)) if portal is not None else None
|
| 495 |
+
shown_by_bot, _ = pipeline.match_people(q, merged if merged is not None else [])
|
| 496 |
+
return {"query": q, "portal_reachable": portal is not None,
|
| 497 |
+
"portal_people": [{"name": p["name"], "portal_url": p["portal_url"], "api_uuid": p.get("uuid") or None,
|
| 498 |
+
"shown_by_bot": p in shown_by_bot} for p in (merged or [])],
|
| 499 |
+
"api_count": raw.get("count"), "api_returned": len(items), "staff_field_returned": field_returned,
|
| 500 |
+
"api_people": rows}
|
| 501 |
|
| 502 |
|
| 503 |
@app.get("/debug/pure")
|
pipeline.py
CHANGED
|
@@ -336,17 +336,63 @@ def match_people(query: str, people: list[dict]) -> tuple[list[dict], list[dict]
|
|
| 336 |
return sorted(matches, key=order), full
|
| 337 |
|
| 338 |
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
if person_uuid:
|
| 342 |
person = await pure.get_person(person_uuid, require_active=False)
|
| 343 |
trace.append({"endpoint": f"persons/{person_uuid}", "hits": 1 if person else 0})
|
| 344 |
return person, []
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
if len(full) == 1:
|
| 351 |
return full[0], []
|
| 352 |
if len(matches) == 1:
|
|
@@ -356,22 +402,23 @@ async def _resolve_person(pure: PureClient, plan: QueryPlan, person_uuid: str |
|
|
| 356 |
return None, []
|
| 357 |
|
| 358 |
|
| 359 |
-
async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None, person_uuid: str | None = None
|
|
|
|
| 360 |
search = search or pure.search_research_outputs
|
| 361 |
trace: list[dict] = []
|
| 362 |
person = None
|
| 363 |
total = 0
|
| 364 |
name_filter = None # set when a named researcher has no current KU profile
|
| 365 |
|
| 366 |
-
if plan.intent == "author_search" and (plan.author_name or person_uuid):
|
| 367 |
-
person, candidates = await _resolve_person(pure, plan, person_uuid, trace)
|
| 368 |
if candidates: # ambiguous name: ask the user to choose instead of guessing
|
| 369 |
shown = candidates[:MAX_CANDIDATES]
|
| 370 |
await pure.fill_org_names(shown)
|
| 371 |
return {"results": [{**c, "choose": True} for c in shown], "unfiltered": len(candidates),
|
| 372 |
"total": len(candidates), "person": None, "trace": trace,
|
| 373 |
"clarify": {"name": plan.author_name, "count": len(candidates), "shown": len(shown)}}
|
| 374 |
-
if person:
|
| 375 |
await pure.fill_org_names([person])
|
| 376 |
outs = await pure.person_research_outputs(person, size=100)
|
| 377 |
trace.append({"endpoint": f"persons/{person['uuid']}/research-outputs", "hits": len(outs)})
|
|
@@ -381,7 +428,9 @@ async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None, pers
|
|
| 381 |
outs = topical or outs
|
| 382 |
variant_hits = [outs]
|
| 383 |
total = len(outs)
|
| 384 |
-
else: # no
|
|
|
|
|
|
|
| 385 |
name_filter = plan.author_name
|
| 386 |
plan.keyword_queries = [f"{plan.author_name or ''} {q}".strip() for q in (plan.keyword_queries or [""])]
|
| 387 |
plan.keyword_queries = [q for q in plan.keyword_queries if q] or [plan.standalone_question]
|
|
@@ -417,7 +466,7 @@ async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None, pers
|
|
| 417 |
qt = _name_tokens(name_filter)
|
| 418 |
result["results"] = [o for o in filtered
|
| 419 |
if any(qt and all(t in _name_tokens(a.get("name", "")) for t in qt) for a in o.get("ku_authors", []))]
|
| 420 |
-
if not result["results"]:
|
| 421 |
result["no_person"] = name_filter
|
| 422 |
return result
|
| 423 |
|
|
@@ -591,10 +640,13 @@ async def _retrieve_datasets(pure: PureClient, plan: QueryPlan) -> dict:
|
|
| 591 |
return r
|
| 592 |
|
| 593 |
|
| 594 |
-
async def retrieve(pure: PureClient, plan: QueryPlan, person_uuid: str | None = None
|
| 595 |
-
|
|
|
|
| 596 |
plan.intent = "author_search"
|
| 597 |
-
|
|
|
|
|
|
|
| 598 |
concept = None
|
| 599 |
if plan.intent in ("topic_search", "thesis_search", "expert_search", "project_search", "collab_search", "count"):
|
| 600 |
concept = await _expand_with_concepts(pure, plan)
|
|
|
|
| 336 |
return sorted(matches, key=order), full
|
| 337 |
|
| 338 |
|
| 339 |
+
USE_PORTAL_PEOPLE = os.environ.get("USE_PORTAL_PEOPLE", "true").lower() != "false"
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _portal_key(url: str) -> str:
|
| 343 |
+
return (url or "").split("?")[0].rstrip("/").lower()
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def merge_portal_people(portal: list[dict], api_people: list[dict]) -> list[dict]:
|
| 347 |
+
"""The portal decides WHO is listed (current KU profiles); the API adds uuid, role, unit."""
|
| 348 |
+
by_url = {_portal_key(p.get("portal_url", "")): p for p in api_people if p.get("portal_url")}
|
| 349 |
+
by_name: dict[tuple, list[dict]] = {}
|
| 350 |
+
for p in api_people:
|
| 351 |
+
by_name.setdefault(tuple(sorted(_name_tokens(p.get("name", "")))), []).append(p)
|
| 352 |
+
merged = []
|
| 353 |
+
for entry in portal:
|
| 354 |
+
api = by_url.get(_portal_key(entry["portal_url"]))
|
| 355 |
+
if api is None:
|
| 356 |
+
same = by_name.get(tuple(sorted(_name_tokens(entry["name"]))), [])
|
| 357 |
+
api = same[0] if len(same) == 1 else None
|
| 358 |
+
if api:
|
| 359 |
+
merged.append({**api, "name": entry["name"], "portal_url": entry["portal_url"]})
|
| 360 |
+
else:
|
| 361 |
+
parts = entry["name"].split()
|
| 362 |
+
merged.append({"kind": "person", "uuid": "", "name": entry["name"], "title": entry["name"],
|
| 363 |
+
"first_name": " ".join(parts[:-1]), "last_name": parts[-1] if parts else "",
|
| 364 |
+
"job_titles": [], "organisations": [], "keywords": [], "orcid": "",
|
| 365 |
+
"active": True, "portal_url": entry["portal_url"]})
|
| 366 |
+
return merged
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
async def _resolve_person(pure: PureClient, plan: QueryPlan, person_uuid: str | None, trace: list[dict],
|
| 370 |
+
exact_name: str | None = None):
|
| 371 |
+
"""β (person, candidates). candidates is non-empty only when the name is ambiguous.
|
| 372 |
+
|
| 373 |
+
Who counts as a KU researcher comes from the Khazna portal's own profile search
|
| 374 |
+
(current, public, KU-only). The API search is used to add details, and on its own
|
| 375 |
+
only if the portal can't be reached.
|
| 376 |
+
"""
|
| 377 |
if person_uuid:
|
| 378 |
person = await pure.get_person(person_uuid, require_active=False)
|
| 379 |
trace.append({"endpoint": f"persons/{person_uuid}", "hits": 1 if person else 0})
|
| 380 |
return person, []
|
| 381 |
+
name = exact_name or plan.author_name
|
| 382 |
+
portal = await pure.portal_person_search(name) if USE_PORTAL_PEOPLE else None
|
| 383 |
+
api_people = await pure.search_persons(name, size=50)
|
| 384 |
+
trace.append({"endpoint": "persons/search", "query": name, "hits": len(api_people)})
|
| 385 |
+
if portal is not None:
|
| 386 |
+
trace.append({"endpoint": "portal/persons", "hits": len(portal)})
|
| 387 |
+
pool = merge_portal_people(portal, api_people)
|
| 388 |
+
else:
|
| 389 |
+
pool = api_people
|
| 390 |
+
matches, full = match_people(name, pool)
|
| 391 |
+
if exact_name: # the user picked this person from a list: never ask again
|
| 392 |
+
pick = full or matches
|
| 393 |
+
return (pick[0] if pick else None), []
|
| 394 |
+
if not matches and len(pool) == 1:
|
| 395 |
+
matches = pool
|
| 396 |
if len(full) == 1:
|
| 397 |
return full[0], []
|
| 398 |
if len(matches) == 1:
|
|
|
|
| 402 |
return None, []
|
| 403 |
|
| 404 |
|
| 405 |
+
async def _retrieve_outputs(pure: PureClient, plan: QueryPlan, search=None, person_uuid: str | None = None,
|
| 406 |
+
exact_name: str | None = None) -> dict:
|
| 407 |
search = search or pure.search_research_outputs
|
| 408 |
trace: list[dict] = []
|
| 409 |
person = None
|
| 410 |
total = 0
|
| 411 |
name_filter = None # set when a named researcher has no current KU profile
|
| 412 |
|
| 413 |
+
if plan.intent == "author_search" and (plan.author_name or person_uuid or exact_name):
|
| 414 |
+
person, candidates = await _resolve_person(pure, plan, person_uuid, trace, exact_name=exact_name)
|
| 415 |
if candidates: # ambiguous name: ask the user to choose instead of guessing
|
| 416 |
shown = candidates[:MAX_CANDIDATES]
|
| 417 |
await pure.fill_org_names(shown)
|
| 418 |
return {"results": [{**c, "choose": True} for c in shown], "unfiltered": len(candidates),
|
| 419 |
"total": len(candidates), "person": None, "trace": trace,
|
| 420 |
"clarify": {"name": plan.author_name, "count": len(candidates), "shown": len(shown)}}
|
| 421 |
+
if person and person.get("uuid"):
|
| 422 |
await pure.fill_org_names([person])
|
| 423 |
outs = await pure.person_research_outputs(person, size=100)
|
| 424 |
trace.append({"endpoint": f"persons/{person['uuid']}/research-outputs", "hits": len(outs)})
|
|
|
|
| 428 |
outs = topical or outs
|
| 429 |
variant_hits = [outs]
|
| 430 |
total = len(outs)
|
| 431 |
+
else: # no API record (or none at all): search outputs, keep only those with a KU author of that name
|
| 432 |
+
if person: # listed on the portal but not matched to an API record: search by their full name
|
| 433 |
+
plan.author_name = person["name"]
|
| 434 |
name_filter = plan.author_name
|
| 435 |
plan.keyword_queries = [f"{plan.author_name or ''} {q}".strip() for q in (plan.keyword_queries or [""])]
|
| 436 |
plan.keyword_queries = [q for q in plan.keyword_queries if q] or [plan.standalone_question]
|
|
|
|
| 466 |
qt = _name_tokens(name_filter)
|
| 467 |
result["results"] = [o for o in filtered
|
| 468 |
if any(qt and all(t in _name_tokens(a.get("name", "")) for t in qt) for a in o.get("ku_authors", []))]
|
| 469 |
+
if not result["results"] and person is None:
|
| 470 |
result["no_person"] = name_filter
|
| 471 |
return result
|
| 472 |
|
|
|
|
| 640 |
return r
|
| 641 |
|
| 642 |
|
| 643 |
+
async def retrieve(pure: PureClient, plan: QueryPlan, person_uuid: str | None = None,
|
| 644 |
+
person_name: str | None = None) -> dict:
|
| 645 |
+
if person_uuid or person_name: # the user picked a specific researcher from a list
|
| 646 |
plan.intent = "author_search"
|
| 647 |
+
if person_name:
|
| 648 |
+
plan.author_name = person_name
|
| 649 |
+
return await _retrieve_outputs(pure, plan, person_uuid=person_uuid, exact_name=None if person_uuid else person_name)
|
| 650 |
concept = None
|
| 651 |
if plan.intent in ("topic_search", "thesis_search", "expert_search", "project_search", "collab_search", "count"):
|
| 652 |
concept = await _expand_with_concepts(pure, plan)
|
pure_client.py
CHANGED
|
@@ -29,6 +29,9 @@ import re
|
|
| 29 |
import time
|
| 30 |
from typing import Any
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
import httpx
|
| 33 |
|
| 34 |
# Localised-string keys we prefer, in order. Pure portals are usually en_GB.
|
|
@@ -500,6 +503,27 @@ def normalize_external_organization(item: dict, portal_url: str) -> dict:
|
|
| 500 |
}
|
| 501 |
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
# βββββββββββββββββββββββββββββ client βββββββββββββββββββββββββββββββ
|
| 504 |
class PureClient:
|
| 505 |
def __init__(
|
|
@@ -525,9 +549,31 @@ class PureClient:
|
|
| 525 |
headers={"api-key": api_key, "Accept": "application/json", "Content-Type": "application/json"},
|
| 526 |
transport=transport,
|
| 527 |
)
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
async def aclose(self):
|
| 530 |
await self._client.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
|
| 532 |
# -- low level --------------------------------------------------
|
| 533 |
async def request(self, method: str, path: str, json: dict | None = None, params: dict | None = None) -> dict:
|
|
@@ -587,15 +633,9 @@ class PureClient:
|
|
| 587 |
items = self._filter(data.get("items") or [])
|
| 588 |
if not self.active_persons_only:
|
| 589 |
return [normalize_person(i, self.portal_url) for i in items]
|
| 590 |
-
#
|
| 591 |
-
#
|
| 592 |
-
|
| 593 |
-
kept = []
|
| 594 |
-
for i in items:
|
| 595 |
-
status = staff_status(i)
|
| 596 |
-
if status is True or (status is None and not field_returned):
|
| 597 |
-
kept.append(normalize_person(i, self.portal_url))
|
| 598 |
-
return kept
|
| 599 |
|
| 600 |
async def person_research_outputs(self, person: dict, size: int = 50) -> list[dict]:
|
| 601 |
"""Outputs where `person` is a contributor.
|
|
|
|
| 29 |
import time
|
| 30 |
from typing import Any
|
| 31 |
|
| 32 |
+
import html as _html
|
| 33 |
+
from urllib.parse import quote_plus
|
| 34 |
+
|
| 35 |
import httpx
|
| 36 |
|
| 37 |
# Localised-string keys we prefer, in order. Pure portals are usually en_GB.
|
|
|
|
| 503 |
}
|
| 504 |
|
| 505 |
|
| 506 |
+
# Same parameters as the portal's own "Find Profiles" page: community=false leaves out
|
| 507 |
+
# external Pure Community profiles, so only KU's current public profiles are listed.
|
| 508 |
+
PERSONS_PORTAL_PARAMS = ("&pageSize=50&ordering=rating&descending=false&showAdvanced=false&allConcepts=true"
|
| 509 |
+
"&inferConcepts=true&searchBy=RelatedConcepts&community=false&direct2experts=false")
|
| 510 |
+
_PORTAL_PERSON_RE = re.compile(
|
| 511 |
+
r'<a\b(?=[^>]*\bclass="[^"]*\blink person\b[^"]*")(?=[^>]*\bhref="([^"]+)")[^>]*>\s*<span>(.*?)</span>', re.S)
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def parse_portal_persons(page: str, portal_url: str) -> list[dict]:
|
| 515 |
+
"""Names and profile links from a Khazna portal 'Find Profiles' result page."""
|
| 516 |
+
out, seen = [], set()
|
| 517 |
+
for href, raw in _PORTAL_PERSON_RE.findall(page):
|
| 518 |
+
name = _clean_text(_html.unescape(raw))
|
| 519 |
+
url = href if href.startswith("http") else portal_url.rstrip("/") + href
|
| 520 |
+
key = url.rstrip("/").lower()
|
| 521 |
+
if name and key not in seen:
|
| 522 |
+
seen.add(key)
|
| 523 |
+
out.append({"name": name, "portal_url": url})
|
| 524 |
+
return out
|
| 525 |
+
|
| 526 |
+
|
| 527 |
# βββββββββββββββββββββββββββββ client βββββββββββββββββββββββββββββββ
|
| 528 |
class PureClient:
|
| 529 |
def __init__(
|
|
|
|
| 549 |
headers={"api-key": api_key, "Accept": "application/json", "Content-Type": "application/json"},
|
| 550 |
transport=transport,
|
| 551 |
)
|
| 552 |
+
# Separate client for the public portal pages: never sends the API key.
|
| 553 |
+
self._portal = httpx.AsyncClient(timeout=15.0, transport=transport, follow_redirects=True,
|
| 554 |
+
headers={"Accept": "text/html", "User-Agent": "KU-Khazna-AI-Assistant/1.0"})
|
| 555 |
|
| 556 |
async def aclose(self):
|
| 557 |
await self._client.aclose()
|
| 558 |
+
await self._portal.aclose()
|
| 559 |
+
|
| 560 |
+
async def portal_person_search(self, name: str) -> list[dict] | None:
|
| 561 |
+
"""KU people the Khazna portal lists for `name` (current, public, no external profiles).
|
| 562 |
+
Returns None if the portal can't be reached, so callers can fall back to the API."""
|
| 563 |
+
key = f"portal-persons|{name.lower()}"
|
| 564 |
+
hit = self._cache.get(key)
|
| 565 |
+
if hit and time.time() - hit[0] < self.cache_ttl:
|
| 566 |
+
return hit[1]
|
| 567 |
+
url = f"{self.portal_url}/en/persons/?search={quote_plus(name)}{PERSONS_PORTAL_PARAMS}"
|
| 568 |
+
try:
|
| 569 |
+
r = await self._portal.get(url)
|
| 570 |
+
except (httpx.TimeoutException, httpx.TransportError):
|
| 571 |
+
return None
|
| 572 |
+
if r.status_code != 200:
|
| 573 |
+
return None
|
| 574 |
+
people = parse_portal_persons(r.text, self.portal_url)
|
| 575 |
+
self._cache[key] = (time.time(), people)
|
| 576 |
+
return people
|
| 577 |
|
| 578 |
# -- low level --------------------------------------------------
|
| 579 |
async def request(self, method: str, path: str, json: dict | None = None, params: dict | None = None) -> dict:
|
|
|
|
| 633 |
items = self._filter(data.get("items") or [])
|
| 634 |
if not self.active_persons_only:
|
| 635 |
return [normalize_person(i, self.portal_url) for i in items]
|
| 636 |
+
# KU's Pure does not return staff appointments for every person, so missing data
|
| 637 |
+
# means "unknown" and the person is kept; only an ended appointment drops someone.
|
| 638 |
+
return [normalize_person(i, self.portal_url) for i in items if staff_status(i) is not False]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
async def person_research_outputs(self, person: dict, size: int = 50) -> list[dict]:
|
| 641 |
"""Outputs where `person` is a contributor.
|