nikeshn commited on
Commit
28e7745
Β·
verified Β·
1 Parent(s): b0b9203

Upload 7 files

Browse files
Files changed (4) hide show
  1. README.md +3 -1
  2. app.py +19 -8
  3. pipeline.py +68 -16
  4. 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
- **Current KU staff only:** `/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. If the API returns appointment data for some people in a result set, anyone without it is treated as not current. If it returns none at all, people are kept. 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.
 
 
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
- PERSONS_PORTAL_PARAMS = ("&pageSize=50&ordering=rating&descending=false&showAdvanced=false&allConcepts=true"
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
- retrieval = await pipeline.retrieve(pure, plan, person_uuid=person_uuid)
 
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
- "no staff appointments returned" if status is None and field_returned else
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
- return {"query": q, "pure_count": raw.get("count"), "returned": len(items), "staff_field_returned": field_returned,
489
- "shown": sum(r["result"] == "shown" for r in rows), "people": rows}
 
 
 
 
 
 
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
- 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, require_active=False)
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:
@@ -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) -> dict:
 
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 current KU profile: search outputs, but keep only those with a KU author of that name
 
 
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) -> dict:
595
- if person_uuid: # the user picked a specific researcher from a list
 
596
  plan.intent = "author_search"
597
- return await _retrieve_outputs(pure, plan, person_uuid=person_uuid)
 
 
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
- # If the API returns staff associations for anyone in this batch, a person without
591
- # them is not current staff (rather than "unknown"), so they are dropped too.
592
- field_returned = any("staffOrganizationAssociations" in i for i in items)
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.