nikeshn commited on
Commit
1bacd20
·
verified ·
1 Parent(s): 0c9fa01

Update src/api/agent.py

Browse files
Files changed (1) hide show
  1. src/api/agent.py +125 -412
src/api/agent.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- agent.py — LibBee v3.6
3
 
4
  ╔══════════════════════════════════════════════════════════════════╗
5
  ║ TABLE OF CONTENTS ║
@@ -14,7 +14,7 @@ agent.py — LibBee v3.6
14
  ║ §8 Topic Extraction ║
15
  ║ §9 Query Builders (PRIMO boolean, PubMed URL) ║
16
  ║ §10 Search Preparation & Planning ║
17
- ║ §11 Search Runners (OpenAlex, Semantic Scholar, PRIMO, PubMed)║
18
  ║ §12 Response Builders (badges, thought blocks, SVG mascots) ║
19
  ║ §13 Hardcoded Answers (campus, fulltext, DB recs, theses...) ║
20
  ║ §14 Follow-up System ║
@@ -22,12 +22,29 @@ agent.py — LibBee v3.6
22
  ║ §16 FastAPI Endpoint & Utility Routes ║
23
  ╚══════════════════════════════════════════════════════════════════╝
24
 
25
- Changes over v3.5:
26
- - §4 LibCal Events: EVENTS_RE + _libcal_fetch_events() +
27
- _events_answer() — live upcoming events from KU LibCal calendar
28
- via Cloudflare Worker /events proxy. 30-min cache.
29
- - Routing: EVENTS_RE check added in _agent_query_inner.
30
- - worker.js: /events endpoint added (v3.6).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  """
32
  # ── §1 Imports ────────────────────────────────────────────────────────────────
33
 
@@ -209,18 +226,6 @@ CURRENT_INFO_RE = re.compile(
209
  r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
210
  re.IGNORECASE,
211
  )
212
- SUMMARY_RE = re.compile(
213
- r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
214
- r"|tell me about|give me a summary|give me an overview|give me a brief"
215
- r"|brief (summary|overview|explanation|description)"
216
- r"|research (overview|summary|landscape|state)"
217
- r"|state of (the art|research|the field)"
218
- r"|recent (advances?|developments?|progress|trends?)"
219
- r"|current (state|trends?|advances?|research)"
220
- r"|deep analysis|analysis of|review of|literature review|deep research|deep dive"
221
- r"|what do we know about|how does|how do)\b",
222
- re.IGNORECASE,
223
- )
224
  DATABASE_RECOMMEND_RE = re.compile(
225
  r"("
226
  # explicit recommendation phrasing
@@ -777,15 +782,11 @@ async def _events_answer(days: int = 30) -> str:
777
  """
778
  Return upcoming library events from LibCal.
779
  Falls back to library events calendar link if unavailable.
780
- _libcal_fetch_events returns:
781
- None — API/network failure
782
- [] — API succeeded but no events in the window
783
- [...] — events found
784
  """
785
  EVENTS_URL = "https://kustar.libcal.com/calendar/events"
786
  events = await _libcal_fetch_events(days=days, limit=15)
787
 
788
- if events is None:
789
  return (
790
  "<strong>📅 Khalifa University Library Events</strong><br><br>"
791
  "Live event data is currently unavailable. Browse all upcoming events here:<br>"
@@ -793,7 +794,7 @@ async def _events_answer(days: int = 30) -> str:
793
  f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
794
  )
795
 
796
- if len(events) == 0:
797
  return (
798
  "<strong>📅 Khalifa University Library Events</strong><br><br>"
799
  f"No upcoming events found in the next {days} days.<br><br>"
@@ -938,10 +939,6 @@ def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
938
  return msgs
939
 
940
 
941
- def _is_summary_request(question: str) -> bool:
942
- return bool(SUMMARY_RE.search(question or ""))
943
-
944
-
945
  def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
946
  if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
947
  return False
@@ -973,12 +970,12 @@ def _looks_medical_search(question: str) -> bool:
973
  q = (question or "").lower()
974
  if not any(kw in q for kw in MEDICAL_KEYWORDS):
975
  return False
976
- return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
977
 
978
 
979
  def _looks_research_question(question: str) -> bool:
980
  q = question or ""
981
- return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
982
 
983
 
984
  def _rule_based_classify(question: str) -> Dict[str, str]:
@@ -1799,216 +1796,45 @@ async def _prepare_queries(question: str, context: SearchContextPayload, model:
1799
 
1800
 
1801
  # ── §11 Search Runners ────────────────────────────────────────────────────────
1802
-
1803
- # ── Semantic Scholar + Summary ────────────────────────────────────────────────
1804
-
1805
- async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
1806
- clean_topic = _strip_resource_noise(topic)
1807
- if not clean_topic:
1808
- clean_topic = topic
1809
- try:
1810
- params = {
1811
- "query": clean_topic,
1812
- "limit": limit,
1813
- "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
1814
- }
1815
- async with httpx.AsyncClient(timeout=15) as client:
1816
- r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
1817
- if r.status_code != 200:
1818
- logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
1819
- return []
1820
- data = r.json()
1821
- results = []
1822
- for paper in data.get("data", []):
1823
- abstract = paper.get("abstract") or ""
1824
- if not abstract or len(abstract) < 50:
1825
- continue
1826
- author_list = paper.get("authors") or []
1827
- first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
1828
- year = paper.get("year") or "n.d."
1829
- paper_id = paper.get("paperId", "")
1830
- doi = (paper.get("externalIds") or {}).get("DOI", "")
1831
- pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
1832
- link = (
1833
- pdf_url
1834
- or (f"https://doi.org/{doi}" if doi else "")
1835
- or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
1836
- )
1837
- results.append({
1838
- "title": paper.get("title", "Untitled"),
1839
- "authors": (
1840
- ", ".join(a.get("name", "") for a in author_list[:3])
1841
- + (" et al." if len(author_list) > 3 else "")
1842
- ),
1843
- "first_author": first_author_last,
1844
- "year": year,
1845
- "citation_key": f"{first_author_last}, {year}",
1846
- "venue": paper.get("venue", ""),
1847
- "abstract": abstract[:700],
1848
- "link": link,
1849
- "_source": "Semantic Scholar",
1850
- })
1851
- return results
1852
- except Exception as e:
1853
- logger.error(f"Semantic Scholar search failed: {e}")
1854
- return []
1855
 
1856
 
1857
- async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
1858
  """
1859
- Search OpenAlex using ?search= — same relevance engine as the browser link.
1860
- Passes the full corrected natural language query, strips only boolean operators.
1861
- """
1862
- try:
1863
- from urllib.parse import quote as _oa_quote
1864
- # Strip boolean operators only — keep all keywords including proper nouns
1865
- clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
1866
- clean = re.sub(r'\s+', ' ', clean).strip()[:200]
1867
- encoded = _oa_quote(clean, safe='')
1868
- select = "id,title,authorships,publication_year,primary_location,abstract_inverted_index,doi,cited_by_count,open_access"
1869
- url = (
1870
- f"https://api.openalex.org/works"
1871
- f"?search={encoded}"
1872
- f"&filter=has_abstract:true,type:article,is_paratext:false"
1873
- f"&sort=relevance_score:desc"
1874
- f"&per_page={limit}"
1875
- f"&select={select}"
1876
- )
1877
- headers = {"User-Agent": "LibBee/3.2 (mailto:libse@ku.ac.ae)"}
1878
- async with httpx.AsyncClient(timeout=10) as client:
1879
- r = await client.get(url, headers=headers)
1880
- if r.status_code != 200:
1881
- logger.warning(f"OpenAlex search failed: {r.status_code}")
1882
- return []
1883
- works = r.json().get("results", [])
1884
-
1885
- results = []
1886
- for work in works:
1887
- title = work.get("title") or "Untitled"
1888
-
1889
- # Reconstruct abstract from inverted index
1890
- inv = work.get("abstract_inverted_index") or {}
1891
- abstract = ""
1892
- if inv:
1893
- word_positions = []
1894
- for word, positions in inv.items():
1895
- for pos in positions:
1896
- word_positions.append((pos, word))
1897
- word_positions.sort(key=lambda x: x[0])
1898
- abstract = " ".join(w for _, w in word_positions)[:700]
1899
-
1900
- if not abstract:
1901
- continue
1902
-
1903
- authorships = work.get("authorships") or []
1904
- author_names = [
1905
- a.get("author", {}).get("display_name", "")
1906
- for a in authorships[:4]
1907
- if a.get("author", {}).get("display_name")
1908
- ]
1909
- authors_str = ", ".join(author_names[:3])
1910
- if len(author_names) > 3:
1911
- authors_str += " et al."
1912
- first_last = author_names[0].split()[-1] if author_names else "Unknown"
1913
-
1914
- year = str(work.get("publication_year") or "n.d.")
1915
- doi = (work.get("doi") or "").replace("https://doi.org/", "")
1916
- oa_url = (work.get("open_access") or {}).get("oa_url") or ""
1917
- link = (
1918
- oa_url if oa_url
1919
- else (f"https://doi.org/{doi}" if doi else "")
1920
- or work.get("id", "https://openalex.org")
1921
- )
1922
- venue = (
1923
- ((work.get("primary_location") or {}).get("source") or {})
1924
- .get("display_name") or ""
1925
- )
1926
-
1927
- results.append({
1928
- "title": title,
1929
- "authors": authors_str or "Unknown",
1930
- "first_author": first_last,
1931
- "year": year,
1932
- "citation_key": f"{first_last}, {year}",
1933
- "venue": venue,
1934
- "abstract": abstract,
1935
- "link": link,
1936
- "_source": "OpenAlex",
1937
- })
1938
-
1939
- logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
1940
- return results
1941
-
1942
- except Exception as e:
1943
- logger.warning(f"OpenAlex search failed: {e}")
1944
- return []
1945
-
1946
 
1947
- async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
 
 
1948
  settings = get_settings()
1949
  if not settings.openai_api_key and not settings.anthropic_api_key:
1950
  return ""
1951
- if not papers:
1952
- return ""
1953
- context_text = ""
1954
- for i, paper in enumerate(papers, 1):
1955
- context_text += (
1956
- f"[{i}] {paper['citation_key']}: {paper['title']}\n"
1957
- f"Abstract: {paper['abstract']}\n\n"
1958
- )
1959
- system = (
1960
- "You are a research librarian writing a concise academic literature snapshot. "
1961
- "Using ONLY the numbered references provided, write a 4-6 sentence summary on the given topic. "
1962
- "Use in-text citations as [1], [2], [3] etc. referring to the numbered references. "
1963
- "Cite at least 4 different references. "
1964
- "Use HTML <br><br> for paragraph breaks. Do not use bullet points or markdown. "
1965
- "Only state what is explicitly supported by the abstracts. "
1966
- "Do NOT invent content."
1967
- )
1968
  try:
1969
- llm = _get_llm(model, temperature=0.2, max_tokens=560)
1970
  response = await llm.ainvoke([
1971
- {"role": "system", "content": system},
1972
- {"role": "user", "content": f"Topic: {topic}\n\nReferences:\n{context_text}\nWrite the summary."},
 
 
 
 
 
 
 
1973
  ])
1974
- summary = response.content.strip()
1975
- for i in range(len(papers), 0, -1):
1976
- paper = papers[i - 1]
1977
- title_escaped = _escape(paper["title"])
1978
- cite_html = (
1979
- f'<a href="{paper["link"]}" target="_blank" '
1980
- f'title="{title_escaped}" '
1981
- f'style="color:#1e40af;font-weight:600;text-decoration:none">'
1982
- f'[{paper["citation_key"]}]</a>'
1983
- )
1984
- summary = summary.replace(f"[{i}]", cite_html)
1985
- return summary
1986
  except Exception as e:
1987
- logger.error(f"Summary generation failed: {e}")
1988
  return ""
1989
 
1990
 
1991
- def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
1992
- if not papers:
1993
- return ""
1994
- statements: List[str] = []
1995
- for paper in papers[:4]:
1996
- sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
1997
- lead = sentences[0].strip() if sentences else ""
1998
- if not lead:
1999
- continue
2000
- citation = (
2001
- f'<a href="{paper["link"]}" target="_blank" '
2002
- f'style="color:#1e40af;font-weight:600;text-decoration:none">'
2003
- f'[{_escape(paper["citation_key"])}]</a>'
2004
- )
2005
- statements.append(f"{_escape(lead)} {citation}")
2006
- if not statements:
2007
- return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
2008
- intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
2009
- return intro + "<br><br>".join(statements[:4])
2010
-
2011
-
2012
  # ── §12 Response Builders ─────────────────────────────────────────────────────
2013
 
2014
  # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
@@ -3108,30 +2934,37 @@ def _highly_cited_note(topic_escaped: str) -> str:
3108
  )
3109
 
3110
 
3111
- # ── Research Snapshot ─────────────────────────────────────────────────────────
3112
-
3113
- async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
3114
  """
3115
- Research snapshot — no API calls.
3116
- Shows PRIMO browser link + AI tools footer only.
3117
  """
3118
- topic = context.topic
3119
- primo_url = _primo_clean_url(context)
3120
- answer = _search_trace_block(f"Research on {topic}", context)
 
 
 
 
 
 
 
 
3121
  answer += (
3122
- f"<strong>📚 Research starting points: {_escape(topic)}</strong><br><br>"
3123
- "Your query has been prepared and pre-loaded across multiple platforms. "
3124
- "Click any platform below to search instantly, or use the boolean search tip "
3125
- "to search directly in any KU database."
3126
- f'<br><br><a href="{primo_url}" target="_blank" style="color:#003366;font-weight:700">'
3127
- f'Search PRIMO for: {_escape(topic)} →</a>'
 
 
3128
  )
3129
  answer += _ai_tools_footer(context)
3130
- return answer, [], []
3131
-
3132
- # ── §14 Follow-up System ──────────────────────────────────────────────────────
3133
 
3134
- # ── Follow-up System ──────────────────────────────────────────────────────────
3135
 
3136
  def _filters_summary(context: SearchContextPayload) -> str:
3137
  filters: List[str] = []
@@ -3193,25 +3026,6 @@ def _parse_refinement_action(question: str) -> Optional[str]:
3193
  # Alternative search terms — NEW
3194
  if ALT_TERMS_RE.search(q):
3195
  return "alt_terms"
3196
- # summarize_topic: ONLY match short/topicless follow-ups like "summarize this",
3197
- # "give me an overview", "what does the literature say" — NOT questions that
3198
- # include an explicit new topic like "summarize quantum physics".
3199
- # A question with 4+ non-stop content words almost certainly has its own topic
3200
- # and should be handled as a fresh query, not a follow-up.
3201
- if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
3202
- _STOP = {
3203
- 'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
3204
- 'was','were','be','been','have','has','had','do','does','did','will','would',
3205
- 'could','should','may','its','this','that','these','those','about','me','my',
3206
- 'give','show','tell','please','can','you','i','need','want','get','find',
3207
- 'research','summary','overview','summarize','summarise','brief','literature',
3208
- }
3209
- content_words = [t for t in re.findall(r'[a-z0-9]+', q)
3210
- if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
3211
- # Any explicit topic word → treat as a fresh query, not a follow-up
3212
- if content_words:
3213
- return None
3214
- return "summarize_topic"
3215
  if re.search(r"\bpubmed\b", q):
3216
  return "search_pubmed"
3217
  if re.search(r"\bprimo\b", q):
@@ -3350,37 +3164,42 @@ async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
3350
  async def _search_follow_up(
3351
  context: SearchContextPayload, model: str, summary_mode: bool = False
3352
  ) -> Tuple[str, List[dict]]:
 
 
 
 
 
 
 
 
 
 
3353
  topic = context.display_topic or context.topic
3354
- suggestions: List[dict] = []
3355
- topic_follow_ups = await _generate_topic_follow_ups(topic, model)
3356
- suggestions.extend(topic_follow_ups)
3357
-
3358
- filter_suggestions: List[dict] = []
3359
- if summary_mode:
3360
- filter_suggestions.append({
3361
- "label": f"Show KU-accessible articles on {topic}",
3362
- "question": f"Find KU-accessible articles on {topic}",
3363
- "action": "articles_only",
3364
  "context_id": context.context_id,
3365
- })
3366
- if context.intent == "search_medical" and context.source != "pubmed":
3367
- filter_suggestions.append({
3368
- "label": "Search PubMed instead",
3369
- "question": f"Search PubMed for {topic}",
3370
- "action": "search_pubmed",
3371
- "context_id": context.context_id,
3372
- })
3373
- filter_suggestions.append({
3374
- "label": "Research snapshot",
3375
- "question": f"Give me a brief research snapshot on {topic}",
3376
- "action": "summarize_topic",
3377
- "context_id": context.context_id,
3378
- })
3379
-
3380
- suggestions.extend(filter_suggestions[:2])
 
3381
  question_text = (
3382
- f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
3383
- "Or I can refine the search with filters — just ask."
3384
  )
3385
  return question_text, suggestions[:5]
3386
 
@@ -3476,6 +3295,7 @@ async def _run_search_mode(
3476
 
3477
  return answer, [], [], source_url
3478
 
 
3479
  def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
3480
  dedup: Dict[str, SearchContextPayload] = {}
3481
  for ctx in contexts:
@@ -3703,73 +3523,6 @@ async def _agent_query_inner(
3703
 
3704
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
3705
 
3706
- if is_follow_up and base_context:
3707
- _safe_metrics_increment("follow_up_hits")
3708
- context = _apply_follow_up_action(base_context, follow_up_action)
3709
-
3710
- if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
3711
- context = await _prepare_queries(question, context, model, True)
3712
- if follow_up_action == "summarize_topic":
3713
- answer, search_results, sources = await _research_snapshot(context, model)
3714
- else:
3715
- # deep_research_tools / best_databases follow-up
3716
- answer = (
3717
- f"{_search_trace_block(question, context)}"
3718
- f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
3719
- f"Here are the best AI tools and resources for a deep dive on this topic."
3720
- + _source_badge("libbee")
3721
- + _ai_tools_footer(context)
3722
- )
3723
- search_results = []
3724
- sources = []
3725
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
3726
- _safe_metrics_increment("search_handoffs")
3727
- return AgentResponse(
3728
- answer=answer,
3729
- intent=context.intent,
3730
- tools_used=["follow_up_resolution", follow_up_action],
3731
- search_results=search_results,
3732
- sources=sources,
3733
- model_used=model,
3734
- response_time=time.time() - start_time,
3735
- corrected_query=context.topic,
3736
- natural_query=context.ai_tool_query,
3737
- database_query=context.primo_boolean_query,
3738
- ai_tool_query=context.ai_tool_query,
3739
- primo_boolean_query=context.primo_boolean_query,
3740
- primo_search_url=_primo_clean_url(context),
3741
- primo_ai_url=_tool_urls(context).get("primo_ai"),
3742
- pubmed_search_url=_tool_urls(context).get("pubmed"),
3743
- follow_up_question=follow_up_question,
3744
- follow_up_suggestions=follow_up_suggestions,
3745
- search_context=_context_to_dict(context),
3746
- is_follow_up=True,
3747
- )
3748
-
3749
- answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
3750
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
3751
- return AgentResponse(
3752
- answer=answer,
3753
- intent=context.intent,
3754
- tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
3755
- search_results=search_results,
3756
- sources=sources,
3757
- model_used=model,
3758
- response_time=time.time() - start_time,
3759
- corrected_query=context.topic,
3760
- natural_query=context.ai_tool_query,
3761
- database_query=context.primo_boolean_query,
3762
- ai_tool_query=context.ai_tool_query,
3763
- primo_boolean_query=context.primo_boolean_query,
3764
- primo_search_url=source_url,
3765
- primo_ai_url=_tool_urls(context).get("primo_ai"),
3766
- pubmed_search_url=_tool_urls(context).get("pubmed"),
3767
- follow_up_question=follow_up_question,
3768
- follow_up_suggestions=follow_up_suggestions,
3769
- search_context=_context_to_dict(context),
3770
- is_follow_up=True,
3771
- )
3772
-
3773
  if is_follow_up and base_context:
3774
  _safe_metrics_increment("follow_up_hits")
3775
  context = _apply_follow_up_action(base_context, follow_up_action)
@@ -3790,29 +3543,23 @@ async def _agent_query_inner(
3790
  is_follow_up=True,
3791
  )
3792
 
3793
- if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
3794
  context = await _prepare_queries(question, context, model, True)
3795
- if follow_up_action == "summarize_topic":
3796
- answer, search_results, sources = await _research_snapshot(context, model)
3797
- else:
3798
- # deep_research_tools / best_databases follow-up
3799
- answer = (
3800
- f"{_search_trace_block(question, context)}"
3801
- f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
3802
- f"Here are the best AI tools and resources for a deep dive on this topic."
3803
- + _source_badge("libbee")
3804
- + _ai_tools_footer(context)
3805
- )
3806
- search_results = []
3807
- sources = []
3808
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
3809
  _safe_metrics_increment("search_handoffs")
3810
  return AgentResponse(
3811
  answer=answer,
3812
  intent=context.intent,
3813
  tools_used=["follow_up_resolution", follow_up_action],
3814
- search_results=search_results,
3815
- sources=sources,
3816
  model_used=model,
3817
  response_time=time.time() - start_time,
3818
  corrected_query=context.topic,
@@ -4081,6 +3828,7 @@ async def _agent_query_inner(
4081
  _prepare_queries(question, _ctx_books, model, False),
4082
  )
4083
  # No API — just build PRIMO links for articles and books
 
4084
  _src_url_a = _primo_clean_url(_ctx_articles)
4085
  _src_url_b = _primo_clean_url(_ctx_books)
4086
 
@@ -4131,44 +3879,9 @@ async def _agent_query_inner(
4131
  )
4132
 
4133
  context = await _prepare_queries(question, context, model, False)
4134
- summary_mode = _is_summary_request(question)
4135
- deep_research_mode = bool(re.search(
4136
- r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
4137
- question, re.IGNORECASE,
4138
- ))
4139
  is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
4140
 
4141
- if summary_mode or deep_research_mode:
4142
- answer, search_results, sources = await _research_snapshot(context, model)
4143
- if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer:
4144
- answer += f"<br><br>{_ai_tools_footer(context)}"
4145
- if is_highly_cited:
4146
- answer += _highly_cited_note(_escape(topic))
4147
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
4148
- _safe_metrics_increment("search_handoffs")
4149
- return AgentResponse(
4150
- answer=answer,
4151
- intent=intent,
4152
- tools_used=["research_snapshot"],
4153
- search_results=search_results,
4154
- sources=sources,
4155
- model_used=model,
4156
- response_time=time.time() - start_time,
4157
- corrected_query=context.topic,
4158
- natural_query=context.ai_tool_query,
4159
- database_query=context.primo_boolean_query,
4160
- ai_tool_query=context.ai_tool_query,
4161
- primo_boolean_query=context.primo_boolean_query,
4162
- primo_search_url=_primo_clean_url(context),
4163
- primo_ai_url=_tool_urls(context).get("primo_ai"),
4164
- pubmed_search_url=_tool_urls(context).get("pubmed"),
4165
- follow_up_question=follow_up_question,
4166
- follow_up_suggestions=follow_up_suggestions,
4167
- search_context=_context_to_dict(context),
4168
- )
4169
-
4170
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
4171
- # Append highly-cited guidance if requested
4172
  if is_highly_cited:
4173
  answer += _highly_cited_note(_escape(topic))
4174
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
 
1
  """
2
+ agent.py — LibBee v3.7
3
 
4
  ╔══════════════════════════════════════════════════════════════════╗
5
  ║ TABLE OF CONTENTS ║
 
14
  ║ §8 Topic Extraction ║
15
  ║ §9 Query Builders (PRIMO boolean, PubMed URL) ║
16
  ║ §10 Search Preparation & Planning ║
17
+ ║ §11 Search Runners (PRIMO, PubMed) ← v3.7 ║
18
  ║ §12 Response Builders (badges, thought blocks, SVG mascots) ║
19
  ║ §13 Hardcoded Answers (campus, fulltext, DB recs, theses...) ║
20
  ║ §14 Follow-up System ║
 
22
  ║ §16 FastAPI Endpoint & Utility Routes ║
23
  ╚══════════════════════════════════════════════════════════════════╝
24
 
25
+ Changes over v3.6 (v3.7):
26
+ - §11 _semantic_scholar_search, _openalex_search, _generate_cited_summary,
27
+ _extractive_snapshot_from_papers, and _research_snapshot removed.
28
+ Approach: LibBee navigates, not synthesizes. Deep research is handed
29
+ off to Consensus, LeapSpace, Scopus AI, PRIMO AI — platforms built
30
+ for that purpose. See git history to restore these functions.
31
+ - §11 _topic_intro() added — lightweight 3-sentence LLM topic intro
32
+ (~180 tokens, no external API calls) prepended to all academic/medical
33
+ search responses.
34
+ - §12 _run_search_mode() updated — single PRIMO link (all resources) with
35
+ filter hint instead of separate articles/books links.
36
+ - §14 _search_follow_up() simplified — removed filter chips (peer reviewed,
37
+ date range, resource type). Replaced with 4 purposeful chips:
38
+ alternative search terms, best databases, full text help, search strategy.
39
+ Users apply filters directly in PRIMO/database platforms.
40
+ - §14 summarize_topic follow-up action removed.
41
+ - §3 SUMMARY_RE removed — no longer used for routing.
42
+ - §15 summary_mode / deep_research_mode detection removed from
43
+ _agent_query_inner search_academic handler.
44
+ - §15 Inline _CATALOGUE_RE / _BOOK_RE compilation removed — was firing
45
+ per-request. Catalogue block removed entirely (PRIMO boolean handles
46
+ all resource types).
47
+ - §15 asyncio.get_running_loop() replaces deprecated get_event_loop().
48
  """
49
  # ── §1 Imports ────────────────────────────────────────────────────────────────
50
 
 
226
  r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
227
  re.IGNORECASE,
228
  )
 
 
 
 
 
 
 
 
 
 
 
 
229
  DATABASE_RECOMMEND_RE = re.compile(
230
  r"("
231
  # explicit recommendation phrasing
 
782
  """
783
  Return upcoming library events from LibCal.
784
  Falls back to library events calendar link if unavailable.
 
 
 
 
785
  """
786
  EVENTS_URL = "https://kustar.libcal.com/calendar/events"
787
  events = await _libcal_fetch_events(days=days, limit=15)
788
 
789
+ if not events:
790
  return (
791
  "<strong>📅 Khalifa University Library Events</strong><br><br>"
792
  "Live event data is currently unavailable. Browse all upcoming events here:<br>"
 
794
  f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
795
  )
796
 
797
+ if not events:
798
  return (
799
  "<strong>📅 Khalifa University Library Events</strong><br><br>"
800
  f"No upcoming events found in the next {days} days.<br><br>"
 
939
  return msgs
940
 
941
 
 
 
 
 
942
  def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
943
  if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
944
  return False
 
970
  q = (question or "").lower()
971
  if not any(kw in q for kw in MEDICAL_KEYWORDS):
972
  return False
973
+ return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q))
974
 
975
 
976
  def _looks_research_question(question: str) -> bool:
977
  q = question or ""
978
+ return bool(RESEARCH_CUE_RE.search(q))
979
 
980
 
981
  def _rule_based_classify(question: str) -> Dict[str, str]:
 
1796
 
1797
 
1798
  # ── §11 Search Runners ────────────────────────────────────────────────────────
1799
+ #
1800
+ # v3.7 NOTE: _semantic_scholar_search, _openalex_search, _generate_cited_summary,
1801
+ # _extractive_snapshot_from_papers, and _research_snapshot were removed in v3.7.
1802
+ # Approach: LibBee navigates to research platforms, it does not synthesize.
1803
+ # Deep research is handled by Consensus, LeapSpace, Scopus AI, PRIMO AI.
1804
+ # See git history to restore these functions if needed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1805
 
1806
 
1807
+ async def _topic_intro(topic: str, model: str) -> str:
1808
  """
1809
+ Generate a concise 3-sentence topic introduction for academic/medical queries.
1810
+ ~180 tokens. No external API calls — LLM knowledge only.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1811
 
1812
+ LibBee v3.7 approach: provide brief context, then hand off to PRIMO and
1813
+ AI research platforms (Consensus, LeapSpace, Scopus AI) for deep research.
1814
+ """
1815
  settings = get_settings()
1816
  if not settings.openai_api_key and not settings.anthropic_api_key:
1817
  return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1818
  try:
1819
+ llm = _get_llm(model, temperature=0.2, max_tokens=180)
1820
  response = await llm.ainvoke([
1821
+ {"role": "system", "content": (
1822
+ "You are LibBee, the Khalifa University Library AI Assistant. "
1823
+ "Write exactly 3 clear, factual sentences introducing the given research topic "
1824
+ "for a university student or researcher. "
1825
+ "Cover: what the topic is, why it matters, and one key area of current research interest. "
1826
+ "Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
1827
+ "Be concise and informative."
1828
+ )},
1829
+ {"role": "user", "content": f"Research topic: {topic}"},
1830
  ])
1831
+ intro = response.content.strip()
1832
+ return intro if intro else ""
 
 
 
 
 
 
 
 
 
 
1833
  except Exception as e:
1834
+ logger.warning(f"_topic_intro failed: {e}")
1835
  return ""
1836
 
1837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1838
  # ── §12 Response Builders ─────────────────────────────────────────────────────
1839
 
1840
  # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
 
2934
  )
2935
 
2936
 
2937
+ async def _run_search_mode(
2938
+ question: str, context: SearchContextPayload, model: str, is_follow_up: bool
2939
+ ) -> Tuple[str, List[dict], List[dict], str]:
2940
  """
2941
+ Search mode — builds PRIMO link + topic intro + AI tools footer.
2942
+ No external academic API calls (v3.7).
2943
  """
2944
+ context = await _prepare_queries(question, context, model, is_follow_up)
2945
+ source_url = _primo_clean_url(context)
2946
+ topic = _escape(context.display_topic or context.topic)
2947
+
2948
+ # 3-sentence topic intro — lightweight LLM call, no external APIs
2949
+ intro = await _topic_intro(context.display_topic or context.topic, model)
2950
+
2951
+ answer = _search_trace_block(question, context)
2952
+ if intro:
2953
+ answer += f'<div style="margin-bottom:12px;color:#374151;font-size:.88rem;line-height:1.7">{intro}</div>'
2954
+ answer += _search_answer_intro(context, is_follow_up)
2955
  answer += (
2956
+ f'<br><br>'
2957
+ f'<a href="{source_url}" target="_blank" '
2958
+ f'style="display:inline-block;padding:9px 18px;background:#003366;color:#fff;'
2959
+ f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
2960
+ f'🔍 Search PRIMO Library Discovery for: {topic} →</a>'
2961
+ f'<br><div style="margin-top:6px;font-size:.78rem;color:#6b7280">'
2962
+ f'💡 Inside PRIMO you can filter by <strong>Articles</strong>, <strong>Books</strong>, '
2963
+ f'<strong>Peer Reviewed</strong>, date range, and more.</div>'
2964
  )
2965
  answer += _ai_tools_footer(context)
 
 
 
2966
 
2967
+ return answer, [], [], source_url
2968
 
2969
  def _filters_summary(context: SearchContextPayload) -> str:
2970
  filters: List[str] = []
 
3026
  # Alternative search terms — NEW
3027
  if ALT_TERMS_RE.search(q):
3028
  return "alt_terms"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3029
  if re.search(r"\bpubmed\b", q):
3030
  return "search_pubmed"
3031
  if re.search(r"\bprimo\b", q):
 
3164
  async def _search_follow_up(
3165
  context: SearchContextPayload, model: str, summary_mode: bool = False
3166
  ) -> Tuple[str, List[dict]]:
3167
+ """
3168
+ Follow-up chips after academic/medical search.
3169
+ v3.7: Filter chips removed (peer reviewed, date range, resource type, summarize).
3170
+ Users apply filters directly in PRIMO and database platforms.
3171
+ Only chips that add value LibBee provides that platforms cannot:
3172
+ - Alternative search terms (LLM-generated synonyms)
3173
+ - Best databases for this subject
3174
+ - How to get full text
3175
+ - Refine search strategy
3176
+ """
3177
  topic = context.display_topic or context.topic
3178
+ suggestions: List[dict] = [
3179
+ {
3180
+ "label": "💡 Alternative search terms",
3181
+ "question": f"Suggest alternative search terms for {topic}",
3182
+ "action": "alt_terms",
 
 
 
 
 
3183
  "context_id": context.context_id,
3184
+ },
3185
+ {
3186
+ "label": "🗄️ Best databases for this subject",
3187
+ "question": f"Best databases for {topic}",
3188
+ },
3189
+ {
3190
+ "label": "📄 How do I get full text?",
3191
+ "question": "How do I get full text for an article?",
3192
+ },
3193
+ {
3194
+ "label": "🔍 Refine my search strategy",
3195
+ "question": f"Help me refine my search strategy for {topic}",
3196
+ },
3197
+ ]
3198
+ # Add topic-specific follow-ups from LLM
3199
+ topic_follow_ups = await _generate_topic_follow_ups(topic, model)
3200
+ suggestions = topic_follow_ups + suggestions
3201
  question_text = (
3202
+ f"Here are some ways I can help further with <strong>{_escape(topic)}</strong>:"
 
3203
  )
3204
  return question_text, suggestions[:5]
3205
 
 
3295
 
3296
  return answer, [], [], source_url
3297
 
3298
+
3299
  def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
3300
  dedup: Dict[str, SearchContextPayload] = {}
3301
  for ctx in contexts:
 
3523
 
3524
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
3525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3526
  if is_follow_up and base_context:
3527
  _safe_metrics_increment("follow_up_hits")
3528
  context = _apply_follow_up_action(base_context, follow_up_action)
 
3543
  is_follow_up=True,
3544
  )
3545
 
3546
+ if follow_up_action in {"deep_research_tools", "best_databases"}:
3547
  context = await _prepare_queries(question, context, model, True)
3548
+ answer = (
3549
+ f"{_search_trace_block(question, context)}"
3550
+ f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
3551
+ f"Here are the best AI tools and resources for a deep dive on this topic."
3552
+ + _source_badge("libbee")
3553
+ + _ai_tools_footer(context)
3554
+ )
3555
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
 
 
 
 
 
 
3556
  _safe_metrics_increment("search_handoffs")
3557
  return AgentResponse(
3558
  answer=answer,
3559
  intent=context.intent,
3560
  tools_used=["follow_up_resolution", follow_up_action],
3561
+ search_results=[],
3562
+ sources=[],
3563
  model_used=model,
3564
  response_time=time.time() - start_time,
3565
  corrected_query=context.topic,
 
3828
  _prepare_queries(question, _ctx_books, model, False),
3829
  )
3830
  # No API — just build PRIMO links for articles and books
3831
+ _ctx_articles = await _prepare_queries(question, _ctx_articles, model, False)
3832
  _src_url_a = _primo_clean_url(_ctx_articles)
3833
  _src_url_b = _primo_clean_url(_ctx_books)
3834
 
 
3879
  )
3880
 
3881
  context = await _prepare_queries(question, context, model, False)
 
 
 
 
 
3882
  is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
3883
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3884
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
 
3885
  if is_highly_cited:
3886
  answer += _highly_cited_note(_escape(topic))
3887
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)