nikeshn commited on
Commit
48d5d76
Β·
verified Β·
1 Parent(s): c281d32

Upload 2 files

Browse files
Files changed (2) hide show
  1. agent.py +1958 -0
  2. search.py +374 -0
agent.py ADDED
@@ -0,0 +1,1958 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py β€” LibBee v3 (fixed)
3
+
4
+ Fixes applied:
5
+ 1. _extract_topic() β€” explicitly strips resource-type words (articles/books/papers)
6
+ from LLM output and regex fallback. Topic is ALWAYS clean keywords only.
7
+
8
+ 2. _make_primo_discovery_query() β€” removed all resource-type words from boolean.
9
+ Boolean is now: "keyword1 keyword2 keyword3" only.
10
+ Resource type handled exclusively via SearchRequest.resource_type β†’ PRIMO facet.
11
+
12
+ 3. _search_primo_catalog() β€” now passes resource_type=context.resource_type so
13
+ the rtype FACET is correctly sent to PRIMO.
14
+
15
+ 4. _search_pubmed_articles() β€” year_from/year_to now wired through SearchRequest,
16
+ fixed in search.py to use mindate/maxdate params (not keywords).
17
+
18
+ 5. _search_follow_up() β€” now calls LLM to generate 3 topic-specific intellectual
19
+ follow-up questions (related subtopics, methodological angles, applications).
20
+ Generic filter options remain as a secondary set after the topic-specific ones.
21
+
22
+ 6. _primo_url_from_boolean() β€” now uses clean topic (not polluted boolean) for the
23
+ displayed PRIMO URL, matching exactly what the API actually searches.
24
+
25
+ 7. _generate_cited_summary() / Semantic Scholar β€” improved: relevance filter on
26
+ abstracts, better fallback, clearer citation replacement.
27
+ """
28
+ import html
29
+ import json
30
+ import logging
31
+ import re
32
+ import time
33
+ import uuid
34
+ from datetime import datetime
35
+ from typing import Any, Dict, List, Optional, Tuple
36
+ from urllib.parse import quote
37
+
38
+ import httpx
39
+ from fastapi import APIRouter, Request
40
+ from pydantic import BaseModel, ConfigDict, Field
41
+
42
+ from src.config import get_settings
43
+ from src.services.staff_service import (
44
+ STAFF_DIRECTORY,
45
+ match_staff_name,
46
+ match_staff_role,
47
+ should_attempt_staff_lookup,
48
+ staff_name_answer,
49
+ staff_role_answer,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+ router = APIRouter()
54
+
55
+
56
+ # ── Pydantic Models ───────────────────────────────────────────────────────────
57
+
58
+ class ChatMessage(BaseModel):
59
+ role: str
60
+ content: str
61
+
62
+
63
+ class SearchContextPayload(BaseModel):
64
+ context_id: str
65
+ topic: str
66
+ display_topic: Optional[str] = None
67
+ intent: str = "search_academic"
68
+ source: str = "primo"
69
+ resource_type: str = "articles" # "articles" | "books" | "both"
70
+ peer_reviewed: bool = False
71
+ open_access: bool = False
72
+ year_from: Optional[str] = None
73
+ year_to: Optional[str] = None
74
+ ai_tool_query: Optional[str] = None
75
+ primo_boolean_query: Optional[str] = None
76
+ created_at: float = Field(default_factory=time.time)
77
+
78
+
79
+ class ClientStatePayload(BaseModel):
80
+ session_id: Optional[str] = None
81
+ active_search_context_id: Optional[str] = None
82
+ recent_search_contexts: List[SearchContextPayload] = Field(default_factory=list)
83
+ follow_up_action: Optional[str] = None
84
+ follow_up_context_id: Optional[str] = None
85
+
86
+
87
+ class AgentRequest(BaseModel):
88
+ question: str
89
+ model: str = "gpt"
90
+ history: List[ChatMessage] = Field(default_factory=list)
91
+ client_state: Optional[ClientStatePayload] = None
92
+
93
+
94
+ class AgentResponse(BaseModel):
95
+ model_config = ConfigDict(extra="ignore")
96
+
97
+ answer: str
98
+ intent: str = "general"
99
+ tools_used: List[str] = Field(default_factory=list)
100
+ search_results: List[dict] = Field(default_factory=list)
101
+ sources: List[dict] = Field(default_factory=list)
102
+ model_used: str = "gpt"
103
+ response_time: float = 0
104
+ corrected_query: Optional[str] = None
105
+ natural_query: Optional[str] = None
106
+ database_query: Optional[str] = None
107
+ ask_librarian: Optional[bool] = None
108
+ is_follow_up: bool = False
109
+ follow_up_question: Optional[str] = None
110
+ follow_up_suggestions: List[dict] = Field(default_factory=list)
111
+ search_context: Optional[dict] = None
112
+ ai_tool_query: Optional[str] = None
113
+ primo_boolean_query: Optional[str] = None
114
+ primo_search_url: Optional[str] = None
115
+
116
+
117
+ # ── Constants ─────────────────────────────────────────────────────────────────
118
+
119
+ LIBRARY_HOURS_URL = "https://library.ku.ac.ae/hours"
120
+ KU_MAIN_URL = "https://www.ku.ac.ae/"
121
+ ASK_LIBRARIAN_URL = "https://library.ku.ac.ae/AskUs"
122
+ PRIMO_BASE_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
123
+ PRIMO_AI_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
124
+ HISTORY_WINDOW = 6
125
+ CURRENT_YEAR = datetime.utcnow().year
126
+
127
+ # Words that must never appear in a PRIMO/PubMed keyword query β€” they are filter signals
128
+ _RESOURCE_TYPE_NOISE = re.compile(
129
+ r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
130
+ r"|peer[- ]reviewed|open[- ]access|studies|study|research)\b",
131
+ re.IGNORECASE,
132
+ )
133
+
134
+ SOCIAL_RE = re.compile(
135
+ r"^(hi|hello|hey|good morning|good afternoon|good evening|how are you|hows it going"
136
+ r"|how are you doing|who are you|what are you|tell me about yourself|what can you do"
137
+ r"|what do you do|how can you help|what help can you provide|are you a bot|are you a robot"
138
+ r"|are you ai|are you an ai|thank you|thanks|thx|bye|goodbye|see you|lol|haha"
139
+ r"|you are silly|are you silly|are you dumb|stupid bot|dumb bot|idiot bot"
140
+ r"|joke|tell me a joke)$",
141
+ re.IGNORECASE,
142
+ )
143
+ PURE_GREETING_RE = re.compile(
144
+ r"^(hi|hello|hey|good morning|good afternoon|good evening)[!.\s]*$",
145
+ re.IGNORECASE,
146
+ )
147
+ GREETING_FOLLOWUP_RE = re.compile(
148
+ r"^(yes|yes please|yes check|sure|okay|ok|yeah|yep|please do|go ahead)$",
149
+ re.IGNORECASE,
150
+ )
151
+ HOURS_RE = re.compile(
152
+ r"\b(library hours?|opening hours?|closing hours?|open hours?|open today|open tomorrow"
153
+ r"|open now|open on|open this|closed today|closed tomorrow|closed on|closed this"
154
+ r"|when.*open|when.*close|when.*does.*library|what time.*open|what time.*close"
155
+ r"|what time.*library|opening time|closing time|is.*library.*open|is.*campus.*library"
156
+ r"|is.*habshan.*open|is.*san.*open|is.*library.*closed|will.*library.*open"
157
+ r"|will.*it.*open|does.*library.*open|library.*open.*today|library.*close.*today"
158
+ r"|habshan.*open|san.*open|main campus.*open|ramadan hours?|weekend hours?"
159
+ r"|friday hours?|saturday hours?|sunday hours?|library.*schedule|schedule.*library"
160
+ r"|what.*hours|till.*when|until.*when|what day.*open|which day.*open"
161
+ r"|what day.*closed|which day.*closed)\b",
162
+ re.IGNORECASE,
163
+ )
164
+ KU_CAMPUS_RE = re.compile(
165
+ r"\b(where is the library|where is.*library|library.*location|location.*library"
166
+ r"|which building|what building|which floor|main campus library|san campus library"
167
+ r"|habshan library|sas al nakhl library|library.*address|address.*library"
168
+ r"|how to get to.*library|how.*reach.*library|how.*find.*library"
169
+ r"|library.*campus(es)?|campus(es)?.*library|how many.*librar|how many.*branch"
170
+ r"|library.*branch(es)?|branch(es)?.*library|which campus.*library"
171
+ r"|library.*which campus|library.*map|map.*library|how many libraries"
172
+ r"|what libraries|which libraries|(ku|khalifa).*(has|have|got).*(librar|branch)"
173
+ r"|(librar|branch).*(ku|khalifa).*(has|have)"
174
+ r"|(all|both|two|2).*(libraries|library|branches)"
175
+ r"|tell me about.*(campus|libraries|branches)|describe.*(campus|libraries)"
176
+ r"|(what|where).*(campus|campuses).*(librar))\b",
177
+ re.IGNORECASE,
178
+ )
179
+ RESEARCH_CUE_RE = re.compile(
180
+ r"\b(find|search|look for|show me|get me|give me|locate|recommend|suggest"
181
+ r"|articles?|papers?|books?|journals?|studies|literature|research|systematic review"
182
+ r"|evidence|sources|database|databases|peer reviewed|open access|advances?"
183
+ r"|approach(es)?|method(s|ology)?|technique[s]?|application[s]?|framework[s]?"
184
+ r"|model[s]?|algorithm[s]?|publication[s]?|recent work|current state|state of the art)\b",
185
+ re.IGNORECASE,
186
+ )
187
+ LIBRARY_CUE_RE = re.compile(
188
+ r"\b(library|librarian|borrow|loan|renew|fine|study room|room booking|reserve a room"
189
+ r"|account|my library|interlibrary|ill|khazna|orcid|open access|apc|refworks"
190
+ r"|libkey|hours|location|contact|visitor|alumni|database access|off campus|remote access)\b",
191
+ re.IGNORECASE,
192
+ )
193
+ MEDICAL_SEARCH_RE = re.compile(
194
+ r"\b(pubmed|embase|cinahl|clinicalkey|cochrane|uptodate|medline|systematic review"
195
+ r"|clinical trial|biomedical literature|medical literature)\b",
196
+ re.IGNORECASE,
197
+ )
198
+ CURRENT_INFO_RE = re.compile(
199
+ r"\b(current|currently|latest|today|recent|recently|now|this week|this month"
200
+ r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
201
+ re.IGNORECASE,
202
+ )
203
+ SUMMARY_RE = re.compile(
204
+ r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
205
+ r"|tell me about|give me a summary|give me an overview|give me a brief"
206
+ r"|brief (summary|overview|explanation|description)"
207
+ r"|research (overview|summary|landscape|state)"
208
+ r"|state of (the art|research|the field)"
209
+ r"|recent (advances?|developments?|progress|trends?)"
210
+ r"|current (state|trends?|advances?|research)"
211
+ r"|deep analysis|analysis of|review of|literature review|deep research|deep dive"
212
+ r"|what do we know about|how does|how do)\b",
213
+ re.IGNORECASE,
214
+ )
215
+
216
+ BEST_DATABASES_RE = re.compile(
217
+ r"\b(best databases?|which databases?|which database|best database|recommended databases?)\b",
218
+ re.IGNORECASE,
219
+ )
220
+
221
+ MEDICAL_KEYWORDS = [
222
+ "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
223
+ "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
224
+ "genomics", "pathology", "surgery", "mental health", "nutrition", "public health",
225
+ "disease", "therapy", "treatment", "diagnosis", "patient", "drug", "pharmaceutical",
226
+ "hospital", "cancer", "diabetes", "heart", "brain", "cell biology", "immunology",
227
+ "biochemistry", "microbiology", "virology", "pediatric", "psychiatric", "dental",
228
+ "ophthalmology", "radiology", "anesthesia", "emergency medicine", "geriatric",
229
+ "crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
230
+ ]
231
+
232
+ REFINEMENT_STOP_WORDS = {
233
+ "make", "it", "them", "those", "these", "the", "a", "an", "for", "to", "only", "show",
234
+ "find", "give", "me", "please", "and", "with", "within", "limit", "narrow", "search",
235
+ "results", "on", "about", "of", "this", "that", "topic", "same", "using", "from",
236
+ "peer", "reviewed", "review", "open", "access", "books", "book", "articles", "article",
237
+ "papers", "paper", "years", "year", "last", "past", "recent", "instead", "summary",
238
+ "overview", "brief", "pubmed", "primo", "database", "databases", "best", "deep", "research",
239
+ "tools", "tool", "guide", "ku", "library",
240
+ }
241
+
242
+ _CLASSIFIER_SYSTEM = """You are the routing brain of LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE).
243
+ Your only job: read the user message and return a single JSON object routing it to the right handler.
244
+
245
+ INTENTS β€” pick exactly one:
246
+ "social" β€” casual chat, greetings, emotions, compliments, complaints, vague help requests
247
+ "library_info" β€” questions about KU Library services, hours, rooms, borrowing, fines, ILL, staff, databases, ORCID, RefWorks, open access, APC, account, printing, remote access, LibKey, acquisitions, Khazna, thesis submission, library policies, alumni access
248
+ "search_academic"β€” user wants to FIND/GET articles/papers/books on a topic, OR wants a summary/overview/analysis/explanation of an academic/research topic
249
+ "search_medical" β€” same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
250
+ "general_recent" β€” questions about TRULY live/real-time data: breaking news, live sports scores, today's weather, current stock prices
251
+ "general" β€” ALL other factual questions: university info, people, history, science definitions, technology explanations
252
+
253
+ RULES:
254
+ 1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
255
+ 2. "I don't know how to search for articles" = library_info.
256
+ 3. "I am fed up with searching" alone = social.
257
+ 4. "I need help finding articles on diabetes" = search_medical.
258
+ 5. KU = Khalifa University. Do NOT confuse with Kuwait University.
259
+ 6. ANY summary, overview, analysis, deep dive, review, or explanation of an academic/research topic = search_academic or search_medical.
260
+ 7. Questions about people, university leadership, history, science definitions = general.
261
+ 8. Only use general_recent for truly live data changing by the minute.
262
+
263
+ For "social" intent ONLY: include "casual_answer" β€” a warm 1-3 sentence LibBee response (no markdown, no bullets).
264
+ For all other intents: omit "casual_answer".
265
+
266
+ Respond ONLY with valid JSON. No preamble, no markdown fences.
267
+ """
268
+
269
+
270
+ # ── Utilities ─────────────────────────────────────────────────────────────────
271
+
272
+ def _escape(text: str) -> str:
273
+ return html.escape(text or "")
274
+
275
+
276
+ def _normalize_whitespace(text: str) -> str:
277
+ return re.sub(r"\s+", " ", (text or "").strip())
278
+
279
+
280
+ def _title_case_topic(topic: str) -> str:
281
+ return _normalize_whitespace(topic).strip().strip(".?")
282
+
283
+
284
+ def _strip_resource_noise(text: str) -> str:
285
+ """
286
+ Remove resource-type and filter words that are PRIMO/PubMed facets, not q= keywords.
287
+ Belt-and-braces: called on every topic before it touches an external search API.
288
+ """
289
+ cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
290
+ # Clean up dangling boolean operators
291
+ cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
292
+ cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
293
+ cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
294
+ return _normalize_whitespace(cleaned)
295
+
296
+
297
+ def _source_badge(source: str, url: str = "") -> str:
298
+ styles = {
299
+ "rag": ("πŸ“–", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
300
+ "primo": ("πŸ“š", "KU Library PRIMO", "#eff6ff", "#1e40af"),
301
+ "semantic": ("πŸŽ“", "Semantic Scholar", "#f0fdf4", "#166534"),
302
+ "pubmed": ("πŸ₯", "PubMed", "#d1fae5", "#065f46"),
303
+ "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
304
+ "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
305
+ "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
306
+ "ku_library": ("πŸ“‹", "KU Library", "#eff6ff", "#1e40af"),
307
+ "llm": ("πŸ€–", "AI Knowledge Base", "#f3f4f6", "#374151"),
308
+ }
309
+ icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
310
+ inner = f"{icon} Source: {label}"
311
+ if url:
312
+ inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
313
+ return (
314
+ f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
315
+ f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
316
+ f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
317
+ )
318
+
319
+
320
+ def _safe_metrics_increment(key: str) -> None:
321
+ try:
322
+ from app import metrics_service
323
+ metrics_service.incr(key)
324
+ except Exception:
325
+ return
326
+
327
+
328
+ def _safe_metrics_bucket(bucket: str, key: str) -> None:
329
+ try:
330
+ from app import metrics_service
331
+ metrics_service.incr_bucket(bucket, key)
332
+ except Exception:
333
+ return
334
+
335
+
336
+ def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
337
+ msgs = []
338
+ for m in history[-HISTORY_WINDOW:]:
339
+ if m.role in ("user", "assistant") and m.content:
340
+ msgs.append({"role": m.role, "content": m.content[:300]})
341
+ return msgs
342
+
343
+
344
+ def _is_summary_request(question: str) -> bool:
345
+ return bool(SUMMARY_RE.search(question or ""))
346
+
347
+
348
+ def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
349
+ if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
350
+ return False
351
+ for m in reversed(history):
352
+ if m.role == "assistant":
353
+ last = m.content.lower()
354
+ return "i'm libbee" in last or "are you looking for one of these" in last
355
+ return False
356
+
357
+
358
+ def _looks_library_hours_question(question: str) -> bool:
359
+ q = (question or "").strip().lower()
360
+ return bool(HOURS_RE.search(q)) and any(
361
+ kw in q for kw in (
362
+ "library", "campus", "habshan", "san", "hours", "open", "close",
363
+ "schedule", "till", "until", "today", "tomorrow", "friday",
364
+ "saturday", "sunday", "week", "ramadan",
365
+ )
366
+ )
367
+
368
+
369
+ def _looks_campus_question(question: str) -> bool:
370
+ return bool(KU_CAMPUS_RE.search(question or ""))
371
+
372
+
373
+ def _looks_medical_search(question: str) -> bool:
374
+ q = (question or "").lower()
375
+ if not any(kw in q for kw in MEDICAL_KEYWORDS):
376
+ return False
377
+ return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
378
+
379
+
380
+ def _looks_research_question(question: str) -> bool:
381
+ q = question or ""
382
+ return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
383
+
384
+
385
+ def _rule_based_classify(question: str) -> Dict[str, str]:
386
+ q = (question or "").lower()
387
+ if SOCIAL_RE.match((question or "").strip()):
388
+ return {
389
+ "intent": "social",
390
+ "casual_answer": (
391
+ "Hello! I'm LibBee, the KU Library AI Assistant. "
392
+ "I'm here to help you with articles, books, databases, and library services. "
393
+ "What would you like to find today?"
394
+ ),
395
+ }
396
+ if _looks_medical_search(question):
397
+ return {"intent": "search_medical"}
398
+ if _looks_research_question(question):
399
+ return {"intent": "search_academic"}
400
+ if LIBRARY_CUE_RE.search(q):
401
+ return {"intent": "library_info"}
402
+ if CURRENT_INFO_RE.search(q):
403
+ return {"intent": "general_recent"}
404
+ return {"intent": "general"}
405
+
406
+
407
+ def _hours_answer() -> str:
408
+ return (
409
+ "Here are the current <strong>Khalifa University Library</strong> hours:<br><br>"
410
+ "<strong>Habshan Library (SAN Campus):</strong><br>"
411
+ "Sunday – Wednesday: 8:00 AM – 7:00 PM<br>"
412
+ "Thursday: 8:00 AM – 4:00 PM<br>"
413
+ "Friday: Closed<br>"
414
+ "Saturday: 12:00 PM – 8:00 PM<br><br>"
415
+ "⚠️ Hours may change during exam periods, public holidays, and Ramadan. "
416
+ "Always check the official Library Hours page for the latest times:<br>"
417
+ f'<a href="{LIBRARY_HOURS_URL}" target="_blank">{LIBRARY_HOURS_URL}</a>'
418
+ )
419
+
420
+
421
+ def _campus_answer() -> str:
422
+ return (
423
+ "Khalifa University Library has <strong>two branches</strong>:<br><br>"
424
+ "<strong>1. Main Campus Library</strong><br>"
425
+ "Location: E Building, Main Campus, Abu Dhabi<br>"
426
+ 'Reference desk: <a href="tel:+97123124604">+971 2 312 4604</a><br><br>'
427
+ "<strong>2. Habshan Library (SAN Campus)</strong><br>"
428
+ "Location: Building 5, Ground Floor &amp; First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi<br>"
429
+ 'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
430
+ "Both branches offer study spaces, computer workstations, printing, scanning, "
431
+ "and access to physical collections.<br><br>"
432
+ f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
433
+ 'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
434
+ )
435
+
436
+
437
+ def _greeting_menu_clarify_answer() -> str:
438
+ return (
439
+ "Sure β€” what would you like help with?<br><br>"
440
+ "You can choose one of these or type your question directly:<br>"
441
+ "β€’ Find articles and books<br>"
442
+ "β€’ Get full text for an article<br>"
443
+ "β€’ Submit an Interlibrary Loan (ILL) request<br>"
444
+ "β€’ Contact a librarian<br>"
445
+ "β€’ Check library hours"
446
+ )
447
+
448
+
449
+ # ── LLM Helpers ───────────────────────────────────────────────────────────────
450
+
451
+ def _get_llm(model: str, temperature: float, max_tokens: int):
452
+ settings = get_settings()
453
+ if model == "claude" and settings.anthropic_api_key:
454
+ from langchain_anthropic import ChatAnthropic
455
+ return ChatAnthropic(
456
+ model="claude-3-haiku-20240307",
457
+ temperature=temperature,
458
+ max_tokens=max_tokens,
459
+ anthropic_api_key=settings.anthropic_api_key,
460
+ )
461
+ from langchain_openai import ChatOpenAI
462
+ return ChatOpenAI(
463
+ model="gpt-4o-mini",
464
+ temperature=temperature,
465
+ max_tokens=max_tokens,
466
+ openai_api_key=settings.openai_api_key,
467
+ )
468
+
469
+
470
+ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
471
+ settings = get_settings()
472
+ if not settings.openai_api_key:
473
+ return _rule_based_classify(question)
474
+ try:
475
+ ctx_parts = [
476
+ f"{m.role}: {m.content[:100]}"
477
+ for m in history[-HISTORY_WINDOW:]
478
+ if m.role in ("user", "assistant")
479
+ ]
480
+ ctx = " | ".join(ctx_parts) if ctx_parts else "none"
481
+ llm = _get_llm(model, temperature=0, max_tokens=120)
482
+ response = await llm.ainvoke([
483
+ {"role": "system", "content": _CLASSIFIER_SYSTEM},
484
+ {"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
485
+ ])
486
+ raw = response.content.strip()
487
+ if raw.startswith("```"):
488
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
489
+ result = json.loads(raw)
490
+ intent = result.get("intent", "general")
491
+ if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general"}:
492
+ intent = "general"
493
+ return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
494
+ except Exception as e:
495
+ logger.warning(f"LLM classifier error: {e} β€” falling back to rule-based")
496
+ return _rule_based_classify(question)
497
+
498
+
499
+ async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
500
+ if hint and len(hint.strip()) > 20:
501
+ return hint.strip()
502
+ settings = get_settings()
503
+ if not settings.openai_api_key:
504
+ return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant β€” always happy to help!"
505
+ try:
506
+ llm = _get_llm(model, temperature=0.6, max_tokens=160)
507
+ msgs = [{"role": "system", "content": (
508
+ "You are LibBee, the Khalifa University Library AI Assistant. "
509
+ "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
510
+ "No markdown, no bullet points."
511
+ )}]
512
+ msgs.extend(_build_history_messages(history))
513
+ msgs.append({"role": "user", "content": question})
514
+ response = await llm.ainvoke(msgs)
515
+ return response.content.strip()
516
+ except Exception:
517
+ return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant β€” always happy to help!"
518
+
519
+
520
+ async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
521
+ settings = get_settings()
522
+ if not settings.openai_api_key:
523
+ return rag_results[0]["content"] if rag_results else ""
524
+ context = "\n\n---\n\n".join(r["content"] for r in rag_results)
525
+ system = (
526
+ "You are LibBee, the Khalifa University Library AI Assistant. "
527
+ "Answer the user's question using ONLY the context provided below. "
528
+ "Be concise, friendly, and use HTML <br> for line breaks where needed. "
529
+ f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
530
+ "Never invent facts or URLs.\n\n"
531
+ f"CONTEXT:\n{context}"
532
+ )
533
+ try:
534
+ llm = _get_llm(model, temperature=0.2, max_tokens=380)
535
+ msgs = [{"role": "system", "content": system}]
536
+ msgs.extend(_build_history_messages(history))
537
+ msgs.append({"role": "user", "content": question})
538
+ response = await llm.ainvoke(msgs)
539
+ return response.content.strip()
540
+ except Exception as e:
541
+ logger.error(f"_rag_answer error: {e}")
542
+ return (
543
+ "I'm having trouble generating an answer right now. "
544
+ f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
545
+ )
546
+
547
+
548
+ async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
549
+ settings = get_settings()
550
+ if not settings.openai_api_key:
551
+ return (
552
+ "I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
553
+ f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
554
+ )
555
+ system = (
556
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
557
+ "Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
558
+ "KU means Khalifa University. If the topic is academic, briefly offer to help find library resources."
559
+ )
560
+ try:
561
+ llm = _get_llm(model, temperature=0.3, max_tokens=360)
562
+ msgs = [{"role": "system", "content": system}]
563
+ msgs.extend(_build_history_messages(history))
564
+ msgs.append({"role": "user", "content": question})
565
+ response = await llm.ainvoke(msgs)
566
+ return response.content.strip()
567
+ except Exception as e:
568
+ logger.error(f"_llm_general_answer error: {e}")
569
+ return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
570
+
571
+
572
+ async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
573
+ settings = get_settings()
574
+ if model == "claude" and settings.anthropic_api_key:
575
+ try:
576
+ import anthropic
577
+ client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
578
+ response = client.messages.create(
579
+ model="claude-3-haiku-20240307",
580
+ max_tokens=520,
581
+ system=(
582
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
583
+ "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
584
+ ),
585
+ tools=[{"type": "web_search_20250305", "name": "web_search"}],
586
+ messages=[{"role": "user", "content": question}],
587
+ )
588
+ text = "".join(block.text for block in response.content if hasattr(block, "text"))
589
+ if text.strip():
590
+ return text.strip()
591
+ except Exception as e:
592
+ logger.warning(f"Claude web search failed: {e}")
593
+
594
+ if settings.openai_api_key:
595
+ try:
596
+ from openai import OpenAI
597
+ client = OpenAI(api_key=settings.openai_api_key)
598
+ response = client.responses.create(
599
+ model="gpt-4o-mini",
600
+ tools=[{"type": "web_search_preview"}],
601
+ instructions=(
602
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
603
+ "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
604
+ ),
605
+ input=question,
606
+ )
607
+ text = ""
608
+ for item in response.output:
609
+ if hasattr(item, "content"):
610
+ for block in item.content:
611
+ if hasattr(block, "text"):
612
+ text += block.text
613
+ if text.strip():
614
+ return text.strip()
615
+ except Exception as e:
616
+ logger.warning(f"GPT web search failed: {e}")
617
+
618
+ return await _llm_general_answer(question, history, model)
619
+
620
+
621
+ # ── Topic Extraction ──────────────────────────────────────────────────────────
622
+
623
+ def _extract_topic_regex(question: str) -> str:
624
+ """Fast regex-based topic extraction β€” strips boilerplate prefixes and filter words."""
625
+ q = _normalize_whitespace(question)
626
+ prefixes = [
627
+ "can u give me a deep analysis of ", "can you give me a deep analysis of ",
628
+ "give me a deep analysis of ", "deep analysis of ",
629
+ "give me a summary of ", "give me a summary on ", "give me a summary about ",
630
+ "give me an overview of ", "give me an overview on ",
631
+ "give me a brief overview of ", "give me a brief summary of ",
632
+ "summarize the latest studies in ", "summarize latest studies in ",
633
+ "summarize the latest research on ", "summarize latest research on ",
634
+ "summarize recent studies in ", "summarize recent research on ",
635
+ "latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
636
+ "summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
637
+ "overview of ", "overview on ", "brief overview of ", "brief summary of ",
638
+ "analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
639
+ "what is ", "what are ", "describe ", "review of ", "literature review on ",
640
+ "recent advances in ", "recent developments in ", "state of the art in ",
641
+ "state of research on ", "research overview of ", "research summary of ",
642
+ "find peer reviewed articles on ", "find peer reviewed papers on ",
643
+ "find articles on ", "find papers on ", "find books on ", "find articles about ",
644
+ "find papers about ", "search for articles on ", "search for papers on ",
645
+ "i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
646
+ "i need books on ", "give me articles on ", "show me articles on ",
647
+ "get me articles on ", "look for articles on ", "articles on ", "papers on ",
648
+ "books on ", "research on ", "literature on ", "find ", "search for ",
649
+ "i need ", "give me ", "show me ", "get me ",
650
+ ]
651
+ lower = q.lower()
652
+ for prefix in prefixes:
653
+ if lower.startswith(prefix):
654
+ q = q[len(prefix):]
655
+ break
656
+ # Strip trailing noise
657
+ q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
658
+ q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
659
+ # FIX: strip resource-type words that leaked through prefix stripping
660
+ q = _strip_resource_noise(q)
661
+ return _normalize_whitespace(q).strip(".?")
662
+
663
+
664
+ async def _extract_topic(question: str, model: str) -> str:
665
+ """
666
+ LLM-based topic extraction.
667
+ CRITICAL: instructs LLM to never return resource-type words (articles/books/papers).
668
+ Falls back to regex if LLM unavailable or returns empty.
669
+ """
670
+ settings = get_settings()
671
+ if not settings.openai_api_key:
672
+ return _extract_topic_regex(question)
673
+ try:
674
+ llm = _get_llm(model, temperature=0, max_tokens=32)
675
+ response = await llm.ainvoke([
676
+ {"role": "system", "content": (
677
+ "Extract only the core research topic from the user's question. "
678
+ "Return 2-7 keywords ONLY β€” no punctuation, no sentence, no explanation. "
679
+ "CRITICAL: do NOT include words like articles, papers, books, journals, "
680
+ "peer-reviewed, open-access, studies, or research in your output. "
681
+ "Those are filters, not topic words. Return ONLY the subject matter.\n"
682
+ "Examples:\n"
683
+ "'find articles on indian politics' β†’ indian politics\n"
684
+ "'peer reviewed papers on climate change 2020' β†’ climate change\n"
685
+ "'books on structural engineering UAE' β†’ structural engineering UAE\n"
686
+ "'give me a summary of quantum physics advances' β†’ quantum physics advances\n"
687
+ "'i need peer reviewed research on machine learning healthcare' β†’ machine learning healthcare"
688
+ )},
689
+ {"role": "user", "content": question},
690
+ ])
691
+ topic = response.content.strip().strip('"').strip("'").strip(".")
692
+ # Belt-and-braces: strip any noise the LLM snuck in anyway
693
+ topic = _strip_resource_noise(topic)
694
+ topic = _normalize_whitespace(topic).strip(".?")
695
+ if topic and len(topic) > 2:
696
+ logger.info(f"LLM topic: '{topic}' ← '{question[:60]}'")
697
+ return topic
698
+ except Exception as e:
699
+ logger.warning(f"Topic extraction failed: {e}")
700
+ return _extract_topic_regex(question)
701
+
702
+
703
+ # ── Query Building ────────────────────────────────────────────────────────────
704
+
705
+ def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
706
+ q = (question or "").lower()
707
+ if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
708
+ return "books"
709
+ if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
710
+ return "articles"
711
+ return existing or "articles"
712
+
713
+
714
+ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
715
+ q = (question or "").lower()
716
+ match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
717
+ if match:
718
+ years = int(match.group(2))
719
+ return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
720
+ between = re.search(r"\b(?:from|between)\s+(20\d{2}|19\d{2})\s+(?:to|and|-)\s+(20\d{2}|19\d{2})\b", q)
721
+ if between:
722
+ return between.group(1), between.group(2)
723
+ years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
724
+ if len(years) >= 2:
725
+ return min(years), max(years)
726
+ if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
727
+ return years[0], str(CURRENT_YEAR)
728
+ return None, None
729
+
730
+
731
+ def _make_primo_boolean_query(context: SearchContextPayload) -> str:
732
+ """
733
+ FIX: Build a clean boolean for display purposes ONLY.
734
+ Contains ONLY topic keywords β€” NO resource-type words.
735
+ Resource type is expressed as a PRIMO facet in search_primo(), never here.
736
+ """
737
+ topic = _title_case_topic(context.display_topic or context.topic)
738
+ # Strip any remaining noise one final time
739
+ topic = _strip_resource_noise(topic)
740
+ if not topic:
741
+ topic = context.topic or "library search"
742
+ return f'"{_normalize_whitespace(topic)}"'
743
+
744
+
745
+ def _primo_clean_url(context: SearchContextPayload) -> str:
746
+ """
747
+ Build the PRIMO discovery URL using the clean topic β€” matching exactly what the API searches.
748
+ """
749
+ clean_topic = _strip_resource_noise(context.topic)
750
+ if not clean_topic:
751
+ clean_topic = context.topic
752
+ return f"{PRIMO_BASE_URL}&query=any,contains,{quote(clean_topic)}&lang=en&mode=advanced&offset=0"
753
+
754
+
755
+ def _topic_keywords(text: str) -> List[str]:
756
+ cleaned = _normalize_whitespace(re.sub(r"[^a-zA-Z0-9\s-]", " ", text or ""))
757
+ stop = {
758
+ "of", "on", "in", "the", "a", "an", "and", "or", "for", "to", "with", "by", "from", "at",
759
+ "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will",
760
+ "would", "could", "should", "may", "its", "this", "that", "these", "those", "about", "impact",
761
+ "role", "effect", "use", "study", "analysis", "review", "what", "how", "why", "when", "where",
762
+ "which", "recent", "latest", "current", "summary", "summarize", "overview", "brief",
763
+ # FIX: resource-type words must never appear as keywords
764
+ "research", "studies", "articles", "article", "papers", "paper", "books", "book",
765
+ "journals", "journal", "publications", "publication",
766
+ }
767
+ tokens: List[str] = []
768
+ for word in cleaned.split():
769
+ word = word.strip("-").lower()
770
+ if not word or word in stop or len(word) < 3:
771
+ continue
772
+ if word not in tokens:
773
+ tokens.append(word)
774
+ return tokens[:6]
775
+
776
+
777
+ def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
778
+ """Build the clean natural language query for AI tools (LeapSpace, Scopus AI, Consensus)."""
779
+ topic = context.display_topic or context.topic
780
+ topic = _title_case_topic(topic)
781
+ scope = context.resource_type
782
+ filters = []
783
+ if context.peer_reviewed:
784
+ filters.append("peer-reviewed")
785
+ if context.open_access:
786
+ filters.append("open-access")
787
+ if context.year_from and context.year_to:
788
+ filters.append(f"published between {context.year_from} and {context.year_to}")
789
+ elif context.year_from:
790
+ filters.append(f"published from {context.year_from} onward")
791
+
792
+ if is_follow_up:
793
+ if scope == "books":
794
+ base = f"Find books on {topic}"
795
+ elif scope == "both":
796
+ base = f"Find articles and books on {topic}"
797
+ else:
798
+ base = f"Find research articles on {topic}"
799
+ else:
800
+ base = _normalize_whitespace(question).strip(".?")
801
+ if len(base.split()) < 4:
802
+ base = f"Find research on {topic}"
803
+
804
+ if filters:
805
+ return f"{base} with {', '.join(filters)}."
806
+ return base.rstrip(".") + "."
807
+
808
+
809
+ async def _grammar_refine_query(text: str, model: str) -> str:
810
+ settings = get_settings()
811
+ if not settings.openai_api_key:
812
+ return _normalize_whitespace(text)
813
+ try:
814
+ llm = _get_llm(model, temperature=0, max_tokens=60)
815
+ response = await llm.ainvoke([
816
+ {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
817
+ {"role": "user", "content": text},
818
+ ])
819
+ refined = _normalize_whitespace(response.content)
820
+ return refined or _normalize_whitespace(text)
821
+ except Exception:
822
+ return _normalize_whitespace(text)
823
+
824
+
825
+ def _context_to_dict(context: SearchContextPayload) -> dict:
826
+ return context.model_dump()
827
+
828
+
829
+ async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
830
+ """Populate ai_tool_query and primo_boolean_query on the context."""
831
+ natural = _compose_ai_tool_query(question, context, is_follow_up)
832
+ natural = await _grammar_refine_query(natural, model)
833
+ context.ai_tool_query = natural
834
+ # FIX: boolean is now clean topic only β€” resource type goes to PRIMO as a facet
835
+ context.primo_boolean_query = _make_primo_boolean_query(context)
836
+ return context
837
+
838
+
839
+ # ── Semantic Scholar + Summary ────────────────────────────────────────────────
840
+
841
+ async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
842
+ """Search Semantic Scholar. Returns only papers that have abstracts."""
843
+ # FIX: strip resource noise from topic before hitting external API
844
+ clean_topic = _strip_resource_noise(topic)
845
+ if not clean_topic:
846
+ clean_topic = topic
847
+ try:
848
+ params = {
849
+ "query": clean_topic,
850
+ "limit": limit,
851
+ "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
852
+ }
853
+ async with httpx.AsyncClient(timeout=15) as client:
854
+ r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
855
+ if r.status_code != 200:
856
+ logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
857
+ return []
858
+ data = r.json()
859
+ results = []
860
+ for paper in data.get("data", []):
861
+ abstract = paper.get("abstract") or ""
862
+ if not abstract or len(abstract) < 50:
863
+ continue
864
+ author_list = paper.get("authors") or []
865
+ first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
866
+ year = paper.get("year") or "n.d."
867
+ paper_id = paper.get("paperId", "")
868
+ doi = (paper.get("externalIds") or {}).get("DOI", "")
869
+ pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
870
+ link = (
871
+ pdf_url
872
+ or (f"https://doi.org/{doi}" if doi else "")
873
+ or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
874
+ )
875
+ results.append({
876
+ "title": paper.get("title", "Untitled"),
877
+ "authors": (
878
+ ", ".join(a.get("name", "") for a in author_list[:3])
879
+ + (" et al." if len(author_list) > 3 else "")
880
+ ),
881
+ "first_author": first_author_last,
882
+ "year": year,
883
+ "citation_key": f"{first_author_last}, {year}",
884
+ "venue": paper.get("venue", ""),
885
+ "abstract": abstract[:700],
886
+ "link": link,
887
+ "_source": "Semantic Scholar",
888
+ })
889
+ logger.info(f"Semantic Scholar: {len(results)} papers with abstracts for '{clean_topic}'")
890
+ return results
891
+ except Exception as e:
892
+ logger.error(f"Semantic Scholar search failed: {e}")
893
+ return []
894
+
895
+
896
+ async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
897
+ """
898
+ Generate a research summary with clickable in-text citations [Author, Year].
899
+ Each [N] in the LLM output is replaced with a hyperlink to the actual paper.
900
+ """
901
+ settings = get_settings()
902
+ if not settings.openai_api_key or not papers:
903
+ return ""
904
+
905
+ context_text = ""
906
+ for i, paper in enumerate(papers, 1):
907
+ context_text += (
908
+ f"[{i}] {paper['citation_key']}: {paper['title']}\n"
909
+ f"Abstract: {paper['abstract']}\n\n"
910
+ )
911
+
912
+ system = (
913
+ "You are a research librarian writing a concise academic literature snapshot. "
914
+ "Using ONLY the numbered references provided, write a 4-6 sentence summary on the given topic. "
915
+ "Use in-text citations as [1], [2], [3] etc. referring to the numbered references. "
916
+ "Cite at least 4 different references. "
917
+ "Use HTML <br><br> for paragraph breaks. Do not use bullet points or markdown. "
918
+ "Only state what is explicitly supported by the abstracts. "
919
+ "Do NOT invent content."
920
+ )
921
+
922
+ try:
923
+ llm = _get_llm(model, temperature=0.2, max_tokens=560)
924
+ response = await llm.ainvoke([
925
+ {"role": "system", "content": system},
926
+ {"role": "user", "content": f"Topic: {topic}\n\nReferences:\n{context_text}\nWrite the summary."},
927
+ ])
928
+ summary = response.content.strip()
929
+
930
+ # Replace [N] with clickable citation links β€” work backwards to avoid [1] matching inside [10]
931
+ for i in range(len(papers), 0, -1):
932
+ paper = papers[i - 1]
933
+ title_escaped = _escape(paper["title"])
934
+ cite_html = (
935
+ f'<a href="{paper["link"]}" target="_blank" '
936
+ f'title="{title_escaped}" '
937
+ f'style="color:#1e40af;font-weight:600;text-decoration:none">'
938
+ f'[{paper["citation_key"]}]</a>'
939
+ )
940
+ summary = summary.replace(f"[{i}]", cite_html)
941
+ return summary
942
+ except Exception as e:
943
+ logger.error(f"Summary generation failed: {e}")
944
+ return ""
945
+
946
+
947
+ def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
948
+ """Fallback: extract lead sentences from abstracts when LLM summary fails."""
949
+ if not papers:
950
+ return ""
951
+ statements: List[str] = []
952
+ for paper in papers[:4]:
953
+ sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
954
+ lead = sentences[0].strip() if sentences else ""
955
+ if not lead:
956
+ continue
957
+ citation = (
958
+ f'<a href="{paper["link"]}" target="_blank" '
959
+ f'style="color:#1e40af;font-weight:600;text-decoration:none">'
960
+ f'[{_escape(paper["citation_key"])}]</a>'
961
+ )
962
+ statements.append(f"{_escape(lead)} {citation}")
963
+ if not statements:
964
+ return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
965
+ intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
966
+ return intro + "<br><br>".join(statements[:4])
967
+
968
+
969
+ # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
970
+
971
+ from src.api.search import SearchRequest, search_primo, search_pubmed
972
+
973
+
974
+ async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
975
+ """
976
+ FIX: now passes resource_type so the rtype FACET is correctly sent to PRIMO.
977
+ Topic is stripped of noise before hitting the API.
978
+ """
979
+ settings = get_settings()
980
+ # FIX: strip resource noise from topic β€” belt-and-braces
981
+ clean_topic = _strip_resource_noise(context.topic)
982
+ if not clean_topic:
983
+ clean_topic = context.topic
984
+
985
+ result = await search_primo(
986
+ SearchRequest(
987
+ query=clean_topic,
988
+ source="primo",
989
+ limit=limit,
990
+ peer_reviewed=context.peer_reviewed,
991
+ open_access=context.open_access,
992
+ year_from=context.year_from,
993
+ year_to=context.year_to,
994
+ resource_type=context.resource_type, # FIX: was missing β€” resource type now goes as facet
995
+ ),
996
+ settings.primo_api_key,
997
+ )
998
+ source_url = _primo_clean_url(context)
999
+ return result, source_url
1000
+
1001
+
1002
+ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
1003
+ """
1004
+ FIX: year_from/year_to now correctly wired through SearchRequest β†’ mindate/maxdate in search.py.
1005
+ """
1006
+ clean_topic = _strip_resource_noise(context.topic)
1007
+ if not clean_topic:
1008
+ clean_topic = context.topic
1009
+
1010
+ result = await search_pubmed(
1011
+ SearchRequest(
1012
+ query=clean_topic,
1013
+ source="pubmed",
1014
+ limit=limit,
1015
+ peer_reviewed=context.peer_reviewed,
1016
+ year_from=context.year_from, # FIX: now actually used in search.py
1017
+ year_to=context.year_to, # FIX: now actually used in search.py
1018
+ )
1019
+ )
1020
+ pubmed_url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(clean_topic)}"
1021
+ if context.year_from and context.year_to:
1022
+ pubmed_url += f"&filter=datesearch.y_custom&dates.from={context.year_from}&dates.to={context.year_to}"
1023
+ return result, pubmed_url
1024
+
1025
+
1026
+ # ── AI Tools / Database Guidance ─────────────────────────────────────────────
1027
+
1028
+ def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1029
+ ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1030
+ clean_topic = _strip_resource_noise(context.topic)
1031
+ return {
1032
+ "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1033
+ "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
1034
+ "consensus": f"https://consensus.app/results/?q={ai_query}",
1035
+ "primo_discovery": _primo_clean_url(context),
1036
+ }
1037
+
1038
+
1039
+ def _ai_source_links_html(context: SearchContextPayload) -> str:
1040
+ urls = _tool_urls(context)
1041
+ return (
1042
+ f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> Β· '
1043
+ f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> Β· '
1044
+ f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a> Β· '
1045
+ f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
1046
+ )
1047
+
1048
+
1049
+ def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1050
+ topic = _escape(context.display_topic or context.topic)
1051
+ links = _ai_source_links_html(context)
1052
+ if context.intent == "search_medical":
1053
+ return (
1054
+ f"For a stronger evidence trail on <strong>{topic}</strong>, start with <strong>PubMed</strong> or <strong>Embase</strong> for the core medical literature, "
1055
+ f"then continue with {links}."
1056
+ )
1057
+ return (
1058
+ f"For deeper work on <strong>{topic}</strong>, continue with {links}. "
1059
+ "Use Scopus AI for broader scholarly mapping, LeapSpace for guided evidence exploration, "
1060
+ "Consensus for quick paper-grounded answers, and PRIMO Library Discovery for KU-accessible material."
1061
+ )
1062
+
1063
+
1064
+ def _looks_database_recommendation_question(question: str) -> bool:
1065
+ q = (question or "").lower()
1066
+ return bool(BEST_DATABASES_RE.search(q)) and any(kw in q for kw in [
1067
+ "database", "databases", "subject", "engineering", "medicine", "medical", "ai", "computer",
1068
+ "physics", "chemistry", "chemical", "civil", "mechanical", "electrical", "business",
1069
+ "biomedical", "nursing", "systematic review", "health"
1070
+ ])
1071
+
1072
+
1073
+ def _database_recommendation_payload(question: str) -> Dict[str, Any]:
1074
+ q = (question or "").lower()
1075
+ general = {
1076
+ "title": "General / multidisciplinary research",
1077
+ "intro": "These are the best starting databases for broad academic searching at KU.",
1078
+ "items": [
1079
+ {"name": "PRIMO Library Discovery", "url": "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU", "why": "Best first stop for KU-accessible books, articles, journals, and holdings."},
1080
+ {"name": "Scopus", "url": "https://khalifa.idm.oclc.org/login?url=https://www.scopus.com", "why": "Broad multidisciplinary database for scholarly discovery and citation tracking."},
1081
+ {"name": "Web of Science", "url": "https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com", "why": "Strong citation database for high-level literature mapping and journal evaluation."},
1082
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Large full-text platform across science, engineering, and medicine."},
1083
+ ],
1084
+ }
1085
+ subjects = [
1086
+ (["systematic review", "medicine", "medical", "health", "nursing", "clinical", "biomedical", "pharmacy"], {
1087
+ "title": "Medical and health sciences",
1088
+ "intro": "For medical evidence and subject searching, start with these databases.",
1089
+ "items": [
1090
+ {"name": "PubMed@KU", "url": "http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib", "why": "Core biomedical literature with KU full-text linking."},
1091
+ {"name": "Embase", "url": "https://khalifa.idm.oclc.org/login?url=https://www.embase.com", "why": "Excellent for biomedical, drugs, devices, and evidence searching."},
1092
+ {"name": "CINAHL Ultimate", "url": "https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul", "why": "Best for nursing and allied health literature."},
1093
+ {"name": "Cochrane Library", "url": "https://khalifa.idm.oclc.org/login?url=https://www.cochranelibrary.com", "why": "Key source for systematic reviews and evidence-based medicine."},
1094
+ {"name": "UpToDate", "url": "https://khalifa.idm.oclc.org/login?url=https://www.uptodate.com", "why": "Clinical point-of-care resource for evidence summaries."},
1095
+ ],
1096
+ }),
1097
+ (["computer science", "machine learning", "artificial intelligence", " ai", "cyber", "software", "algorithm", "computer", "data science"], {
1098
+ "title": "Computer science and AI",
1099
+ "intro": "For computing, AI, software, and algorithms, these are the best starting points.",
1100
+ "items": [
1101
+ {"name": "ACM Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://dl.acm.org", "why": "Core computer science literature."},
1102
+ {"name": "IEEE Xplore", "url": "https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org", "why": "Strong for AI, computing systems, networks, and engineering."},
1103
+ {"name": "arXiv", "url": "https://arxiv.org", "why": "Useful for fast-moving preprints in AI and computer science."},
1104
+ {"name": "Scopus", "url": "https://khalifa.idm.oclc.org/login?url=https://www.scopus.com", "why": "Good for citation tracking and broader discovery."},
1105
+ ],
1106
+ }),
1107
+ (["electrical", "electronics", "signals", "power systems", "communications"], {
1108
+ "title": "Electrical engineering",
1109
+ "intro": "For electrical engineering topics, start here.",
1110
+ "items": [
1111
+ {"name": "IEEE Xplore", "url": "https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org", "why": "Core database for electrical engineering literature and standards."},
1112
+ {"name": "INSPEC", "url": "https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec", "why": "Strong indexing for EE, electronics, communications, and computing."},
1113
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Good full-text coverage across electrical engineering and controls."},
1114
+ ],
1115
+ }),
1116
+ (["mechanical", "thermo", "fluids", "manufacturing"], {
1117
+ "title": "Mechanical engineering",
1118
+ "intro": "For mechanical engineering, these are the strongest starts.",
1119
+ "items": [
1120
+ {"name": "ASME Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org", "why": "Core mechanical engineering journals and conference papers."},
1121
+ {"name": "Knovel", "url": "https://khalifa.idm.oclc.org/login?url=https://app.knovel.com", "why": "Engineering reference books and technical data."},
1122
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Strong full-text support for applied engineering topics."},
1123
+ ],
1124
+ }),
1125
+ (["civil", "geotechnical", "transportation", "construction", "structures"], {
1126
+ "title": "Civil engineering",
1127
+ "intro": "For civil engineering and infrastructure topics, start here.",
1128
+ "items": [
1129
+ {"name": "ASCE Library", "url": "https://khalifa.idm.oclc.org/login?url=https://ascelibrary.org", "why": "Core civil engineering journals and conference papers."},
1130
+ {"name": "ASTM Compass", "url": "https://khalifa.idm.oclc.org/login?url=https://compass.astm.org", "why": "Important standards and testing information for materials and engineering."},
1131
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Useful full-text coverage across civil engineering subjects."},
1132
+ ],
1133
+ }),
1134
+ (["aerospace", "aerodynamics", "propulsion", "aircraft"], {
1135
+ "title": "Aerospace engineering",
1136
+ "intro": "For aerospace topics, these are the best databases to begin with.",
1137
+ "items": [
1138
+ {"name": "AIAA Aerospace Research Central", "url": "https://khalifa.idm.oclc.org/login?url=https://arc.aiaa.org/", "why": "Core aerospace journals and conference literature."},
1139
+ {"name": "ASME Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org", "why": "Useful for thermal, mechanical, and fluid systems relevant to aerospace."},
1140
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Good full-text support for aerospace materials and systems."},
1141
+ ],
1142
+ }),
1143
+ (["chemical engineering", "catalysis", "separations", "chemistry", "chemical"], {
1144
+ "title": "Chemistry and chemical engineering",
1145
+ "intro": "For chemistry and chemical engineering, these are the strongest starting points.",
1146
+ "items": [
1147
+ {"name": "ACS Publications", "url": "https://khalifa.idm.oclc.org/login?url=https://pubs.acs.org", "why": "Core chemistry journals."},
1148
+ {"name": "SciFindern", "url": "https://khalifa.idm.oclc.org/login?url=https://scifinder-n.cas.org", "why": "Best for substances, reactions, and chemistry references."},
1149
+ {"name": "Reaxys", "url": "https://khalifa.idm.oclc.org/login?url=https://www.reaxys.com", "why": "Useful for chemistry reactions and property searching."},
1150
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Strong full-text source for chemical engineering journals."},
1151
+ ],
1152
+ }),
1153
+ (["physics", "quantum", "optics", "plasma"], {
1154
+ "title": "Physics",
1155
+ "intro": "For physics topics, start with these databases and platforms.",
1156
+ "items": [
1157
+ {"name": "APS Journals", "url": "https://khalifa.idm.oclc.org/login?url=https://journals.aps.org", "why": "Core American Physical Society journals."},
1158
+ {"name": "AIP", "url": "https://khalifa.idm.oclc.org/login?url=https://aip.scitation.org", "why": "Major physics and applied physics content."},
1159
+ {"name": "arXiv", "url": "https://arxiv.org", "why": "Open preprints in physics and related areas."},
1160
+ ],
1161
+ }),
1162
+ (["business", "management", "marketing", "economics", "finance"], {
1163
+ "title": "Business and management",
1164
+ "intro": "For business-related topics, start with these databases.",
1165
+ "items": [
1166
+ {"name": "Business Source Complete", "url": "https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=bsh", "why": "Comprehensive business database."},
1167
+ {"name": "Emerald Insight", "url": "https://khalifa.idm.oclc.org/login?url=https://www.emerald.com/insight", "why": "Strong for management, marketing, and business journals."},
1168
+ {"name": "ProQuest Databases", "url": "https://khalifa.idm.oclc.org/login?url=https://search.proquest.com", "why": "Broad business and dissertation coverage."},
1169
+ ],
1170
+ }),
1171
+ ]
1172
+ for keywords, payload in subjects:
1173
+ if any(k in q for k in keywords):
1174
+ return payload
1175
+ return general
1176
+
1177
+
1178
+ def _database_recommendation_answer(question: str) -> str:
1179
+ payload = _database_recommendation_payload(question)
1180
+ answer = f"<strong>Best databases for { _escape(payload['title']) }</strong><br><br>{_escape(payload['intro'])}<br>"
1181
+ for item in payload['items']:
1182
+ answer += (
1183
+ f'<br>β€’ <a href="{item["url"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(item["name"])}</a><br>'
1184
+ f'<span style="font-size:.9rem;color:#4b5563">{_escape(item["why"])}</span>'
1185
+ )
1186
+ answer += (
1187
+ f'<br><br>You can also start in <a href="{PRIMO_BASE_URL}" target="_blank">PRIMO Library Discovery</a> '
1188
+ 'and then narrow by resource type, date, and peer-reviewed content.'
1189
+ )
1190
+ answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
1191
+ return answer
1192
+
1193
+
1194
+ # ── Research Snapshot ─────────────────────────────────────────────────────────
1195
+
1196
+ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
1197
+ """
1198
+ Full research snapshot:
1199
+ 1. Semantic Scholar β†’ cited summary with hyperlinked [Author, Year] citations
1200
+ 2. PRIMO β†’ KU-accessible results appended below
1201
+ """
1202
+ topic = context.topic
1203
+ papers = await _semantic_scholar_search(topic, limit=8)
1204
+ references: List[dict] = []
1205
+ primo_result, primo_url = await _search_primo_catalog(context, limit=6)
1206
+ search_results = primo_result.get("results", []) or []
1207
+
1208
+ if papers:
1209
+ summary = await _generate_cited_summary(topic, papers, model)
1210
+ if not summary:
1211
+ _safe_metrics_increment("summary_fallbacks")
1212
+ summary = _extractive_snapshot_from_papers(topic, papers)
1213
+
1214
+ answer = f"<strong>πŸ“– Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
1215
+ if search_results:
1216
+ answer += (
1217
+ "<br><br><strong>KU Library starting points:</strong> "
1218
+ "I also pulled KU-accessible results below so you can move directly from the snapshot to subscribed material."
1219
+ )
1220
+ answer += f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1221
+ answer += "<br><br><strong>Selected references:</strong><br>"
1222
+ for i, paper in enumerate(papers[:6], 1):
1223
+ answer += (
1224
+ f'<br><strong>[{i}]</strong> '
1225
+ f'<a href="{paper["link"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(paper["title"])}</a><br>'
1226
+ f'<span style="font-size:.8rem;color:#6b7280">'
1227
+ f'{_escape(paper["authors"])} Β· {paper["year"]} Β· {_escape(paper["venue"] or "")}'
1228
+ f'</span>'
1229
+ )
1230
+ answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1231
+ if search_results:
1232
+ answer += _source_badge("primo", primo_url)
1233
+ references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1234
+ return answer, search_results, references
1235
+
1236
+ if search_results:
1237
+ answer = (
1238
+ f"<strong>πŸ“š KU Library starting points on {_escape(topic)}</strong><br><br>"
1239
+ "I couldn't build a Semantic Scholar snapshot right now, but here are KU-accessible library results you can open immediately. "
1240
+ f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1241
+ )
1242
+ else:
1243
+ answer = (
1244
+ f"I couldn't retrieve a research snapshot for <strong>{_escape(topic)}</strong> right now.<br><br>"
1245
+ f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1246
+ )
1247
+ answer += _source_badge("primo", primo_url)
1248
+ return answer, search_results, []
1249
+
1250
+
1251
+ # ── Follow-up System ──────────────────────────────────────────────────────────
1252
+
1253
+ def _filters_summary(context: SearchContextPayload) -> str:
1254
+ filters: List[str] = []
1255
+ if context.peer_reviewed:
1256
+ filters.append("peer reviewed only")
1257
+ if context.open_access:
1258
+ filters.append("open access")
1259
+ if context.year_from and context.year_to:
1260
+ filters.append(f"{context.year_from}–{context.year_to}")
1261
+ elif context.year_from:
1262
+ filters.append(f"from {context.year_from}")
1263
+ if context.resource_type == "books":
1264
+ filters.append("books")
1265
+ elif context.resource_type == "articles":
1266
+ filters.append("articles")
1267
+ elif context.resource_type == "both":
1268
+ filters.append("articles and books")
1269
+ return ", ".join(filters)
1270
+
1271
+
1272
+ def _question_has_new_topic(question: str, base_topic: str) -> bool:
1273
+ q = (question or "").lower()
1274
+ cleaned = re.sub(
1275
+ r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
1276
+ r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
1277
+ " ", q,
1278
+ )
1279
+ tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
1280
+ if len(tokens) < 3:
1281
+ return False
1282
+ base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
1283
+ overlap = sum(1 for t in tokens if t in base_tokens)
1284
+ return overlap <= max(1, min(2, len(base_tokens)))
1285
+
1286
+
1287
+ def _parse_refinement_action(question: str) -> Optional[str]:
1288
+ q = (question or "").lower().strip()
1289
+ if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
1290
+ return "peer_reviewed_only"
1291
+ if re.search(r"\b(open access|oa only|only open access)\b", q):
1292
+ return "open_access_only"
1293
+ if re.search(r"\b(last|past)\s+5\s+years?\b", q):
1294
+ return "last_5_years"
1295
+ if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
1296
+ return "last_10_years"
1297
+ if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
1298
+ return "books_only"
1299
+ if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
1300
+ return "articles_only"
1301
+ if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
1302
+ return "both_resources"
1303
+ if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
1304
+ return "summarize_topic"
1305
+ if re.search(r"\bpubmed\b", q):
1306
+ return "search_pubmed"
1307
+ if re.search(r"\bprimo\b", q):
1308
+ return "search_primo"
1309
+ if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
1310
+ return "best_databases"
1311
+ if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
1312
+ return "deep_research_tools"
1313
+ return None
1314
+
1315
+
1316
+ def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
1317
+ if not client_state or not client_state.recent_search_contexts:
1318
+ return None
1319
+ contexts = list(client_state.recent_search_contexts)
1320
+ if client_state.follow_up_context_id:
1321
+ for ctx in contexts:
1322
+ if ctx.context_id == client_state.follow_up_context_id:
1323
+ return ctx
1324
+ if client_state.active_search_context_id:
1325
+ for ctx in contexts:
1326
+ if ctx.context_id == client_state.active_search_context_id:
1327
+ return ctx
1328
+ contexts.sort(key=lambda c: c.created_at, reverse=True)
1329
+ return contexts[0]
1330
+
1331
+
1332
+ def _detect_follow_up(question: str, client_state: Optional[ClientStatePayload]) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
1333
+ base_context = _resolve_base_context(client_state)
1334
+ if not base_context:
1335
+ return False, None, None
1336
+ explicit_action = (client_state.follow_up_action if client_state else None) or None
1337
+ if explicit_action:
1338
+ return True, explicit_action, base_context
1339
+ action = _parse_refinement_action(question)
1340
+ if not action:
1341
+ return False, None, None
1342
+ if _question_has_new_topic(question, base_context.topic):
1343
+ return False, None, None
1344
+ return True, action, base_context
1345
+
1346
+
1347
+ def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
1348
+ return SearchContextPayload.model_validate(base_context.model_dump())
1349
+
1350
+
1351
+ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
1352
+ context = _clone_context(base_context)
1353
+ context.context_id = str(uuid.uuid4())
1354
+ context.created_at = time.time()
1355
+ if action == "peer_reviewed_only":
1356
+ context.peer_reviewed = True
1357
+ elif action == "open_access_only":
1358
+ context.open_access = True
1359
+ elif action == "last_5_years":
1360
+ context.year_from = str(CURRENT_YEAR - 4)
1361
+ context.year_to = str(CURRENT_YEAR)
1362
+ elif action == "last_10_years":
1363
+ context.year_from = str(CURRENT_YEAR - 9)
1364
+ context.year_to = str(CURRENT_YEAR)
1365
+ elif action == "books_only":
1366
+ context.resource_type = "books"
1367
+ elif action == "articles_only":
1368
+ context.resource_type = "articles"
1369
+ elif action == "both_resources":
1370
+ context.resource_type = "both"
1371
+ elif action == "search_pubmed":
1372
+ context.source = "pubmed"
1373
+ context.intent = "search_medical"
1374
+ context.resource_type = "articles"
1375
+ elif action == "search_primo":
1376
+ context.source = "primo"
1377
+ return context
1378
+
1379
+
1380
+ async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1381
+ """
1382
+ FIX: Generate 3 topic-specific intellectual follow-up questions using LLM.
1383
+ These are ASPECT-based (related subtopics, methodological angles, applications)
1384
+ not generic filter suggestions like "peer reviewed only".
1385
+ """
1386
+ settings = get_settings()
1387
+ if not settings.openai_api_key:
1388
+ return []
1389
+ try:
1390
+ llm = _get_llm(model, temperature=0.5, max_tokens=200)
1391
+ response = await llm.ainvoke([
1392
+ {"role": "system", "content": (
1393
+ "You are a research librarian helping a user explore a topic more deeply. "
1394
+ "Given a research topic, generate exactly 3 short follow-up questions that explore "
1395
+ "DIFFERENT ASPECTS of the topic β€” such as subtopics, methodological angles, "
1396
+ "applications, comparisons, or related fields. "
1397
+ "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
1398
+ "Return ONLY a JSON array of 3 strings, each under 12 words. "
1399
+ "No explanation, no preamble, no markdown.\n"
1400
+ "Example for 'quantum computing':\n"
1401
+ '[\"What are the main hardware approaches in quantum computing?\", '
1402
+ '"How does quantum error correction work?\", '
1403
+ '"Applications of quantum computing in cryptography\"]'
1404
+ )},
1405
+ {"role": "user", "content": f"Topic: {topic}"},
1406
+ ])
1407
+ raw = response.content.strip()
1408
+ if raw.startswith("```"):
1409
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
1410
+ questions = json.loads(raw)
1411
+ if not isinstance(questions, list):
1412
+ return []
1413
+ return [
1414
+ {"label": q.strip("?") + "?", "question": q.strip("?") + " β€” find articles", "action": "new_search"}
1415
+ for q in questions[:3]
1416
+ if isinstance(q, str) and q.strip()
1417
+ ]
1418
+ except Exception as e:
1419
+ logger.warning(f"Topic follow-up generation failed: {e}")
1420
+ return []
1421
+
1422
+
1423
+ async def _search_follow_up(
1424
+ context: SearchContextPayload, model: str, summary_mode: bool = False
1425
+ ) -> Tuple[str, List[dict]]:
1426
+ """
1427
+ FIX: Now generates topic-specific intellectual follow-ups (LLM-based)
1428
+ PLUS a smaller set of generic filter options as secondary suggestions.
1429
+ Total suggestions: 3 topic-specific + up to 2 filter options = max 5.
1430
+ """
1431
+ topic = context.display_topic or context.topic
1432
+ suggestions: List[dict] = []
1433
+
1434
+ # ── Topic-specific intellectual follow-ups (LLM-generated) ───────────────
1435
+ topic_follow_ups = await _generate_topic_follow_ups(topic, model)
1436
+ suggestions.extend(topic_follow_ups)
1437
+
1438
+ # ── Generic filter options (secondary) ────────────────────────────────────
1439
+ filter_suggestions: List[dict] = []
1440
+ if summary_mode:
1441
+ filter_suggestions.append({
1442
+ "label": f"Show KU-accessible articles on {topic}",
1443
+ "question": f"Find KU-accessible articles on {topic}",
1444
+ "action": "articles_only",
1445
+ "context_id": context.context_id,
1446
+ })
1447
+ filter_suggestions.append({
1448
+ "label": f"Limit {topic} to the last 5 years",
1449
+ "question": f"Limit {topic} to the last 5 years",
1450
+ "action": "last_5_years",
1451
+ "context_id": context.context_id,
1452
+ })
1453
+ else:
1454
+ if not context.peer_reviewed and context.source == "primo":
1455
+ filter_suggestions.append({
1456
+ "label": f"Peer reviewed only",
1457
+ "question": f"Make the {topic} search peer reviewed only",
1458
+ "action": "peer_reviewed_only",
1459
+ "context_id": context.context_id,
1460
+ })
1461
+ if not (context.year_from and context.year_to == str(CURRENT_YEAR)):
1462
+ filter_suggestions.append({
1463
+ "label": "Last 5 years",
1464
+ "question": f"Limit {topic} to the last 5 years",
1465
+ "action": "last_5_years",
1466
+ "context_id": context.context_id,
1467
+ })
1468
+ if context.intent == "search_medical" and context.source != "pubmed":
1469
+ filter_suggestions.append({
1470
+ "label": "Search PubMed instead",
1471
+ "question": f"Search PubMed for {topic}",
1472
+ "action": "search_pubmed",
1473
+ "context_id": context.context_id,
1474
+ })
1475
+ filter_suggestions.append({
1476
+ "label": "Research snapshot",
1477
+ "question": f"Give me a brief research snapshot on {topic}",
1478
+ "action": "summarize_topic",
1479
+ "context_id": context.context_id,
1480
+ })
1481
+
1482
+ # Add up to 2 filter suggestions after topic-specific ones
1483
+ suggestions.extend(filter_suggestions[:2])
1484
+
1485
+ question_text = (
1486
+ f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
1487
+ "Or I can refine the search with filters β€” just ask."
1488
+ )
1489
+ return question_text, suggestions[:5]
1490
+
1491
+
1492
+ def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
1493
+ q = (question or "").lower()
1494
+ if _looks_library_hours_question(question):
1495
+ return (
1496
+ "Would you like the library contact details too?",
1497
+ [
1498
+ {"label": "Show library contact details", "question": "Show the library contact details"},
1499
+ {"label": "Open the live library hours page", "question": "Open the live library hours page"},
1500
+ ],
1501
+ )
1502
+ if "study room" in q or "room" in q:
1503
+ return (
1504
+ "Would you like the study-room booking link?",
1505
+ [
1506
+ {"label": "Study-room booking link", "question": "Show the study-room booking link"},
1507
+ {"label": "Library services for both campuses", "question": "Show library services for both campuses"},
1508
+ ],
1509
+ )
1510
+ if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
1511
+ return (
1512
+ "Would you like me to recommend the best KU databases for this topic?",
1513
+ [
1514
+ {"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
1515
+ {"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
1516
+ ],
1517
+ )
1518
+ return (
1519
+ "Would you like a related library service or staff contact?",
1520
+ [
1521
+ {"label": "Best staff contact", "question": "Show the best staff contact for this"},
1522
+ {"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
1523
+ ],
1524
+ )
1525
+
1526
+
1527
+ def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
1528
+ return (
1529
+ "Would you like a brief explanation, current web information, or KU Library resources on this topic?",
1530
+ [
1531
+ {"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
1532
+ {"label": "Current web information", "question": f"Show current web information on {question}"},
1533
+ {"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
1534
+ ],
1535
+ )
1536
+
1537
+
1538
+ def _social_follow_up() -> Tuple[str, List[dict]]:
1539
+ return (
1540
+ "What would you like help with next?",
1541
+ [
1542
+ {"label": "Find articles on a topic", "question": "Find articles on a topic"},
1543
+ {"label": "Check a library service", "question": "Check a library service"},
1544
+ {"label": "Contact a librarian", "question": "Contact a librarian"},
1545
+ ],
1546
+ )
1547
+
1548
+
1549
+ def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
1550
+ topic = _escape(context.display_topic or context.topic)
1551
+ resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
1552
+ location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
1553
+ if is_follow_up:
1554
+ return f"I updated your previous search on <strong>{topic}</strong> in the <strong>{location}</strong> and looked for <strong>{resource}</strong>."
1555
+ return f"I searched the <strong>{location}</strong> for <strong>{resource}</strong> on <strong>{topic}</strong>."
1556
+
1557
+
1558
+ async def _run_search_mode(
1559
+ question: str, context: SearchContextPayload, model: str, is_follow_up: bool
1560
+ ) -> Tuple[str, List[dict], List[dict], str]:
1561
+ context = await _prepare_queries(question, context, model, is_follow_up)
1562
+ filters = _filters_summary(context)
1563
+
1564
+ if context.source == "pubmed":
1565
+ result, source_url = await _search_pubmed_articles(context, limit=6)
1566
+ source_name = "pubmed"
1567
+ else:
1568
+ result, source_url = await _search_primo_catalog(context, limit=6)
1569
+ source_name = "primo"
1570
+
1571
+ search_results = result.get("results", []) or []
1572
+ total = result.get("total", len(search_results))
1573
+
1574
+ answer = _search_answer_intro(context, is_follow_up)
1575
+ if filters:
1576
+ answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1577
+ if search_results:
1578
+ answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
1579
+ else:
1580
+ answer += (
1581
+ "<br><br>I couldn't retrieve matching results right now. "
1582
+ "You can still try the direct library links and AI research tools below."
1583
+ )
1584
+ if context.intent == "search_medical" and context.source != "pubmed":
1585
+ answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1586
+
1587
+ answer += _source_badge(source_name, source_url)
1588
+ sources = []
1589
+ for item in search_results[:6]:
1590
+ link = item.get("primo_url") or item.get("link")
1591
+ if link:
1592
+ sources.append({"title": item.get("title", "Result"), "url": link})
1593
+ return answer, search_results, sources, source_url
1594
+
1595
+
1596
+ def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
1597
+ dedup: Dict[str, SearchContextPayload] = {}
1598
+ for ctx in contexts:
1599
+ dedup[ctx.context_id] = ctx
1600
+ ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
1601
+ return ordered[:5]
1602
+
1603
+
1604
+ # ── Main Endpoint ──────────────────────────────────────────────────────────────
1605
+
1606
+ @router.post("", response_model=AgentResponse)
1607
+ async def agent_query(request: Request, req: AgentRequest):
1608
+ start_time = time.time()
1609
+ question = _normalize_whitespace(req.question)
1610
+ history = req.history
1611
+ model = req.model
1612
+ client_state = req.client_state
1613
+ _safe_metrics_increment("agent_requests")
1614
+
1615
+ if not question:
1616
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1617
+ return AgentResponse(
1618
+ answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
1619
+ intent="social",
1620
+ model_used=model,
1621
+ response_time=time.time() - start_time,
1622
+ follow_up_question=follow_up_question,
1623
+ follow_up_suggestions=follow_up_suggestions,
1624
+ )
1625
+
1626
+ if PURE_GREETING_RE.match(question):
1627
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1628
+ return AgentResponse(
1629
+ answer=(
1630
+ "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
1631
+ "I can help you find articles and books, search databases, access full text, request Interlibrary Loan (ILL), "
1632
+ "and answer questions about library services and staff."
1633
+ + _source_badge("libbee")
1634
+ ),
1635
+ intent="social_greeting",
1636
+ model_used=model,
1637
+ response_time=time.time() - start_time,
1638
+ follow_up_question=follow_up_question,
1639
+ follow_up_suggestions=follow_up_suggestions,
1640
+ )
1641
+
1642
+ staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
1643
+ if staff_match:
1644
+ _safe_metrics_bucket("intents", "staff_lookup")
1645
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1646
+ return AgentResponse(
1647
+ answer=staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1648
+ intent="library_info",
1649
+ tools_used=["staff_name_match"],
1650
+ model_used=model,
1651
+ response_time=time.time() - start_time,
1652
+ follow_up_question=follow_up_question,
1653
+ follow_up_suggestions=follow_up_suggestions,
1654
+ )
1655
+
1656
+ staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
1657
+ if staff_role_match:
1658
+ _safe_metrics_bucket("intents", "staff_lookup")
1659
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1660
+ return AgentResponse(
1661
+ answer=staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1662
+ intent="library_info",
1663
+ tools_used=["staff_role_match"],
1664
+ model_used=model,
1665
+ response_time=time.time() - start_time,
1666
+ follow_up_question=follow_up_question,
1667
+ follow_up_suggestions=follow_up_suggestions,
1668
+ )
1669
+
1670
+ if _is_greeting_menu_followup(question, history):
1671
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1672
+ return AgentResponse(
1673
+ answer=_greeting_menu_clarify_answer() + _source_badge("libbee"),
1674
+ intent="social_greeting",
1675
+ is_follow_up=True,
1676
+ model_used=model,
1677
+ response_time=time.time() - start_time,
1678
+ follow_up_question=follow_up_question,
1679
+ follow_up_suggestions=follow_up_suggestions,
1680
+ )
1681
+
1682
+ if _looks_library_hours_question(question):
1683
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1684
+ return AgentResponse(
1685
+ answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
1686
+ intent="library_info",
1687
+ tools_used=["hours_hardcoded"],
1688
+ ask_librarian=False,
1689
+ model_used=model,
1690
+ response_time=time.time() - start_time,
1691
+ follow_up_question=follow_up_question,
1692
+ follow_up_suggestions=follow_up_suggestions,
1693
+ )
1694
+
1695
+ if _looks_campus_question(question):
1696
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1697
+ return AgentResponse(
1698
+ answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1699
+ intent="library_info",
1700
+ tools_used=["campus_hardcoded"],
1701
+ ask_librarian=False,
1702
+ model_used=model,
1703
+ response_time=time.time() - start_time,
1704
+ follow_up_question=follow_up_question,
1705
+ follow_up_suggestions=follow_up_suggestions,
1706
+ )
1707
+
1708
+ if _looks_database_recommendation_question(question):
1709
+ follow_up_question = "Would you like me to search one of these databases for you next?"
1710
+ follow_up_suggestions = [
1711
+ {"label": "Search PRIMO on this topic", "question": question.replace("best databases", "find articles").replace("which database should I use for", "find articles on")},
1712
+ {"label": "Show off-campus access help", "question": "How do I access databases off campus?"},
1713
+ ]
1714
+ return AgentResponse(
1715
+ answer=_database_recommendation_answer(question),
1716
+ intent="library_info",
1717
+ tools_used=["database_recommendation"],
1718
+ model_used=model,
1719
+ response_time=time.time() - start_time,
1720
+ follow_up_question=follow_up_question,
1721
+ follow_up_suggestions=follow_up_suggestions,
1722
+ )
1723
+
1724
+ is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1725
+
1726
+ if is_follow_up and base_context:
1727
+ _safe_metrics_increment("follow_up_hits")
1728
+ context = _apply_follow_up_action(base_context, follow_up_action)
1729
+
1730
+ if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1731
+ context = await _prepare_queries(question, context, model, True)
1732
+ if follow_up_action == "summarize_topic":
1733
+ answer, search_results, sources = await _research_snapshot(context, model)
1734
+ elif follow_up_action == "best_databases":
1735
+ answer = _database_recommendation_answer(context.display_topic or context.topic)
1736
+ search_results = []
1737
+ sources = []
1738
+ else:
1739
+ answer = (
1740
+ f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
1741
+ f"{_database_guidance_for_topic(context)}"
1742
+ + _source_badge("libbee")
1743
+ )
1744
+ search_results = []
1745
+ sources = []
1746
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1747
+ _safe_metrics_increment("search_handoffs")
1748
+ return AgentResponse(
1749
+ answer=answer,
1750
+ intent=context.intent,
1751
+ tools_used=["follow_up_resolution", follow_up_action],
1752
+ search_results=search_results,
1753
+ sources=sources,
1754
+ model_used=model,
1755
+ response_time=time.time() - start_time,
1756
+ corrected_query=context.topic,
1757
+ natural_query=context.ai_tool_query,
1758
+ database_query=context.primo_boolean_query,
1759
+ ai_tool_query=context.ai_tool_query,
1760
+ primo_boolean_query=context.primo_boolean_query,
1761
+ primo_search_url=_primo_clean_url(context),
1762
+ follow_up_question=follow_up_question,
1763
+ follow_up_suggestions=follow_up_suggestions,
1764
+ search_context=_context_to_dict(context),
1765
+ is_follow_up=True,
1766
+ )
1767
+
1768
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
1769
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
1770
+ return AgentResponse(
1771
+ answer=answer,
1772
+ intent=context.intent,
1773
+ tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
1774
+ search_results=search_results,
1775
+ sources=sources,
1776
+ model_used=model,
1777
+ response_time=time.time() - start_time,
1778
+ corrected_query=context.topic,
1779
+ natural_query=context.ai_tool_query,
1780
+ database_query=context.primo_boolean_query,
1781
+ ai_tool_query=context.ai_tool_query,
1782
+ primo_boolean_query=context.primo_boolean_query,
1783
+ primo_search_url=source_url,
1784
+ follow_up_question=follow_up_question,
1785
+ follow_up_suggestions=follow_up_suggestions,
1786
+ search_context=_context_to_dict(context),
1787
+ is_follow_up=True,
1788
+ )
1789
+
1790
+ cls_result = await _llm_classify(question, history, model)
1791
+ intent = cls_result.get("intent", "general")
1792
+ _safe_metrics_bucket("intents", intent)
1793
+ casual_hint = cls_result.get("casual_answer", "")
1794
+
1795
+ if intent == "social":
1796
+ answer = casual_hint if casual_hint and len(casual_hint.strip()) > 20 else await _libbee_casual_response(question, history, model, casual_hint)
1797
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1798
+ return AgentResponse(
1799
+ answer=answer + _source_badge("libbee"),
1800
+ intent="social_greeting",
1801
+ tools_used=["libbee_casual"],
1802
+ model_used=model,
1803
+ response_time=time.time() - start_time,
1804
+ follow_up_question=follow_up_question,
1805
+ follow_up_suggestions=follow_up_suggestions,
1806
+ )
1807
+
1808
+ if intent == "library_info":
1809
+ try:
1810
+ from app import rag_service
1811
+ rag_results = await rag_service.hybrid_search(question, top_k=4)
1812
+ except Exception as e:
1813
+ logger.error(f"RAG search failed: {e}")
1814
+ rag_results = []
1815
+
1816
+ if not rag_results:
1817
+ answer = (
1818
+ "I couldn't find specific information on that in my knowledge base.<br><br>"
1819
+ "Please contact our librarians directly:<br>"
1820
+ 'πŸ“§ <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
1821
+ f'πŸ”— <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1822
+ )
1823
+ answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
1824
+ sources = []
1825
+ tools_used = ["rag_no_results"]
1826
+ else:
1827
+ answer = await _rag_answer(question, rag_results, history, model)
1828
+ answer += _source_badge("rag", "https://library.ku.ac.ae/")
1829
+ sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1830
+ tools_used = ["rag_hybrid_search"]
1831
+
1832
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1833
+ return AgentResponse(
1834
+ answer=answer,
1835
+ intent="library_info",
1836
+ tools_used=tools_used,
1837
+ sources=sources,
1838
+ ask_librarian=not bool(rag_results),
1839
+ model_used=model,
1840
+ response_time=time.time() - start_time,
1841
+ follow_up_question=follow_up_question,
1842
+ follow_up_suggestions=follow_up_suggestions,
1843
+ )
1844
+
1845
+ if intent in ("search_academic", "search_medical"):
1846
+ topic = await _extract_topic(question, model)
1847
+ topic = _title_case_topic(topic)
1848
+ year_from, year_to = _parse_year_filters(question)
1849
+ context = SearchContextPayload(
1850
+ context_id=str(uuid.uuid4()),
1851
+ topic=topic,
1852
+ display_topic=topic,
1853
+ intent=intent,
1854
+ source="primo",
1855
+ resource_type=_derive_resource_type(question),
1856
+ peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
1857
+ open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
1858
+ year_from=year_from,
1859
+ year_to=year_to,
1860
+ )
1861
+
1862
+ context = await _prepare_queries(question, context, model, False)
1863
+ summary_mode = _is_summary_request(question)
1864
+ deep_research_mode = bool(re.search(
1865
+ r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
1866
+ question, re.IGNORECASE,
1867
+ ))
1868
+
1869
+ if summary_mode or deep_research_mode:
1870
+ answer, search_results, sources = await _research_snapshot(context, model)
1871
+ if deep_research_mode and "Research guide" not in answer:
1872
+ answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1873
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1874
+ _safe_metrics_increment("search_handoffs")
1875
+ return AgentResponse(
1876
+ answer=answer,
1877
+ intent=intent,
1878
+ tools_used=["research_snapshot"],
1879
+ search_results=search_results,
1880
+ sources=sources,
1881
+ model_used=model,
1882
+ response_time=time.time() - start_time,
1883
+ corrected_query=context.topic,
1884
+ natural_query=context.ai_tool_query,
1885
+ database_query=context.primo_boolean_query,
1886
+ ai_tool_query=context.ai_tool_query,
1887
+ primo_boolean_query=context.primo_boolean_query,
1888
+ primo_search_url=_primo_clean_url(context),
1889
+ follow_up_question=follow_up_question,
1890
+ follow_up_suggestions=follow_up_suggestions,
1891
+ search_context=_context_to_dict(context),
1892
+ )
1893
+
1894
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
1895
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
1896
+ return AgentResponse(
1897
+ answer=answer,
1898
+ intent=intent,
1899
+ tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
1900
+ search_results=search_results,
1901
+ sources=sources,
1902
+ model_used=model,
1903
+ response_time=time.time() - start_time,
1904
+ corrected_query=context.topic,
1905
+ natural_query=context.ai_tool_query,
1906
+ database_query=context.primo_boolean_query,
1907
+ ai_tool_query=context.ai_tool_query,
1908
+ primo_boolean_query=context.primo_boolean_query,
1909
+ primo_search_url=source_url,
1910
+ follow_up_question=follow_up_question,
1911
+ follow_up_suggestions=follow_up_suggestions,
1912
+ search_context=_context_to_dict(context),
1913
+ )
1914
+
1915
+ if intent == "general_recent":
1916
+ answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
1917
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
1918
+ return AgentResponse(
1919
+ answer=answer,
1920
+ intent="general_recent",
1921
+ tools_used=["web_search"],
1922
+ model_used=model,
1923
+ response_time=time.time() - start_time,
1924
+ follow_up_question=follow_up_question,
1925
+ follow_up_suggestions=follow_up_suggestions,
1926
+ )
1927
+
1928
+ answer = await _web_search_answer(question, history, model) + _source_badge("web")
1929
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
1930
+ return AgentResponse(
1931
+ answer=answer,
1932
+ intent="general",
1933
+ tools_used=["web_search"],
1934
+ model_used=model,
1935
+ response_time=time.time() - start_time,
1936
+ follow_up_question=follow_up_question,
1937
+ follow_up_suggestions=follow_up_suggestions,
1938
+ )
1939
+
1940
+
1941
+ # ── Utility Endpoints ──────────────────────────────────────────────────────────
1942
+
1943
+ @router.get("/test")
1944
+ async def test():
1945
+ return {
1946
+ "status": "ok",
1947
+ "message": "Agent endpoint is working!",
1948
+ "staff_loaded": len(STAFF_DIRECTORY),
1949
+ }
1950
+
1951
+
1952
+ @router.get("/rag-status")
1953
+ async def rag_status():
1954
+ try:
1955
+ from app import rag_service
1956
+ return rag_service.get_stats()
1957
+ except Exception as e:
1958
+ return {"error": str(e), "ready": False}
search.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ search.py β€” LibBee v3
3
+ Key fixes:
4
+ - PRIMO: resource type (articles/books) sent as rtype FACET, not q= keyword
5
+ - PRIMO: year filter sent as facet_searchcreationdate FACET, not keyword
6
+ - PubMed: year filter wired to mindate/maxdate params (was silently ignored before)
7
+ - PubMed: peer_reviewed mapped to free full text filter
8
+ - query sent to both APIs is clean topic keywords only β€” no rtype noise
9
+ """
10
+ import logging
11
+ from typing import Optional
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from fastapi import APIRouter
16
+ from pydantic import BaseModel, field_validator
17
+
18
+ from src.config import get_settings
19
+
20
+ logger = logging.getLogger(__name__)
21
+ router = APIRouter()
22
+
23
+
24
+ class SearchRequest(BaseModel):
25
+ query: str
26
+ source: str = "primo"
27
+ limit: int = 5
28
+ peer_reviewed: bool = False
29
+ open_access: bool = False
30
+ year_from: Optional[str] = None
31
+ year_to: Optional[str] = None
32
+ # resource_type controls rtype FACET β€” NOT injected into q= keyword query
33
+ resource_type: str = "articles" # "articles" | "books" | "both"
34
+
35
+ @field_validator("query")
36
+ @classmethod
37
+ def query_not_empty(cls, v: str) -> str:
38
+ v = v.strip()
39
+ if not v:
40
+ raise ValueError("query must not be empty")
41
+ if len(v) > 500:
42
+ raise ValueError("query must be under 500 characters")
43
+ return v
44
+
45
+ @field_validator("limit")
46
+ @classmethod
47
+ def limit_in_range(cls, v: int) -> int:
48
+ if not 1 <= v <= 20:
49
+ raise ValueError("limit must be between 1 and 20")
50
+ return v
51
+
52
+ @field_validator("source")
53
+ @classmethod
54
+ def valid_source(cls, v: str) -> str:
55
+ allowed = {"primo", "pubmed", "scholar"}
56
+ if v not in allowed:
57
+ raise ValueError(f"source must be one of: {allowed}")
58
+ return v
59
+
60
+ @field_validator("resource_type")
61
+ @classmethod
62
+ def valid_resource_type(cls, v: str) -> str:
63
+ allowed = {"articles", "books", "both"}
64
+ if v not in allowed:
65
+ return "articles"
66
+ return v
67
+
68
+
69
+ def _safe_metrics_increment(key: str) -> None:
70
+ try:
71
+ from app import metrics_service
72
+ metrics_service.incr(key)
73
+ except Exception:
74
+ return
75
+
76
+
77
+ def _safe_metrics_bucket(bucket: str, key: str) -> None:
78
+ try:
79
+ from app import metrics_service
80
+ metrics_service.incr_bucket(bucket, key)
81
+ except Exception:
82
+ return
83
+
84
+
85
+ @router.post("")
86
+ async def search_endpoint(req: SearchRequest):
87
+ _safe_metrics_increment("search_requests")
88
+ _safe_metrics_bucket("search_sources", req.source)
89
+ settings = get_settings()
90
+ if req.source == "primo":
91
+ return await search_primo(req, settings.primo_api_key)
92
+ if req.source == "pubmed":
93
+ return await search_pubmed(req)
94
+ return await search_scholar(req)
95
+
96
+
97
+ def _build_exact_primo_url(doc: dict, query_encoded: str) -> str:
98
+ pnx = doc.get("pnx", {}) or {}
99
+ control = pnx.get("control", {}) or {}
100
+ search = pnx.get("search", {}) or {}
101
+ recordid = ""
102
+ for candidate in [
103
+ control.get("recordid"),
104
+ control.get("sourcerecordid"),
105
+ search.get("recordid"),
106
+ search.get("addsrcrecordid"),
107
+ ]:
108
+ if isinstance(candidate, list) and candidate:
109
+ recordid = str(candidate[0]).strip()
110
+ if recordid:
111
+ break
112
+ elif isinstance(candidate, str) and candidate.strip():
113
+ recordid = candidate.strip()
114
+ break
115
+ if recordid:
116
+ return (
117
+ "https://khalifa.primo.exlibrisgroup.com/discovery/fulldisplay"
118
+ f"?docid={quote(recordid)}&context=L&vid=971KUOSTAR_INST:KU&lang=en"
119
+ f"&search_scope=MyInst_and_CI&tab=Everything&query=any,contains,{query_encoded}&offset=0"
120
+ )
121
+ return (
122
+ f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
123
+ f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
124
+ )
125
+
126
+
127
+ # ── PRIMO ──────────────────────────────────────────────────────────────────────
128
+
129
+ async def search_primo(req: SearchRequest, api_key: str):
130
+ """
131
+ Send clean topic keywords to q=any,contains.
132
+ Resource type, peer review, open access, and year are all FACETS β€” never keywords.
133
+
134
+ PRIMO facet reference:
135
+ facet_rtype,include,articles β†’ journal articles
136
+ facet_rtype,include,books β†’ books / ebooks
137
+ facet_tlevel,include,peer_reviewed
138
+ facet_tlevel,include,online_resources (open access proxy)
139
+ facet_searchcreationdate,include,YYYY|,|YYYY
140
+ """
141
+ if not api_key:
142
+ logger.warning("PRIMO_API_KEY not configured")
143
+ _safe_metrics_bucket("errors", "primo_missing_api_key")
144
+ return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
145
+
146
+ # ── The query sent to PRIMO must be ONLY topic keywords ─────���────────────
147
+ # Strip any residual resource-type words that may have slipped through
148
+ # topic extraction (belt-and-braces defence).
149
+ clean_query = _strip_resource_noise(req.query)
150
+ if not clean_query:
151
+ clean_query = req.query # fallback if stripping removes everything
152
+
153
+ query_encoded = quote(clean_query)
154
+
155
+ url = (
156
+ f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
157
+ f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
158
+ f"&q=any,contains,{query_encoded}&lang=en&sort=rank&limit={req.limit}"
159
+ f"&offset=0&mode=advanced&apikey={api_key}"
160
+ )
161
+
162
+ # ── Build facets β€” resource type, quality, year ───────────────────────────
163
+ facets = []
164
+
165
+ # Resource type β†’ rtype facet (NOT a keyword)
166
+ if req.resource_type == "articles":
167
+ facets.append("facet_rtype,include,articles")
168
+ elif req.resource_type == "books":
169
+ facets.append("facet_rtype,include,books")
170
+ # "both" β†’ no rtype facet (search everything)
171
+
172
+ if req.peer_reviewed:
173
+ facets.append("facet_tlevel,include,peer_reviewed")
174
+
175
+ if req.open_access:
176
+ facets.append("facet_tlevel,include,online_resources")
177
+
178
+ if req.year_from or req.year_to:
179
+ yf = req.year_from or "0001"
180
+ yt = req.year_to or "9999"
181
+ facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
182
+
183
+ for facet in facets:
184
+ url += f"&multiFacets={quote(facet)}"
185
+
186
+ logger.info(
187
+ "PRIMO query=%r resource_type=%s peer_reviewed=%s year=%s-%s",
188
+ clean_query, req.resource_type, req.peer_reviewed, req.year_from, req.year_to,
189
+ )
190
+
191
+ try:
192
+ async with httpx.AsyncClient(timeout=20) as client:
193
+ r = await client.get(url, headers={"Accept": "application/json"})
194
+ logger.info("PRIMO status=%s", r.status_code)
195
+ if r.status_code != 200:
196
+ _safe_metrics_bucket("errors", f"primo_{r.status_code}")
197
+ return {"error": f"PRIMO API error: {r.status_code}", "results": [], "source": "PRIMO"}
198
+
199
+ data = r.json()
200
+ docs = data.get("docs", [])
201
+ results = []
202
+ for doc in docs[: req.limit]:
203
+ pnx = doc.get("pnx", {})
204
+ d = pnx.get("display", {})
205
+ results.append({
206
+ "title": (d.get("title") or ["Untitled"])[0],
207
+ "creator": "; ".join(d.get("creator") or d.get("contributor") or []) or "Unknown",
208
+ "date": (d.get("date") or [""])[0],
209
+ "type": (d.get("type") or [""])[0],
210
+ "source": (d.get("source") or [""])[0],
211
+ "primo_url": _build_exact_primo_url(doc, query_encoded),
212
+ "_source": "PRIMO",
213
+ })
214
+ return {
215
+ "total": data.get("info", {}).get("total", 0),
216
+ "results": results,
217
+ "source": "PRIMO",
218
+ }
219
+
220
+ except httpx.TimeoutException:
221
+ _safe_metrics_bucket("errors", "primo_timeout")
222
+ return {"error": "PRIMO API timed out β€” please try again", "results": [], "source": "PRIMO"}
223
+ except Exception as exc:
224
+ logger.error("PRIMO search error: %s", exc, exc_info=True)
225
+ _safe_metrics_bucket("errors", "primo_exception")
226
+ return {"error": str(exc), "results": [], "source": "PRIMO"}
227
+
228
+
229
+ def _strip_resource_noise(query: str) -> str:
230
+ """
231
+ Remove resource-type and filter words that should be facets, not q= keywords.
232
+ Called as a belt-and-braces guard before sending to PRIMO/PubMed.
233
+ """
234
+ noise = re.compile(
235
+ r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
236
+ r"|peer[- ]reviewed|open[- ]access|studies|study|research)\b",
237
+ re.IGNORECASE,
238
+ )
239
+ cleaned = noise.sub(" ", query)
240
+ # Collapse leftover AND/OR operators with no operands
241
+ cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
242
+ cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
243
+ cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
244
+ return re.sub(r"\s+", " ", cleaned).strip()
245
+
246
+
247
+ import re # noqa: E402 (needed for _strip_resource_noise above)
248
+
249
+
250
+ # ── PubMed ─────────────────────────────────────────────────────────────────────
251
+
252
+ async def search_pubmed(req: SearchRequest):
253
+ """
254
+ Year filters use mindate/maxdate params β€” NOT injected into the search term.
255
+ Peer-reviewed filter uses [pt] (publication type) in the term.
256
+ """
257
+ # Build clean term β€” topic keywords only
258
+ clean_query = _strip_resource_noise(req.query)
259
+ if not clean_query:
260
+ clean_query = req.query
261
+
262
+ # Add peer-reviewed as a PubMed publication-type filter (appended to term)
263
+ term = clean_query
264
+ if req.peer_reviewed:
265
+ term = f"({term}) AND Journal Article[pt]"
266
+
267
+ query_encoded = quote(term)
268
+
269
+ # Date params (separate from search term β€” correct PubMed approach)
270
+ date_params = ""
271
+ if req.year_from or req.year_to:
272
+ yf = req.year_from or "1900"
273
+ yt = req.year_to or "3000"
274
+ date_params = f"&mindate={yf}&maxdate={yt}&datetype=pdat"
275
+
276
+ logger.info(
277
+ "PubMed term=%r peer_reviewed=%s year=%s-%s",
278
+ term, req.peer_reviewed, req.year_from, req.year_to,
279
+ )
280
+
281
+ try:
282
+ async with httpx.AsyncClient(timeout=15) as client:
283
+ search_url = (
284
+ f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
285
+ f"?db=pubmed&term={query_encoded}&retmax={req.limit}&retmode=json{date_params}"
286
+ )
287
+ r = await client.get(search_url)
288
+ if r.status_code != 200:
289
+ _safe_metrics_bucket("errors", "pubmed_search_failed")
290
+ return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
291
+
292
+ ids = r.json().get("esearchresult", {}).get("idlist", [])
293
+ if not ids:
294
+ return {"total": 0, "results": [], "source": "PubMed"}
295
+
296
+ fetch_url = (
297
+ f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
298
+ f"?db=pubmed&id={','.join(ids)}&retmode=json"
299
+ )
300
+ r2 = await client.get(fetch_url)
301
+ if r2.status_code != 200:
302
+ _safe_metrics_bucket("errors", "pubmed_fetch_failed")
303
+ return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
304
+
305
+ data = r2.json().get("result", {})
306
+ results = []
307
+ for pid in ids:
308
+ rec = data.get(pid, {})
309
+ if not isinstance(rec, dict):
310
+ continue
311
+ author_list = rec.get("authors", [])
312
+ authors = ", ".join(a.get("name", "") for a in author_list[:3])
313
+ if len(author_list) > 3:
314
+ authors += " et al."
315
+ results.append({
316
+ "title": rec.get("title", ""),
317
+ "creator": authors,
318
+ "date": rec.get("pubdate", ""),
319
+ "source": rec.get("fulljournalname", rec.get("source", "")),
320
+ "pmid": pid,
321
+ "link": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
322
+ "type": "Journal Article",
323
+ "_source": "PubMed",
324
+ })
325
+ return {"total": len(ids), "results": results, "source": "PubMed"}
326
+
327
+ except Exception as exc:
328
+ logger.error("PubMed search error: %s", exc)
329
+ _safe_metrics_bucket("errors", "pubmed_exception")
330
+ return {"error": str(exc), "results": [], "source": "PubMed"}
331
+
332
+
333
+ # ── Semantic Scholar ───────────────────────────────────────────────────────────
334
+
335
+ async def search_scholar(req: SearchRequest):
336
+ try:
337
+ params = {
338
+ "query": req.query,
339
+ "limit": req.limit,
340
+ "fields": "title,authors,year,venue,externalIds,citationCount",
341
+ }
342
+ async with httpx.AsyncClient(timeout=15) as client:
343
+ r = await client.get(
344
+ "https://api.semanticscholar.org/graph/v1/paper/search",
345
+ params=params,
346
+ )
347
+ if r.status_code != 200:
348
+ _safe_metrics_bucket("errors", f"semantic_{r.status_code}")
349
+ return {"error": f"Semantic Scholar API: {r.status_code}", "results": [], "source": "Semantic Scholar"}
350
+
351
+ data = r.json()
352
+ results = []
353
+ for paper in data.get("data", []):
354
+ author_list = paper.get("authors") or []
355
+ authors = ", ".join(a.get("name", "") for a in author_list[:3])
356
+ if len(author_list) > 3:
357
+ authors += " et al."
358
+ paper_id = paper.get("paperId")
359
+ results.append({
360
+ "title": paper.get("title", ""),
361
+ "creator": authors,
362
+ "date": str(paper.get("year", "")),
363
+ "source": paper.get("venue", ""),
364
+ "citations": paper.get("citationCount", 0),
365
+ "link": f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "",
366
+ "type": "Article",
367
+ "_source": "Semantic Scholar",
368
+ })
369
+ return {"total": data.get("total", 0), "results": results, "source": "Semantic Scholar"}
370
+
371
+ except Exception as exc:
372
+ logger.error("Semantic Scholar search error: %s", exc)
373
+ _safe_metrics_bucket("errors", "semantic_exception")
374
+ return {"error": str(exc), "results": [], "source": "Semantic Scholar"}