nikeshn commited on
Commit
169fbd2
·
verified ·
1 Parent(s): 6694b37

Upload 12 files

Browse files
src/agentcore/__init__.py CHANGED
@@ -1 +1 @@
1
- """LibBee agent core: constraint-driven intent-routing pipeline, split by pipeline stage."""
 
1
+ """LibBee agent core: constraint-driven intent-routing pipeline, split by pipeline stage."""
src/agentcore/classify.py CHANGED
@@ -1,130 +1,130 @@
1
- """Stage 2-3 of the pipeline: rule-based pre-classifier and the GPT-4o-mini intent classifier."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
-
19
- from src.agentcore.models import ChatMessage
20
- from src.agentcore.constants import (
21
- CURRENT_INFO_RE,
22
- GREETING_FOLLOWUP_RE,
23
- HISTORY_WINDOW,
24
- HOURS_RE,
25
- KU_CAMPUS_RE,
26
- LIBRARY_CUE_RE,
27
- MEDICAL_KEYWORDS,
28
- MEDICAL_SEARCH_RE,
29
- RESEARCH_CUE_RE,
30
- SOCIAL_RE,
31
- SUMMARY_RE,
32
- _CLASSIFIER_SYSTEM,
33
- )
34
- from src.agentcore.utils import _get_llm
35
-
36
- logger = logging.getLogger(__name__)
37
-
38
- def _is_summary_request(question: str) -> bool:
39
- return bool(SUMMARY_RE.search(question or ""))
40
-
41
-
42
- def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
43
- if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
44
- return False
45
- for m in reversed(history):
46
- if m.role == "assistant":
47
- last = m.content.lower()
48
- return "i'm libbee" in last or "are you looking for one of these" in last
49
- return False
50
-
51
-
52
- def _looks_library_hours_question(question: str) -> bool:
53
- q = (question or "").strip().lower()
54
- return bool(HOURS_RE.search(q)) and any(
55
- kw in q for kw in (
56
- "library", "campus", "habshan", "san", "hours", "open", "close",
57
- "schedule", "till", "until", "today", "tomorrow", "friday",
58
- "saturday", "sunday", "week", "ramadan",
59
- )
60
- )
61
-
62
-
63
- def _looks_campus_question(question: str) -> bool:
64
- return bool(KU_CAMPUS_RE.search(question or ""))
65
-
66
-
67
- def _looks_medical_search(question: str) -> bool:
68
- q = (question or "").lower()
69
- if not any(kw in q for kw in MEDICAL_KEYWORDS):
70
- return False
71
- return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
72
-
73
-
74
- def _looks_research_question(question: str) -> bool:
75
- q = question or ""
76
- return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
77
-
78
-
79
- def _rule_based_classify(question: str) -> Dict[str, str]:
80
- q = (question or "").lower()
81
- if SOCIAL_RE.match((question or "").strip()):
82
- return {
83
- "intent": "social",
84
- "casual_answer": (
85
- "Hello! I'm LibBee, the KU Library AI Assistant. "
86
- "I'm here to help you with articles, books, databases, and library services. "
87
- "What would you like to find today?"
88
- ),
89
- }
90
- if _looks_medical_search(question):
91
- return {"intent": "search_medical"}
92
- if _looks_research_question(question):
93
- return {"intent": "search_academic"}
94
- if LIBRARY_CUE_RE.search(q):
95
- return {"intent": "library_info"}
96
- if CURRENT_INFO_RE.search(q):
97
- return {"intent": "general_recent"}
98
- return {"intent": "general"}
99
-
100
-
101
- async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
102
- settings = get_settings()
103
- if not settings.openai_api_key and not settings.anthropic_api_key:
104
- return _rule_based_classify(question)
105
- try:
106
- ctx_parts = [
107
- f"{m.role}: {m.content[:100]}"
108
- for m in history[-HISTORY_WINDOW:]
109
- if m.role in ("user", "assistant")
110
- ]
111
- ctx = " | ".join(ctx_parts) if ctx_parts else "none"
112
- classify_model = "gpt" if settings.openai_api_key else model
113
- llm = _get_llm(classify_model, temperature=0, max_tokens=120)
114
- response = await llm.ainvoke([
115
- {"role": "system", "content": _CLASSIFIER_SYSTEM},
116
- {"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
117
- ])
118
- raw = response.content.strip()
119
- if raw.startswith("```"):
120
- raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
121
- result = json.loads(raw)
122
- intent = result.get("intent", "general")
123
- if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general", "sensitive"}:
124
- intent = "general"
125
- return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
126
- except Exception as e:
127
- logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
128
- return _rule_based_classify(question)
129
-
130
-
 
1
+ """Stage 2-3 of the pipeline: rule-based pre-classifier and the GPT-4o-mini intent classifier."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+
19
+ from src.agentcore.models import ChatMessage
20
+ from src.agentcore.constants import (
21
+ CURRENT_INFO_RE,
22
+ GREETING_FOLLOWUP_RE,
23
+ HISTORY_WINDOW,
24
+ HOURS_RE,
25
+ KU_CAMPUS_RE,
26
+ LIBRARY_CUE_RE,
27
+ MEDICAL_KEYWORDS,
28
+ MEDICAL_SEARCH_RE,
29
+ RESEARCH_CUE_RE,
30
+ SOCIAL_RE,
31
+ SUMMARY_RE,
32
+ _CLASSIFIER_SYSTEM,
33
+ )
34
+ from src.agentcore.utils import _get_llm
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ def _is_summary_request(question: str) -> bool:
39
+ return bool(SUMMARY_RE.search(question or ""))
40
+
41
+
42
+ def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
43
+ if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
44
+ return False
45
+ for m in reversed(history):
46
+ if m.role == "assistant":
47
+ last = m.content.lower()
48
+ return "i'm libbee" in last or "are you looking for one of these" in last
49
+ return False
50
+
51
+
52
+ def _looks_library_hours_question(question: str) -> bool:
53
+ q = (question or "").strip().lower()
54
+ return bool(HOURS_RE.search(q)) and any(
55
+ kw in q for kw in (
56
+ "library", "campus", "habshan", "san", "hours", "open", "close",
57
+ "schedule", "till", "until", "today", "tomorrow", "friday",
58
+ "saturday", "sunday", "week", "ramadan",
59
+ )
60
+ )
61
+
62
+
63
+ def _looks_campus_question(question: str) -> bool:
64
+ return bool(KU_CAMPUS_RE.search(question or ""))
65
+
66
+
67
+ def _looks_medical_search(question: str) -> bool:
68
+ q = (question or "").lower()
69
+ if not any(kw in q for kw in MEDICAL_KEYWORDS):
70
+ return False
71
+ return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
72
+
73
+
74
+ def _looks_research_question(question: str) -> bool:
75
+ q = question or ""
76
+ return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
77
+
78
+
79
+ def _rule_based_classify(question: str) -> Dict[str, str]:
80
+ q = (question or "").lower()
81
+ if SOCIAL_RE.match((question or "").strip()):
82
+ return {
83
+ "intent": "social",
84
+ "casual_answer": (
85
+ "Hello! I'm LibBee, the KU Library AI Assistant. "
86
+ "I'm here to help you with articles, books, databases, and library services. "
87
+ "What would you like to find today?"
88
+ ),
89
+ }
90
+ if _looks_medical_search(question):
91
+ return {"intent": "search_medical"}
92
+ if _looks_research_question(question):
93
+ return {"intent": "search_academic"}
94
+ if LIBRARY_CUE_RE.search(q):
95
+ return {"intent": "library_info"}
96
+ if CURRENT_INFO_RE.search(q):
97
+ return {"intent": "general_recent"}
98
+ return {"intent": "general"}
99
+
100
+
101
+ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
102
+ settings = get_settings()
103
+ if not settings.openai_api_key and not settings.anthropic_api_key:
104
+ return _rule_based_classify(question)
105
+ try:
106
+ ctx_parts = [
107
+ f"{m.role}: {m.content[:100]}"
108
+ for m in history[-HISTORY_WINDOW:]
109
+ if m.role in ("user", "assistant")
110
+ ]
111
+ ctx = " | ".join(ctx_parts) if ctx_parts else "none"
112
+ classify_model = "gpt" if settings.openai_api_key else model
113
+ llm = _get_llm(classify_model, temperature=0, max_tokens=120)
114
+ response = await llm.ainvoke([
115
+ {"role": "system", "content": _CLASSIFIER_SYSTEM},
116
+ {"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
117
+ ])
118
+ raw = response.content.strip()
119
+ if raw.startswith("```"):
120
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
121
+ result = json.loads(raw)
122
+ intent = result.get("intent", "general")
123
+ if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general", "sensitive"}:
124
+ intent = "general"
125
+ return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
126
+ except Exception as e:
127
+ logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
128
+ return _rule_based_classify(question)
129
+
130
+
src/agentcore/constants.py CHANGED
The diff for this file is too large to render. See raw diff
 
src/agentcore/intents_general.py CHANGED
@@ -1,160 +1,160 @@
1
- """general / social intents: web-search answers, general LLM answers, casual persona replies."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
-
19
- from src.agentcore.models import ChatMessage
20
- from src.agentcore.constants import KU_MAIN_URL, _GUARDRAIL, _URL_INSTRUCTION
21
- from src.agentcore.utils import _build_history_messages, _get_llm, _get_runtime_config
22
-
23
- logger = logging.getLogger(__name__)
24
-
25
- async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
26
- """LLM #1 — casual social response. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
27
- if hint and len(hint.strip()) > 20:
28
- return hint.strip()
29
- settings = get_settings()
30
- if not settings.openai_api_key and not settings.anthropic_api_key:
31
- return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
32
- try:
33
- llm = _get_llm(model, temperature=0.6, max_tokens=160)
34
- msgs = [{"role": "system", "content": (
35
- "You are LibBee, the Khalifa University Library AI Assistant. "
36
- "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
37
- "No markdown, no bullet points. "
38
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
39
- )}]
40
- _cfg = _get_runtime_config()
41
- _ci = _cfg.get("custom_instructions", "").strip()
42
- if _ci:
43
- msgs[0]["content"] += " " + _ci
44
- msgs.extend(_build_history_messages(history))
45
- msgs.append({"role": "user", "content": question})
46
- response = await llm.ainvoke(msgs)
47
- return response.content.strip()
48
- except Exception:
49
- return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
50
-
51
-
52
- async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
53
- """LLM #3 — general factual answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
54
- settings = get_settings()
55
- if not settings.openai_api_key and not settings.anthropic_api_key:
56
- return (
57
- "I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
58
- f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
59
- )
60
- system = (
61
- "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
62
- "Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
63
- "KU means Khalifa University. If the topic is academic, briefly offer to help find library resources. "
64
- "When referencing KU Library services, include these verified links where relevant: "
65
- "Library homepage: https://library.ku.ac.ae | "
66
- "Ask a Librarian: https://library.ku.ac.ae/AskUs | "
67
- "Library hours: https://library.ku.ac.ae/hours | "
68
- "ILL requests: https://library.ku.ac.ae/ill/ | "
69
- "Study rooms: https://library.ku.ac.ae/rooms/ | "
70
- "E-resources: https://library.ku.ac.ae/eresources | "
71
- "Khazna repository: https://khazna.ku.ac.ae | "
72
- "PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
73
- "Only use these exact URLs — never invent others. "
74
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
75
- )
76
- _cfg = _get_runtime_config()
77
- _ci = _cfg.get("custom_instructions", "").strip()
78
- if _ci:
79
- system += " " + _ci
80
- try:
81
- llm = _get_llm(model, temperature=0.3, max_tokens=360)
82
- msgs = [{"role": "system", "content": system}]
83
- msgs.extend(_build_history_messages(history))
84
- msgs.append({"role": "user", "content": question})
85
- response = await llm.ainvoke(msgs)
86
- return response.content.strip()
87
- except Exception as e:
88
- logger.error(f"_llm_general_answer error: {e}")
89
- return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
90
-
91
-
92
- async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
93
- """LLM #4 — web search answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
94
- settings = get_settings()
95
- _WEB_SYSTEM = (
96
- "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
97
- "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks. "
98
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
99
- )
100
- if model == "claude" and settings.anthropic_api_key:
101
- try:
102
- import anthropic
103
- client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
104
- response = client.messages.create(
105
- model="claude-haiku-4-5-20251001",
106
- max_tokens=520,
107
- system=_WEB_SYSTEM,
108
- tools=[{"type": "web_search_20250305", "name": "web_search"}],
109
- messages=[{"role": "user", "content": question}],
110
- )
111
- text = "".join(block.text for block in response.content if hasattr(block, "text"))
112
- if text.strip():
113
- return text.strip()
114
- except Exception as e:
115
- logger.warning(f"Claude web search failed: {e}")
116
- if settings.openai_api_key:
117
- try:
118
- from openai import OpenAI
119
- client = OpenAI(api_key=settings.openai_api_key)
120
- response = client.responses.create(
121
- model="gpt-4o-mini",
122
- tools=[{"type": "web_search_preview"}],
123
- instructions=_WEB_SYSTEM,
124
- input=question,
125
- )
126
- text = ""
127
- for item in response.output:
128
- if hasattr(item, "content"):
129
- for block in item.content:
130
- if hasattr(block, "text"):
131
- text += block.text
132
- if text.strip():
133
- return text.strip()
134
- except Exception as e:
135
- logger.warning(f"GPT web search failed: {e}")
136
- return await _llm_general_answer(question, history, model)
137
-
138
-
139
- def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
140
- return (
141
- "Would you like a brief explanation, current web information, or KU Library resources on this topic?",
142
- [
143
- {"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
144
- {"label": "Current web information", "question": f"Show current web information on {question}"},
145
- {"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
146
- ],
147
- )
148
-
149
-
150
- def _social_follow_up() -> Tuple[str, List[dict]]:
151
- return (
152
- "What would you like help with next?",
153
- [
154
- {"label": "Find articles on a topic", "question": "Find articles on a topic"},
155
- {"label": "Check a library service", "question": "Check a library service"},
156
- {"label": "Contact a librarian", "question": "Contact a librarian"},
157
- ],
158
- )
159
-
160
-
 
1
+ """general / social intents: web-search answers, general LLM answers, casual persona replies."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+
19
+ from src.agentcore.models import ChatMessage
20
+ from src.agentcore.constants import KU_MAIN_URL, _GUARDRAIL, _URL_INSTRUCTION
21
+ from src.agentcore.utils import _build_history_messages, _get_llm, _get_runtime_config
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
26
+ """LLM #1 — casual social response. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
27
+ if hint and len(hint.strip()) > 20:
28
+ return hint.strip()
29
+ settings = get_settings()
30
+ if not settings.openai_api_key and not settings.anthropic_api_key:
31
+ return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
32
+ try:
33
+ llm = _get_llm(model, temperature=0.6, max_tokens=160)
34
+ msgs = [{"role": "system", "content": (
35
+ "You are LibBee, the Khalifa University Library AI Assistant. "
36
+ "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
37
+ "No markdown, no bullet points. "
38
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
39
+ )}]
40
+ _cfg = _get_runtime_config()
41
+ _ci = _cfg.get("custom_instructions", "").strip()
42
+ if _ci:
43
+ msgs[0]["content"] += " " + _ci
44
+ msgs.extend(_build_history_messages(history))
45
+ msgs.append({"role": "user", "content": question})
46
+ response = await llm.ainvoke(msgs)
47
+ return response.content.strip()
48
+ except Exception:
49
+ return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
50
+
51
+
52
+ async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
53
+ """LLM #3 — general factual answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
54
+ settings = get_settings()
55
+ if not settings.openai_api_key and not settings.anthropic_api_key:
56
+ return (
57
+ "I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
58
+ f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
59
+ )
60
+ system = (
61
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
62
+ "Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
63
+ "KU means Khalifa University. If the topic is academic, briefly offer to help find library resources. "
64
+ "When referencing KU Library services, include these verified links where relevant: "
65
+ "Library homepage: https://library.ku.ac.ae | "
66
+ "Ask a Librarian: https://library.ku.ac.ae/AskUs | "
67
+ "Library hours: https://library.ku.ac.ae/hours | "
68
+ "ILL requests: https://library.ku.ac.ae/ill/ | "
69
+ "Study rooms: https://library.ku.ac.ae/rooms/ | "
70
+ "E-resources: https://library.ku.ac.ae/eresources | "
71
+ "Khazna repository: https://khazna.ku.ac.ae | "
72
+ "PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
73
+ "Only use these exact URLs — never invent others. "
74
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
75
+ )
76
+ _cfg = _get_runtime_config()
77
+ _ci = _cfg.get("custom_instructions", "").strip()
78
+ if _ci:
79
+ system += " " + _ci
80
+ try:
81
+ llm = _get_llm(model, temperature=0.3, max_tokens=360)
82
+ msgs = [{"role": "system", "content": system}]
83
+ msgs.extend(_build_history_messages(history))
84
+ msgs.append({"role": "user", "content": question})
85
+ response = await llm.ainvoke(msgs)
86
+ return response.content.strip()
87
+ except Exception as e:
88
+ logger.error(f"_llm_general_answer error: {e}")
89
+ return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
90
+
91
+
92
+ async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
93
+ """LLM #4 — web search answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
94
+ settings = get_settings()
95
+ _WEB_SYSTEM = (
96
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
97
+ "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks. "
98
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
99
+ )
100
+ if model == "claude" and settings.anthropic_api_key:
101
+ try:
102
+ import anthropic
103
+ client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
104
+ response = await client.messages.create(
105
+ model="claude-haiku-4-5-20251001",
106
+ max_tokens=520,
107
+ system=_WEB_SYSTEM,
108
+ tools=[{"type": "web_search_20250305", "name": "web_search"}],
109
+ messages=[{"role": "user", "content": question}],
110
+ )
111
+ text = "".join(block.text for block in response.content if hasattr(block, "text"))
112
+ if text.strip():
113
+ return text.strip()
114
+ except Exception as e:
115
+ logger.warning(f"Claude web search failed: {e}")
116
+ if settings.openai_api_key:
117
+ try:
118
+ from openai import AsyncOpenAI
119
+ client = AsyncOpenAI(api_key=settings.openai_api_key)
120
+ response = await client.responses.create(
121
+ model="gpt-4o-mini",
122
+ tools=[{"type": "web_search_preview"}],
123
+ instructions=_WEB_SYSTEM,
124
+ input=question,
125
+ )
126
+ text = ""
127
+ for item in response.output:
128
+ if hasattr(item, "content"):
129
+ for block in item.content:
130
+ if hasattr(block, "text"):
131
+ text += block.text
132
+ if text.strip():
133
+ return text.strip()
134
+ except Exception as e:
135
+ logger.warning(f"GPT web search failed: {e}")
136
+ return await _llm_general_answer(question, history, model)
137
+
138
+
139
+ def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
140
+ return (
141
+ "Would you like a brief explanation, current web information, or KU Library resources on this topic?",
142
+ [
143
+ {"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
144
+ {"label": "Current web information", "question": f"Show current web information on {question}"},
145
+ {"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
146
+ ],
147
+ )
148
+
149
+
150
+ def _social_follow_up() -> Tuple[str, List[dict]]:
151
+ return (
152
+ "What would you like help with next?",
153
+ [
154
+ {"label": "Find articles on a topic", "question": "Find articles on a topic"},
155
+ {"label": "Check a library service", "question": "Check a library service"},
156
+ {"label": "Contact a librarian", "question": "Contact a librarian"},
157
+ ],
158
+ )
159
+
160
+
src/agentcore/intents_library.py CHANGED
@@ -1,280 +1,280 @@
1
- """library_info intent handlers: campus, RAG answers, database recommendations, full-text chain, theses."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
- from src.services.staff_service import (
19
- STAFF_DIRECTORY,
20
- match_staff_name,
21
- match_staff_role,
22
- should_attempt_staff_lookup,
23
- staff_name_answer,
24
- staff_role_answer,
25
- )
26
-
27
- from src.agentcore.models import ChatMessage
28
- from src.agentcore.constants import (
29
- ASK_LIBRARIAN_URL,
30
- DATABASE_RECOMMEND_RE,
31
- _DB_GENERAL,
32
- _DB_SUBJECTS,
33
- _GUARDRAIL,
34
- _URL_INSTRUCTION,
35
- )
36
- from src.agentcore.utils import (
37
- _build_history_messages,
38
- _escape,
39
- _get_llm,
40
- _get_runtime_config,
41
- _normalize_whitespace,
42
- _strip_resource_noise,
43
- )
44
- from src.agentcore.classify import _looks_library_hours_question
45
-
46
- logger = logging.getLogger(__name__)
47
-
48
- def _campus_answer() -> str:
49
- return (
50
- "Khalifa University Library has <strong>two branches</strong>:<br><br>"
51
- "<strong>1. Main Campus Library</strong><br>"
52
- "Location: E Building, Main Campus, Abu Dhabi<br>"
53
- 'Reference desk: <a href="tel:+97123124604">+971 2 312 4604</a><br><br>'
54
- "<strong>2. Habshan Library (SAN Campus)</strong><br>"
55
- "Location: Building 5, Ground Floor &amp; First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi<br>"
56
- 'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
57
- "Both branches offer study spaces, computer workstations, printing, scanning, "
58
- "and access to physical collections.<br><br>"
59
- f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
60
- 'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
61
- )
62
-
63
-
64
- def _greeting_menu_clarify_answer() -> str:
65
- return (
66
- "Sure — what would you like help with?<br><br>"
67
- "You can choose one of these or type your question directly:<br>"
68
- "• Find articles and books<br>"
69
- "• Get full text for an article<br>"
70
- "• Submit an Interlibrary Loan (ILL) request<br>"
71
- "• Contact a librarian<br>"
72
- "• Check library hours"
73
- )
74
-
75
-
76
- async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
77
- """LLM #2 — RAG answer from KB chunks. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
78
- settings = get_settings()
79
- if not settings.openai_api_key and not settings.anthropic_api_key:
80
- return rag_results[0]["content"] if rag_results else ""
81
- context = "\n\n---\n\n".join(r["content"] for r in rag_results)
82
- system = (
83
- "You are LibBee, the Khalifa University Library AI Assistant. "
84
- "Answer the user's question using ONLY the context provided below. "
85
- "Be concise, friendly, and use HTML <br> for line breaks where needed. "
86
- "IMPORTANT — LINKS: When the context contains a URL (starting with https:// or http://), "
87
- "include it as a clickable HTML link using <a href=\"URL\" target=\"_blank\">link text</a>. "
88
- "For example, if context mentions 'library.ku.ac.ae/ill/' include it as a link. "
89
- "NEVER invent or guess URLs that do not appear in the context. "
90
- "Only link to URLs that are explicitly present in the context below. "
91
- f"If the context does not contain enough information, say so and suggest Ask a Librarian at "
92
- f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> or '
93
- f'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.\n\n'
94
- f"CONTEXT:\n{context}\n\n"
95
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
96
- )
97
- _cfg = _get_runtime_config()
98
- _ci = _cfg.get("custom_instructions", "").strip()
99
- if _ci:
100
- system += "\n\nAdditional instructions: " + _ci
101
- try:
102
- llm = _get_llm(model, temperature=0.2, max_tokens=380)
103
- msgs = [{"role": "system", "content": system}]
104
- msgs.extend(_build_history_messages(history))
105
- msgs.append({"role": "user", "content": question})
106
- response = await llm.ainvoke(msgs)
107
- return response.content.strip()
108
- except Exception as e:
109
- logger.error(f"_rag_answer error: {e}")
110
- return (
111
- "I'm having trouble generating an answer right now. "
112
- f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
113
- )
114
-
115
-
116
- def _is_database_recommendation_question(question: str) -> bool:
117
- return bool(DATABASE_RECOMMEND_RE.search(question or ""))
118
-
119
-
120
- def _match_db_subject(q: str) -> Optional[str]:
121
- checks = [
122
- (["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
123
- (["robotics", "autonomous system", "intelligent system"], "robotics"),
124
- (["artificial intelligence", "machine learning", "deep learning", "nlp", "natural language processing", "neural network", "cybersecurity", "computer science", "software engineering", "algorithm", "data structure"], "computer_science"),
125
- (["computer engineering", "embedded system", "computer architecture", "networking", "digital system"], "computer_science"),
126
- (["aerospace", "aerodynamic", "propulsion", "aircraft", "aviation", "flight system"], "aerospace"),
127
- (["biomedical engineering", "medical device", "biomechanic", "bioinstrument", "tissue engineering", "biomedical imaging"], "biomedical"),
128
- (["chemical engineering", "process engineering", "reaction engineering", "separation", "catalysis"], "chemical_engineering"),
129
- (["chemistry", "organic chemistry", "analytical chemistry", "inorganic", "chemical", "chemical properties"], "chemistry"),
130
- (["civil engineering", "structural engineering", "geotechnical", "transportation engineering", "water resources", "construction"], "civil_engineering"),
131
- (["electrical engineering", "circuit", "power system", "signal processing", "control system", "communication system", "electronics"], "electrical_engineering"),
132
- (["energy engineering", "renewable energy", "sustainable energy", "clean energy", "energy transition", "solar", "wind power"], "energy"),
133
- (["engineering management", "engineering systems", "systems engineering", "operations management", "innovation management"], "engineering_management"),
134
- (["mechanical engineering", "thermofluid", "thermodynamics", "fluid mechanics", "manufacturing", "mechatronics"], "mechanical_engineering"),
135
- (["nuclear engineering", "reactor", "radiation", "nuclear material", "nuclear safety"], "nuclear"),
136
- (["petroleum engineering", "oil and gas", "drilling", "reservoir", "subsurface", "petroleum"], "petroleum"),
137
- (["earth science", "geology", "geophysics", "geochemistry", "planetary science", "geoscience"], "earth_science"),
138
- (["environment", "sustainability", "climate change", "sustainable development", "conservation", "environmental policy", "ecology"], "environment"),
139
- (["materials science", "nanotechnology", "nanomaterial", "biomaterial", "advanced material", "functional material"], "materials"),
140
- (["cell biology", "molecular biology", "genetics", "genomics", "biotechnology", "bioinformatics", "microbiology", "virology"], "biology"),
141
- (["medicine", "medical", "health science", "clinical", "nursing", "pharmacy", "biomedical literature", "pharmacology", "clinical trial", "systematic review", "evidence-based", "patient care"], "medical"),
142
- (["business", "management", "finance", "economics", "accounting", "entrepreneurship", "marketing", "strategy", "leadership", "international relations", "political science", "social science", "humanities", "law", "education", "psychology", "sociology"], "business"),
143
- (["physics", "optics", "photonics", "quantum physics", "condensed matter", "theoretical physics", "astrophysics"], "physics"),
144
- (["mathematics", "statistics", "data science", "calculus", "algebra", "optimization", "probability", "mathematical model"], "mathematics"),
145
- (["impact factor", "journal metrics", "citescore", "jcr", "quartile", "research impact", "scival", "bibliometric"], "metrics"),
146
- (["dissertation", "thesis", "doctoral", "master thesis", "repository", "phd thesis"], "theses"),
147
- ]
148
- for keywords, key in checks:
149
- if any(kw in q for kw in keywords):
150
- return key
151
- return None
152
-
153
-
154
- def _extract_db_topic(question: str) -> str:
155
- q = _normalize_whitespace(question or "")
156
- patterns = [
157
- r"^(best|good|recommended?)\s+databases?\s+for\s+",
158
- r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
159
- r"^databases?\s+for\s+",
160
- r"^where should i (search|start)\s+(for\s+)?",
161
- r"^what (database|databases)\s+(for|should i use for)\s+",
162
- ]
163
- lower = q.lower()
164
- for pat in patterns:
165
- m = re.match(pat, lower)
166
- if m:
167
- q = q[m.end():]
168
- break
169
- q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
170
- q = _strip_resource_noise(q)
171
- return _normalize_whitespace(q).strip(".?") or "this subject"
172
-
173
-
174
- def _database_recommendation_answer(question: str) -> str:
175
- q = (question or "").lower()
176
- subject = _extract_db_topic(question)
177
- topic = _escape(subject)
178
- subject_key = _match_db_subject(q)
179
- dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
180
- dbs = list(dbs)[:5]
181
- answer = f"<strong>Recommended databases for {topic}</strong><br><br>"
182
- answer += "Here are the strongest KU-subscribed starting points:<br>"
183
- for name, url, why in dbs:
184
- answer += f'<br>• <a href="{url}" target="_blank"><strong>{_escape(name)}</strong></a> — {_escape(why)}'
185
- if not subject_key:
186
- answer += (
187
- "<br><br>💡 For subject-specific databases, ask me: "
188
- "<em>\"best databases for [your subject]\"</em> — e.g. physics, chemistry, medicine, civil engineering."
189
- )
190
- answer += (
191
- f'<br><br>Browse all 50+ KU databases: '
192
- f'<a href="https://library.ku.ac.ae/eresources" target="_blank">library.ku.ac.ae/eresources</a>'
193
- )
194
- answer += "<br><br>Want me to run a direct search on any of these for a specific topic?"
195
- return answer
196
-
197
-
198
- def _fulltext_chain_answer(question: str) -> str:
199
- return (
200
- "<strong>📄 How to get full text for an article</strong><br><br>"
201
- "Follow this chain in order — each step is faster than the next:<br><br>"
202
- "<strong>Step 1 — Search KU PRIMO</strong><br>"
203
- "Find the article in <a href=\"https://khalifa.primo.exlibrisgroup.com/discovery/search"
204
- "?vid=971KUOSTAR_INST:KU\" target=\"_blank\">KU Library Discovery</a>. "
205
- "If KU subscribes, you'll see a <em>Full Text Available</em> or <em>Online Access</em> link. "
206
- "Click it — it passes through the IDM proxy automatically when on campus or logged in.<br><br>"
207
- "<strong>Step 2 — Try LibKey Nomad (browser extension)</strong><br>"
208
- "<a href=\"https://libkey.io/libraries/3025/\" target=\"_blank\">LibKey Nomad</a> is a free browser "
209
- "extension that detects articles you're viewing and fetches KU's full text automatically. "
210
- "Install it once and it works on every publisher site.<br><br>"
211
- "<strong>Step 3 — Try the publisher or open access</strong><br>"
212
- "Visit the DOI link directly. Many authors post their accepted manuscript on "
213
- "<a href=\"https://arxiv.org\" target=\"_blank\">arXiv</a>, "
214
- "<a href=\"https://www.researchgate.net\" target=\"_blank\">ResearchGate</a>, or their institutional page. "
215
- "<a href=\"https://openalex.org\" target=\"_blank\">OpenAlex</a> also links to many legal open-access PDFs.<br><br>"
216
- "<strong>Step 4 — Submit an Interlibrary Loan (ILL) request</strong><br>"
217
- "If none of the above work, KU Library can borrow the article from another library — usually within 1–5 working days. "
218
- "Submit here: <a href=\"https://library.ku.ac.ae/ill/\" target=\"_blank\">library.ku.ac.ae/ill/</a><br>"
219
- "Contact: <strong>Suaad Al Jneibi</strong> · "
220
- "<a href=\"mailto:suaad.aljneibi@ku.ac.ae\">suaad.aljneibi@ku.ac.ae</a> · +971 2 312 4278"
221
- )
222
-
223
-
224
- def _theses_answer() -> str:
225
- return (
226
- "<strong>📘 Finding KU theses, dissertations, and institutional research</strong><br><br>"
227
- "<strong>Khalifa University Khazna Repository</strong><br>"
228
- "Khazna is KU's institutional repository — it holds KU theses, dissertations, "
229
- "faculty publications, conference papers, and open-access research outputs.<br>"
230
- '🔗 <a href="https://khazna.ku.ac.ae" target="_blank">khazna.ku.ac.ae</a><br><br>'
231
- "<strong>ProQuest Dissertations &amp; Theses Global</strong><br>"
232
- "KU subscribes to ProQuest D&T Global — the world's largest database of "
233
- "theses and dissertations from universities worldwide.<br>"
234
- '🔗 <a href="https://khalifa.idm.oclc.org/login?url=https://www.proquest.com/pqdtglobal" target="_blank">'
235
- "Access via KU Library</a><br><br>"
236
- "<strong>Other sources</strong><br>"
237
- '• <a href="https://ethos.bl.uk" target="_blank">EThOS</a> — UK theses (British Library)<br>'
238
- '• <a href="https://www.dart-europe.org" target="_blank">DART-Europe</a> — European theses<br>'
239
- '• <a href="https://ndltd.org" target="_blank">NDLTD</a> — Global networked digital library of theses<br><br>'
240
- "For help with Khazna deposits or ORCID integration, contact: "
241
- "<strong>Nikesh Narayanan</strong> · "
242
- "<a href=\"mailto:nikesh.narayanan@ku.ac.ae\">nikesh.narayanan@ku.ac.ae</a>"
243
- )
244
-
245
-
246
- def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
247
- q = (question or "").lower()
248
- if _looks_library_hours_question(question):
249
- return (
250
- "Would you like the library contact details too?",
251
- [
252
- {"label": "Show library contact details", "question": "Show the library contact details"},
253
- {"label": "Open the live library hours page", "question": "Open the live library hours page"},
254
- ],
255
- )
256
- if "study room" in q or "room" in q:
257
- return (
258
- "Would you like the study-room booking link?",
259
- [
260
- {"label": "Study-room booking link", "question": "Show the study-room booking link"},
261
- {"label": "Library services for both campuses", "question": "Show library services for both campuses"},
262
- ],
263
- )
264
- if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
265
- return (
266
- "Would you like me to recommend the best KU databases for this topic?",
267
- [
268
- {"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
269
- {"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
270
- ],
271
- )
272
- return (
273
- "Would you like a related library service or staff contact?",
274
- [
275
- {"label": "Best staff contact", "question": "Show the best staff contact for this"},
276
- {"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
277
- ],
278
- )
279
-
280
-
 
1
+ """library_info intent handlers: campus, RAG answers, database recommendations, full-text chain, theses."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+ from src.services.staff_service import (
19
+ STAFF_DIRECTORY,
20
+ match_staff_name,
21
+ match_staff_role,
22
+ should_attempt_staff_lookup,
23
+ staff_name_answer,
24
+ staff_role_answer,
25
+ )
26
+
27
+ from src.agentcore.models import ChatMessage
28
+ from src.agentcore.constants import (
29
+ ASK_LIBRARIAN_URL,
30
+ DATABASE_RECOMMEND_RE,
31
+ _DB_GENERAL,
32
+ _DB_SUBJECTS,
33
+ _GUARDRAIL,
34
+ _URL_INSTRUCTION,
35
+ )
36
+ from src.agentcore.utils import (
37
+ _build_history_messages,
38
+ _escape,
39
+ _get_llm,
40
+ _get_runtime_config,
41
+ _normalize_whitespace,
42
+ _strip_resource_noise,
43
+ )
44
+ from src.agentcore.classify import _looks_library_hours_question
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ def _campus_answer() -> str:
49
+ return (
50
+ "Khalifa University Library has <strong>two branches</strong>:<br><br>"
51
+ "<strong>1. Main Campus Library</strong><br>"
52
+ "Location: E Building, Main Campus, Abu Dhabi<br>"
53
+ 'Reference desk: <a href="tel:+97123124604">+971 2 312 4604</a><br><br>'
54
+ "<strong>2. Habshan Library (SAN Campus)</strong><br>"
55
+ "Location: Building 5, Ground Floor &amp; First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi<br>"
56
+ 'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
57
+ "Both branches offer study spaces, computer workstations, printing, scanning, "
58
+ "and access to physical collections.<br><br>"
59
+ f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
60
+ 'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
61
+ )
62
+
63
+
64
+ def _greeting_menu_clarify_answer() -> str:
65
+ return (
66
+ "Sure — what would you like help with?<br><br>"
67
+ "You can choose one of these or type your question directly:<br>"
68
+ "• Find articles and books<br>"
69
+ "• Get full text for an article<br>"
70
+ "• Submit an Interlibrary Loan (ILL) request<br>"
71
+ "• Contact a librarian<br>"
72
+ "• Check library hours"
73
+ )
74
+
75
+
76
+ async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
77
+ """LLM #2 — RAG answer from KB chunks. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
78
+ settings = get_settings()
79
+ if not settings.openai_api_key and not settings.anthropic_api_key:
80
+ return rag_results[0]["content"] if rag_results else ""
81
+ context = "\n\n---\n\n".join(r["content"] for r in rag_results)
82
+ system = (
83
+ "You are LibBee, the Khalifa University Library AI Assistant. "
84
+ "Answer the user's question using ONLY the context provided below. "
85
+ "Be concise, friendly, and use HTML <br> for line breaks where needed. "
86
+ "IMPORTANT — LINKS: When the context contains a URL (starting with https:// or http://), "
87
+ "include it as a clickable HTML link using <a href=\"URL\" target=\"_blank\">link text</a>. "
88
+ "For example, if context mentions 'library.ku.ac.ae/ill/' include it as a link. "
89
+ "NEVER invent or guess URLs that do not appear in the context. "
90
+ "Only link to URLs that are explicitly present in the context below. "
91
+ f"If the context does not contain enough information, say so and suggest Ask a Librarian at "
92
+ f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> or '
93
+ f'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.\n\n'
94
+ f"CONTEXT:\n{context}\n\n"
95
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
96
+ )
97
+ _cfg = _get_runtime_config()
98
+ _ci = _cfg.get("custom_instructions", "").strip()
99
+ if _ci:
100
+ system += "\n\nAdditional instructions: " + _ci
101
+ try:
102
+ llm = _get_llm(model, temperature=0.2, max_tokens=380)
103
+ msgs = [{"role": "system", "content": system}]
104
+ msgs.extend(_build_history_messages(history))
105
+ msgs.append({"role": "user", "content": question})
106
+ response = await llm.ainvoke(msgs)
107
+ return response.content.strip()
108
+ except Exception as e:
109
+ logger.error(f"_rag_answer error: {e}")
110
+ return (
111
+ "I'm having trouble generating an answer right now. "
112
+ f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
113
+ )
114
+
115
+
116
+ def _is_database_recommendation_question(question: str) -> bool:
117
+ return bool(DATABASE_RECOMMEND_RE.search(question or ""))
118
+
119
+
120
+ def _match_db_subject(q: str) -> Optional[str]:
121
+ checks = [
122
+ (["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
123
+ (["robotics", "autonomous system", "intelligent system"], "robotics"),
124
+ (["artificial intelligence", "machine learning", "deep learning", "nlp", "natural language processing", "neural network", "cybersecurity", "computer science", "software engineering", "algorithm", "data structure"], "computer_science"),
125
+ (["computer engineering", "embedded system", "computer architecture", "networking", "digital system"], "computer_science"),
126
+ (["aerospace", "aerodynamic", "propulsion", "aircraft", "aviation", "flight system"], "aerospace"),
127
+ (["biomedical engineering", "medical device", "biomechanic", "bioinstrument", "tissue engineering", "biomedical imaging"], "biomedical"),
128
+ (["chemical engineering", "process engineering", "reaction engineering", "separation", "catalysis"], "chemical_engineering"),
129
+ (["chemistry", "organic chemistry", "analytical chemistry", "inorganic", "chemical", "chemical properties"], "chemistry"),
130
+ (["civil engineering", "structural engineering", "geotechnical", "transportation engineering", "water resources", "construction"], "civil_engineering"),
131
+ (["electrical engineering", "circuit", "power system", "signal processing", "control system", "communication system", "electronics"], "electrical_engineering"),
132
+ (["energy engineering", "renewable energy", "sustainable energy", "clean energy", "energy transition", "solar", "wind power"], "energy"),
133
+ (["engineering management", "engineering systems", "systems engineering", "operations management", "innovation management"], "engineering_management"),
134
+ (["mechanical engineering", "thermofluid", "thermodynamics", "fluid mechanics", "manufacturing", "mechatronics"], "mechanical_engineering"),
135
+ (["nuclear engineering", "reactor", "radiation", "nuclear material", "nuclear safety"], "nuclear"),
136
+ (["petroleum engineering", "oil and gas", "drilling", "reservoir", "subsurface", "petroleum"], "petroleum"),
137
+ (["earth science", "geology", "geophysics", "geochemistry", "planetary science", "geoscience"], "earth_science"),
138
+ (["environment", "sustainability", "climate change", "sustainable development", "conservation", "environmental policy", "ecology"], "environment"),
139
+ (["materials science", "nanotechnology", "nanomaterial", "biomaterial", "advanced material", "functional material"], "materials"),
140
+ (["cell biology", "molecular biology", "genetics", "genomics", "biotechnology", "bioinformatics", "microbiology", "virology"], "biology"),
141
+ (["medicine", "medical", "health science", "clinical", "nursing", "pharmacy", "biomedical literature", "pharmacology", "clinical trial", "systematic review", "evidence-based", "patient care"], "medical"),
142
+ (["business", "management", "finance", "economics", "accounting", "entrepreneurship", "marketing", "strategy", "leadership", "international relations", "political science", "social science", "humanities", "law", "education", "psychology", "sociology"], "business"),
143
+ (["physics", "optics", "photonics", "quantum physics", "condensed matter", "theoretical physics", "astrophysics"], "physics"),
144
+ (["mathematics", "statistics", "data science", "calculus", "algebra", "optimization", "probability", "mathematical model"], "mathematics"),
145
+ (["impact factor", "journal metrics", "citescore", "jcr", "quartile", "research impact", "scival", "bibliometric"], "metrics"),
146
+ (["dissertation", "thesis", "doctoral", "master thesis", "repository", "phd thesis"], "theses"),
147
+ ]
148
+ for keywords, key in checks:
149
+ if any(kw in q for kw in keywords):
150
+ return key
151
+ return None
152
+
153
+
154
+ def _extract_db_topic(question: str) -> str:
155
+ q = _normalize_whitespace(question or "")
156
+ patterns = [
157
+ r"^(best|good|recommended?)\s+databases?\s+for\s+",
158
+ r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
159
+ r"^databases?\s+for\s+",
160
+ r"^where should i (search|start)\s+(for\s+)?",
161
+ r"^what (database|databases)\s+(for|should i use for)\s+",
162
+ ]
163
+ lower = q.lower()
164
+ for pat in patterns:
165
+ m = re.match(pat, lower)
166
+ if m:
167
+ q = q[m.end():]
168
+ break
169
+ q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
170
+ q = _strip_resource_noise(q)
171
+ return _normalize_whitespace(q).strip(".?") or "this subject"
172
+
173
+
174
+ def _database_recommendation_answer(question: str) -> str:
175
+ q = (question or "").lower()
176
+ subject = _extract_db_topic(question)
177
+ topic = _escape(subject)
178
+ subject_key = _match_db_subject(q)
179
+ dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
180
+ dbs = list(dbs)[:5]
181
+ answer = f"<strong>Recommended databases for {topic}</strong><br><br>"
182
+ answer += "Here are the strongest KU-subscribed starting points:<br>"
183
+ for name, url, why in dbs:
184
+ answer += f'<br>• <a href="{url}" target="_blank"><strong>{_escape(name)}</strong></a> — {_escape(why)}'
185
+ if not subject_key:
186
+ answer += (
187
+ "<br><br>💡 For subject-specific databases, ask me: "
188
+ "<em>\"best databases for [your subject]\"</em> — e.g. physics, chemistry, medicine, civil engineering."
189
+ )
190
+ answer += (
191
+ f'<br><br>Browse all 50+ KU databases: '
192
+ f'<a href="https://library.ku.ac.ae/eresources" target="_blank">library.ku.ac.ae/eresources</a>'
193
+ )
194
+ answer += "<br><br>Want me to run a direct search on any of these for a specific topic?"
195
+ return answer
196
+
197
+
198
+ def _fulltext_chain_answer(question: str) -> str:
199
+ return (
200
+ "<strong>📄 How to get full text for an article</strong><br><br>"
201
+ "Follow this chain in order — each step is faster than the next:<br><br>"
202
+ "<strong>Step 1 — Search KU PRIMO</strong><br>"
203
+ "Find the article in <a href=\"https://khalifa.primo.exlibrisgroup.com/discovery/search"
204
+ "?vid=971KUOSTAR_INST:KU\" target=\"_blank\">KU Library Discovery</a>. "
205
+ "If KU subscribes, you'll see a <em>Full Text Available</em> or <em>Online Access</em> link. "
206
+ "Click it — it passes through the IDM proxy automatically when on campus or logged in.<br><br>"
207
+ "<strong>Step 2 — Try LibKey Nomad (browser extension)</strong><br>"
208
+ "<a href=\"https://libkey.io/libraries/3025/\" target=\"_blank\">LibKey Nomad</a> is a free browser "
209
+ "extension that detects articles you're viewing and fetches KU's full text automatically. "
210
+ "Install it once and it works on every publisher site.<br><br>"
211
+ "<strong>Step 3 — Try the publisher or open access</strong><br>"
212
+ "Visit the DOI link directly. Many authors post their accepted manuscript on "
213
+ "<a href=\"https://arxiv.org\" target=\"_blank\">arXiv</a>, "
214
+ "<a href=\"https://www.researchgate.net\" target=\"_blank\">ResearchGate</a>, or their institutional page. "
215
+ "<a href=\"https://openalex.org\" target=\"_blank\">OpenAlex</a> also links to many legal open-access PDFs.<br><br>"
216
+ "<strong>Step 4 — Submit an Interlibrary Loan (ILL) request</strong><br>"
217
+ "If none of the above work, KU Library can borrow the article from another library — usually within 1–5 working days. "
218
+ "Submit here: <a href=\"https://library.ku.ac.ae/ill/\" target=\"_blank\">library.ku.ac.ae/ill/</a><br>"
219
+ "Contact: <strong>Suaad Al Jneibi</strong> · "
220
+ "<a href=\"mailto:suaad.aljneibi@ku.ac.ae\">suaad.aljneibi@ku.ac.ae</a> · +971 2 312 4278"
221
+ )
222
+
223
+
224
+ def _theses_answer() -> str:
225
+ return (
226
+ "<strong>📘 Finding KU theses, dissertations, and institutional research</strong><br><br>"
227
+ "<strong>Khalifa University Khazna Repository</strong><br>"
228
+ "Khazna is KU's institutional repository — it holds KU theses, dissertations, "
229
+ "faculty publications, conference papers, and open-access research outputs.<br>"
230
+ '🔗 <a href="https://khazna.ku.ac.ae" target="_blank">khazna.ku.ac.ae</a><br><br>'
231
+ "<strong>ProQuest Dissertations &amp; Theses Global</strong><br>"
232
+ "KU subscribes to ProQuest D&T Global — the world's largest database of "
233
+ "theses and dissertations from universities worldwide.<br>"
234
+ '🔗 <a href="https://khalifa.idm.oclc.org/login?url=https://www.proquest.com/pqdtglobal" target="_blank">'
235
+ "Access via KU Library</a><br><br>"
236
+ "<strong>Other sources</strong><br>"
237
+ '• <a href="https://ethos.bl.uk" target="_blank">EThOS</a> — UK theses (British Library)<br>'
238
+ '• <a href="https://www.dart-europe.org" target="_blank">DART-Europe</a> — European theses<br>'
239
+ '• <a href="https://ndltd.org" target="_blank">NDLTD</a> — Global networked digital library of theses<br><br>'
240
+ "For help with Khazna deposits or ORCID integration, contact: "
241
+ "<strong>Nikesh Narayanan</strong> · "
242
+ "<a href=\"mailto:nikesh.narayanan@ku.ac.ae\">nikesh.narayanan@ku.ac.ae</a>"
243
+ )
244
+
245
+
246
+ def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
247
+ q = (question or "").lower()
248
+ if _looks_library_hours_question(question):
249
+ return (
250
+ "Would you like the library contact details too?",
251
+ [
252
+ {"label": "Show library contact details", "question": "Show the library contact details"},
253
+ {"label": "Open the live library hours page", "question": "Open the live library hours page"},
254
+ ],
255
+ )
256
+ if "study room" in q or "room" in q:
257
+ return (
258
+ "Would you like the study-room booking link?",
259
+ [
260
+ {"label": "Study-room booking link", "question": "Show the study-room booking link"},
261
+ {"label": "Library services for both campuses", "question": "Show library services for both campuses"},
262
+ ],
263
+ )
264
+ if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
265
+ return (
266
+ "Would you like me to recommend the best KU databases for this topic?",
267
+ [
268
+ {"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
269
+ {"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
270
+ ],
271
+ )
272
+ return (
273
+ "Would you like a related library service or staff contact?",
274
+ [
275
+ {"label": "Best staff contact", "question": "Show the best staff contact for this"},
276
+ {"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
277
+ ],
278
+ )
279
+
280
+
src/agentcore/intents_search.py CHANGED
@@ -1,891 +1,901 @@
1
- """search_academic / search_medical machinery: topic extraction, boolean building, URLs, research snapshot, follow-up state."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
-
19
- from src.agentcore.models import ClientStatePayload, SearchContextPayload
20
- from src.agentcore.scholarly import fetch_evidence_panel
21
- from src.agentcore.constants import (
22
- ALT_TERMS_RE,
23
- CURRENT_YEAR,
24
- REFINEMENT_STOP_WORDS,
25
- REVIEW_ONLY_RE,
26
- _BOOLEAN_SYSTEM,
27
- _BUILD_SEARCH_PLAN_SYSTEM,
28
- _GUARDRAIL,
29
- _URL_INSTRUCTION,
30
- )
31
- from src.agentcore.utils import (
32
- _clean_database_keywords,
33
- _escape,
34
- _get_llm,
35
- _light_strip_retrieval_boilerplate,
36
- _normalize_whitespace,
37
- _primo_clean_url,
38
- _sanitize_boolean_for_primo,
39
- _shared_build_primo_boolean_query,
40
- _strip_resource_noise,
41
- _title_case_topic,
42
- )
43
- from src.agentcore.rendering import _ai_tools_footer, _search_trace_block, _tool_urls
44
-
45
- logger = logging.getLogger(__name__)
46
-
47
- def _extract_topic_regex(question: str) -> str:
48
- q = _normalize_whitespace(question)
49
- prefixes = [
50
- "can u give me a deep analysis of ", "can you give me a deep analysis of ",
51
- "give me a deep analysis of ", "deep analysis of ",
52
- "give me a summary of ", "give me a summary on ", "give me a summary about ",
53
- "give me an overview of ", "give me an overview on ",
54
- "give me a brief overview of ", "give me a brief summary of ",
55
- "summarize the latest studies in ", "summarize latest studies in ",
56
- "summarize the latest research on ", "summarize latest research on ",
57
- "summarize recent studies in ", "summarize recent research on ",
58
- "latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
59
- "summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
60
- "overview of ", "overview on ", "brief overview of ", "brief summary of ",
61
- "analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
62
- "what is ", "what are ", "describe ", "review of ", "literature review on ",
63
- "recent advances in ", "recent developments in ", "state of the art in ",
64
- "state of research on ", "research overview of ", "research summary of ",
65
- "find peer reviewed articles on ", "find peer reviewed papers on ",
66
- "find articles on ", "find papers on ", "find books on ", "find articles about ",
67
- "find papers about ", "search for articles on ", "search for papers on ",
68
- "i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
69
- "i need books on ", "give me articles on ", "show me articles on ",
70
- "get me articles on ", "look for articles on ", "articles on ", "papers on ",
71
- "books on ", "research on ", "literature on ", "find ", "search for ",
72
- "i need ", "give me ", "show me ", "get me ",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  ]
74
- lower = q.lower()
75
- for prefix in prefixes:
76
- if lower.startswith(prefix):
77
- q = q[len(prefix):]
78
- break
79
- q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
80
- q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
81
- q = _strip_resource_noise(q)
82
- return _normalize_whitespace(q).strip(".?")
83
-
84
-
85
- async def _extract_topic(question: str, model: str) -> str:
86
- settings = get_settings()
87
- if not settings.openai_api_key and not settings.anthropic_api_key:
88
- return _extract_topic_regex(question)
89
- try:
90
- llm = _get_llm(model, temperature=0, max_tokens=32)
91
- response = await llm.ainvoke([
92
- {"role": "system", "content": (
93
- "Extract only the core research topic from the user's question. "
94
- "Return 2-7 keywords ONLY — no punctuation, no sentence, no explanation. "
95
- "CRITICAL: do NOT include words like articles, papers, books, journals, "
96
- "peer-reviewed, open-access, studies, or research in your output. "
97
- "Those are filters, not topic words. Return ONLY the subject matter.\n"
98
- "Examples:\n"
99
- "'find articles on indian politics' → indian politics\n"
100
- "'peer reviewed papers on climate change 2020' → climate change\n"
101
- "'books on structural engineering UAE' → structural engineering UAE\n"
102
- "'give me a summary of quantum physics advances' → quantum physics advances\n"
103
- "'i need peer reviewed research on machine learning healthcare' → machine learning healthcare"
104
- )},
105
- {"role": "user", "content": question},
106
- ])
107
- topic = response.content.strip().strip('"').strip("'").strip(".")
108
- topic = _strip_resource_noise(topic)
109
- topic = _normalize_whitespace(topic).strip(".?")
110
- if topic and len(topic) > 2:
111
- return topic
112
- except Exception as e:
113
- logger.warning(f"Topic extraction failed: {e}")
114
- return _extract_topic_regex(question)
115
-
116
-
117
- async def _llm_build_boolean_query(topic: str, model: str) -> str:
118
- settings = get_settings()
119
- if not settings.openai_api_key and not settings.anthropic_api_key:
120
- return _shared_build_primo_boolean_query(topic)
121
- try:
122
- llm = _get_llm(model, temperature=0, max_tokens=120)
123
- response = await llm.ainvoke([
124
- {"role": "system", "content": _BOOLEAN_SYSTEM},
125
- {"role": "user", "content": f"Input: {topic.strip()}"},
126
- ])
127
- result = response.content.strip()
128
- if not result or len(result) > 500:
129
- raise ValueError("Bad LLM boolean output")
130
- result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
131
- if '"' not in result and ' AND ' not in result and ' OR ' not in result:
132
- result = f'"{result}"'
133
- logger.info(f"LLM boolean: {result!r} ← topic: {topic!r}")
134
- return result
135
- except Exception as e:
136
- logger.warning(f"LLM boolean query failed: {e} — using regex fallback")
137
- return _shared_build_primo_boolean_query(topic)
138
-
139
-
140
- def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
141
- q = (question or "").lower()
142
- if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
143
- return "books"
144
- if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
145
- return "articles"
146
- return existing or "articles"
147
-
148
-
149
- def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
150
- q = (question or "").lower()
151
- match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
152
- if match:
153
- years = int(match.group(2))
154
- return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
155
- 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)
156
- if between:
157
- return between.group(1), between.group(2)
158
- years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
159
- if len(years) >= 2:
160
- return min(years), max(years)
161
- if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
162
- return years[0], str(CURRENT_YEAR)
163
- return None, None
164
-
165
-
166
- def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
167
- topic = context.display_topic or context.topic
168
- topic = _title_case_topic(topic)
169
- scope = context.resource_type
170
- filters = []
171
- if context.peer_reviewed:
172
- filters.append("peer-reviewed")
173
- if context.open_access:
174
- filters.append("open-access")
175
- if context.year_from and context.year_to:
176
- filters.append(f"published between {context.year_from} and {context.year_to}")
177
- elif context.year_from:
178
- filters.append(f"published from {context.year_from} onward")
179
- if is_follow_up:
180
- if scope == "books":
181
- base = f"Find books on {topic}"
182
- elif scope == "both":
183
- base = f"Find articles and books on {topic}"
184
- else:
185
- base = f"Find research articles on {topic}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  else:
187
- base = _normalize_whitespace(question).strip(".?")
188
- if len(base.split()) < 4:
189
- base = f"Find research on {topic}"
190
- if filters:
191
- return f"{base} with {', '.join(filters)}."
192
- return base.rstrip(".") + "."
193
-
194
-
195
- def _context_to_dict(context: SearchContextPayload) -> dict:
196
- return context.model_dump()
197
-
198
-
199
- async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
200
- settings = get_settings()
201
- if not settings.openai_api_key and not settings.anthropic_api_key:
202
- return True, topic
203
- try:
204
- llm = _get_llm(model, temperature=0, max_tokens=40)
205
- response = await llm.ainvoke([
206
- {"role": "system", "content": (
207
- "You are a topic validator for an academic library search system. "
208
- "Given a word or phrase, decide if it is a recognisable research topic, "
209
- "subject area, acronym, proper noun, or concept — even if misspelled.\n\n"
210
- "Rules:\n"
211
- "- If recognisable or a fixable typo: return JSON {\"valid\": true, \"corrected\": \"<corrected spelling>\"}\n"
212
- "- If gibberish, random characters, or completely unrecognisable: return JSON {\"valid\": false, \"corrected\": \"\"}\n"
213
- "- Acronyms like NLP, ML, AI, CRISPR, IoT are always valid.\n"
214
- "- Proper nouns (country names, people, organisations) are always valid.\n"
215
- "- Misspellings like 'machne lernig' → corrected: 'machine learning' → valid: true\n"
216
- "- Random strings like 'sdfmdnoc', 'xyzabc123', 'qwerty' → valid: false\n"
217
- "Return ONLY valid JSON. No explanation."
218
- )},
219
- {"role": "user", "content": f"Topic: {topic}"},
220
- ])
221
- raw = response.content.strip()
222
- if raw.startswith("```"):
223
- raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
224
- s, e = raw.find("{"), raw.rfind("}")
225
- if s != -1 and e > s:
226
- result = json.loads(raw[s:e + 1])
227
- is_valid = bool(result.get("valid", True))
228
- corrected = str(result.get("corrected") or topic).strip()
229
- return is_valid, corrected
230
- return True, topic
231
- except Exception as exc:
232
- logger.warning(f"_validate_topic failed: {exc} — failing open")
233
- return True, topic
234
-
235
-
236
- async def _build_search_plan(question: str, model: str) -> dict:
237
- settings = get_settings()
238
- raw_query = _normalize_whitespace(question or "")
239
- light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
240
- _stripped = light_query.strip()
241
- _is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
242
- _is_too_short = len(_stripped) < 2
243
- _is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
244
- _content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
245
- _no_content = len(_content_words) == 0 and len(_stripped) > 0
246
- if _is_pure_number or _is_too_short or _is_single_char or _no_content:
247
- _display = _stripped or raw_query
248
- return {
249
- "corrected": raw_query, "natural": light_query, "boolean": "",
250
- "database_query": "", "year_from": "", "year_to": "",
251
- "peer_reviewed": False, "open_access": False,
252
- "clarification_needed": True,
253
- "clarification_message": (
254
- f"I want to make sure I search for the right thing. "
255
- f"Could you clarify what <strong>{_escape(_display)}</strong> refers to? "
256
- f"For example, is it a course code, a specific topic name, a year, "
257
- f"or something else? The more detail you provide, "
258
- f"the better I can build your search."
259
- ),
260
- }
261
- if settings.openai_api_key or settings.anthropic_api_key:
262
- _valid, _corrected_topic = await _validate_topic(_stripped, model)
263
- if not _valid:
264
- return {
265
- "corrected": raw_query, "natural": light_query, "boolean": "",
266
- "database_query": "", "year_from": "", "year_to": "",
267
- "peer_reviewed": False, "open_access": False,
268
- "clarification_needed": True,
269
- "clarification_message": (
270
- f"I couldn't recognise <strong>{_escape(_stripped)}</strong> as a research topic. "
271
- f"Could you check the spelling, or describe what you're looking for in more detail? "
272
- f"For example: <em>\"find research on machine learning\"</em> or "
273
- f"<em>\"articles on renewable energy in UAE\"</em>."
274
- ),
275
- }
276
- if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
277
- logger.info(f"_validate_topic corrected: {_stripped!r} → {_corrected_topic!r}")
278
- light_query = _corrected_topic
279
- if settings.openai_api_key or settings.anthropic_api_key:
280
- try:
281
- llm = _get_llm(model, temperature=0, max_tokens=300)
282
- response = await llm.ainvoke([
283
- {"role": "system", "content": _BUILD_SEARCH_PLAN_SYSTEM},
284
- {"role": "user", "content": f'Query: "{light_query}"'},
285
- ])
286
- raw = response.content.strip()
287
- if raw.startswith("```"):
288
- raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
289
- s, e = raw.find("{"), raw.rfind("}")
290
- if s != -1 and e > s:
291
- result = json.loads(raw[s:e + 1])
292
- else:
293
- raise ValueError("No JSON found in response")
294
- corrected = (result.get("corrected") or raw_query).strip() or raw_query
295
- natural = (result.get("natural") or corrected).strip() or corrected
296
- boolean = (result.get("boolean") or "").strip()
297
- has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
298
- has_parens = "(" in boolean if boolean else False
299
- if not has_ops or not has_parens:
300
- boolean = _shared_build_primo_boolean_query(corrected)
301
- boolean = _sanitize_boolean_for_primo(boolean)
302
- year_from = str(result.get("year_from") or "").strip()
303
- year_to = str(result.get("year_to") or "").strip()
304
- if not year_from and not year_to:
305
- yf, yt = _parse_year_filters(raw_query)
306
- year_from = yf or ""
307
- year_to = yt or ""
308
- return {
309
- "corrected": corrected,
310
- "natural": natural,
311
- "boolean": boolean,
312
- "database_query": _clean_database_keywords(boolean),
313
- "year_from": year_from,
314
- "year_to": year_to,
315
- "peer_reviewed": bool(result.get("peer_reviewed", False)),
316
- "open_access": bool(result.get("open_access", False)),
317
- }
318
- except Exception as e:
319
- logger.warning(f"_build_search_plan LLM failed: {e} — using regex fallback")
320
- boolean = _shared_build_primo_boolean_query(light_query)
321
- yf, yt = _parse_year_filters(raw_query)
322
- pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
323
- oa = bool(re.search(r"\bopen[- ]access\b", raw_query, re.IGNORECASE))
324
- return {
325
- "corrected": raw_query,
326
- "natural": light_query,
327
- "boolean": boolean,
328
- "database_query": _clean_database_keywords(boolean),
329
- "year_from": yf or "",
330
- "year_to": yt or "",
331
- "peer_reviewed": pr,
332
- "open_access": oa,
333
- }
334
-
335
-
336
- async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
337
- topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
338
- q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
339
- plan = await _build_search_plan(q, model)
340
- if plan.get("clarification_needed"):
341
- context.clarification_needed = True
342
- context.clarification_message = plan.get("clarification_message", "")
343
- return context
344
- context.ai_tool_query = plan["natural"]
345
- context.primo_boolean_query = plan["boolean"]
346
- if not context.year_from and plan.get("year_from"):
347
- context.year_from = plan["year_from"]
348
- if not context.year_to and plan.get("year_to"):
349
- context.year_to = plan["year_to"]
350
- if not context.peer_reviewed and plan.get("peer_reviewed"):
351
- context.peer_reviewed = True
352
- if not context.open_access and plan.get("open_access"):
353
- context.open_access = True
354
- return context
355
-
356
-
357
- async def _topic_intro(topic: str, model: str) -> str:
358
- """LLM #5 — 3-sentence topic intro. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
359
- settings = get_settings()
360
- if not settings.openai_api_key and not settings.anthropic_api_key:
361
- return ""
362
- try:
363
- llm = _get_llm(model, temperature=0.2, max_tokens=180)
364
- response = await llm.ainvoke([
365
- {"role": "system", "content": (
366
- "You are LibBee, the Khalifa University Library AI Assistant. "
367
- "Write exactly 3 clear, factual sentences introducing the given research topic "
368
- "for a university student or researcher. "
369
- "Cover: what the topic is, why it matters, and one key area of current research interest. "
370
- "Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
371
- "Be concise and informative. "
372
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
373
- )},
374
- {"role": "user", "content": f"Research topic: {topic}"},
375
- ])
376
- intro = response.content.strip()
377
- return intro if intro else ""
378
- except Exception as e:
379
- logger.warning(f"_topic_intro failed: {e}")
380
- return ""
381
-
382
-
383
-
384
- def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
385
- if not papers:
386
- return ""
387
- statements: List[str] = []
388
- for paper in papers[:4]:
389
- sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
390
- lead = sentences[0].strip() if sentences else ""
391
- if not lead:
392
- continue
393
- citation = (
394
- f'<a href="{paper["link"]}" target="_blank" '
395
- f'style="color:#1e40af;font-weight:600;text-decoration:none">'
396
- f'[{_escape(paper["citation_key"])}]</a>'
397
- )
398
- statements.append(f"{_escape(lead)} {citation}")
399
- if not statements:
400
- return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
401
- intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
402
- return intro + "<br><br>".join(statements[:4])
403
-
404
-
405
- def _database_guidance_for_topic(context: SearchContextPayload) -> str:
406
- topic = _escape(context.display_topic or context.topic)
407
- urls = _tool_urls(context)
408
- ai_tools = (
409
- f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> · '
410
- f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> · '
411
- f'<a href="{urls["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
412
- f'<a href="{urls["primo_ai"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO AI Assistant</a> · '
413
- f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a>'
414
- )
415
- more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
416
- if context.intent == "search_medical" and urls.get("pubmed"):
417
- more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
418
- return (
419
- f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
420
- f"For more results, use {more_results}. Medical topics are strongest in PubMed, Embase, and CINAHL."
421
- )
422
- return (
423
- f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
424
- f"For more results, use {more_results}."
425
- )
426
-
427
-
428
- async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
429
- plan = await _build_search_plan(question, model)
430
- topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
431
- boolean = _escape(plan.get("boolean", ""))
432
- natural = _escape(plan.get("natural", ""))
433
- explanation = (
434
- f"<strong>🔍 Search strategy for: {topic}</strong><br><br>"
435
- f"<strong>1. Identify your key concepts</strong><br>"
436
- f"Break the topic into 2–3 core concepts. For your question, I identified these concepts "
437
- f"and suggested synonyms for each using OR, then joined concepts with AND:<br><br>"
438
- f'<code style="display:block;padding:8px 12px;background:#1a1a2e;color:#C8A951;'
439
- f'border-radius:8px;font-size:.82rem;word-break:break-all">{boolean}</code><br>'
440
- f"<strong>Why AND and OR?</strong> AND narrows — both concepts must appear. "
441
- f"OR broadens — any synonym counts. Quoting phrases like "
442
- f"<code>\"machine learning\"</code> keeps them together as an exact phrase.<br><br>"
443
- f"<strong>2. Apply filters</strong><br>"
444
- f"Use database facets to limit by: peer-reviewed, date range, document type (article / review / book), "
445
- f"language, or open access. In PRIMO, these appear in the left sidebar after you search.<br><br>"
446
- f"<strong>3. Natural language query for AI tools</strong><br>"
447
- f"For AI-powered tools (LeapSpace, Scopus AI, Consensus), use a conversational query:<br>"
448
- f'<em style="color:var(--color-text-secondary)">{natural}</em><br><br>'
449
- f"<strong>4. Iterate</strong><br>"
450
- f"Too many results → add more AND terms or apply filters. "
451
- f"Too few → remove an AND group or use broader synonyms. "
452
- f"You can ask me to refine: <em>\"narrow to peer-reviewed only\"</em>, "
453
- f"<em>\"limit to last 5 years\"</em>, or <em>\"suggest alternative keywords\"</em>."
454
- )
455
- return explanation, plan
456
-
457
-
458
- async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
459
- topic = (context.display_topic or context.topic) if context else question
460
- settings = get_settings()
461
- if not settings.openai_api_key and not settings.anthropic_api_key:
462
- return (
463
- f"<strong>Alternative search terms for: {_escape(topic)}</strong><br><br>"
464
- "Try combining these broader, narrower, and related terms:<br>"
465
- "• Use broader terms if too few results<br>"
466
- "• Use narrower/specific terms if too many results<br>"
467
- "• Try acronyms and full forms (e.g. AI / Artificial Intelligence)<br>"
468
- "• Include British and American spellings (e.g. organisation/organization)"
469
- )
470
- try:
471
- llm = _get_llm(model, temperature=0.4, max_tokens=300)
472
- response = await llm.ainvoke([
473
- {"role": "system", "content": (
474
- "You are an academic librarian. Given a research topic, generate a structured list of alternative "
475
- "search terms. Group them as: Broader terms, Narrower/specific terms, Related concepts, Acronyms/abbreviations. "
476
- "Format as HTML using <strong> for group labels and <br> for line breaks. "
477
- "Keep it concise — 3-4 terms per group maximum. No bullet points, use • instead."
478
- )},
479
- {"role": "user", "content": f"Research topic: {topic}"},
480
- ])
481
- terms = response.content.strip()
482
- return (
483
- f"<strong>💡 Alternative search terms for: {_escape(topic)}</strong><br><br>"
484
- f"{terms}<br><br>"
485
- f"<strong>Tip:</strong> In PRIMO or databases, use OR between synonyms within a concept group, "
486
- f"and AND between different concept groups. Ask me to run a new search with any of these."
487
- )
488
- except Exception as e:
489
- logger.warning(f"_alternative_terms_answer failed: {e}")
490
- return f"<strong>Alternative terms for {_escape(topic)}</strong><br><br>Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
491
-
492
-
493
- def _citation_chain_answer() -> str:
494
- return (
495
- "<strong>🔗 How to trace citations forward and backward from a paper</strong><br><br>"
496
- "<strong>Backward citation (who does this paper cite?)</strong><br>"
497
- "Read the paper's reference list — every source it cites is a potential lead. "
498
- "This gives you foundational and seminal works on the topic.<br><br>"
499
- "<strong>Forward citation (who has cited this paper since publication?)</strong><br>"
500
- "Use these tools — paste in the DOI or title:<br>"
501
- '• <a href="https://www-scopus-com.khalifa.idm.oclc.org/pages/ai" target="_blank"><strong>Scopus</strong></a> '
502
- '— search the article, then click "Cited by N documents"<br>'
503
- '• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
504
- '— search the article, click "Times Cited"<br>'
505
- '• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
506
- '— free, excellent for CS and engineering, shows "Citations" tab<br>'
507
- '• <a href="https://openalex.org" target="_blank"><strong>OpenAlex</strong></a> '
508
- '— fully open, API-accessible citation graph<br><br>'
509
- "<strong>Lateral search (similar papers)</strong><br>"
510
- "In PRIMO, use <em>\"Find Similar\"</em>. In Semantic Scholar, use <em>\"Recommended Papers\"</em>. "
511
- "In Scopus, use <em>\"Related Documents\"</em>.<br><br>"
512
- "<strong>Tip:</strong> Start with one highly cited foundational paper, trace forward to find the newest "
513
- "work, and backward to understand the theoretical roots."
514
- )
515
-
516
-
517
- def _predatory_eval_answer() -> str:
518
- return (
519
- "<strong>✅ How to tell if an article or journal is peer-reviewed, scholarly, or predatory</strong><br><br>"
520
- "<strong>Is it peer-reviewed?</strong><br>"
521
- "• Check the journal's website for a peer-review statement or editorial process description<br>"
522
- "• In PRIMO, tick the <em>Peer-reviewed</em> filter in the left sidebar<br>"
523
- "• Check if the journal is indexed in "
524
- '<a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank">Scopus</a> or '
525
- '<a href="https://www.webofscience.com" target="_blank">Web of Science</a> — '
526
- "indexed = generally peer-reviewed<br><br>"
527
- "<strong>Is it a legitimate journal?</strong><br>"
528
- "• <a href=\"https://doaj.org\" target=\"_blank\"><strong>DOAJ</strong></a> "
529
- "— Directory of Open Access Journals (vetted, legitimate OA)<br>"
530
- "• <a href=\"https://mjl.clarivate.com\" target=\"_blank\"><strong>Web of Science Master Journal List</strong></a><br>"
531
- "• <a href=\"https://www.scopus.com/sources\" target=\"_blank\"><strong>Scopus Source List</strong></a><br>"
532
- "• Think · Check · Submit: <a href=\"https://thinkchecksubmit.org\" target=\"_blank\">thinkchecksubmit.org</a> "
533
- "— a checklist to assess any journal<br><br>"
534
- "<strong>Warning signs of predatory journals</strong><br>"
535
- "• Unsolicited email invitation to submit<br>"
536
- "• No clear peer-review process or very fast acceptance (days)<br>"
537
- "• High article processing charges (APCs) with no clear metrics<br>"
538
- "• Not indexed in Scopus or Web of Science<br>"
539
- "• Generic or misleading journal name ('International Journal of...')<br><br>"
540
- "<strong>Need help checking a specific journal?</strong> Ask our E-Resources Librarian: "
541
- "<strong>Rani Anand</strong> · <a href=\"mailto:rani.anand@ku.ac.ae\">rani.anand@ku.ac.ae</a>"
542
- )
543
-
544
-
545
- def _highly_cited_note(topic_escaped: str) -> str:
546
- return (
547
- f"<br><br><strong>📊 Finding highly cited papers on {topic_escaped}</strong><br>"
548
- "PRIMO doesn't sort by citation count, but these tools do:<br>"
549
- '• <a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank"><strong>Scopus</strong></a> '
550
- '— search your topic → Sort by <em>Cited by (highest)</em><br>'
551
- '• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
552
- '— search → Sort by <em>Times Cited</em><br>'
553
- '• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
554
- '— free, sort by <em>Citation Count</em><br>'
555
- '• <a href="https://scholar.google.com" target="_blank"><strong>Google Scholar</strong></a> '
556
- '— sort by <em>Cited by</em> (broader but includes grey literature)'
557
- )
558
-
559
-
560
- async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
561
- """Research snapshot = prepared platform links + a live open-evidence panel.
562
-
563
- The evidence panel queries OpenAlex first (Semantic Scholar as fallback),
564
- then enriches results with legal open-access links via Unpaywall and fills
565
- metadata gaps via Crossref. Every external call is best-effort: if the open
566
- indexes are unreachable the handler degrades to the prepared-links block
567
- (the entire pre-3.8 behaviour), so the user always gets an answer.
568
- """
569
- topic = context.topic
570
- primo_url = _primo_clean_url(context)
571
- answer = _search_trace_block(f"Research on {topic}", context)
572
- answer += (
573
- f"<strong>📚 Research starting points: {_escape(topic)}</strong><br><br>"
574
- "Your query has been prepared and pre-loaded across multiple platforms. "
575
- "Click any platform below to search instantly, or use the boolean search tip "
576
- "to search directly in any KU database."
577
- f'<br><br><a href="{primo_url}" target="_blank" style="color:#003366;font-weight:700">'
578
- f'Search PRIMO for: {_escape(topic)} →</a>'
579
- )
580
-
581
- # Shared open-evidence panel (see scholarly.fetch_evidence_panel)
582
- papers, panel_html = await fetch_evidence_panel(topic)
583
- answer += panel_html
584
-
585
- answer += _ai_tools_footer(context)
586
- return answer, papers, []
587
-
588
-
589
- def _filters_summary(context: SearchContextPayload) -> str:
590
- filters: List[str] = []
591
- if context.peer_reviewed:
592
- filters.append("peer reviewed only")
593
- if context.open_access:
594
- filters.append("open access")
595
- if context.year_from and context.year_to:
596
- filters.append(f"{context.year_from}–{context.year_to}")
597
- elif context.year_from:
598
- filters.append(f"from {context.year_from}")
599
- if context.resource_type == "books":
600
- filters.append("books")
601
- elif context.resource_type == "articles":
602
- filters.append("articles")
603
- elif context.resource_type == "both":
604
- filters.append("articles and books")
605
- return ", ".join(filters)
606
-
607
-
608
- def _question_has_new_topic(question: str, base_topic: str) -> bool:
609
- q = (question or "").lower()
610
- cleaned = re.sub(
611
- r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
612
- r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
613
- " ", q,
614
- )
615
- tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
616
- if len(tokens) < 3:
617
- return False
618
- base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
619
- overlap = sum(1 for t in tokens if t in base_tokens)
620
- return overlap <= max(1, min(2, len(base_tokens)))
621
-
622
-
623
- def _parse_refinement_action(question: str) -> Optional[str]:
624
- q = (question or "").lower().strip()
625
- if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
626
- return "peer_reviewed_only"
627
- if re.search(r"\b(open access|oa only|only open access)\b", q):
628
- return "open_access_only"
629
- if re.search(r"\b(last|past)\s+5\s+years?\b", q):
630
- return "last_5_years"
631
- if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
632
- return "last_10_years"
633
- m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
634
- if m:
635
- return f"last_{m.group(2)}_years"
636
- if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
637
- return "books_only"
638
- if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
639
- return "articles_only"
640
- if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
641
- return "both_resources"
642
- if REVIEW_ONLY_RE.search(q):
643
- return "review_articles_only"
644
- if ALT_TERMS_RE.search(q):
645
- return "alt_terms"
646
- if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
647
- _STOP = {
648
- 'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
649
- 'was','were','be','been','have','has','had','do','does','did','will','would',
650
- 'could','should','may','its','this','that','these','those','about','me','my',
651
- 'give','show','tell','please','can','you','i','need','want','get','find',
652
- 'research','summary','overview','summarize','summarise','brief','literature',
653
- }
654
- content_words = [t for t in re.findall(r'[a-z0-9]+', q)
655
- if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
656
- if content_words:
657
- return None
658
- return "summarize_topic"
659
- if re.search(r"\bpubmed\b", q):
660
- return "search_pubmed"
661
- if re.search(r"\bprimo\b", q):
662
- return "search_primo"
663
- if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
664
- return "best_databases"
665
- if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
666
- return "deep_research_tools"
667
- return None
668
-
669
-
670
- def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
671
- if not client_state or not client_state.recent_search_contexts:
672
- return None
673
- contexts = list(client_state.recent_search_contexts)
674
- if client_state.follow_up_context_id:
675
- for ctx in contexts:
676
- if ctx.context_id == client_state.follow_up_context_id:
677
- return ctx
678
- if client_state.active_search_context_id:
679
- for ctx in contexts:
680
- if ctx.context_id == client_state.active_search_context_id:
681
- return ctx
682
- contexts.sort(key=lambda c: c.created_at, reverse=True)
683
- return contexts[0]
684
-
685
-
686
- def _detect_follow_up(
687
- question: str, client_state: Optional[ClientStatePayload]
688
- ) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
689
- base_context = _resolve_base_context(client_state)
690
- if not base_context:
691
- return False, None, None
692
- explicit_action = (client_state.follow_up_action if client_state else None) or None
693
- if explicit_action:
694
- return True, explicit_action, base_context
695
- action = _parse_refinement_action(question)
696
- if not action:
697
- return False, None, None
698
- if _question_has_new_topic(question, base_context.topic):
699
- return False, None, None
700
- return True, action, base_context
701
-
702
-
703
- def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
704
- return SearchContextPayload.model_validate(base_context.model_dump())
705
-
706
-
707
- def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
708
- context = _clone_context(base_context)
709
- context.context_id = str(uuid.uuid4())
710
- context.created_at = time.time()
711
- if action == "peer_reviewed_only":
712
- context.peer_reviewed = True
713
- elif action == "open_access_only":
714
- context.open_access = True
715
- elif action == "last_5_years":
716
- context.year_from = str(CURRENT_YEAR - 4)
717
- context.year_to = str(CURRENT_YEAR)
718
- elif action == "last_10_years":
719
- context.year_from = str(CURRENT_YEAR - 9)
720
- context.year_to = str(CURRENT_YEAR)
721
- elif action == "last_3_years":
722
- context.year_from = str(CURRENT_YEAR - 2)
723
- context.year_to = str(CURRENT_YEAR)
724
- elif action == "last_2_years":
725
- context.year_from = str(CURRENT_YEAR - 1)
726
- context.year_to = str(CURRENT_YEAR)
727
- elif action and action.startswith("last_") and action.endswith("_years"):
728
- try:
729
- n = int(action.split("_")[1])
730
- context.year_from = str(CURRENT_YEAR - n + 1)
731
- context.year_to = str(CURRENT_YEAR)
732
- except (IndexError, ValueError):
733
- pass
734
- elif action == "books_only":
735
- context.resource_type = "books"
736
- elif action == "articles_only":
737
- context.resource_type = "articles"
738
- elif action == "both_resources":
739
- context.resource_type = "both"
740
- elif action == "review_articles_only":
741
- context.resource_type = "articles"
742
- context.peer_reviewed = True
743
- elif action == "search_pubmed":
744
- context.source = "pubmed"
745
- context.intent = "search_medical"
746
- context.resource_type = "articles"
747
- elif action == "search_primo":
748
- context.source = "primo"
749
- return context
750
-
751
-
752
- async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
753
- settings = get_settings()
754
- if not settings.openai_api_key and not settings.anthropic_api_key:
755
- return []
756
- try:
757
- llm = _get_llm(model, temperature=0.5, max_tokens=200)
758
- response = await llm.ainvoke([
759
- {"role": "system", "content": (
760
- "You are a research librarian helping a user explore a topic more deeply. "
761
- "Given a research topic, generate exactly 3 short follow-up questions that explore "
762
- "DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
763
- "applications, comparisons, or related fields. "
764
- "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
765
- "Return ONLY a JSON array of 3 strings, each under 12 words. "
766
- "No explanation, no preamble, no markdown.\n"
767
- "Example for 'quantum computing':\n"
768
- '[\"What are the main hardware approaches in quantum computing?\", '
769
- '"How does quantum error correction work?\", '
770
- '"Applications of quantum computing in cryptography\"]'
771
- )},
772
- {"role": "user", "content": f"Topic: {topic}"},
773
- ])
774
- raw = response.content.strip()
775
- if raw.startswith("```"):
776
- raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
777
- questions = json.loads(raw)
778
- if not isinstance(questions, list):
779
- return []
780
- return [
781
- {"label": q.strip("?") + "?", "question": q.strip("?") + "?"}
782
- for q in questions[:3]
783
- if isinstance(q, str) and q.strip()
784
- ]
785
- except Exception as e:
786
- logger.warning(f"Topic follow-up generation failed: {e}")
787
- return []
788
-
789
-
790
- async def _search_follow_up(
791
- context: SearchContextPayload, model: str, summary_mode: bool = False
792
- ) -> Tuple[str, List[dict]]:
793
- topic = context.display_topic or context.topic
794
- suggestions: List[dict] = []
795
- topic_follow_ups = await _generate_topic_follow_ups(topic, model)
796
- suggestions.extend(topic_follow_ups)
797
-
798
- filter_suggestions: List[dict] = []
799
- if summary_mode:
800
- filter_suggestions.append({
801
- "label": f"Show KU-accessible articles on {topic}",
802
- "question": f"Find KU-accessible articles on {topic}",
803
- "action": "articles_only",
804
- "context_id": context.context_id,
805
- })
806
- if context.intent == "search_medical" and context.source != "pubmed":
807
- filter_suggestions.append({
808
- "label": "Search PubMed instead",
809
- "question": f"Search PubMed for {topic}",
810
- "action": "search_pubmed",
811
- "context_id": context.context_id,
812
- })
813
- filter_suggestions.append({
814
- "label": "Research snapshot",
815
- "question": f"Give me a brief research snapshot on {topic}",
816
- "action": "summarize_topic",
817
- "context_id": context.context_id,
818
- })
819
-
820
- suggestions.extend(filter_suggestions[:2])
821
- question_text = (
822
- f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
823
- "Or I can refine the search with filters — just ask."
824
- )
825
- return question_text, suggestions[:5]
826
-
827
-
828
- def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
829
- topic = _escape(context.display_topic or context.topic)
830
- resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
831
- location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
832
- if is_follow_up:
833
- action = f"I updated your previous search on <strong>{topic}</strong> in the <strong>{location}</strong> and looked for <strong>{resource}</strong>."
834
- else:
835
- action = f"I searched for <strong>{resource}</strong> on <strong>{topic}</strong>."
836
- return action
837
-
838
-
839
- async def _run_search_mode(
840
- question: str, context: SearchContextPayload, model: str, is_follow_up: bool
841
- ) -> Tuple[str, List[dict], List[dict], str]:
842
- """
843
- Search mode — builds PRIMO link + 3-sentence topic intro + AI tools footer.
844
- No external academic API calls (v3.8).
845
- Includes clarification gate for ambiguous/gibberish queries.
846
- """
847
- context = await _prepare_queries(question, context, model, is_follow_up)
848
-
849
- # Clarification gate
850
- if context.clarification_needed and context.clarification_message:
851
- return context.clarification_message, [], [], ""
852
-
853
- source_url = _primo_clean_url(context)
854
- topic = _escape(context.display_topic or context.topic)
855
-
856
- # 3-sentence topic intro AND the open-evidence panel, fetched concurrently:
857
- # the panel's network time hides inside the intro's LLM latency instead of
858
- # adding to it. Both degrade independently if they fail.
859
- intro, (papers, panel_html) = await asyncio.gather(
860
- _topic_intro(context.display_topic or context.topic, model),
861
- fetch_evidence_panel(context.display_topic or context.topic),
862
- )
863
-
864
- answer = _search_trace_block(question, context)
865
- if intro:
866
- answer += f'<div style="margin-bottom:12px;color:#374151;font-size:.88rem;line-height:1.7">{intro}</div>'
867
- answer += _search_answer_intro(context, is_follow_up)
868
- answer += (
869
- f'<br><br>'
870
- f'<a href="{source_url}" target="_blank" '
871
- f'style="display:inline-block;padding:9px 18px;background:#003366;color:#fff;'
872
- f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
873
- f'🔍 Search PRIMO Library Discovery for: {topic} →</a>'
874
- f'<br><div style="margin-top:6px;font-size:.78rem;color:#6b7280">'
875
- f'💡 Inside PRIMO you can filter by <strong>Articles</strong>, <strong>Books</strong>, '
876
- f'<strong>Peer Reviewed</strong>, date range, and more.</div>'
877
- )
878
- answer += panel_html
879
- answer += _ai_tools_footer(context)
880
-
881
- return answer, papers, [], source_url
882
-
883
-
884
- def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
885
- dedup: Dict[str, SearchContextPayload] = {}
886
- for ctx in contexts:
887
- dedup[ctx.context_id] = ctx
888
- ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
889
- return ordered[:5]
890
-
891
-
 
1
+ """search_academic / search_medical machinery: topic extraction, boolean building, URLs, research snapshot, follow-up state."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+
19
+ from src.agentcore.models import ClientStatePayload, SearchContextPayload
20
+ from src.agentcore.scholarly import fetch_evidence_panel
21
+ from src.agentcore.constants import (
22
+ ALT_TERMS_RE,
23
+ CURRENT_YEAR,
24
+ REFINEMENT_STOP_WORDS,
25
+ REVIEW_ONLY_RE,
26
+ _BOOLEAN_SYSTEM,
27
+ _BUILD_SEARCH_PLAN_SYSTEM,
28
+ _GUARDRAIL,
29
+ _URL_INSTRUCTION,
30
+ )
31
+ from src.agentcore.utils import (
32
+ _clean_database_keywords,
33
+ _escape,
34
+ _get_llm,
35
+ _light_strip_retrieval_boilerplate,
36
+ _normalize_whitespace,
37
+ _primo_clean_url,
38
+ _sanitize_boolean_for_primo,
39
+ _shared_build_primo_boolean_query,
40
+ _strip_resource_noise,
41
+ _title_case_topic,
42
+ )
43
+ from src.agentcore.rendering import _ai_tools_footer, _search_trace_block, _tool_urls
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ def _extract_topic_regex(question: str) -> str:
48
+ q = _normalize_whitespace(question)
49
+ prefixes = [
50
+ "can u give me a deep analysis of ", "can you give me a deep analysis of ",
51
+ "give me a deep analysis of ", "deep analysis of ",
52
+ "give me a summary of ", "give me a summary on ", "give me a summary about ",
53
+ "give me an overview of ", "give me an overview on ",
54
+ "give me a brief overview of ", "give me a brief summary of ",
55
+ "summarize the latest studies in ", "summarize latest studies in ",
56
+ "summarize the latest research on ", "summarize latest research on ",
57
+ "summarize recent studies in ", "summarize recent research on ",
58
+ "latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
59
+ "summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
60
+ "overview of ", "overview on ", "brief overview of ", "brief summary of ",
61
+ "analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
62
+ "what is ", "what are ", "describe ", "review of ", "literature review on ",
63
+ "recent advances in ", "recent developments in ", "state of the art in ",
64
+ "state of research on ", "research overview of ", "research summary of ",
65
+ "find peer reviewed articles on ", "find peer reviewed papers on ",
66
+ "find articles on ", "find papers on ", "find books on ", "find articles about ",
67
+ "find papers about ", "search for articles on ", "search for papers on ",
68
+ "i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
69
+ "i need books on ", "give me articles on ", "show me articles on ",
70
+ "get me articles on ", "look for articles on ", "articles on ", "papers on ",
71
+ "books on ", "research on ", "literature on ", "find ", "search for ",
72
+ "i need ", "give me ", "show me ", "get me ",
73
+ ]
74
+ lower = q.lower()
75
+ for prefix in prefixes:
76
+ if lower.startswith(prefix):
77
+ q = q[len(prefix):]
78
+ break
79
+ q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
80
+ q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
81
+ q = _strip_resource_noise(q)
82
+ return _normalize_whitespace(q).strip(".?")
83
+
84
+
85
+ async def _extract_topic(question: str, model: str) -> str:
86
+ settings = get_settings()
87
+ if not settings.openai_api_key and not settings.anthropic_api_key:
88
+ return _extract_topic_regex(question)
89
+ try:
90
+ llm = _get_llm(model, temperature=0, max_tokens=32)
91
+ response = await llm.ainvoke([
92
+ {"role": "system", "content": (
93
+ "Extract only the core research topic from the user's question. "
94
+ "Return 2-7 keywords ONLY — no punctuation, no sentence, no explanation. "
95
+ "CRITICAL: do NOT include words like articles, papers, books, journals, "
96
+ "peer-reviewed, open-access, studies, or research in your output. "
97
+ "Those are filters, not topic words. Return ONLY the subject matter.\n"
98
+ "Examples:\n"
99
+ "'find articles on indian politics' → indian politics\n"
100
+ "'peer reviewed papers on climate change 2020' → climate change\n"
101
+ "'books on structural engineering UAE' → structural engineering UAE\n"
102
+ "'give me a summary of quantum physics advances' → quantum physics advances\n"
103
+ "'i need peer reviewed research on machine learning healthcare' → machine learning healthcare"
104
+ )},
105
+ {"role": "user", "content": question},
106
+ ])
107
+ topic = response.content.strip().strip('"').strip("'").strip(".")
108
+ topic = _strip_resource_noise(topic)
109
+ topic = _normalize_whitespace(topic).strip(".?")
110
+ if topic and len(topic) > 2:
111
+ return topic
112
+ except Exception as e:
113
+ logger.warning(f"Topic extraction failed: {e}")
114
+ return _extract_topic_regex(question)
115
+
116
+
117
+ async def _llm_build_boolean_query(topic: str, model: str) -> str:
118
+ settings = get_settings()
119
+ if not settings.openai_api_key and not settings.anthropic_api_key:
120
+ return _shared_build_primo_boolean_query(topic)
121
+ try:
122
+ llm = _get_llm(model, temperature=0, max_tokens=120)
123
+ response = await llm.ainvoke([
124
+ {"role": "system", "content": _BOOLEAN_SYSTEM},
125
+ {"role": "user", "content": f"Input: {topic.strip()}"},
126
+ ])
127
+ result = response.content.strip()
128
+ if not result or len(result) > 500:
129
+ raise ValueError("Bad LLM boolean output")
130
+ result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
131
+ if '"' not in result and ' AND ' not in result and ' OR ' not in result:
132
+ result = f'"{result}"'
133
+ logger.info(f"LLM boolean: {result!r} ← topic: {topic!r}")
134
+ return result
135
+ except Exception as e:
136
+ logger.warning(f"LLM boolean query failed: {e} — using regex fallback")
137
+ return _shared_build_primo_boolean_query(topic)
138
+
139
+
140
+ def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
141
+ q = (question or "").lower()
142
+ if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
143
+ return "books"
144
+ if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
145
+ return "articles"
146
+ return existing or "articles"
147
+
148
+
149
+ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
150
+ q = (question or "").lower()
151
+ match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
152
+ if match:
153
+ years = int(match.group(2))
154
+ return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
155
+ 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)
156
+ if between:
157
+ return between.group(1), between.group(2)
158
+ years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
159
+ if len(years) >= 2:
160
+ return min(years), max(years)
161
+ if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
162
+ return years[0], str(CURRENT_YEAR)
163
+ return None, None
164
+
165
+
166
+ def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
167
+ topic = context.display_topic or context.topic
168
+ topic = _title_case_topic(topic)
169
+ scope = context.resource_type
170
+ filters = []
171
+ if context.peer_reviewed:
172
+ filters.append("peer-reviewed")
173
+ if context.open_access:
174
+ filters.append("open-access")
175
+ if context.year_from and context.year_to:
176
+ filters.append(f"published between {context.year_from} and {context.year_to}")
177
+ elif context.year_from:
178
+ filters.append(f"published from {context.year_from} onward")
179
+ if is_follow_up:
180
+ if scope == "books":
181
+ base = f"Find books on {topic}"
182
+ elif scope == "both":
183
+ base = f"Find articles and books on {topic}"
184
+ else:
185
+ base = f"Find research articles on {topic}"
186
+ else:
187
+ base = _normalize_whitespace(question).strip(".?")
188
+ if len(base.split()) < 4:
189
+ base = f"Find research on {topic}"
190
+ if filters:
191
+ return f"{base} with {', '.join(filters)}."
192
+ return base.rstrip(".") + "."
193
+
194
+
195
+ def _context_to_dict(context: SearchContextPayload) -> dict:
196
+ return context.model_dump()
197
+
198
+
199
+ async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
200
+ settings = get_settings()
201
+ if not settings.openai_api_key and not settings.anthropic_api_key:
202
+ return True, topic
203
+ try:
204
+ llm = _get_llm(model, temperature=0, max_tokens=40)
205
+ response = await llm.ainvoke([
206
+ {"role": "system", "content": (
207
+ "You are a topic validator for an academic library search system. "
208
+ "Given a word or phrase, decide if it is a recognisable research topic, "
209
+ "subject area, acronym, proper noun, or concept — even if misspelled.\n\n"
210
+ "Rules:\n"
211
+ "- If recognisable or a fixable typo: return JSON {\"valid\": true, \"corrected\": \"<corrected spelling>\"}\n"
212
+ "- If gibberish, random characters, or completely unrecognisable: return JSON {\"valid\": false, \"corrected\": \"\"}\n"
213
+ "- Acronyms like NLP, ML, AI, CRISPR, IoT are always valid.\n"
214
+ "- Proper nouns (country names, people, organisations) are always valid.\n"
215
+ "- Misspellings like 'machne lernig' → corrected: 'machine learning' → valid: true\n"
216
+ "- Random strings like 'sdfmdnoc', 'xyzabc123', 'qwerty' → valid: false\n"
217
+ "Return ONLY valid JSON. No explanation."
218
+ )},
219
+ {"role": "user", "content": f"Topic: {topic}"},
220
+ ])
221
+ raw = response.content.strip()
222
+ if raw.startswith("```"):
223
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
224
+ s, e = raw.find("{"), raw.rfind("}")
225
+ if s != -1 and e > s:
226
+ result = json.loads(raw[s:e + 1])
227
+ is_valid = bool(result.get("valid", True))
228
+ corrected = str(result.get("corrected") or topic).strip()
229
+ return is_valid, corrected
230
+ return True, topic
231
+ except Exception as exc:
232
+ logger.warning(f"_validate_topic failed: {exc} — failing open")
233
+ return True, topic
234
+
235
+
236
+ async def _build_search_plan(question: str, model: str) -> dict:
237
+ settings = get_settings()
238
+ raw_query = _normalize_whitespace(question or "")
239
+ light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
240
+ _stripped = light_query.strip()
241
+ _is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
242
+ _is_too_short = len(_stripped) < 2
243
+ _is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
244
+ _content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
245
+ _no_content = len(_content_words) == 0 and len(_stripped) > 0
246
+ if _is_pure_number or _is_too_short or _is_single_char or _no_content:
247
+ _display = _stripped or raw_query
248
+ return {
249
+ "corrected": raw_query, "natural": light_query, "boolean": "",
250
+ "database_query": "", "year_from": "", "year_to": "",
251
+ "peer_reviewed": False, "open_access": False,
252
+ "clarification_needed": True,
253
+ "clarification_message": (
254
+ f"I want to make sure I search for the right thing. "
255
+ f"Could you clarify what <strong>{_escape(_display)}</strong> refers to? "
256
+ f"For example, is it a course code, a specific topic name, a year, "
257
+ f"or something else? The more detail you provide, "
258
+ f"the better I can build your search."
259
+ ),
260
+ }
261
+ if settings.openai_api_key or settings.anthropic_api_key:
262
+ _valid, _corrected_topic = await _validate_topic(_stripped, model)
263
+ if not _valid:
264
+ return {
265
+ "corrected": raw_query, "natural": light_query, "boolean": "",
266
+ "database_query": "", "year_from": "", "year_to": "",
267
+ "peer_reviewed": False, "open_access": False,
268
+ "clarification_needed": True,
269
+ "clarification_message": (
270
+ f"I couldn't recognise <strong>{_escape(_stripped)}</strong> as a research topic. "
271
+ f"Could you check the spelling, or describe what you're looking for in more detail? "
272
+ f"For example: <em>\"find research on machine learning\"</em> or "
273
+ f"<em>\"articles on renewable energy in UAE\"</em>."
274
+ ),
275
+ }
276
+ if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
277
+ logger.info(f"_validate_topic corrected: {_stripped!r} → {_corrected_topic!r}")
278
+ light_query = _corrected_topic
279
+ if settings.openai_api_key or settings.anthropic_api_key:
280
+ try:
281
+ llm = _get_llm(model, temperature=0, max_tokens=300)
282
+ response = await llm.ainvoke([
283
+ {"role": "system", "content": _BUILD_SEARCH_PLAN_SYSTEM},
284
+ {"role": "user", "content": f'Query: "{light_query}"'},
285
+ ])
286
+ raw = response.content.strip()
287
+ if raw.startswith("```"):
288
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
289
+ s, e = raw.find("{"), raw.rfind("}")
290
+ if s != -1 and e > s:
291
+ result = json.loads(raw[s:e + 1])
292
+ else:
293
+ raise ValueError("No JSON found in response")
294
+ corrected = (result.get("corrected") or raw_query).strip() or raw_query
295
+ natural = (result.get("natural") or corrected).strip() or corrected
296
+ boolean = (result.get("boolean") or "").strip()
297
+ has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
298
+ has_parens = "(" in boolean if boolean else False
299
+ if not has_ops or not has_parens:
300
+ boolean = _shared_build_primo_boolean_query(corrected)
301
+ boolean = _sanitize_boolean_for_primo(boolean)
302
+ year_from = str(result.get("year_from") or "").strip()
303
+ year_to = str(result.get("year_to") or "").strip()
304
+ if not year_from and not year_to:
305
+ yf, yt = _parse_year_filters(raw_query)
306
+ year_from = yf or ""
307
+ year_to = yt or ""
308
+ return {
309
+ "corrected": corrected,
310
+ "natural": natural,
311
+ "boolean": boolean,
312
+ "database_query": _clean_database_keywords(boolean),
313
+ "year_from": year_from,
314
+ "year_to": year_to,
315
+ "peer_reviewed": bool(result.get("peer_reviewed", False)),
316
+ "open_access": bool(result.get("open_access", False)),
317
+ }
318
+ except Exception as e:
319
+ logger.warning(f"_build_search_plan LLM failed: {e} — using regex fallback")
320
+ boolean = _shared_build_primo_boolean_query(light_query)
321
+ yf, yt = _parse_year_filters(raw_query)
322
+ pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
323
+ oa = bool(re.search(r"\bopen[- ]access\b", raw_query, re.IGNORECASE))
324
+ return {
325
+ "corrected": raw_query,
326
+ "natural": light_query,
327
+ "boolean": boolean,
328
+ "database_query": _clean_database_keywords(boolean),
329
+ "year_from": yf or "",
330
+ "year_to": yt or "",
331
+ "peer_reviewed": pr,
332
+ "open_access": oa,
333
+ }
334
+
335
+
336
+ async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
337
+ topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
338
+ q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
339
+ plan = await _build_search_plan(q, model)
340
+ if plan.get("clarification_needed"):
341
+ context.clarification_needed = True
342
+ context.clarification_message = plan.get("clarification_message", "")
343
+ return context
344
+ context.ai_tool_query = plan["natural"]
345
+ context.primo_boolean_query = plan["boolean"]
346
+ if not context.year_from and plan.get("year_from"):
347
+ context.year_from = plan["year_from"]
348
+ if not context.year_to and plan.get("year_to"):
349
+ context.year_to = plan["year_to"]
350
+ if not context.peer_reviewed and plan.get("peer_reviewed"):
351
+ context.peer_reviewed = True
352
+ if not context.open_access and plan.get("open_access"):
353
+ context.open_access = True
354
+ return context
355
+
356
+
357
+ async def _topic_intro(topic: str, model: str) -> str:
358
+ """LLM #5 — 3-sentence topic intro. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
359
+ settings = get_settings()
360
+ if not settings.openai_api_key and not settings.anthropic_api_key:
361
+ return ""
362
+ try:
363
+ llm = _get_llm(model, temperature=0.2, max_tokens=180)
364
+ response = await llm.ainvoke([
365
+ {"role": "system", "content": (
366
+ "You are LibBee, the Khalifa University Library AI Assistant. "
367
+ "Write exactly 3 clear, factual sentences introducing the given research topic "
368
+ "for a university student or researcher. "
369
+ "Cover: what the topic is, why it matters, and one key area of current research interest. "
370
+ "Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
371
+ "Be concise and informative. "
372
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
373
+ )},
374
+ {"role": "user", "content": f"Research topic: {topic}"},
375
+ ])
376
+ intro = response.content.strip()
377
+ return intro if intro else ""
378
+ except Exception as e:
379
+ logger.warning(f"_topic_intro failed: {e}")
380
+ return ""
381
+
382
+
383
+
384
+ def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
385
+ if not papers:
386
+ return ""
387
+ statements: List[str] = []
388
+ for paper in papers[:4]:
389
+ sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
390
+ lead = sentences[0].strip() if sentences else ""
391
+ if not lead:
392
+ continue
393
+ citation = (
394
+ f'<a href="{paper["link"]}" target="_blank" '
395
+ f'style="color:#1e40af;font-weight:600;text-decoration:none">'
396
+ f'[{_escape(paper["citation_key"])}]</a>'
397
+ )
398
+ statements.append(f"{_escape(lead)} {citation}")
399
+ if not statements:
400
+ return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
401
+ intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
402
+ return intro + "<br><br>".join(statements[:4])
403
+
404
+
405
+ def _database_guidance_for_topic(context: SearchContextPayload) -> str:
406
+ topic = _escape(context.display_topic or context.topic)
407
+ urls = _tool_urls(context)
408
+ ai_tools = (
409
+ f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> · '
410
+ f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> · '
411
+ f'<a href="{urls["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
412
+ f'<a href="{urls["primo_ai"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO AI Assistant</a> · '
413
+ f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a>'
414
+ )
415
+ more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
416
+ if context.intent == "search_medical" and urls.get("pubmed"):
417
+ more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
418
+ return (
419
+ f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
420
+ f"For more results, use {more_results}. Medical topics are strongest in PubMed, Embase, and CINAHL."
421
+ )
422
+ return (
423
+ f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
424
+ f"For more results, use {more_results}."
425
+ )
426
+
427
+
428
+ async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
429
+ plan = await _build_search_plan(question, model)
430
+ topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
431
+ boolean = _escape(plan.get("boolean", ""))
432
+ natural = _escape(plan.get("natural", ""))
433
+ explanation = (
434
+ f"<strong>🔍 Search strategy for: {topic}</strong><br><br>"
435
+ f"<strong>1. Identify your key concepts</strong><br>"
436
+ f"Break the topic into 2–3 core concepts. For your question, I identified these concepts "
437
+ f"and suggested synonyms for each using OR, then joined concepts with AND:<br><br>"
438
+ f'<code style="display:block;padding:8px 12px;background:#1a1a2e;color:#C8A951;'
439
+ f'border-radius:8px;font-size:.82rem;word-break:break-all">{boolean}</code><br>'
440
+ f"<strong>Why AND and OR?</strong> AND narrows — both concepts must appear. "
441
+ f"OR broadens — any synonym counts. Quoting phrases like "
442
+ f"<code>\"machine learning\"</code> keeps them together as an exact phrase.<br><br>"
443
+ f"<strong>2. Apply filters</strong><br>"
444
+ f"Use database facets to limit by: peer-reviewed, date range, document type (article / review / book), "
445
+ f"language, or open access. In PRIMO, these appear in the left sidebar after you search.<br><br>"
446
+ f"<strong>3. Natural language query for AI tools</strong><br>"
447
+ f"For AI-powered tools (LeapSpace, Scopus AI, Consensus), use a conversational query:<br>"
448
+ f'<em style="color:var(--color-text-secondary)">{natural}</em><br><br>'
449
+ f"<strong>4. Iterate</strong><br>"
450
+ f"Too many results → add more AND terms or apply filters. "
451
+ f"Too few → remove an AND group or use broader synonyms. "
452
+ f"You can ask me to refine: <em>\"narrow to peer-reviewed only\"</em>, "
453
+ f"<em>\"limit to last 5 years\"</em>, or <em>\"suggest alternative keywords\"</em>."
454
+ )
455
+ return explanation, plan
456
+
457
+
458
+ async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
459
+ topic = (context.display_topic or context.topic) if context else question
460
+ settings = get_settings()
461
+ if not settings.openai_api_key and not settings.anthropic_api_key:
462
+ return (
463
+ f"<strong>Alternative search terms for: {_escape(topic)}</strong><br><br>"
464
+ "Try combining these broader, narrower, and related terms:<br>"
465
+ "• Use broader terms if too few results<br>"
466
+ "• Use narrower/specific terms if too many results<br>"
467
+ "• Try acronyms and full forms (e.g. AI / Artificial Intelligence)<br>"
468
+ "• Include British and American spellings (e.g. organisation/organization)"
469
+ )
470
+ try:
471
+ llm = _get_llm(model, temperature=0.4, max_tokens=300)
472
+ response = await llm.ainvoke([
473
+ {"role": "system", "content": (
474
+ "You are an academic librarian. Given a research topic, generate a structured list of alternative "
475
+ "search terms. Group them as: Broader terms, Narrower/specific terms, Related concepts, Acronyms/abbreviations. "
476
+ "Format as HTML using <strong> for group labels and <br> for line breaks. "
477
+ "Keep it concise — 3-4 terms per group maximum. No bullet points, use • instead."
478
+ )},
479
+ {"role": "user", "content": f"Research topic: {topic}"},
480
+ ])
481
+ terms = response.content.strip()
482
+ return (
483
+ f"<strong>💡 Alternative search terms for: {_escape(topic)}</strong><br><br>"
484
+ f"{terms}<br><br>"
485
+ f"<strong>Tip:</strong> In PRIMO or databases, use OR between synonyms within a concept group, "
486
+ f"and AND between different concept groups. Ask me to run a new search with any of these."
487
+ )
488
+ except Exception as e:
489
+ logger.warning(f"_alternative_terms_answer failed: {e}")
490
+ return f"<strong>Alternative terms for {_escape(topic)}</strong><br><br>Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
491
+
492
+
493
+ def _citation_chain_answer() -> str:
494
+ return (
495
+ "<strong>🔗 How to trace citations forward and backward from a paper</strong><br><br>"
496
+ "<strong>Backward citation (who does this paper cite?)</strong><br>"
497
+ "Read the paper's reference list — every source it cites is a potential lead. "
498
+ "This gives you foundational and seminal works on the topic.<br><br>"
499
+ "<strong>Forward citation (who has cited this paper since publication?)</strong><br>"
500
+ "Use these tools — paste in the DOI or title:<br>"
501
+ '• <a href="https://www-scopus-com.khalifa.idm.oclc.org/pages/ai" target="_blank"><strong>Scopus</strong></a> '
502
+ '— search the article, then click "Cited by N documents"<br>'
503
+ '• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
504
+ '— search the article, click "Times Cited"<br>'
505
+ '• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
506
+ '— free, excellent for CS and engineering, shows "Citations" tab<br>'
507
+ '• <a href="https://openalex.org" target="_blank"><strong>OpenAlex</strong></a> '
508
+ '— fully open, API-accessible citation graph<br><br>'
509
+ "<strong>Lateral search (similar papers)</strong><br>"
510
+ "In PRIMO, use <em>\"Find Similar\"</em>. In Semantic Scholar, use <em>\"Recommended Papers\"</em>. "
511
+ "In Scopus, use <em>\"Related Documents\"</em>.<br><br>"
512
+ "<strong>Tip:</strong> Start with one highly cited foundational paper, trace forward to find the newest "
513
+ "work, and backward to understand the theoretical roots."
514
+ )
515
+
516
+
517
+ def _predatory_eval_answer() -> str:
518
+ return (
519
+ "<strong>✅ How to tell if an article or journal is peer-reviewed, scholarly, or predatory</strong><br><br>"
520
+ "<strong>Is it peer-reviewed?</strong><br>"
521
+ "• Check the journal's website for a peer-review statement or editorial process description<br>"
522
+ "• In PRIMO, tick the <em>Peer-reviewed</em> filter in the left sidebar<br>"
523
+ "• Check if the journal is indexed in "
524
+ '<a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank">Scopus</a> or '
525
+ '<a href="https://www.webofscience.com" target="_blank">Web of Science</a> — '
526
+ "indexed = generally peer-reviewed<br><br>"
527
+ "<strong>Is it a legitimate journal?</strong><br>"
528
+ "• <a href=\"https://doaj.org\" target=\"_blank\"><strong>DOAJ</strong></a> "
529
+ "— Directory of Open Access Journals (vetted, legitimate OA)<br>"
530
+ "• <a href=\"https://mjl.clarivate.com\" target=\"_blank\"><strong>Web of Science Master Journal List</strong></a><br>"
531
+ "• <a href=\"https://www.scopus.com/sources\" target=\"_blank\"><strong>Scopus Source List</strong></a><br>"
532
+ "• Think · Check · Submit: <a href=\"https://thinkchecksubmit.org\" target=\"_blank\">thinkchecksubmit.org</a> "
533
+ "— a checklist to assess any journal<br><br>"
534
+ "<strong>Warning signs of predatory journals</strong><br>"
535
+ "• Unsolicited email invitation to submit<br>"
536
+ "• No clear peer-review process or very fast acceptance (days)<br>"
537
+ "• High article processing charges (APCs) with no clear metrics<br>"
538
+ "• Not indexed in Scopus or Web of Science<br>"
539
+ "• Generic or misleading journal name ('International Journal of...')<br><br>"
540
+ "<strong>Need help checking a specific journal?</strong> Ask our E-Resources Librarian: "
541
+ "<strong>Rani Anand</strong> · <a href=\"mailto:rani.anand@ku.ac.ae\">rani.anand@ku.ac.ae</a>"
542
+ )
543
+
544
+
545
+ def _highly_cited_note(topic_escaped: str) -> str:
546
+ return (
547
+ f"<br><br><strong>📊 Finding highly cited papers on {topic_escaped}</strong><br>"
548
+ "PRIMO doesn't sort by citation count, but these tools do:<br>"
549
+ '• <a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank"><strong>Scopus</strong></a> '
550
+ '— search your topic → Sort by <em>Cited by (highest)</em><br>'
551
+ '• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
552
+ '— search → Sort by <em>Times Cited</em><br>'
553
+ '• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
554
+ '— free, sort by <em>Citation Count</em><br>'
555
+ '• <a href="https://scholar.google.com" target="_blank"><strong>Google Scholar</strong></a> '
556
+ '— sort by <em>Cited by</em> (broader but includes grey literature)'
557
+ )
558
+
559
+
560
+ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
561
+ """Research snapshot = prepared platform links + a live open-evidence panel.
562
+
563
+ The evidence panel queries OpenAlex first (Semantic Scholar as fallback),
564
+ then enriches results with legal open-access links via Unpaywall and fills
565
+ metadata gaps via Crossref. Every external call is best-effort: if the open
566
+ indexes are unreachable the handler degrades to the prepared-links block
567
+ (the entire pre-3.8 behaviour), so the user always gets an answer.
568
+ """
569
+ topic = context.topic
570
+ primo_url = _primo_clean_url(context)
571
+ answer = _search_trace_block(f"Research on {topic}", context)
572
+ answer += (
573
+ f"<strong>📚 Research starting points: {_escape(topic)}</strong><br><br>"
574
+ "Your query has been prepared and pre-loaded across multiple platforms. "
575
+ "Click any platform below to search instantly, or use the boolean search tip "
576
+ "to search directly in any KU database."
577
+ f'<br><br><a href="{primo_url}" target="_blank" style="color:#003366;font-weight:700">'
578
+ f'Search PRIMO for: {_escape(topic)} →</a>'
579
+ )
580
+
581
+ # Shared open-evidence panel (see scholarly.fetch_evidence_panel)
582
+ papers, panel_html = await fetch_evidence_panel(topic, context=context)
583
+ answer += panel_html
584
+
585
+ answer += _ai_tools_footer(context)
586
+ sources = [
587
+ {"title": paper.get("title", ""), "url": paper.get("link", ""),
588
+ "doi": paper.get("doi", ""), "provider": paper.get("_source", "")}
589
+ for paper in papers
590
  ]
591
+ return answer, papers, sources
592
+
593
+
594
+ def _filters_summary(context: SearchContextPayload) -> str:
595
+ filters: List[str] = []
596
+ if context.peer_reviewed:
597
+ filters.append("peer reviewed only")
598
+ if context.open_access:
599
+ filters.append("open access")
600
+ if context.year_from and context.year_to:
601
+ filters.append(f"{context.year_from}–{context.year_to}")
602
+ elif context.year_from:
603
+ filters.append(f"from {context.year_from}")
604
+ if context.resource_type == "books":
605
+ filters.append("books")
606
+ elif context.resource_type == "articles":
607
+ filters.append("articles")
608
+ elif context.resource_type == "both":
609
+ filters.append("articles and books")
610
+ return ", ".join(filters)
611
+
612
+
613
+ def _question_has_new_topic(question: str, base_topic: str) -> bool:
614
+ q = (question or "").lower()
615
+ cleaned = re.sub(
616
+ r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
617
+ r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
618
+ " ", q,
619
+ )
620
+ tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
621
+ if len(tokens) < 3:
622
+ return False
623
+ base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
624
+ overlap = sum(1 for t in tokens if t in base_tokens)
625
+ return overlap <= max(1, min(2, len(base_tokens)))
626
+
627
+
628
+ def _parse_refinement_action(question: str) -> Optional[str]:
629
+ q = (question or "").lower().strip()
630
+ if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
631
+ return "peer_reviewed_only"
632
+ if re.search(r"\b(open access|oa only|only open access)\b", q):
633
+ return "open_access_only"
634
+ if re.search(r"\b(last|past)\s+5\s+years?\b", q):
635
+ return "last_5_years"
636
+ if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
637
+ return "last_10_years"
638
+ m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
639
+ if m:
640
+ return f"last_{m.group(2)}_years"
641
+ if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
642
+ return "books_only"
643
+ if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
644
+ return "articles_only"
645
+ if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
646
+ return "both_resources"
647
+ if REVIEW_ONLY_RE.search(q):
648
+ return "review_articles_only"
649
+ if ALT_TERMS_RE.search(q):
650
+ return "alt_terms"
651
+ if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
652
+ _STOP = {
653
+ 'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
654
+ 'was','were','be','been','have','has','had','do','does','did','will','would',
655
+ 'could','should','may','its','this','that','these','those','about','me','my',
656
+ 'give','show','tell','please','can','you','i','need','want','get','find',
657
+ 'research','summary','overview','summarize','summarise','brief','literature',
658
+ }
659
+ content_words = [t for t in re.findall(r'[a-z0-9]+', q)
660
+ if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
661
+ if content_words:
662
+ return None
663
+ return "summarize_topic"
664
+ if re.search(r"\bpubmed\b", q):
665
+ return "search_pubmed"
666
+ if re.search(r"\bprimo\b", q):
667
+ return "search_primo"
668
+ if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
669
+ return "best_databases"
670
+ if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
671
+ return "deep_research_tools"
672
+ return None
673
+
674
+
675
+ def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
676
+ if not client_state or not client_state.recent_search_contexts:
677
+ return None
678
+ contexts = list(client_state.recent_search_contexts)
679
+ if client_state.follow_up_context_id:
680
+ for ctx in contexts:
681
+ if ctx.context_id == client_state.follow_up_context_id:
682
+ return ctx
683
+ if client_state.active_search_context_id:
684
+ for ctx in contexts:
685
+ if ctx.context_id == client_state.active_search_context_id:
686
+ return ctx
687
+ contexts.sort(key=lambda c: c.created_at, reverse=True)
688
+ return contexts[0]
689
+
690
+
691
+ def _detect_follow_up(
692
+ question: str, client_state: Optional[ClientStatePayload]
693
+ ) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
694
+ base_context = _resolve_base_context(client_state)
695
+ if not base_context:
696
+ return False, None, None
697
+ explicit_action = (client_state.follow_up_action if client_state else None) or None
698
+ if explicit_action:
699
+ return True, explicit_action, base_context
700
+ action = _parse_refinement_action(question)
701
+ if not action:
702
+ return False, None, None
703
+ if _question_has_new_topic(question, base_context.topic):
704
+ return False, None, None
705
+ return True, action, base_context
706
+
707
+
708
+ def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
709
+ return SearchContextPayload.model_validate(base_context.model_dump())
710
+
711
+
712
+ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
713
+ context = _clone_context(base_context)
714
+ context.context_id = str(uuid.uuid4())
715
+ context.created_at = time.time()
716
+ if action == "peer_reviewed_only":
717
+ context.peer_reviewed = True
718
+ elif action == "open_access_only":
719
+ context.open_access = True
720
+ elif action == "last_5_years":
721
+ context.year_from = str(CURRENT_YEAR - 4)
722
+ context.year_to = str(CURRENT_YEAR)
723
+ elif action == "last_10_years":
724
+ context.year_from = str(CURRENT_YEAR - 9)
725
+ context.year_to = str(CURRENT_YEAR)
726
+ elif action == "last_3_years":
727
+ context.year_from = str(CURRENT_YEAR - 2)
728
+ context.year_to = str(CURRENT_YEAR)
729
+ elif action == "last_2_years":
730
+ context.year_from = str(CURRENT_YEAR - 1)
731
+ context.year_to = str(CURRENT_YEAR)
732
+ elif action and action.startswith("last_") and action.endswith("_years"):
733
+ try:
734
+ n = int(action.split("_")[1])
735
+ context.year_from = str(CURRENT_YEAR - n + 1)
736
+ context.year_to = str(CURRENT_YEAR)
737
+ except (IndexError, ValueError):
738
+ pass
739
+ elif action == "books_only":
740
+ context.resource_type = "books"
741
+ elif action == "articles_only":
742
+ context.resource_type = "articles"
743
+ elif action == "both_resources":
744
+ context.resource_type = "both"
745
+ elif action == "review_articles_only":
746
+ context.resource_type = "articles"
747
+ context.peer_reviewed = True
748
+ elif action == "search_pubmed":
749
+ context.source = "pubmed"
750
+ context.intent = "search_medical"
751
+ context.resource_type = "articles"
752
+ elif action == "search_primo":
753
+ context.source = "primo"
754
+ return context
755
+
756
+
757
+ async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
758
+ settings = get_settings()
759
+ if not settings.openai_api_key and not settings.anthropic_api_key:
760
+ return []
761
+ try:
762
+ llm = _get_llm(model, temperature=0.5, max_tokens=200)
763
+ response = await llm.ainvoke([
764
+ {"role": "system", "content": (
765
+ "You are a research librarian helping a user explore a topic more deeply. "
766
+ "Given a research topic, generate exactly 3 short follow-up questions that explore "
767
+ "DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
768
+ "applications, comparisons, or related fields. "
769
+ "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
770
+ "Return ONLY a JSON array of 3 strings, each under 12 words. "
771
+ "No explanation, no preamble, no markdown.\n"
772
+ "Example for 'quantum computing':\n"
773
+ '[\"What are the main hardware approaches in quantum computing?\", '
774
+ '"How does quantum error correction work?\", '
775
+ '"Applications of quantum computing in cryptography\"]'
776
+ )},
777
+ {"role": "user", "content": f"Topic: {topic}"},
778
+ ])
779
+ raw = response.content.strip()
780
+ if raw.startswith("```"):
781
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
782
+ questions = json.loads(raw)
783
+ if not isinstance(questions, list):
784
+ return []
785
+ return [
786
+ {"label": q.strip("?") + "?", "question": q.strip("?") + "?"}
787
+ for q in questions[:3]
788
+ if isinstance(q, str) and q.strip()
789
+ ]
790
+ except Exception as e:
791
+ logger.warning(f"Topic follow-up generation failed: {e}")
792
+ return []
793
+
794
+
795
+ async def _search_follow_up(
796
+ context: SearchContextPayload, model: str, summary_mode: bool = False
797
+ ) -> Tuple[str, List[dict]]:
798
+ topic = context.display_topic or context.topic
799
+ suggestions: List[dict] = []
800
+ topic_follow_ups = await _generate_topic_follow_ups(topic, model)
801
+ suggestions.extend(topic_follow_ups)
802
+
803
+ filter_suggestions: List[dict] = []
804
+ if summary_mode:
805
+ filter_suggestions.append({
806
+ "label": f"Show KU-accessible articles on {topic}",
807
+ "question": f"Find KU-accessible articles on {topic}",
808
+ "action": "articles_only",
809
+ "context_id": context.context_id,
810
+ })
811
+ if context.intent == "search_medical" and context.source != "pubmed":
812
+ filter_suggestions.append({
813
+ "label": "Search PubMed instead",
814
+ "question": f"Search PubMed for {topic}",
815
+ "action": "search_pubmed",
816
+ "context_id": context.context_id,
817
+ })
818
+ filter_suggestions.append({
819
+ "label": "Research snapshot",
820
+ "question": f"Give me a brief research snapshot on {topic}",
821
+ "action": "summarize_topic",
822
+ "context_id": context.context_id,
823
+ })
824
+
825
+ suggestions.extend(filter_suggestions[:2])
826
+ question_text = (
827
+ f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
828
+ "Or I can refine the search with filters — just ask."
829
+ )
830
+ return question_text, suggestions[:5]
831
+
832
+
833
+ def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
834
+ topic = _escape(context.display_topic or context.topic)
835
+ resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
836
+ location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
837
+ if is_follow_up:
838
+ action = f"I updated your prepared search on <strong>{topic}</strong> for the <strong>{location}</strong> and <strong>{resource}</strong>."
839
  else:
840
+ action = f"I prepared a search for <strong>{resource}</strong> on <strong>{topic}</strong>."
841
+ return action
842
+
843
+
844
+ async def _run_search_mode(
845
+ question: str, context: SearchContextPayload, model: str, is_follow_up: bool
846
+ ) -> Tuple[str, List[dict], List[dict], str]:
847
+ """
848
+ Search mode — builds PRIMO link + 3-sentence topic intro + AI tools footer.
849
+ No external academic API calls (v3.8).
850
+ Includes clarification gate for ambiguous/gibberish queries.
851
+ """
852
+ context = await _prepare_queries(question, context, model, is_follow_up)
853
+
854
+ # Clarification gate
855
+ if context.clarification_needed and context.clarification_message:
856
+ return context.clarification_message, [], [], ""
857
+
858
+ source_url = _primo_clean_url(context)
859
+ topic = _escape(context.display_topic or context.topic)
860
+
861
+ # 3-sentence topic intro AND the open-evidence panel, fetched concurrently:
862
+ # the panel's network time hides inside the intro's LLM latency instead of
863
+ # adding to it. Both degrade independently if they fail.
864
+ intro, (papers, panel_html) = await asyncio.gather(
865
+ _topic_intro(context.display_topic or context.topic, model),
866
+ fetch_evidence_panel(context.display_topic or context.topic, context=context),
867
+ )
868
+
869
+ answer = _search_trace_block(question, context)
870
+ if intro:
871
+ answer += f'<div style="margin-bottom:12px;color:#374151;font-size:.88rem;line-height:1.7">{intro}</div>'
872
+ answer += _search_answer_intro(context, is_follow_up)
873
+ answer += (
874
+ f'<br><br>'
875
+ f'<a href="{source_url}" target="_blank" '
876
+ f'style="display:inline-block;padding:9px 18px;background:#003366;color:#fff;'
877
+ f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
878
+ f'🔍 Search PRIMO Library Discovery for: {topic} →</a>'
879
+ f'<br><div style="margin-top:6px;font-size:.78rem;color:#6b7280">'
880
+ f'💡 Inside PRIMO you can filter by <strong>Articles</strong>, <strong>Books</strong>, '
881
+ f'<strong>Peer Reviewed</strong>, date range, and more.</div>'
882
+ )
883
+ answer += panel_html
884
+ answer += _ai_tools_footer(context)
885
+
886
+ sources = [
887
+ {"title": paper.get("title", ""), "url": paper.get("link", ""),
888
+ "doi": paper.get("doi", ""), "provider": paper.get("_source", "")}
889
+ for paper in papers
890
+ ]
891
+ return answer, papers, sources, source_url
892
+
893
+
894
+ def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
895
+ dedup: Dict[str, SearchContextPayload] = {}
896
+ for ctx in contexts:
897
+ dedup[ctx.context_id] = ctx
898
+ ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
899
+ return ordered[:5]
900
+
901
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agentcore/libcal.py CHANGED
@@ -1,333 +1,333 @@
1
- """LibCal client + formatting: live opening hours and events via the Cloudflare Worker proxy."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
-
19
- from src.agentcore.constants import LIBRARY_HOURS_URL
20
- from src.agentcore.utils import _escape
21
-
22
- logger = logging.getLogger(__name__)
23
-
24
- _libcal_hours_cache: Dict[str, Any] = {}
25
-
26
-
27
- _LIBCAL_HOURS_TTL = 60 * 60
28
-
29
-
30
- async def _libcal_fetch_hours() -> Optional[dict]:
31
- now = time.time()
32
- cached_at = _libcal_hours_cache.get("cached_at", 0)
33
- if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
34
- return _libcal_hours_cache["data"]
35
- settings = get_settings()
36
- worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
37
- if not worker_url:
38
- logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set — cannot fetch hours")
39
- return None
40
- from datetime import timezone, timedelta
41
- uae_now = datetime.now(timezone(timedelta(hours=4)))
42
- date_from = uae_now.strftime("%Y-%m-%d")
43
- date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
44
- try:
45
- async with httpx.AsyncClient(timeout=10) as client:
46
- r = await client.get(
47
- f"{worker_url.rstrip('/')}/hours",
48
- params={"from": date_from, "to": date_to},
49
- )
50
- r.raise_for_status()
51
- data = r.json()
52
- if data.get("error"):
53
- logger.warning("LibCal worker error: %s", data["error"])
54
- return None
55
- _libcal_hours_cache["data"] = data
56
- _libcal_hours_cache["cached_at"] = now
57
- logger.info("LibCal: hours refreshed via worker")
58
- return data
59
- except Exception as exc:
60
- logger.warning("LibCal hours fetch failed: %s", exc)
61
- return None
62
-
63
-
64
- def _parse_libcal_time(raw: str) -> str:
65
- if not raw:
66
- return raw
67
- raw = raw.strip().replace("\u2013", "-").replace("–", "-")
68
- raw = re.sub(r"\s+", "", raw)
69
- raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
70
- return raw
71
-
72
-
73
- def _format_libcal_day(day_data: dict) -> str:
74
- if not day_data or not isinstance(day_data, dict):
75
- return "See hours page"
76
- status = str(day_data.get("status") or "").lower().strip()
77
- note = str(day_data.get("note") or "").strip()
78
- if status in ("closed", "0", "false"):
79
- return f"Closed{f' — {note}' if note else ''}"
80
- if status == "24hours":
81
- return "Open 24 hours"
82
- hours_list = day_data.get("hours") or []
83
- if hours_list and isinstance(hours_list, list):
84
- first = hours_list[0]
85
- f = first.get("from") or first.get("opens") or ""
86
- t = first.get("to") or first.get("closes") or ""
87
- if f and t:
88
- return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
89
- opens = day_data.get("opens") or day_data.get("open") or ""
90
- closes = day_data.get("closes") or day_data.get("close") or ""
91
- if opens and closes:
92
- return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
93
- times = day_data.get("times") or []
94
- if times and isinstance(times, list):
95
- first = times[0]
96
- f = first.get("from") or first.get("opens") or ""
97
- t = first.get("to") or first.get("closes") or ""
98
- if f and t:
99
- return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
100
- if status in ("open",):
101
- return "Open — see hours page for times"
102
- return "See hours page"
103
-
104
-
105
- async def _hours_answer() -> str:
106
- from datetime import timezone, timedelta
107
- uae_now = datetime.now(timezone(timedelta(hours=4)))
108
- today_str = uae_now.strftime("%Y-%m-%d")
109
- day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
110
- today_name = day_names[uae_now.weekday()]
111
- hours_data = await _libcal_fetch_hours()
112
- if not hours_data:
113
- return (
114
- "Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
115
- "⚠️ Live hours are currently unavailable. Please check the official schedule:<br>"
116
- f'<a href="{LIBRARY_HOURS_URL}" target="_blank" '
117
- f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
118
- "<strong>📍 Contacts:</strong><br>"
119
- 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
120
- 'Habshan Library · SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
121
- )
122
- location_labels = {
123
- "main_campus": "Main Campus Library",
124
- "habshan": "Habshan Library (SAN Campus)",
125
- }
126
- def _today_line(loc_name: str, loc_data: dict) -> str:
127
- dates = loc_data.get("dates", {})
128
- today_entry = dates.get(today_str, {})
129
- if not today_entry:
130
- return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
131
- hours_str = _format_libcal_day(today_entry)
132
- is_closed = hours_str.lower().startswith("closed")
133
- colour = "#dc2626" if is_closed else "#16a34a"
134
- return (
135
- f"📅 <strong>Today ({today_name}):</strong> "
136
- f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
137
- )
138
- def _week_table(loc_data: dict) -> str:
139
- dates = loc_data.get("dates", {})
140
- if not dates:
141
- return ""
142
- rows = ""
143
- for date_key in sorted(dates.keys())[:7]:
144
- try:
145
- from datetime import date as _date
146
- d = _date.fromisoformat(date_key)
147
- day_label = d.strftime("%a %d %b")
148
- is_today = date_key == today_str
149
- except ValueError:
150
- day_label = date_key
151
- is_today = False
152
- hours_str = _format_libcal_day(dates[date_key])
153
- is_closed = hours_str.lower().startswith("closed")
154
- colour = "#dc2626" if is_closed else "#16a34a"
155
- weight = "700" if is_today else "400"
156
- bg = "#fffbeb" if is_today else "transparent"
157
- rows += (
158
- f"<tr style='background:{bg}'>"
159
- f"<td style='padding:3px 12px 3px 0;color:#374151;font-weight:{weight}'>{day_label}</td>"
160
- f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{hours_str}</td>"
161
- f"</tr>"
162
- )
163
- if not rows:
164
- return ""
165
- return (
166
- "<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
167
- + rows + "</table>"
168
- )
169
- answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
170
- for key, label in location_labels.items():
171
- loc = hours_data.get(key)
172
- if not loc:
173
- continue
174
- answer += f"<strong>📍 {label}</strong><br>"
175
- answer += _today_line(label, loc) + "<br>"
176
- week_tbl = _week_table(loc)
177
- if week_tbl:
178
- answer += week_tbl + "<br><br>"
179
- else:
180
- answer += "<br>"
181
- answer += (
182
- "⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
183
- f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
184
- f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
185
- '<span style="font-size:.82rem;color:#6b7280">'
186
- 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
187
- 'Habshan Library · Building 5, SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
188
- '</span>'
189
- )
190
- return answer
191
-
192
-
193
- _libcal_events_cache: Dict[str, Any] = {}
194
-
195
-
196
- _LIBCAL_EVENTS_TTL = 30 * 60
197
-
198
-
199
- async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
200
- now = time.time()
201
- cache_key = f"{days}_{limit}"
202
- cached = _libcal_events_cache.get(cache_key, {})
203
- if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
204
- return cached["data"]
205
- settings = get_settings()
206
- worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
207
- if not worker_url:
208
- logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
209
- return None
210
- try:
211
- async with httpx.AsyncClient(timeout=10) as client:
212
- r = await client.get(
213
- f"{worker_url.rstrip('/')}/events",
214
- params={"days": days, "limit": limit},
215
- )
216
- r.raise_for_status()
217
- data = r.json()
218
- if data.get("error"):
219
- logger.warning("LibCal events worker error: %s", data["error"])
220
- return None
221
- events_raw = data.get("events", [])
222
- def _safe_str(val) -> str:
223
- if val is None:
224
- return ""
225
- if isinstance(val, str):
226
- return val
227
- if isinstance(val, dict):
228
- return str(val.get("name") or val.get("title") or "")
229
- if isinstance(val, list):
230
- return ", ".join(_safe_str(v) for v in val if v)
231
- return str(val)
232
- events = []
233
- for e in events_raw:
234
- cats = e.get("category") or e.get("categories") or []
235
- if isinstance(cats, list):
236
- cat_str = ", ".join(_safe_str(c) for c in cats)
237
- else:
238
- cat_str = _safe_str(cats)
239
- events.append({
240
- "id": e.get("id", ""),
241
- "title": _safe_str(e.get("title") or "Untitled Event"),
242
- "start": _safe_str(e.get("start") or ""),
243
- "end": _safe_str(e.get("end") or ""),
244
- "allDay": bool(e.get("allday") or e.get("allDay")),
245
- "location": _safe_str(e.get("location") or ""),
246
- "description": _safe_str(e.get("description") or e.get("summary") or "")[:300],
247
- "url": _safe_str(e.get("url") or ""),
248
- "registration": bool(e.get("registration") or e.get("seats")),
249
- "campus": _safe_str(e.get("campus") or ""),
250
- "category": cat_str,
251
- })
252
- _libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
253
- logger.info("LibCal: %d events fetched", len(events))
254
- return events
255
- except Exception as exc:
256
- logger.warning("LibCal events fetch failed: %s", exc)
257
- return None
258
-
259
-
260
- def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
261
- if not start:
262
- return ""
263
- try:
264
- from datetime import datetime as _dt
265
- s = _dt.fromisoformat(start.replace("Z", "+00:00"))
266
- date_str = s.strftime("%a %d %b")
267
- if all_day:
268
- return date_str
269
- time_str = s.strftime("%I:%M %p").lstrip("0")
270
- if end:
271
- e = _dt.fromisoformat(end.replace("Z", "+00:00"))
272
- end_str = e.strftime("%I:%M %p").lstrip("0")
273
- return f"{date_str} · {time_str} – {end_str}"
274
- return f"{date_str} · {time_str}"
275
- except Exception:
276
- return start[:16].replace("T", " ")
277
-
278
-
279
- async def _events_answer(days: int = 30) -> str:
280
- EVENTS_URL = "https://kustar.libcal.com/calendar/events"
281
- events = await _libcal_fetch_events(days=days, limit=15)
282
- if not events:
283
- return (
284
- "<strong>📅 Khalifa University Library Events</strong><br><br>"
285
- "Live event data is currently unavailable. Browse all upcoming events here:<br>"
286
- f'<a href="{EVENTS_URL}" target="_blank" '
287
- f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
288
- )
289
- if not events:
290
- return (
291
- "<strong>📅 Khalifa University Library Events</strong><br><br>"
292
- f"No upcoming events found in the next {days} days.<br><br>"
293
- f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
294
- f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
295
- )
296
- answer = f"<strong>📅 Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
297
- for ev in events[:10]:
298
- title = _escape(str(ev.get("title") or "Untitled Event"))
299
- when = _format_event_datetime(
300
- str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
301
- )
302
- location = _escape(str(ev.get("location") or ""))
303
- desc = _escape(str(ev.get("description") or ""))
304
- url = str(ev.get("url") or EVENTS_URL)
305
- reg = bool(ev.get("registration"))
306
- answer += (
307
- f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
308
- f'background:#f8fafc;border-radius:0 8px 8px 0">'
309
- f'<div style="font-weight:700;font-size:.88rem;color:#003366">'
310
- f'<a href="{url}" target="_blank" style="color:#003366;text-decoration:none">{title}</a></div>'
311
- )
312
- if when:
313
- answer += f'<div style="font-size:.80rem;color:#374151;margin-top:2px">🕐 {when}</div>'
314
- if location:
315
- answer += f'<div style="font-size:.80rem;color:#374151">📍 {location}</div>'
316
- if desc:
317
- answer += f'<div style="font-size:.78rem;color:#6b7280;margin-top:4px">{desc[:180]}{"…" if len(desc) > 180 else ""}</div>'
318
- if reg:
319
- answer += (
320
- f'<div style="margin-top:5px">'
321
- f'<a href="{url}" target="_blank" '
322
- f'style="font-size:.76rem;font-weight:700;color:#003366;'
323
- f'padding:3px 10px;border:1px solid #003366;border-radius:6px;text-decoration:none">'
324
- f'Register →</a></div>'
325
- )
326
- answer += '</div>'
327
- answer += (
328
- f'<br><a href="{EVENTS_URL}" target="_blank" '
329
- f'style="font-weight:700;color:#003366">View full library events calendar →</a>'
330
- )
331
- return answer
332
-
333
-
 
1
+ """LibCal client + formatting: live opening hours and events via the Cloudflare Worker proxy."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+
19
+ from src.agentcore.constants import LIBRARY_HOURS_URL
20
+ from src.agentcore.utils import _escape
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _libcal_hours_cache: Dict[str, Any] = {}
25
+
26
+
27
+ _LIBCAL_HOURS_TTL = 60 * 60
28
+
29
+
30
+ async def _libcal_fetch_hours() -> Optional[dict]:
31
+ now = time.time()
32
+ cached_at = _libcal_hours_cache.get("cached_at", 0)
33
+ if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
34
+ return _libcal_hours_cache["data"]
35
+ settings = get_settings()
36
+ worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
37
+ if not worker_url:
38
+ logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set — cannot fetch hours")
39
+ return None
40
+ from datetime import timezone, timedelta
41
+ uae_now = datetime.now(timezone(timedelta(hours=4)))
42
+ date_from = uae_now.strftime("%Y-%m-%d")
43
+ date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
44
+ try:
45
+ async with httpx.AsyncClient(timeout=10) as client:
46
+ r = await client.get(
47
+ f"{worker_url.rstrip('/')}/hours",
48
+ params={"from": date_from, "to": date_to},
49
+ )
50
+ r.raise_for_status()
51
+ data = r.json()
52
+ if data.get("error"):
53
+ logger.warning("LibCal worker error: %s", data["error"])
54
+ return None
55
+ _libcal_hours_cache["data"] = data
56
+ _libcal_hours_cache["cached_at"] = now
57
+ logger.info("LibCal: hours refreshed via worker")
58
+ return data
59
+ except Exception as exc:
60
+ logger.warning("LibCal hours fetch failed: %s", exc)
61
+ return None
62
+
63
+
64
+ def _parse_libcal_time(raw: str) -> str:
65
+ if not raw:
66
+ return raw
67
+ raw = raw.strip().replace("\u2013", "-").replace("–", "-")
68
+ raw = re.sub(r"\s+", "", raw)
69
+ raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
70
+ return raw
71
+
72
+
73
+ def _format_libcal_day(day_data: dict) -> str:
74
+ if not day_data or not isinstance(day_data, dict):
75
+ return "See hours page"
76
+ status = str(day_data.get("status") or "").lower().strip()
77
+ note = str(day_data.get("note") or "").strip()
78
+ if status in ("closed", "0", "false"):
79
+ return f"Closed{f' — {note}' if note else ''}"
80
+ if status == "24hours":
81
+ return "Open 24 hours"
82
+ hours_list = day_data.get("hours") or []
83
+ if hours_list and isinstance(hours_list, list):
84
+ first = hours_list[0]
85
+ f = first.get("from") or first.get("opens") or ""
86
+ t = first.get("to") or first.get("closes") or ""
87
+ if f and t:
88
+ return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
89
+ opens = day_data.get("opens") or day_data.get("open") or ""
90
+ closes = day_data.get("closes") or day_data.get("close") or ""
91
+ if opens and closes:
92
+ return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
93
+ times = day_data.get("times") or []
94
+ if times and isinstance(times, list):
95
+ first = times[0]
96
+ f = first.get("from") or first.get("opens") or ""
97
+ t = first.get("to") or first.get("closes") or ""
98
+ if f and t:
99
+ return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
100
+ if status in ("open",):
101
+ return "Open — see hours page for times"
102
+ return "See hours page"
103
+
104
+
105
+ async def _hours_answer() -> str:
106
+ from datetime import timezone, timedelta
107
+ uae_now = datetime.now(timezone(timedelta(hours=4)))
108
+ today_str = uae_now.strftime("%Y-%m-%d")
109
+ day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
110
+ today_name = day_names[uae_now.weekday()]
111
+ hours_data = await _libcal_fetch_hours()
112
+ if not hours_data:
113
+ return (
114
+ "Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
115
+ "⚠️ Live hours are currently unavailable. Please check the official schedule:<br>"
116
+ f'<a href="{LIBRARY_HOURS_URL}" target="_blank" '
117
+ f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
118
+ "<strong>📍 Contacts:</strong><br>"
119
+ 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
120
+ 'Habshan Library · SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
121
+ )
122
+ location_labels = {
123
+ "main_campus": "Main Campus Library",
124
+ "habshan": "Habshan Library (SAN Campus)",
125
+ }
126
+ def _today_line(loc_name: str, loc_data: dict) -> str:
127
+ dates = loc_data.get("dates", {})
128
+ today_entry = dates.get(today_str, {})
129
+ if not today_entry:
130
+ return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
131
+ hours_str = _format_libcal_day(today_entry)
132
+ is_closed = hours_str.lower().startswith("closed")
133
+ colour = "#dc2626" if is_closed else "#16a34a"
134
+ return (
135
+ f"📅 <strong>Today ({today_name}):</strong> "
136
+ f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
137
+ )
138
+ def _week_table(loc_data: dict) -> str:
139
+ dates = loc_data.get("dates", {})
140
+ if not dates:
141
+ return ""
142
+ rows = ""
143
+ for date_key in sorted(dates.keys())[:7]:
144
+ try:
145
+ from datetime import date as _date
146
+ d = _date.fromisoformat(date_key)
147
+ day_label = d.strftime("%a %d %b")
148
+ is_today = date_key == today_str
149
+ except ValueError:
150
+ day_label = date_key
151
+ is_today = False
152
+ hours_str = _format_libcal_day(dates[date_key])
153
+ is_closed = hours_str.lower().startswith("closed")
154
+ colour = "#dc2626" if is_closed else "#16a34a"
155
+ weight = "700" if is_today else "400"
156
+ bg = "#fffbeb" if is_today else "transparent"
157
+ rows += (
158
+ f"<tr style='background:{bg}'>"
159
+ f"<td style='padding:3px 12px 3px 0;color:#374151;font-weight:{weight}'>{day_label}</td>"
160
+ f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{hours_str}</td>"
161
+ f"</tr>"
162
+ )
163
+ if not rows:
164
+ return ""
165
+ return (
166
+ "<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
167
+ + rows + "</table>"
168
+ )
169
+ answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
170
+ for key, label in location_labels.items():
171
+ loc = hours_data.get(key)
172
+ if not loc:
173
+ continue
174
+ answer += f"<strong>📍 {label}</strong><br>"
175
+ answer += _today_line(label, loc) + "<br>"
176
+ week_tbl = _week_table(loc)
177
+ if week_tbl:
178
+ answer += week_tbl + "<br><br>"
179
+ else:
180
+ answer += "<br>"
181
+ answer += (
182
+ "⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
183
+ f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
184
+ f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
185
+ '<span style="font-size:.82rem;color:#6b7280">'
186
+ 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
187
+ 'Habshan Library · Building 5, SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
188
+ '</span>'
189
+ )
190
+ return answer
191
+
192
+
193
+ _libcal_events_cache: Dict[str, Any] = {}
194
+
195
+
196
+ _LIBCAL_EVENTS_TTL = 30 * 60
197
+
198
+
199
+ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
200
+ now = time.time()
201
+ cache_key = f"{days}_{limit}"
202
+ cached = _libcal_events_cache.get(cache_key, {})
203
+ if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
204
+ return cached["data"]
205
+ settings = get_settings()
206
+ worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
207
+ if not worker_url:
208
+ logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
209
+ return None
210
+ try:
211
+ async with httpx.AsyncClient(timeout=10) as client:
212
+ r = await client.get(
213
+ f"{worker_url.rstrip('/')}/events",
214
+ params={"days": days, "limit": limit},
215
+ )
216
+ r.raise_for_status()
217
+ data = r.json()
218
+ if data.get("error"):
219
+ logger.warning("LibCal events worker error: %s", data["error"])
220
+ return None
221
+ events_raw = data.get("events", [])
222
+ def _safe_str(val) -> str:
223
+ if val is None:
224
+ return ""
225
+ if isinstance(val, str):
226
+ return val
227
+ if isinstance(val, dict):
228
+ return str(val.get("name") or val.get("title") or "")
229
+ if isinstance(val, list):
230
+ return ", ".join(_safe_str(v) for v in val if v)
231
+ return str(val)
232
+ events = []
233
+ for e in events_raw:
234
+ cats = e.get("category") or e.get("categories") or []
235
+ if isinstance(cats, list):
236
+ cat_str = ", ".join(_safe_str(c) for c in cats)
237
+ else:
238
+ cat_str = _safe_str(cats)
239
+ events.append({
240
+ "id": e.get("id", ""),
241
+ "title": _safe_str(e.get("title") or "Untitled Event"),
242
+ "start": _safe_str(e.get("start") or ""),
243
+ "end": _safe_str(e.get("end") or ""),
244
+ "allDay": bool(e.get("allday") or e.get("allDay")),
245
+ "location": _safe_str(e.get("location") or ""),
246
+ "description": _safe_str(e.get("description") or e.get("summary") or "")[:300],
247
+ "url": _safe_str(e.get("url") or ""),
248
+ "registration": bool(e.get("registration") or e.get("seats")),
249
+ "campus": _safe_str(e.get("campus") or ""),
250
+ "category": cat_str,
251
+ })
252
+ _libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
253
+ logger.info("LibCal: %d events fetched", len(events))
254
+ return events
255
+ except Exception as exc:
256
+ logger.warning("LibCal events fetch failed: %s", exc)
257
+ return None
258
+
259
+
260
+ def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
261
+ if not start:
262
+ return ""
263
+ try:
264
+ from datetime import datetime as _dt
265
+ s = _dt.fromisoformat(start.replace("Z", "+00:00"))
266
+ date_str = s.strftime("%a %d %b")
267
+ if all_day:
268
+ return date_str
269
+ time_str = s.strftime("%I:%M %p").lstrip("0")
270
+ if end:
271
+ e = _dt.fromisoformat(end.replace("Z", "+00:00"))
272
+ end_str = e.strftime("%I:%M %p").lstrip("0")
273
+ return f"{date_str} · {time_str} – {end_str}"
274
+ return f"{date_str} · {time_str}"
275
+ except Exception:
276
+ return start[:16].replace("T", " ")
277
+
278
+
279
+ async def _events_answer(days: int = 30) -> str:
280
+ EVENTS_URL = "https://kustar.libcal.com/calendar/events"
281
+ events = await _libcal_fetch_events(days=days, limit=15)
282
+ if not events:
283
+ return (
284
+ "<strong>📅 Khalifa University Library Events</strong><br><br>"
285
+ "Live event data is currently unavailable. Browse all upcoming events here:<br>"
286
+ f'<a href="{EVENTS_URL}" target="_blank" '
287
+ f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
288
+ )
289
+ if not events:
290
+ return (
291
+ "<strong>📅 Khalifa University Library Events</strong><br><br>"
292
+ f"No upcoming events found in the next {days} days.<br><br>"
293
+ f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
294
+ f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
295
+ )
296
+ answer = f"<strong>📅 Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
297
+ for ev in events[:10]:
298
+ title = _escape(str(ev.get("title") or "Untitled Event"))
299
+ when = _format_event_datetime(
300
+ str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
301
+ )
302
+ location = _escape(str(ev.get("location") or ""))
303
+ desc = _escape(str(ev.get("description") or ""))
304
+ url = str(ev.get("url") or EVENTS_URL)
305
+ reg = bool(ev.get("registration"))
306
+ answer += (
307
+ f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
308
+ f'background:#f8fafc;border-radius:0 8px 8px 0">'
309
+ f'<div style="font-weight:700;font-size:.88rem;color:#003366">'
310
+ f'<a href="{url}" target="_blank" style="color:#003366;text-decoration:none">{title}</a></div>'
311
+ )
312
+ if when:
313
+ answer += f'<div style="font-size:.80rem;color:#374151;margin-top:2px">🕐 {when}</div>'
314
+ if location:
315
+ answer += f'<div style="font-size:.80rem;color:#374151">📍 {location}</div>'
316
+ if desc:
317
+ answer += f'<div style="font-size:.78rem;color:#6b7280;margin-top:4px">{desc[:180]}{"…" if len(desc) > 180 else ""}</div>'
318
+ if reg:
319
+ answer += (
320
+ f'<div style="margin-top:5px">'
321
+ f'<a href="{url}" target="_blank" '
322
+ f'style="font-size:.76rem;font-weight:700;color:#003366;'
323
+ f'padding:3px 10px;border:1px solid #003366;border-radius:6px;text-decoration:none">'
324
+ f'Register →</a></div>'
325
+ )
326
+ answer += '</div>'
327
+ answer += (
328
+ f'<br><a href="{EVENTS_URL}" target="_blank" '
329
+ f'style="font-weight:700;color:#003366">View full library events calendar →</a>'
330
+ )
331
+ return answer
332
+
333
+
src/agentcore/models.py CHANGED
@@ -1,71 +1,69 @@
1
- """Pydantic request/response models shared across the pipeline (Stage 0: I/O contracts)."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
 
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
 
17
- from src.config import get_settings, LIBBEE_VERSION
18
 
19
- logger = logging.getLogger(__name__)
20
 
21
  class ChatMessage(BaseModel):
22
- role: str
23
- content: str
 
24
 
25
 
26
  class SearchContextPayload(BaseModel):
27
- context_id: str
28
- topic: str
29
- display_topic: Optional[str] = None
30
- intent: str = "search_academic"
31
- source: str = "primo"
32
- resource_type: str = "articles" # "articles" | "books" | "both"
 
33
  peer_reviewed: bool = False
34
  open_access: bool = False
35
- year_from: Optional[str] = None
36
- year_to: Optional[str] = None
37
- ai_tool_query: Optional[str] = None
38
- primo_boolean_query: Optional[str] = None
39
  created_at: float = Field(default_factory=time.time)
40
- # v3.8 — ambiguity gate
41
  clarification_needed: bool = False
42
- clarification_message: Optional[str] = None
 
 
 
 
 
 
 
 
43
 
44
 
45
  class ClientStatePayload(BaseModel):
46
- session_id: Optional[str] = None
47
- active_search_context_id: Optional[str] = None
48
- recent_search_contexts: List[SearchContextPayload] = Field(default_factory=list)
49
- follow_up_action: Optional[str] = None
50
- follow_up_context_id: Optional[str] = None
 
51
 
52
 
53
  class AgentRequest(BaseModel):
54
- question: str
55
- # FIX: validated literal — malformed model values caught at Pydantic layer
56
  model: Literal["gpt", "claude"] = "gpt"
57
- history: List[ChatMessage] = Field(default_factory=list)
58
  client_state: Optional[ClientStatePayload] = None
59
 
60
 
61
  class AgentResponse(BaseModel):
62
  model_config = ConfigDict(extra="ignore")
63
-
64
  answer: str
65
  intent: str = "general"
66
- tools_used: List[str] = Field(default_factory=list)
67
- search_results: List[dict] = Field(default_factory=list)
68
- sources: List[dict] = Field(default_factory=list)
69
  model_used: str = "gpt"
70
  response_time: float = 0
71
  corrected_query: Optional[str] = None
@@ -74,12 +72,10 @@ class AgentResponse(BaseModel):
74
  ask_librarian: Optional[bool] = None
75
  is_follow_up: bool = False
76
  follow_up_question: Optional[str] = None
77
- follow_up_suggestions: List[dict] = Field(default_factory=list)
78
  search_context: Optional[dict] = None
79
  ai_tool_query: Optional[str] = None
80
  primo_boolean_query: Optional[str] = None
81
  primo_search_url: Optional[str] = None
82
  primo_ai_url: Optional[str] = None
83
  pubmed_search_url: Optional[str] = None
84
-
85
-
 
1
+ """Strict request and response contracts for the LibBee agent."""
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ import time
4
+ from typing import Literal, Optional
5
 
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
7
 
 
8
 
9
  class ChatMessage(BaseModel):
10
+ model_config = ConfigDict(extra="forbid")
11
+ role: Literal["user", "assistant"]
12
+ content: str = Field(min_length=1, max_length=4000)
13
 
14
 
15
  class SearchContextPayload(BaseModel):
16
+ model_config = ConfigDict(extra="ignore")
17
+ context_id: str = Field(min_length=1, max_length=80)
18
+ topic: str = Field(min_length=1, max_length=500)
19
+ display_topic: Optional[str] = Field(default=None, max_length=500)
20
+ intent: Literal["search_academic", "search_medical"] = "search_academic"
21
+ source: Literal["primo", "pubmed"] = "primo"
22
+ resource_type: Literal["articles", "books", "both"] = "articles"
23
  peer_reviewed: bool = False
24
  open_access: bool = False
25
+ year_from: Optional[str] = Field(default=None, pattern=r"^\d{4}$")
26
+ year_to: Optional[str] = Field(default=None, pattern=r"^\d{4}$")
27
+ ai_tool_query: Optional[str] = Field(default=None, max_length=1000)
28
+ primo_boolean_query: Optional[str] = Field(default=None, max_length=1500)
29
  created_at: float = Field(default_factory=time.time)
 
30
  clarification_needed: bool = False
31
+ clarification_message: Optional[str] = Field(default=None, max_length=500)
32
+
33
+ @field_validator("year_to")
34
+ @classmethod
35
+ def years_in_order(cls, value: Optional[str], info):
36
+ start = info.data.get("year_from")
37
+ if value and start and int(value) < int(start):
38
+ raise ValueError("year_to must not be earlier than year_from")
39
+ return value
40
 
41
 
42
  class ClientStatePayload(BaseModel):
43
+ model_config = ConfigDict(extra="ignore")
44
+ session_id: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,80}$")
45
+ active_search_context_id: Optional[str] = Field(default=None, max_length=80)
46
+ recent_search_contexts: list[SearchContextPayload] = Field(default_factory=list, max_length=5)
47
+ follow_up_action: Optional[str] = Field(default=None, max_length=80)
48
+ follow_up_context_id: Optional[str] = Field(default=None, max_length=80)
49
 
50
 
51
  class AgentRequest(BaseModel):
52
+ model_config = ConfigDict(extra="forbid")
53
+ question: str = Field(min_length=1, max_length=2000)
54
  model: Literal["gpt", "claude"] = "gpt"
55
+ history: list[ChatMessage] = Field(default_factory=list, max_length=12)
56
  client_state: Optional[ClientStatePayload] = None
57
 
58
 
59
  class AgentResponse(BaseModel):
60
  model_config = ConfigDict(extra="ignore")
61
+ request_id: str = ""
62
  answer: str
63
  intent: str = "general"
64
+ tools_used: list[str] = Field(default_factory=list)
65
+ search_results: list[dict] = Field(default_factory=list)
66
+ sources: list[dict] = Field(default_factory=list)
67
  model_used: str = "gpt"
68
  response_time: float = 0
69
  corrected_query: Optional[str] = None
 
72
  ask_librarian: Optional[bool] = None
73
  is_follow_up: bool = False
74
  follow_up_question: Optional[str] = None
75
+ follow_up_suggestions: list[dict] = Field(default_factory=list)
76
  search_context: Optional[dict] = None
77
  ai_tool_query: Optional[str] = None
78
  primo_boolean_query: Optional[str] = None
79
  primo_search_url: Optional[str] = None
80
  primo_ai_url: Optional[str] = None
81
  pubmed_search_url: Optional[str] = None
 
 
src/agentcore/orchestrator.py CHANGED
@@ -1,1049 +1,971 @@
1
- """Stage 4 orchestration: the seven-intent routing pipeline (_agent_query_inner) and D1 logging."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from collections import deque
11
- from datetime import datetime
12
- from typing import Any, Dict, List, Literal, Optional, Tuple
13
- from urllib.parse import quote
14
-
15
- import httpx
16
- from pydantic import BaseModel, ConfigDict, Field
17
-
18
- from src.config import get_settings, LIBBEE_VERSION
19
- from src.services.staff_service import (
20
- STAFF_DIRECTORY,
21
- match_staff_name,
22
- match_staff_role,
23
- should_attempt_staff_lookup,
24
- staff_name_answer,
25
- staff_role_answer,
26
- )
27
-
28
- from src.agentcore.models import AgentResponse, SearchContextPayload
29
- from src.agentcore.constants import (
30
- ALT_TERMS_RE,
31
- ASK_LIBRARIAN_URL,
32
- BOOLEAN_STRATEGY_RE,
33
- CITATION_CHAIN_RE,
34
- DUAL_RESOURCE_RE,
35
- EVENTS_RE,
36
- FULLTEXT_REQUEST_RE,
37
- HIGHLY_CITED_RE,
38
- LIBRARY_HOURS_URL,
39
- PARTIAL_TITLE_RE,
40
- PREDATORY_RE,
41
- PURE_GREETING_RE,
42
- THESES_RE,
43
- _INJECTION_RE,
44
- )
45
- from src.agentcore.utils import (
46
- _escape,
47
- _get_runtime_config,
48
- _light_strip_retrieval_boilerplate,
49
- _normalize_whitespace,
50
- _primo_clean_url,
51
- _safe_metrics_bucket,
52
- _safe_metrics_increment,
53
- _title_case_topic,
54
- )
55
- from src.agentcore.classify import (
56
- _is_greeting_menu_followup,
57
- _is_summary_request,
58
- _llm_classify,
59
- _looks_campus_question,
60
- _looks_library_hours_question,
61
- )
62
- from src.agentcore.libcal import _events_answer, _hours_answer
63
- from src.agentcore.rendering import (
64
- _ai_tools_footer,
65
- _contact_footer_for_query,
66
- _search_trace_block,
67
- _source_badge,
68
- _thought_block,
69
- _tool_urls,
70
- )
71
- from src.agentcore.intents_library import (
72
- _campus_answer,
73
- _database_recommendation_answer,
74
- _fulltext_chain_answer,
75
- _greeting_menu_clarify_answer,
76
- _is_database_recommendation_question,
77
- _library_follow_up,
78
- _rag_answer,
79
- _theses_answer,
80
- )
81
- from src.agentcore.intents_search import (
82
- _alternative_terms_answer,
83
- _apply_follow_up_action,
84
- _citation_chain_answer,
85
- _context_to_dict,
86
- _derive_resource_type,
87
- _detect_follow_up,
88
- _extract_topic,
89
- _highly_cited_note,
90
- _parse_year_filters,
91
- _predatory_eval_answer,
92
- _prepare_queries,
93
- _research_snapshot,
94
- _run_search_mode,
95
- _search_follow_up,
96
- _search_strategy_answer,
97
- )
98
- from src.agentcore.intents_general import (
99
- _general_follow_up,
100
- _libbee_casual_response,
101
- _social_follow_up,
102
- _web_search_answer,
103
- )
104
-
105
- logger = logging.getLogger(__name__)
106
-
107
- _EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
108
- _LONG_DIGITS_RE = re.compile(r"\d{6,}")
109
- _TAG_RE = re.compile(r"<[^>]+>")
110
-
111
-
112
- # Strong references to in-flight analytics POSTs.
113
- #
114
- # asyncio keeps only WEAK references to tasks: a task created with
115
- # create_task() and then dropped can be garbage-collected before it finishes,
116
- # silently cancelling the request. That is invisible when the network is fast
117
- # and drops writes unpredictably when the handshake is slow. Holding a
118
- # reference until the task completes is the documented fix.
119
- _ANALYTICS_TASKS = set()
120
-
121
-
122
- def _spawn_background(coro) -> bool:
123
- """Run coro in the background, keeping a strong reference. True if scheduled."""
124
- try:
125
- loop = asyncio.get_running_loop()
126
- except RuntimeError:
127
- try:
128
- loop = asyncio.get_event_loop()
129
- except Exception:
130
- return False
131
- if not loop.is_running():
132
- return False
133
- task = loop.create_task(coro)
134
- _ANALYTICS_TASKS.add(task)
135
- task.add_done_callback(_ANALYTICS_TASKS.discard)
136
- return True
137
-
138
-
139
- def _redact_for_analytics(text: str, max_len: int = 200) -> str:
140
- """Privacy pass before anything reaches the analytics store: e-mail
141
- addresses and long digit runs (IDs, phone numbers) are masked and the
142
- text is truncated. Raw personal identifiers must never be persisted."""
143
- text = _EMAIL_RE.sub("[email]", text or "")
144
- text = _LONG_DIGITS_RE.sub("[number]", text)
145
- return text[:max_len]
146
-
147
-
148
- def _answer_excerpt(answer_html: str, max_len: int = 800) -> str:
149
- """Plain-text excerpt of the rendered answer for offline relevance grading
150
- by the daily KB-gap agent. HTML is stripped and the same redaction applied."""
151
- text = _TAG_RE.sub(" ", answer_html or "")
152
- text = re.sub(r"\s+", " ", text).strip()
153
- return _redact_for_analytics(text, max_len)
154
-
155
-
156
- # ── Analytics retry buffer ───────────────────────────────────────────────────
157
- # Transient failures reaching the Worker (rate limiting, connection resets)
158
- # previously meant the row was lost for good. Failed payloads are parked here
159
- # and re-attempted on subsequent queries, so a blocked window costs a delay
160
- # rather than data. Bounded so a long outage cannot grow memory without limit.
161
-
162
- _PENDING_LOGS: "deque" = deque(maxlen=200)
163
- _PENDING_DROPPED = {"count": 0}
164
-
165
- # Rolling record of the last few analytics-write attempts, so the outcome can be
166
- # inspected through /admin/log-status without access to container logs.
167
- _LOG_ATTEMPTS: "deque" = deque(maxlen=15)
168
-
169
-
170
- def _record_attempt(outcome: str, detail: str = "") -> None:
171
- _LOG_ATTEMPTS.append({
172
- "at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
173
- "outcome": outcome,
174
- "detail": detail[:200],
175
- })
176
-
177
-
178
- def analytics_log_status() -> dict:
179
- """Snapshot of the analytics write path for the admin dashboard."""
180
- return {
181
- "pending_buffered": len(_PENDING_LOGS),
182
- "buffer_capacity": _PENDING_LOGS.maxlen,
183
- "recent_attempts": list(_LOG_ATTEMPTS)[::-1],
184
- }
185
-
186
-
187
- async def _send_with_retry(worker_url: str, token: str, payload: dict,
188
- attempts: int = 2, backoff: float = 2.0):
189
- """_send_log_payload with one retry after a short backoff. Connectivity to
190
- workers.dev from this host drops for short windows; a single immediate
191
- retry recovers a meaningful fraction of those without involving the buffer."""
192
- last_exc = None
193
- for i in range(attempts):
194
- try:
195
- return await _send_log_payload(worker_url, token, payload)
196
- except Exception as exc:
197
- last_exc = exc
198
- if i + 1 < attempts:
199
- await asyncio.sleep(backoff)
200
- raise last_exc
201
-
202
-
203
- async def _send_log_payload(worker_url: str, token: str, payload: dict):
204
- """POST one analytics payload.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
- Returns (status_code, report) where report is the Worker's own JSON verdict.
207
- This matters because the Worker answers HTTP 200 even when the D1 INSERT
208
- itself failed, reporting the problem in a 'warning' field - so a status code
209
- alone cannot distinguish a stored row from a dropped one.
210
  """
211
- from src.services.http_client import make_client as _mk
212
- headers = {"Authorization": f"Bearer {token}"} if token else {}
213
- async with _mk(timeout=10) as client:
214
- resp = await client.post(worker_url.rstrip("/") + "/log",
215
- json=payload, headers=headers)
216
- report = {}
217
- try:
218
- body = resp.json()
219
- if isinstance(body, dict):
220
- report = {k: body.get(k) for k in ("status", "logged", "schema", "warning")
221
- if body.get(k) is not None}
222
- except Exception:
223
- report = {"raw": resp.text[:120]}
224
- return resp.status_code, report
225
-
226
-
227
- _FLUSHER_STARTED = {"on": False}
228
-
229
-
230
- def _ensure_periodic_flusher(worker_url: str, token: str) -> None:
231
- """Start (once) a background loop that retries buffered rows every 90s, so
232
- parked writes land as soon as connectivity returns instead of waiting for
233
- the next user query. The task holds a strong reference via _spawn_background.
234
- Lost on container restart - like the buffer itself - which the keep-alive
235
- pings are there to make rare."""
236
- if _FLUSHER_STARTED["on"]:
237
- return
238
-
239
- async def _loop():
240
- while True:
241
- await asyncio.sleep(90)
242
- if _PENDING_LOGS:
243
- try:
244
- n = await _flush_pending(worker_url, token, max_items=10)
245
- if n:
246
- _record_attempt("flushed", f"{n} buffered row(s) delivered")
247
- except Exception:
248
- pass
249
-
250
- if _spawn_background(_loop()):
251
- _FLUSHER_STARTED["on"] = True
252
- logger.info("Analytics flusher started (90s interval)")
253
-
254
-
255
- async def _flush_pending(worker_url: str, token: str, max_items: int = 5):
256
- """Re-attempt parked payloads oldest-first; stop at the first failure so a
257
- still-blocked Worker is not hammered."""
258
- sent = 0
259
- while _PENDING_LOGS and sent < max_items:
260
- payload = _PENDING_LOGS[0]
261
- try:
262
- status, report = await _send_log_payload(worker_url, token, payload)
263
- except Exception:
264
- return sent
265
- if status >= 400 or report.get("logged") is False:
266
- return sent
267
- _PENDING_LOGS.popleft()
268
- sent += 1
269
- if sent:
270
- logger.info(f"D1 logging: flushed {sent} buffered row(s); "
271
- f"{len(_PENDING_LOGS)} still pending")
272
- return sent
273
-
274
-
275
- def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None:
276
  elapsed = time.time() - start_time
277
- intent = response.intent or ""
278
- tool = ", ".join(response.tools_used or [])[:60]
279
- model_used = response.model_used or ""
280
- result_count = len(response.search_results or [])
281
- try:
282
- from app import get_metrics_service
283
- get_metrics_service().log_query(
284
- question=_redact_for_analytics(question),
285
- intent=intent,
286
- tool=tool,
287
- model=model_used,
288
- response_time=elapsed,
289
- result_count=result_count,
290
- )
291
  except Exception:
292
  pass
293
- try:
294
- settings = get_settings()
295
- worker_url = getattr(settings, "cloudflare_worker_url", "") or ""
296
- if worker_url:
297
- if getattr(settings, "analytics_client_side", False):
298
- # The browser posts this record to the Worker directly; skipping
299
- # here avoids duplicate rows.
300
- _record_attempt("client_side", "logged by browser")
301
- return
302
-
303
- _payload = {
304
- "question": _redact_for_analytics(question),
305
- "tool": f"{intent}:{tool}"[:80],
306
- "model": model_used,
307
- "response_time": round(elapsed, 3),
308
- "result_count": result_count,
309
- # consumed by the daily KB-gap / relevance agent
310
- "answer_excerpt": _answer_excerpt(response.answer),
311
- "source_ids": [
312
- str(src.get("source") or src.get("id") or "")[:60]
313
- for src in (response.sources or [])[:8]
314
- ],
315
- }
316
- _token = getattr(settings, "cloudflare_worker_token", "") or ""
317
-
318
- async def _post_to_worker():
319
- # Drain anything parked by earlier failures first, then send this row.
320
- try:
321
- await _flush_pending(worker_url, _token)
322
- except Exception:
323
- pass
324
- try:
325
- status, report = await _send_with_retry(worker_url, _token, _payload)
326
- except Exception as _exc:
327
- _PENDING_LOGS.append(_payload)
328
- _record_attempt("unreachable", f"{type(_exc).__name__}: {_exc}")
329
- logger.warning(
330
- f"D1 logging unreachable ({type(_exc).__name__}: {_exc}); buffered "
331
- f"({len(_PENDING_LOGS)} pending)"
332
- )
333
- return
334
- if status == 401:
335
- _record_attempt("rejected_401", "token mismatch")
336
- logger.error(
337
- "D1 logging rejected (401). CLOUDFLARE_WORKER_TOKEN does "
338
- "not match the Worker's ANALYTICS_TOKEN secret."
339
- )
340
- elif status in (403, 429):
341
- _PENDING_LOGS.append(_payload)
342
- _record_attempt("throttled", f"HTTP {status}")
343
- logger.warning(
344
- f"D1 logging throttled or blocked (HTTP {status}); buffered "
345
- f"({len(_PENDING_LOGS)} pending). Repeated 403/429 from "
346
- f"workers.dev usually means Cloudflare is rate-limiting this "
347
- f"host; a custom domain on the Worker avoids it."
348
- )
349
- elif status >= 400:
350
- _PENDING_LOGS.append(_payload)
351
- _record_attempt("http_error", f"HTTP {status}")
352
- logger.warning(f"D1 logging failed: HTTP {status}; buffered")
353
- elif report.get("logged") is False:
354
- # Worker answered 200 but told us the row was not stored.
355
- _PENDING_LOGS.append(_payload)
356
- _record_attempt("worker_rejected",
357
- f"HTTP {status} {report.get('warning') or report}")
358
- logger.warning(
359
- f"D1 insert failed Worker-side: {report.get('warning') or report}"
360
- )
361
- elif report.get("warning"):
362
- # Stored, but via the legacy fallback schema - surface it.
363
- _record_attempt("ok_with_warning",
364
- f"HTTP {status} schema={report.get('schema')} "
365
- f"{report.get('warning')}")
366
- logger.warning(f"D1 logged with warning: {report.get('warning')}")
367
- else:
368
- _record_attempt("ok", f"HTTP {status} schema={report.get('schema')}")
369
- logger.debug("D1 logged OK")
370
-
371
- if not _spawn_background(_post_to_worker()):
372
- logger.warning("D1 logging skipped: no running event loop")
373
- else:
374
- _ensure_periodic_flusher(worker_url, _token)
375
- except Exception:
376
- pass
377
-
378
-
379
- async def _agent_query_inner(
380
- question: str,
381
- history,
382
- model: str,
383
- client_state,
384
- start_time: float,
385
- ) -> AgentResponse:
386
-
387
- # ── Prompt injection detection ────────────────────────────────────────────
388
- if question and _INJECTION_RE.search(question):
389
- _safe_metrics_bucket("intents", "injection_blocked")
390
- return AgentResponse(
391
- answer=(
392
- "I'm sorry, but I can't process that request. "
393
- "I'm LibBee, the KU Library AI Assistant — here to help with "
394
- "library services, research, and academic resources. "
395
- f'How can I help you today? <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
396
- ),
397
- intent="blocked",
398
- tools_used=["injection_guard"],
399
- model_used=model,
400
- response_time=time.time() - start_time,
401
- )
402
-
403
- if not question:
404
- follow_up_question, follow_up_suggestions = _social_follow_up()
405
- return AgentResponse(
406
- answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
407
- intent="social",
408
- model_used=model,
409
- response_time=time.time() - start_time,
410
- follow_up_question=follow_up_question,
411
- follow_up_suggestions=follow_up_suggestions,
412
- )
413
-
414
- if PURE_GREETING_RE.match(question):
415
- follow_up_question, follow_up_suggestions = _social_follow_up()
416
- return AgentResponse(
417
- answer=(
418
- _get_runtime_config().get("welcome_message", "").strip()
419
- or (
420
- "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
421
- "I can help you find articles and books, search databases, access full text, "
422
- "request Interlibrary Loan (ILL), and answer questions about library services and staff."
423
- )
424
- + _source_badge("libbee")
425
- ),
426
- intent="social_greeting",
427
- model_used=model,
428
- response_time=time.time() - start_time,
429
- follow_up_question=follow_up_question,
430
- follow_up_suggestions=follow_up_suggestions,
431
- )
432
-
433
- staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
434
- if staff_match:
435
- _safe_metrics_bucket("intents", "staff_lookup")
436
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
437
- return AgentResponse(
438
- answer=_thought_block("staff_lookup", "staff_name_match", question) + staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
439
- intent="library_info",
440
- tools_used=["staff_name_match"],
441
- model_used=model,
442
- response_time=time.time() - start_time,
443
- follow_up_question=follow_up_question,
444
- follow_up_suggestions=follow_up_suggestions,
445
- )
446
-
447
- staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
448
- if staff_role_match:
449
- _safe_metrics_bucket("intents", "staff_lookup")
450
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
451
- return AgentResponse(
452
- answer=_thought_block("staff_lookup", "staff_role_match", question) + staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
453
- intent="library_info",
454
- tools_used=["staff_role_match"],
455
- model_used=model,
456
- response_time=time.time() - start_time,
457
- follow_up_question=follow_up_question,
458
- follow_up_suggestions=follow_up_suggestions,
459
- )
460
-
461
- if _is_greeting_menu_followup(question, history):
462
- follow_up_question, follow_up_suggestions = _social_follow_up()
463
- return AgentResponse(
464
- answer=_thought_block("social_greeting", "greeting_menu", question) + _greeting_menu_clarify_answer() + _source_badge("libbee"),
465
- intent="social_greeting",
466
- is_follow_up=True,
467
- model_used=model,
468
- response_time=time.time() - start_time,
469
- follow_up_question=follow_up_question,
470
- follow_up_suggestions=follow_up_suggestions,
471
- )
472
-
473
- if _looks_library_hours_question(question):
474
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
475
- hours_html = await _hours_answer()
476
- return AgentResponse(
477
- answer=_thought_block("hours", "libcal_live", question) + hours_html + _source_badge("ku_library", LIBRARY_HOURS_URL),
478
- intent="library_info",
479
- tools_used=["libcal_live_hours"],
480
- ask_librarian=False,
481
- model_used=model,
482
- response_time=time.time() - start_time,
483
- follow_up_question=follow_up_question,
484
- follow_up_suggestions=follow_up_suggestions,
485
- )
486
-
487
- if EVENTS_RE.search(question):
488
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
489
- events_html = await _events_answer()
490
- return AgentResponse(
491
- answer=_thought_block("library_info", "libcal_events", question,
492
- "I fetched upcoming events from the KU LibCal events calendar.")
493
- + events_html + _source_badge("ku_library", "https://kustar.libcal.com/calendar/events"),
494
- intent="library_info",
495
- tools_used=["libcal_live_events"],
496
- ask_librarian=False,
497
- model_used=model,
498
- response_time=time.time() - start_time,
499
- follow_up_question=follow_up_question,
500
- follow_up_suggestions=follow_up_suggestions,
501
- )
502
-
503
- if _looks_campus_question(question):
504
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
505
- return AgentResponse(
506
- answer=_thought_block("campus", "campus_hardcoded", question) + _campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
507
- intent="library_info",
508
- tools_used=["campus_hardcoded"],
509
- ask_librarian=False,
510
- model_used=model,
511
- response_time=time.time() - start_time,
512
- follow_up_question=follow_up_question,
513
- follow_up_suggestions=follow_up_suggestions,
514
- )
515
-
516
- if _is_database_recommendation_question(question):
517
- answer = _database_recommendation_answer(question)
518
- answer += _contact_footer_for_query(question, None)
519
- answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
520
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
521
- return AgentResponse(
522
- answer=_thought_block("database_rec", "database_recommendation", question) + answer,
523
- intent="library_info",
524
- tools_used=["database_recommendation"],
525
- model_used=model,
526
- response_time=time.time() - start_time,
527
- follow_up_question=follow_up_question,
528
- follow_up_suggestions=follow_up_suggestions,
529
- )
530
-
531
- is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
532
-
533
- # ── Catalogue availability ────────────────────────────────────────────────
534
- _CATALOGUE_RE = re.compile(
535
- r"\b("
536
- r"available in (the )?(ku|khalifa) library"
537
- r"|in (the )?(ku|khalifa) library"
538
- r"|does (the )?(ku|khalifa) library have"
539
- r"|does (the )?library (have|carry|stock|hold)"
540
- r"|(ku|khalifa) library (has|have|carry|holds?|stocks?)"
541
- r"|ku library (books?|copies|titles?)"
542
- r")\b",
543
- re.IGNORECASE,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  )
545
- _BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
546
-
547
- if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
548
- from urllib.parse import quote as _quote
549
- _topic_raw = re.sub(
550
- r"\b(can you (help me )?find|find|search|look for|show me|help me find"
551
- r"|books? (available|in|on)|available in.{0,30}library|in the ku library"
552
- r"|on|about|regarding|related to|ku library|khalifa university library"
553
- r"|machine learning|please|thanks)\b",
554
- " ", question, flags=re.IGNORECASE,
555
- )
556
- _topic = _normalize_whitespace(_topic_raw).strip(" ,.")
557
- _primo_books_url = (
558
- "https://khalifa.primo.exlibrisgroup.com/discovery/search"
559
- f"?query=any,contains,{_quote(_topic or question)},AND"
560
- "&tab=Everything&search_scope=MyInst_and_CI"
561
- "&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
562
- "&qInclude=facet_rtype,exact,books"
563
- )
564
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
565
-
566
- rag_results = []
567
- try:
568
- from app import get_rag_service
569
- rag_results = await get_rag_service().hybrid_search(question, top_k=3)
570
- except Exception:
571
- pass
572
-
573
- if rag_results:
574
- rag_answer = await _rag_answer(question, rag_results, [], model)
575
- else:
576
- rag_answer = ""
577
-
578
- catalogue_answer = (
579
- f"To find books available in the KU Library on <strong>{_escape(_topic or 'this topic')}</strong>, "
580
- f"search the KU Library catalogue directly:<br><br>"
581
- f'<a href="{_primo_books_url}" target="_blank" style="display:inline-block;padding:9px 16px;'
582
- f'background:#003366;color:#fff;border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
583
- f"📚 Search KU Library catalogue for books →</a><br><br>"
584
- f"The catalogue shows real-time availability — whether a copy is on the shelf, checked out, or available as an e-book."
585
- )
586
- if rag_answer:
587
- catalogue_answer += f"<br><br>{rag_answer}"
588
-
589
- return AgentResponse(
590
- answer=_thought_block("library_info", "catalogue_books", question) + catalogue_answer + _source_badge("primo", _primo_books_url),
591
- intent="library_info",
592
- tools_used=["catalogue_books_redirect"],
593
- model_used=model,
594
- response_time=time.time() - start_time,
595
- follow_up_question=follow_up_question,
596
- follow_up_suggestions=follow_up_suggestions,
597
- )
598
-
599
- if is_follow_up and base_context:
600
- _safe_metrics_increment("follow_up_hits")
601
- context = _apply_follow_up_action(base_context, follow_up_action)
602
-
603
- if follow_up_action == "alt_terms":
604
- alt_answer = await _alternative_terms_answer(question, base_context, model)
605
- follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
606
- return AgentResponse(
607
- answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
608
- intent="library_info",
609
- tools_used=["alt_terms"],
610
- model_used=model,
611
- response_time=time.time() - start_time,
612
- follow_up_question=follow_up_question,
613
- follow_up_suggestions=follow_up_suggestions,
614
- search_context=_context_to_dict(base_context),
615
- is_follow_up=True,
616
- )
617
-
618
- if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
619
- context = await _prepare_queries(question, context, model, True)
620
- if follow_up_action == "summarize_topic":
621
- answer, search_results, sources = await _research_snapshot(context, model)
622
- else:
623
- answer = (
624
- f"{_search_trace_block(question, context)}"
625
- f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
626
- f"Here are the best AI tools and resources for a deep dive on this topic."
627
- + _source_badge("libbee")
628
- + _ai_tools_footer(context)
629
- )
630
- search_results = []
631
- sources = []
632
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
633
- _safe_metrics_increment("search_handoffs")
634
- return AgentResponse(
635
- answer=answer,
636
- intent=context.intent,
637
- tools_used=["follow_up_resolution", follow_up_action],
638
- search_results=search_results,
639
- sources=sources,
640
- model_used=model,
641
- response_time=time.time() - start_time,
642
- corrected_query=context.topic,
643
- natural_query=context.ai_tool_query,
644
- database_query=context.primo_boolean_query,
645
- ai_tool_query=context.ai_tool_query,
646
- primo_boolean_query=context.primo_boolean_query,
647
- primo_search_url=_primo_clean_url(context),
648
- primo_ai_url=_tool_urls(context).get("primo_ai"),
649
- pubmed_search_url=_tool_urls(context).get("pubmed"),
650
- follow_up_question=follow_up_question,
651
- follow_up_suggestions=follow_up_suggestions,
652
- search_context=_context_to_dict(context),
653
- is_follow_up=True,
654
- )
655
-
656
- answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
657
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
658
- return AgentResponse(
659
- answer=answer,
660
- intent=context.intent,
661
- tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
662
- search_results=search_results,
663
- sources=sources,
664
- model_used=model,
665
- response_time=time.time() - start_time,
666
- corrected_query=context.topic,
667
- natural_query=context.ai_tool_query,
668
- database_query=context.primo_boolean_query,
669
- ai_tool_query=context.ai_tool_query,
670
- primo_boolean_query=context.primo_boolean_query,
671
- primo_search_url=source_url,
672
- primo_ai_url=_tool_urls(context).get("primo_ai"),
673
- pubmed_search_url=_tool_urls(context).get("pubmed"),
674
- follow_up_question=follow_up_question,
675
- follow_up_suggestions=follow_up_suggestions,
676
- search_context=_context_to_dict(context),
677
- is_follow_up=True,
678
- )
679
-
680
- # ── Pre-classifier specialist handlers ───────────────────────────────────
681
-
682
- if FULLTEXT_REQUEST_RE.search(question):
683
- ft_answer = _fulltext_chain_answer(question)
684
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
685
- return AgentResponse(
686
- answer=_thought_block("library_info", "fulltext_chain", question,
687
- "Identified a full-text access request. Returning the KU full-text access chain.") + ft_answer + _source_badge("ku_library", "https://library.ku.ac.ae/ill/"),
688
- intent="library_info",
689
- tools_used=["fulltext_chain"],
690
- model_used=model,
691
- response_time=time.time() - start_time,
692
- follow_up_question=follow_up_question,
693
- follow_up_suggestions=follow_up_suggestions,
694
- )
695
-
696
- if THESES_RE.search(question):
697
- th_answer = _theses_answer()
698
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
699
- return AgentResponse(
700
- answer=_thought_block("library_info", "theses_guide", question,
701
- "Identified a theses/repository question.") + th_answer + _source_badge("ku_library", "https://khazna.ku.ac.ae"),
702
- intent="library_info",
703
- tools_used=["theses_guide"],
704
- model_used=model,
705
- response_time=time.time() - start_time,
706
- follow_up_question=follow_up_question,
707
- follow_up_suggestions=follow_up_suggestions,
708
- )
709
-
710
- if PREDATORY_RE.search(question):
711
- pr_answer = _predatory_eval_answer()
712
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
713
- return AgentResponse(
714
- answer=_thought_block("library_info", "predatory_eval", question,
715
- "Identified a question about evaluating journals/peer review.") + pr_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
716
- intent="library_info",
717
- tools_used=["predatory_eval"],
718
- model_used=model,
719
- response_time=time.time() - start_time,
720
- follow_up_question=follow_up_question,
721
- follow_up_suggestions=follow_up_suggestions,
722
- )
723
-
724
- if CITATION_CHAIN_RE.search(question):
725
- cc_answer = _citation_chain_answer()
726
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
727
- return AgentResponse(
728
- answer=_thought_block("library_info", "citation_chain", question,
729
- "Identified a citation tracing / forward-backward search question.") + cc_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
730
- intent="library_info",
731
- tools_used=["citation_chain"],
732
- model_used=model,
733
- response_time=time.time() - start_time,
734
- follow_up_question=follow_up_question,
735
- follow_up_suggestions=follow_up_suggestions,
736
- )
737
-
738
- if BOOLEAN_STRATEGY_RE.search(question):
739
- strategy_answer, _plan = await _search_strategy_answer(question, model)
740
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
741
- return AgentResponse(
742
- answer=_thought_block("library_info", "search_strategy", question,
743
- "Identified a search strategy / boolean building request.") + strategy_answer + _source_badge("libbee"),
744
- intent="library_info",
745
- tools_used=["search_strategy_explain"],
746
- model_used=model,
747
- response_time=time.time() - start_time,
748
- database_query=_plan.get("boolean"),
749
- natural_query=_plan.get("natural"),
750
- follow_up_question=follow_up_question,
751
- follow_up_suggestions=follow_up_suggestions,
752
- )
753
-
754
- if ALT_TERMS_RE.search(question):
755
- alt_answer = await _alternative_terms_answer(question, None, model)
756
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
757
- return AgentResponse(
758
- answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
759
- intent="library_info",
760
- tools_used=["alt_terms"],
761
- model_used=model,
762
- response_time=time.time() - start_time,
763
- follow_up_question=follow_up_question,
764
- follow_up_suggestions=follow_up_suggestions,
765
- )
766
-
767
- cls_result = await _llm_classify(question, history, model)
768
- intent = cls_result.get("intent", "general")
769
- _safe_metrics_bucket("intents", intent)
770
- casual_hint = cls_result.get("casual_answer", "")
771
-
772
- if intent == "social":
773
- answer = (
774
- casual_hint
775
- if casual_hint and len(casual_hint.strip()) > 20
776
- else await _libbee_casual_response(question, history, model, casual_hint)
777
- )
778
- follow_up_question, follow_up_suggestions = _social_follow_up()
779
- return AgentResponse(
780
- answer=_thought_block("social", "libbee_casual", question) + answer + _source_badge("libbee"),
781
- intent="social_greeting",
782
- tools_used=["libbee_casual"],
783
- model_used=model,
784
- response_time=time.time() - start_time,
785
- follow_up_question=follow_up_question,
786
- follow_up_suggestions=follow_up_suggestions,
787
- )
788
-
789
- if intent == "sensitive":
790
- _safe_metrics_bucket("intents", "sensitive_blocked")
791
- follow_up_question, follow_up_suggestions = _social_follow_up()
792
- return AgentResponse(
793
- answer=(
794
- "That's not something I'm able to comment on. "
795
- "As the KU Library AI Assistant, I'm here to support your academic "
796
- "and research needs — finding articles, accessing databases, "
797
- "library services, and research tools.<br><br>"
798
- "Is there something I can help you with today? "
799
- f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">💬 Ask a Librarian</a>'
800
- ),
801
- intent="sensitive_blocked",
802
- tools_used=["sensitive_intent_guard"],
803
- model_used=model,
804
- response_time=time.time() - start_time,
805
- follow_up_question=follow_up_question,
806
- follow_up_suggestions=follow_up_suggestions,
807
- )
808
-
809
- if intent == "library_info":
810
- rag_results = []
811
- try:
812
- from app import get_rag_service
813
- rag_results = await get_rag_service().hybrid_search(question, top_k=4)
814
- except Exception as e:
815
- logger.error(f"RAG search failed: {e}")
816
-
817
- if not rag_results:
818
- answer = (
819
- "I couldn't find specific information on that in my knowledge base.<br><br>"
820
- "Please contact our librarians directly:<br>"
821
- '📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
822
- f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
823
- )
824
- answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
825
- sources = []
826
- tools_used = ["rag_no_results"]
827
- thought = _thought_block("library_info", "rag_no_results", question,
828
- "No matching content was found — I am directing you to Ask a Librarian.")
829
- else:
830
- answer = await _rag_answer(question, rag_results, history, model)
831
- answer += _source_badge("rag", "https://library.ku.ac.ae/")
832
- sources = [
833
- {"title": r.get("title", "Library KB"), "url": r.get("source", "")}
834
- for r in rag_results if r.get("source")
835
- ]
836
- tools_used = ["rag_hybrid_search"]
837
- thought = _thought_block("rag_search", "rag_hybrid_search", question,
838
- f"Hybrid FAISS + BM25 search returned {len(rag_results)} relevant chunk(s) from the KB.")
839
-
840
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
841
- return AgentResponse(
842
- answer=thought + answer,
843
- intent="library_info",
844
- tools_used=tools_used,
845
- sources=sources,
846
- ask_librarian=not bool(rag_results),
847
- model_used=model,
848
- response_time=time.time() - start_time,
849
- follow_up_question=follow_up_question,
850
- follow_up_suggestions=follow_up_suggestions,
851
- )
852
-
853
- if intent in ("search_academic", "search_medical"):
854
- if PARTIAL_TITLE_RE.search(question):
855
- from urllib.parse import quote as _qt
856
- _title_frag = (re.search(r'"([^"]{4,80})"', question) or
857
- re.search(r'"([^"]{4,80})"', question))
858
- _title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
859
- _title_url = (
860
- "https://khalifa.primo.exlibrisgroup.com/discovery/search"
861
- f"?query=title,contains,{_qt(_title_q)},AND"
862
- "&tab=Everything&search_scope=MyInst_and_CI"
863
- "&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
864
- )
865
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
866
- return AgentResponse(
867
- answer=(
868
- _thought_block("library_info", "title_search", question,
869
- "Identified a partial title search — routing to PRIMO title search.") +
870
- f"<strong>📖 Title search in KU Library</strong><br><br>"
871
- f"I'll search the KU Library catalogue by title for: <em>{_escape(_title_q)}</em><br><br>"
872
- f'<a href="{_title_url}" target="_blank" '
873
- f'style="display:inline-block;padding:9px 16px;background:#003366;color:#fff;'
874
- f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
875
- f"🔍 Search by title in PRIMO →</a><br><br>"
876
- "If you only remember part of the title, use PRIMO's advanced search and choose "
877
- "<em>Title</em> from the field dropdown — it supports partial matches.<br>"
878
- "You can also try "
879
- '<a href="https://scholar.google.com" target="_blank">Google Scholar</a> '
880
- "with the partial title in quotes." +
881
- _source_badge("primo", _title_url)
882
- ),
883
- intent="library_info",
884
- tools_used=["title_search"],
885
- model_used=model,
886
- response_time=time.time() - start_time,
887
- follow_up_question=follow_up_question,
888
- follow_up_suggestions=follow_up_suggestions,
889
- )
890
-
891
- topic = await _extract_topic(question, model)
892
- topic = _title_case_topic(topic)
893
- year_from, year_to = _parse_year_filters(question)
894
-
895
- if DUAL_RESOURCE_RE.search(question):
896
- import asyncio as _aio
897
- _ctx_articles = SearchContextPayload(
898
- context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
899
- intent=intent, source="primo", resource_type="articles",
900
- peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
901
- open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
902
- year_from=year_from, year_to=year_to,
903
- )
904
- _ctx_books = SearchContextPayload(
905
- context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
906
- intent=intent, source="primo", resource_type="books",
907
- peer_reviewed=False, open_access=False,
908
- year_from=year_from, year_to=year_to,
909
- )
910
- _ctx_articles, _ctx_books = await _aio.gather(
911
- _prepare_queries(question, _ctx_articles, model, False),
912
- _prepare_queries(question, _ctx_books, model, False),
913
- )
914
- _src_url_a = _primo_clean_url(_ctx_articles)
915
- _src_url_b = _primo_clean_url(_ctx_books)
916
-
917
- _dual_answer = _search_trace_block(question, _ctx_articles)
918
- _dual_answer += (
919
- f"<strong>📄 Articles on {_escape(topic)}</strong><br>"
920
- f'<a href="{_src_url_a}" target="_blank" style="color:#003366;font-weight:700">'
921
- f'Search PRIMO for articles →</a>'
922
- )
923
- _dual_answer += _source_badge("primo", _src_url_a)
924
- _dual_answer += f"<br><br><strong>📚 Books on {_escape(topic)}</strong><br>"
925
- _dual_answer += (
926
- f'<a href="{_src_url_b}" target="_blank" style="color:#003366;font-weight:700">'
927
- f'Search PRIMO for books →</a>'
928
- )
929
- _dual_answer += _source_badge("primo", _src_url_b)
930
- _dual_answer += _ai_tools_footer(_ctx_articles)
931
-
932
- follow_up_question, follow_up_suggestions = await _search_follow_up(_ctx_articles, model)
933
- return AgentResponse(
934
- answer=_dual_answer,
935
- intent=intent,
936
- tools_used=["primo_browser_link"],
937
- search_results=[],
938
- sources=[],
939
- model_used=model,
940
- response_time=time.time() - start_time,
941
- corrected_query=topic,
942
- natural_query=_ctx_articles.ai_tool_query,
943
- primo_boolean_query=_ctx_articles.primo_boolean_query,
944
- primo_search_url=_src_url_a,
945
- follow_up_question=follow_up_question,
946
- follow_up_suggestions=follow_up_suggestions,
947
- search_context=_context_to_dict(_ctx_articles),
948
- )
949
-
950
- context = SearchContextPayload(
951
- context_id=str(uuid.uuid4()),
952
- topic=topic,
953
- display_topic=topic,
954
- intent=intent,
955
- source="primo",
956
- resource_type=_derive_resource_type(question),
957
- peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
958
- open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
959
- year_from=year_from,
960
- year_to=year_to,
961
- )
962
-
963
- context = await _prepare_queries(question, context, model, False)
964
- summary_mode = _is_summary_request(question)
965
- deep_research_mode = bool(re.search(
966
- r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
967
- question, re.IGNORECASE,
968
- ))
969
- is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
970
-
971
- if summary_mode or deep_research_mode:
972
- answer, search_results, sources = await _research_snapshot(context, model)
973
- if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer:
974
- answer += f"<br><br>{_ai_tools_footer(context)}"
975
- if is_highly_cited:
976
- answer += _highly_cited_note(_escape(topic))
977
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
978
- _safe_metrics_increment("search_handoffs")
979
- return AgentResponse(
980
- answer=answer,
981
- intent=intent,
982
- tools_used=["research_snapshot"],
983
- search_results=search_results,
984
- sources=sources,
985
- model_used=model,
986
- response_time=time.time() - start_time,
987
- corrected_query=context.topic,
988
- natural_query=context.ai_tool_query,
989
- database_query=context.primo_boolean_query,
990
- ai_tool_query=context.ai_tool_query,
991
- primo_boolean_query=context.primo_boolean_query,
992
- primo_search_url=_primo_clean_url(context),
993
- primo_ai_url=_tool_urls(context).get("primo_ai"),
994
- pubmed_search_url=_tool_urls(context).get("pubmed"),
995
- follow_up_question=follow_up_question,
996
- follow_up_suggestions=follow_up_suggestions,
997
- search_context=_context_to_dict(context),
998
- )
999
-
1000
- answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
1001
- if is_highly_cited:
1002
- answer += _highly_cited_note(_escape(topic))
1003
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
1004
- return AgentResponse(
1005
- answer=answer,
1006
- intent=intent,
1007
- tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
1008
- search_results=search_results,
1009
- sources=sources,
1010
- model_used=model,
1011
- response_time=time.time() - start_time,
1012
- corrected_query=context.topic,
1013
- natural_query=context.ai_tool_query,
1014
- database_query=context.primo_boolean_query,
1015
- ai_tool_query=context.ai_tool_query,
1016
- primo_boolean_query=context.primo_boolean_query,
1017
- primo_search_url=source_url,
1018
- primo_ai_url=_tool_urls(context).get("primo_ai"),
1019
- pubmed_search_url=_tool_urls(context).get("pubmed"),
1020
- follow_up_question=follow_up_question,
1021
- follow_up_suggestions=follow_up_suggestions,
1022
- search_context=_context_to_dict(context),
1023
- )
1024
-
1025
- if intent == "general_recent":
1026
- answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
1027
- follow_up_question, follow_up_suggestions = _general_follow_up(question)
1028
- return AgentResponse(
1029
- answer=_thought_block("general_recent", "web_search", question) + answer,
1030
- intent="general_recent",
1031
- tools_used=["web_search"],
1032
- model_used=model,
1033
- response_time=time.time() - start_time,
1034
- follow_up_question=follow_up_question,
1035
- follow_up_suggestions=follow_up_suggestions,
1036
- )
1037
-
1038
- # general fallback
1039
- answer = await _web_search_answer(question, history, model) + _source_badge("web")
1040
- follow_up_question, follow_up_suggestions = _general_follow_up(question)
1041
- return AgentResponse(
1042
- answer=_thought_block("general", "web_search", question) + answer,
1043
- intent="general",
1044
- tools_used=["web_search"],
1045
- model_used=model,
1046
- response_time=time.time() - start_time,
1047
- follow_up_question=follow_up_question,
1048
- follow_up_suggestions=follow_up_suggestions,
1049
- )
 
1
+ """Stage 4 orchestration: the seven-intent routing pipeline (_agent_query_inner) and D1 logging."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from collections import deque
11
+ from datetime import datetime
12
+ from typing import Any, Dict, List, Literal, Optional, Tuple
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from src.config import get_settings, LIBBEE_VERSION
19
+ from src.services.staff_service import (
20
+ STAFF_DIRECTORY,
21
+ match_staff_name,
22
+ match_staff_role,
23
+ should_attempt_staff_lookup,
24
+ staff_name_answer,
25
+ staff_role_answer,
26
+ )
27
+
28
+ from src.agentcore.models import AgentResponse, SearchContextPayload
29
+ from src.agentcore.constants import (
30
+ ALT_TERMS_RE,
31
+ ASK_LIBRARIAN_URL,
32
+ BOOLEAN_STRATEGY_RE,
33
+ CITATION_CHAIN_RE,
34
+ DUAL_RESOURCE_RE,
35
+ EVENTS_RE,
36
+ FULLTEXT_REQUEST_RE,
37
+ HIGHLY_CITED_RE,
38
+ LIBRARY_HOURS_URL,
39
+ PARTIAL_TITLE_RE,
40
+ PREDATORY_RE,
41
+ PURE_GREETING_RE,
42
+ THESES_RE,
43
+ _INJECTION_RE,
44
+ )
45
+ from src.agentcore.utils import (
46
+ _escape,
47
+ _get_runtime_config,
48
+ _light_strip_retrieval_boilerplate,
49
+ _normalize_whitespace,
50
+ _primo_clean_url,
51
+ _safe_metrics_bucket,
52
+ _safe_metrics_increment,
53
+ _title_case_topic,
54
+ )
55
+ from src.agentcore.classify import (
56
+ _is_greeting_menu_followup,
57
+ _is_summary_request,
58
+ _llm_classify,
59
+ _looks_campus_question,
60
+ _looks_library_hours_question,
61
+ )
62
+ from src.agentcore.libcal import _events_answer, _hours_answer
63
+ from src.agentcore.rendering import (
64
+ _ai_tools_footer,
65
+ _contact_footer_for_query,
66
+ _search_trace_block,
67
+ _source_badge,
68
+ _thought_block,
69
+ _tool_urls,
70
+ )
71
+ from src.agentcore.intents_library import (
72
+ _campus_answer,
73
+ _database_recommendation_answer,
74
+ _fulltext_chain_answer,
75
+ _greeting_menu_clarify_answer,
76
+ _is_database_recommendation_question,
77
+ _library_follow_up,
78
+ _rag_answer,
79
+ _theses_answer,
80
+ )
81
+ from src.agentcore.intents_search import (
82
+ _alternative_terms_answer,
83
+ _apply_follow_up_action,
84
+ _citation_chain_answer,
85
+ _context_to_dict,
86
+ _derive_resource_type,
87
+ _detect_follow_up,
88
+ _extract_topic,
89
+ _highly_cited_note,
90
+ _parse_year_filters,
91
+ _predatory_eval_answer,
92
+ _prepare_queries,
93
+ _research_snapshot,
94
+ _run_search_mode,
95
+ _search_follow_up,
96
+ _search_strategy_answer,
97
+ )
98
+ from src.agentcore.intents_general import (
99
+ _general_follow_up,
100
+ _libbee_casual_response,
101
+ _social_follow_up,
102
+ _web_search_answer,
103
+ )
104
+
105
+ logger = logging.getLogger(__name__)
106
+
107
+ _EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
108
+ _LONG_DIGITS_RE = re.compile(r"\d{6,}")
109
+ _TAG_RE = re.compile(r"<[^>]+>")
110
+
111
+
112
+ # Strong references to in-flight analytics POSTs.
113
+ #
114
+ # asyncio keeps only WEAK references to tasks: a task created with
115
+ # create_task() and then dropped can be garbage-collected before it finishes,
116
+ # silently cancelling the request. That is invisible when the network is fast
117
+ # and drops writes unpredictably when the handshake is slow. Holding a
118
+ # reference until the task completes is the documented fix.
119
+ _ANALYTICS_TASKS = set()
120
+
121
+
122
+ def _spawn_background(coro) -> bool:
123
+ """Run coro in the background, keeping a strong reference. True if scheduled."""
124
+ try:
125
+ loop = asyncio.get_running_loop()
126
+ except RuntimeError:
127
+ try:
128
+ loop = asyncio.get_event_loop()
129
+ except Exception:
130
+ return False
131
+ if not loop.is_running():
132
+ return False
133
+ task = loop.create_task(coro)
134
+ _ANALYTICS_TASKS.add(task)
135
+ task.add_done_callback(_ANALYTICS_TASKS.discard)
136
+ return True
137
+
138
+
139
+ def _redact_for_analytics(text: str, max_len: int = 200) -> str:
140
+ """Privacy pass before anything reaches the analytics store: e-mail
141
+ addresses and long digit runs (IDs, phone numbers) are masked and the
142
+ text is truncated. Raw personal identifiers must never be persisted."""
143
+ text = _EMAIL_RE.sub("[email]", text or "")
144
+ text = _LONG_DIGITS_RE.sub("[number]", text)
145
+ return text[:max_len]
146
+
147
+
148
+ def _answer_excerpt(answer_html: str, max_len: int = 800) -> str:
149
+ """Plain-text excerpt of the rendered answer for offline relevance grading
150
+ by the daily KB-gap agent. HTML is stripped and the same redaction applied."""
151
+ text = _TAG_RE.sub(" ", answer_html or "")
152
+ text = re.sub(r"\s+", " ", text).strip()
153
+ return _redact_for_analytics(text, max_len)
154
+
155
+
156
+ # ── Analytics retry buffer ───────────────────────────────────────────────────
157
+ # Transient failures reaching the Worker (rate limiting, connection resets)
158
+ # previously meant the row was lost for good. Failed payloads are parked here
159
+ # and re-attempted on subsequent queries, so a blocked window costs a delay
160
+ # rather than data. Bounded so a long outage cannot grow memory without limit.
161
+
162
+ _PENDING_LOGS: "deque" = deque(maxlen=200)
163
+ _PENDING_DROPPED = {"count": 0}
164
+
165
+ # Rolling record of the last few analytics-write attempts, so the outcome can be
166
+ # inspected through /admin/log-status without access to container logs.
167
+ _LOG_ATTEMPTS: "deque" = deque(maxlen=15)
168
+
169
+
170
+ def _record_attempt(outcome: str, detail: str = "") -> None:
171
+ _LOG_ATTEMPTS.append({
172
+ "at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
173
+ "outcome": outcome,
174
+ "detail": detail[:200],
175
+ })
176
+
177
+
178
+ def analytics_log_status() -> dict:
179
+ """Snapshot of the analytics write path for the admin dashboard."""
180
+ return {
181
+ "pending_buffered": len(_PENDING_LOGS),
182
+ "buffer_capacity": _PENDING_LOGS.maxlen,
183
+ "recent_attempts": list(_LOG_ATTEMPTS)[::-1],
184
+ }
185
+
186
+
187
+ async def _send_with_retry(worker_url: str, token: str, payload: dict,
188
+ attempts: int = 2, backoff: float = 2.0):
189
+ """_send_log_payload with one retry after a short backoff. Connectivity to
190
+ workers.dev from this host drops for short windows; a single immediate
191
+ retry recovers a meaningful fraction of those without involving the buffer."""
192
+ last_exc = None
193
+ for i in range(attempts):
194
+ try:
195
+ return await _send_log_payload(worker_url, token, payload)
196
+ except Exception as exc:
197
+ last_exc = exc
198
+ if i + 1 < attempts:
199
+ await asyncio.sleep(backoff)
200
+ raise last_exc
201
+
202
+
203
+ async def _send_log_payload(worker_url: str, token: str, payload: dict):
204
+ """POST one analytics payload.
205
+
206
+ Returns (status_code, report) where report is the Worker's own JSON verdict.
207
+ This matters because the Worker answers HTTP 200 even when the D1 INSERT
208
+ itself failed, reporting the problem in a 'warning' field - so a status code
209
+ alone cannot distinguish a stored row from a dropped one.
210
+ """
211
+ from src.services.http_client import make_client as _mk
212
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
213
+ async with _mk(timeout=10) as client:
214
+ resp = await client.post(worker_url.rstrip("/") + "/log",
215
+ json=payload, headers=headers)
216
+ report = {}
217
+ try:
218
+ body = resp.json()
219
+ if isinstance(body, dict):
220
+ report = {k: body.get(k) for k in ("status", "logged", "schema", "warning")
221
+ if body.get(k) is not None}
222
+ except Exception:
223
+ report = {"raw": resp.text[:120]}
224
+ return resp.status_code, report
225
+
226
+
227
+ _FLUSHER_STARTED = {"on": False}
228
+
229
+
230
+ def _ensure_periodic_flusher(worker_url: str, token: str) -> None:
231
+ """Start (once) a background loop that retries buffered rows every 90s, so
232
+ parked writes land as soon as connectivity returns instead of waiting for
233
+ the next user query. The task holds a strong reference via _spawn_background.
234
+ Lost on container restart - like the buffer itself - which the keep-alive
235
+ pings are there to make rare."""
236
+ if _FLUSHER_STARTED["on"]:
237
+ return
238
+
239
+ async def _loop():
240
+ while True:
241
+ await asyncio.sleep(90)
242
+ if _PENDING_LOGS:
243
+ try:
244
+ n = await _flush_pending(worker_url, token, max_items=10)
245
+ if n:
246
+ _record_attempt("flushed", f"{n} buffered row(s) delivered")
247
+ except Exception:
248
+ pass
249
+
250
+ if _spawn_background(_loop()):
251
+ _FLUSHER_STARTED["on"] = True
252
+ logger.info("Analytics flusher started (90s interval)")
253
+
254
+
255
+ async def _flush_pending(worker_url: str, token: str, max_items: int = 5):
256
+ """Re-attempt parked payloads oldest-first; stop at the first failure so a
257
+ still-blocked Worker is not hammered."""
258
+ sent = 0
259
+ while _PENDING_LOGS and sent < max_items:
260
+ payload = _PENDING_LOGS[0]
261
+ try:
262
+ status, report = await _send_log_payload(worker_url, token, payload)
263
+ except Exception:
264
+ return sent
265
+ if status >= 400 or report.get("logged") is False:
266
+ return sent
267
+ _PENDING_LOGS.popleft()
268
+ sent += 1
269
+ if sent:
270
+ logger.info(f"D1 logging: flushed {sent} buffered row(s); "
271
+ f"{len(_PENDING_LOGS)} still pending")
272
+ return sent
273
+
274
+
275
+ def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None:
276
+ """Keep only local operational counters.
277
 
278
+ Persistent analytics is written by the Cloudflare gateway after it receives
279
+ the origin response. This prevents duplicate rows and removes the old public
280
+ /log dependency.
 
281
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  elapsed = time.time() - start_time
283
+ intent = response.intent or ""
284
+ tool = ", ".join(response.tools_used or [])[:60]
285
+ model_used = response.model_used or ""
286
+ result_count = len(response.search_results or [])
287
+ try:
288
+ from app import get_metrics_service
289
+ get_metrics_service().log_query(
290
+ question=_redact_for_analytics(question),
291
+ intent=intent,
292
+ tool=tool,
293
+ model=model_used,
294
+ response_time=elapsed,
295
+ result_count=result_count,
296
+ )
297
  except Exception:
298
  pass
299
+
300
+
301
+ async def _agent_query_inner(
302
+ question: str,
303
+ history,
304
+ model: str,
305
+ client_state,
306
+ start_time: float,
307
+ ) -> AgentResponse:
308
+
309
+ # ── Prompt injection detection ────────────────────────────────────────────
310
+ if question and _INJECTION_RE.search(question):
311
+ _safe_metrics_bucket("intents", "injection_blocked")
312
+ return AgentResponse(
313
+ answer=(
314
+ "I'm sorry, but I can't process that request. "
315
+ "I'm LibBee, the KU Library AI Assistant — here to help with "
316
+ "library services, research, and academic resources. "
317
+ f'How can I help you today? <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
318
+ ),
319
+ intent="blocked",
320
+ tools_used=["injection_guard"],
321
+ model_used=model,
322
+ response_time=time.time() - start_time,
323
+ )
324
+
325
+ if not question:
326
+ follow_up_question, follow_up_suggestions = _social_follow_up()
327
+ return AgentResponse(
328
+ answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
329
+ intent="social",
330
+ model_used=model,
331
+ response_time=time.time() - start_time,
332
+ follow_up_question=follow_up_question,
333
+ follow_up_suggestions=follow_up_suggestions,
334
+ )
335
+
336
+ if PURE_GREETING_RE.match(question):
337
+ follow_up_question, follow_up_suggestions = _social_follow_up()
338
+ return AgentResponse(
339
+ answer=(
340
+ _get_runtime_config().get("welcome_message", "").strip()
341
+ or (
342
+ "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
343
+ "I can help you find articles and books, search databases, access full text, "
344
+ "request Interlibrary Loan (ILL), and answer questions about library services and staff."
345
+ )
346
+ + _source_badge("libbee")
347
+ ),
348
+ intent="social_greeting",
349
+ model_used=model,
350
+ response_time=time.time() - start_time,
351
+ follow_up_question=follow_up_question,
352
+ follow_up_suggestions=follow_up_suggestions,
353
+ )
354
+
355
+ staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
356
+ if staff_match:
357
+ _safe_metrics_bucket("intents", "staff_lookup")
358
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
359
+ return AgentResponse(
360
+ answer=_thought_block("staff_lookup", "staff_name_match", question) + staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
361
+ intent="library_info",
362
+ tools_used=["staff_name_match"],
363
+ model_used=model,
364
+ response_time=time.time() - start_time,
365
+ follow_up_question=follow_up_question,
366
+ follow_up_suggestions=follow_up_suggestions,
367
+ )
368
+
369
+ staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
370
+ if staff_role_match:
371
+ _safe_metrics_bucket("intents", "staff_lookup")
372
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
373
+ return AgentResponse(
374
+ answer=_thought_block("staff_lookup", "staff_role_match", question) + staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
375
+ intent="library_info",
376
+ tools_used=["staff_role_match"],
377
+ model_used=model,
378
+ response_time=time.time() - start_time,
379
+ follow_up_question=follow_up_question,
380
+ follow_up_suggestions=follow_up_suggestions,
381
+ )
382
+
383
+ if _is_greeting_menu_followup(question, history):
384
+ follow_up_question, follow_up_suggestions = _social_follow_up()
385
+ return AgentResponse(
386
+ answer=_thought_block("social_greeting", "greeting_menu", question) + _greeting_menu_clarify_answer() + _source_badge("libbee"),
387
+ intent="social_greeting",
388
+ is_follow_up=True,
389
+ model_used=model,
390
+ response_time=time.time() - start_time,
391
+ follow_up_question=follow_up_question,
392
+ follow_up_suggestions=follow_up_suggestions,
393
+ )
394
+
395
+ if _looks_library_hours_question(question):
396
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
397
+ hours_html = await _hours_answer()
398
+ return AgentResponse(
399
+ answer=_thought_block("hours", "libcal_live", question) + hours_html + _source_badge("ku_library", LIBRARY_HOURS_URL),
400
+ intent="library_info",
401
+ tools_used=["libcal_live_hours"],
402
+ ask_librarian=False,
403
+ model_used=model,
404
+ response_time=time.time() - start_time,
405
+ follow_up_question=follow_up_question,
406
+ follow_up_suggestions=follow_up_suggestions,
407
+ )
408
+
409
+ if EVENTS_RE.search(question):
410
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
411
+ events_html = await _events_answer()
412
+ return AgentResponse(
413
+ answer=_thought_block("library_info", "libcal_events", question,
414
+ "I fetched upcoming events from the KU LibCal events calendar.")
415
+ + events_html + _source_badge("ku_library", "https://kustar.libcal.com/calendar/events"),
416
+ intent="library_info",
417
+ tools_used=["libcal_live_events"],
418
+ ask_librarian=False,
419
+ model_used=model,
420
+ response_time=time.time() - start_time,
421
+ follow_up_question=follow_up_question,
422
+ follow_up_suggestions=follow_up_suggestions,
423
+ )
424
+
425
+ if _looks_campus_question(question):
426
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
427
+ return AgentResponse(
428
+ answer=_thought_block("campus", "campus_hardcoded", question) + _campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
429
+ intent="library_info",
430
+ tools_used=["campus_hardcoded"],
431
+ ask_librarian=False,
432
+ model_used=model,
433
+ response_time=time.time() - start_time,
434
+ follow_up_question=follow_up_question,
435
+ follow_up_suggestions=follow_up_suggestions,
436
+ )
437
+
438
+ if _is_database_recommendation_question(question):
439
+ answer = _database_recommendation_answer(question)
440
+ answer += _contact_footer_for_query(question, None)
441
+ answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
442
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
443
+ return AgentResponse(
444
+ answer=_thought_block("database_rec", "database_recommendation", question) + answer,
445
+ intent="library_info",
446
+ tools_used=["database_recommendation"],
447
+ model_used=model,
448
+ response_time=time.time() - start_time,
449
+ follow_up_question=follow_up_question,
450
+ follow_up_suggestions=follow_up_suggestions,
451
+ )
452
+
453
+ is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
454
+
455
+ # ── Catalogue availability ────────────────────────────────────────────────
456
+ _CATALOGUE_RE = re.compile(
457
+ r"\b("
458
+ r"available in (the )?(ku|khalifa) library"
459
+ r"|in (the )?(ku|khalifa) library"
460
+ r"|does (the )?(ku|khalifa) library have"
461
+ r"|does (the )?library (have|carry|stock|hold)"
462
+ r"|(ku|khalifa) library (has|have|carry|holds?|stocks?)"
463
+ r"|ku library (books?|copies|titles?)"
464
+ r")\b",
465
+ re.IGNORECASE,
466
+ )
467
+ _BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
468
+
469
+ if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
470
+ from urllib.parse import quote as _quote
471
+ _topic_raw = re.sub(
472
+ r"\b(can you (help me )?find|find|search|look for|show me|help me find"
473
+ r"|books? (available|in|on)|available in.{0,30}library|in the ku library"
474
+ r"|on|about|regarding|related to|ku library|khalifa university library"
475
+ r"|machine learning|please|thanks)\b",
476
+ " ", question, flags=re.IGNORECASE,
477
+ )
478
+ _topic = _normalize_whitespace(_topic_raw).strip(" ,.")
479
+ _primo_books_url = (
480
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search"
481
+ f"?query=any,contains,{_quote(_topic or question)},AND"
482
+ "&tab=Everything&search_scope=MyInst_and_CI"
483
+ "&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
484
+ "&qInclude=facet_rtype,exact,books"
485
+ )
486
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
487
+
488
+ rag_results = []
489
+ try:
490
+ from app import get_rag_service
491
+ rag_results = await get_rag_service().hybrid_search(question, top_k=3)
492
+ except Exception:
493
+ pass
494
+
495
+ if rag_results:
496
+ rag_answer = await _rag_answer(question, rag_results, [], model)
497
+ else:
498
+ rag_answer = ""
499
+
500
+ catalogue_answer = (
501
+ f"To find books available in the KU Library on <strong>{_escape(_topic or 'this topic')}</strong>, "
502
+ f"search the KU Library catalogue directly:<br><br>"
503
+ f'<a href="{_primo_books_url}" target="_blank" style="display:inline-block;padding:9px 16px;'
504
+ f'background:#003366;color:#fff;border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
505
+ f"📚 Search KU Library catalogue for books →</a><br><br>"
506
+ f"The catalogue shows real-time availability — whether a copy is on the shelf, checked out, or available as an e-book."
507
+ )
508
+ if rag_answer:
509
+ catalogue_answer += f"<br><br>{rag_answer}"
510
+
511
+ return AgentResponse(
512
+ answer=_thought_block("library_info", "catalogue_books", question) + catalogue_answer + _source_badge("primo", _primo_books_url),
513
+ intent="library_info",
514
+ tools_used=["catalogue_books_redirect"],
515
+ model_used=model,
516
+ response_time=time.time() - start_time,
517
+ follow_up_question=follow_up_question,
518
+ follow_up_suggestions=follow_up_suggestions,
519
+ )
520
+
521
+ if is_follow_up and base_context:
522
+ _safe_metrics_increment("follow_up_hits")
523
+ context = _apply_follow_up_action(base_context, follow_up_action)
524
+
525
+ if follow_up_action == "alt_terms":
526
+ alt_answer = await _alternative_terms_answer(question, base_context, model)
527
+ follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
528
+ return AgentResponse(
529
+ answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
530
+ intent="library_info",
531
+ tools_used=["alt_terms"],
532
+ model_used=model,
533
+ response_time=time.time() - start_time,
534
+ follow_up_question=follow_up_question,
535
+ follow_up_suggestions=follow_up_suggestions,
536
+ search_context=_context_to_dict(base_context),
537
+ is_follow_up=True,
538
+ )
539
+
540
+ if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
541
+ context = await _prepare_queries(question, context, model, True)
542
+ if follow_up_action == "summarize_topic":
543
+ answer, search_results, sources = await _research_snapshot(context, model)
544
+ else:
545
+ answer = (
546
+ f"{_search_trace_block(question, context)}"
547
+ f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
548
+ f"Here are the best AI tools and resources for a deep dive on this topic."
549
+ + _source_badge("libbee")
550
+ + _ai_tools_footer(context)
551
+ )
552
+ search_results = []
553
+ sources = []
554
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
555
+ _safe_metrics_increment("search_handoffs")
556
+ return AgentResponse(
557
+ answer=answer,
558
+ intent=context.intent,
559
+ tools_used=["follow_up_resolution", follow_up_action],
560
+ search_results=search_results,
561
+ sources=sources,
562
+ model_used=model,
563
+ response_time=time.time() - start_time,
564
+ corrected_query=context.topic,
565
+ natural_query=context.ai_tool_query,
566
+ database_query=context.primo_boolean_query,
567
+ ai_tool_query=context.ai_tool_query,
568
+ primo_boolean_query=context.primo_boolean_query,
569
+ primo_search_url=_primo_clean_url(context),
570
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
571
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
572
+ follow_up_question=follow_up_question,
573
+ follow_up_suggestions=follow_up_suggestions,
574
+ search_context=_context_to_dict(context),
575
+ is_follow_up=True,
576
+ )
577
+
578
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
579
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
580
+ return AgentResponse(
581
+ answer=answer,
582
+ intent=context.intent,
583
+ tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
584
+ search_results=search_results,
585
+ sources=sources,
586
+ model_used=model,
587
+ response_time=time.time() - start_time,
588
+ corrected_query=context.topic,
589
+ natural_query=context.ai_tool_query,
590
+ database_query=context.primo_boolean_query,
591
+ ai_tool_query=context.ai_tool_query,
592
+ primo_boolean_query=context.primo_boolean_query,
593
+ primo_search_url=source_url,
594
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
595
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
596
+ follow_up_question=follow_up_question,
597
+ follow_up_suggestions=follow_up_suggestions,
598
+ search_context=_context_to_dict(context),
599
+ is_follow_up=True,
600
+ )
601
+
602
+ # ── Pre-classifier specialist handlers ───────────────────────────────────
603
+
604
+ if FULLTEXT_REQUEST_RE.search(question):
605
+ ft_answer = _fulltext_chain_answer(question)
606
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
607
+ return AgentResponse(
608
+ answer=_thought_block("library_info", "fulltext_chain", question,
609
+ "Identified a full-text access request. Returning the KU full-text access chain.") + ft_answer + _source_badge("ku_library", "https://library.ku.ac.ae/ill/"),
610
+ intent="library_info",
611
+ tools_used=["fulltext_chain"],
612
+ model_used=model,
613
+ response_time=time.time() - start_time,
614
+ follow_up_question=follow_up_question,
615
+ follow_up_suggestions=follow_up_suggestions,
616
+ )
617
+
618
+ if THESES_RE.search(question):
619
+ th_answer = _theses_answer()
620
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
621
+ return AgentResponse(
622
+ answer=_thought_block("library_info", "theses_guide", question,
623
+ "Identified a theses/repository question.") + th_answer + _source_badge("ku_library", "https://khazna.ku.ac.ae"),
624
+ intent="library_info",
625
+ tools_used=["theses_guide"],
626
+ model_used=model,
627
+ response_time=time.time() - start_time,
628
+ follow_up_question=follow_up_question,
629
+ follow_up_suggestions=follow_up_suggestions,
630
+ )
631
+
632
+ if PREDATORY_RE.search(question):
633
+ pr_answer = _predatory_eval_answer()
634
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
635
+ return AgentResponse(
636
+ answer=_thought_block("library_info", "predatory_eval", question,
637
+ "Identified a question about evaluating journals/peer review.") + pr_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
638
+ intent="library_info",
639
+ tools_used=["predatory_eval"],
640
+ model_used=model,
641
+ response_time=time.time() - start_time,
642
+ follow_up_question=follow_up_question,
643
+ follow_up_suggestions=follow_up_suggestions,
644
+ )
645
+
646
+ if CITATION_CHAIN_RE.search(question):
647
+ cc_answer = _citation_chain_answer()
648
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
649
+ return AgentResponse(
650
+ answer=_thought_block("library_info", "citation_chain", question,
651
+ "Identified a citation tracing / forward-backward search question.") + cc_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
652
+ intent="library_info",
653
+ tools_used=["citation_chain"],
654
+ model_used=model,
655
+ response_time=time.time() - start_time,
656
+ follow_up_question=follow_up_question,
657
+ follow_up_suggestions=follow_up_suggestions,
658
+ )
659
+
660
+ if BOOLEAN_STRATEGY_RE.search(question):
661
+ strategy_answer, _plan = await _search_strategy_answer(question, model)
662
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
663
+ return AgentResponse(
664
+ answer=_thought_block("library_info", "search_strategy", question,
665
+ "Identified a search strategy / boolean building request.") + strategy_answer + _source_badge("libbee"),
666
+ intent="library_info",
667
+ tools_used=["search_strategy_explain"],
668
+ model_used=model,
669
+ response_time=time.time() - start_time,
670
+ database_query=_plan.get("boolean"),
671
+ natural_query=_plan.get("natural"),
672
+ follow_up_question=follow_up_question,
673
+ follow_up_suggestions=follow_up_suggestions,
674
+ )
675
+
676
+ if ALT_TERMS_RE.search(question):
677
+ alt_answer = await _alternative_terms_answer(question, None, model)
678
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
679
+ return AgentResponse(
680
+ answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
681
+ intent="library_info",
682
+ tools_used=["alt_terms"],
683
+ model_used=model,
684
+ response_time=time.time() - start_time,
685
+ follow_up_question=follow_up_question,
686
+ follow_up_suggestions=follow_up_suggestions,
687
+ )
688
+
689
+ cls_result = await _llm_classify(question, history, model)
690
+ intent = cls_result.get("intent", "general")
691
+ _safe_metrics_bucket("intents", intent)
692
+ casual_hint = cls_result.get("casual_answer", "")
693
+
694
+ if intent == "social":
695
+ answer = (
696
+ casual_hint
697
+ if casual_hint and len(casual_hint.strip()) > 20
698
+ else await _libbee_casual_response(question, history, model, casual_hint)
699
+ )
700
+ follow_up_question, follow_up_suggestions = _social_follow_up()
701
+ return AgentResponse(
702
+ answer=_thought_block("social", "libbee_casual", question) + answer + _source_badge("libbee"),
703
+ intent="social_greeting",
704
+ tools_used=["libbee_casual"],
705
+ model_used=model,
706
+ response_time=time.time() - start_time,
707
+ follow_up_question=follow_up_question,
708
+ follow_up_suggestions=follow_up_suggestions,
709
+ )
710
+
711
+ if intent == "sensitive":
712
+ _safe_metrics_bucket("intents", "sensitive_blocked")
713
+ follow_up_question, follow_up_suggestions = _social_follow_up()
714
+ return AgentResponse(
715
+ answer=(
716
+ "That's not something I'm able to comment on. "
717
+ "As the KU Library AI Assistant, I'm here to support your academic "
718
+ "and research needs — finding articles, accessing databases, "
719
+ "library services, and research tools.<br><br>"
720
+ "Is there something I can help you with today? "
721
+ f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">💬 Ask a Librarian</a>'
722
+ ),
723
+ intent="sensitive_blocked",
724
+ tools_used=["sensitive_intent_guard"],
725
+ model_used=model,
726
+ response_time=time.time() - start_time,
727
+ follow_up_question=follow_up_question,
728
+ follow_up_suggestions=follow_up_suggestions,
729
+ )
730
+
731
+ if intent == "library_info":
732
+ rag_results = []
733
+ try:
734
+ from app import get_rag_service
735
+ rag_results = await get_rag_service().hybrid_search(question, top_k=4)
736
+ except Exception as e:
737
+ logger.error(f"RAG search failed: {e}")
738
+
739
+ if not rag_results:
740
+ answer = (
741
+ "I couldn't find specific information on that in my knowledge base.<br><br>"
742
+ "Please contact our librarians directly:<br>"
743
+ '📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
744
+ f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
745
+ )
746
+ answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
747
+ sources = []
748
+ tools_used = ["rag_no_results"]
749
+ thought = _thought_block("library_info", "rag_no_results", question,
750
+ "No matching content was found — I am directing you to Ask a Librarian.")
751
+ else:
752
+ answer = await _rag_answer(question, rag_results, history, model)
753
+ answer += _source_badge("rag", "https://library.ku.ac.ae/")
754
+ sources = [
755
+ {"title": r.get("title", "Library KB"), "url": r.get("source", "")}
756
+ for r in rag_results if r.get("source")
757
+ ]
758
+ tools_used = ["rag_hybrid_search"]
759
+ thought = _thought_block("rag_search", "rag_hybrid_search", question,
760
+ f"Hybrid FAISS + BM25 search returned {len(rag_results)} relevant chunk(s) from the KB.")
761
+
762
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
763
+ return AgentResponse(
764
+ answer=thought + answer,
765
+ intent="library_info",
766
+ tools_used=tools_used,
767
+ sources=sources,
768
+ ask_librarian=not bool(rag_results),
769
+ model_used=model,
770
+ response_time=time.time() - start_time,
771
+ follow_up_question=follow_up_question,
772
+ follow_up_suggestions=follow_up_suggestions,
773
+ )
774
+
775
+ if intent in ("search_academic", "search_medical"):
776
+ if PARTIAL_TITLE_RE.search(question):
777
+ from urllib.parse import quote as _qt
778
+ _title_frag = (re.search(r'"([^"]{4,80})"', question) or
779
+ re.search(r'"([^"]{4,80})"', question))
780
+ _title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
781
+ _title_url = (
782
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search"
783
+ f"?query=title,contains,{_qt(_title_q)},AND"
784
+ "&tab=Everything&search_scope=MyInst_and_CI"
785
+ "&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
786
+ )
787
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
788
+ return AgentResponse(
789
+ answer=(
790
+ _thought_block("library_info", "title_search", question,
791
+ "Identified a partial title search — routing to PRIMO title search.") +
792
+ f"<strong>📖 Title search in KU Library</strong><br><br>"
793
+ f"I'll search the KU Library catalogue by title for: <em>{_escape(_title_q)}</em><br><br>"
794
+ f'<a href="{_title_url}" target="_blank" '
795
+ f'style="display:inline-block;padding:9px 16px;background:#003366;color:#fff;'
796
+ f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
797
+ f"🔍 Search by title in PRIMO →</a><br><br>"
798
+ "If you only remember part of the title, use PRIMO's advanced search and choose "
799
+ "<em>Title</em> from the field dropdown — it supports partial matches.<br>"
800
+ "You can also try "
801
+ '<a href="https://scholar.google.com" target="_blank">Google Scholar</a> '
802
+ "with the partial title in quotes." +
803
+ _source_badge("primo", _title_url)
804
+ ),
805
+ intent="library_info",
806
+ tools_used=["title_search"],
807
+ model_used=model,
808
+ response_time=time.time() - start_time,
809
+ follow_up_question=follow_up_question,
810
+ follow_up_suggestions=follow_up_suggestions,
811
+ )
812
+
813
+ topic = await _extract_topic(question, model)
814
+ topic = _title_case_topic(topic)
815
+ year_from, year_to = _parse_year_filters(question)
816
+
817
+ if DUAL_RESOURCE_RE.search(question):
818
+ import asyncio as _aio
819
+ _ctx_articles = SearchContextPayload(
820
+ context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
821
+ intent=intent, source="primo", resource_type="articles",
822
+ peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
823
+ open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
824
+ year_from=year_from, year_to=year_to,
825
+ )
826
+ _ctx_books = SearchContextPayload(
827
+ context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
828
+ intent=intent, source="primo", resource_type="books",
829
+ peer_reviewed=False, open_access=False,
830
+ year_from=year_from, year_to=year_to,
831
+ )
832
+ _ctx_articles, _ctx_books = await _aio.gather(
833
+ _prepare_queries(question, _ctx_articles, model, False),
834
+ _prepare_queries(question, _ctx_books, model, False),
835
+ )
836
+ _src_url_a = _primo_clean_url(_ctx_articles)
837
+ _src_url_b = _primo_clean_url(_ctx_books)
838
+
839
+ _dual_answer = _search_trace_block(question, _ctx_articles)
840
+ _dual_answer += (
841
+ f"<strong>📄 Articles on {_escape(topic)}</strong><br>"
842
+ f'<a href="{_src_url_a}" target="_blank" style="color:#003366;font-weight:700">'
843
+ f'Search PRIMO for articles →</a>'
844
+ )
845
+ _dual_answer += _source_badge("primo", _src_url_a)
846
+ _dual_answer += f"<br><br><strong>📚 Books on {_escape(topic)}</strong><br>"
847
+ _dual_answer += (
848
+ f'<a href="{_src_url_b}" target="_blank" style="color:#003366;font-weight:700">'
849
+ f'Search PRIMO for books →</a>'
850
+ )
851
+ _dual_answer += _source_badge("primo", _src_url_b)
852
+ _dual_answer += _ai_tools_footer(_ctx_articles)
853
+
854
+ follow_up_question, follow_up_suggestions = await _search_follow_up(_ctx_articles, model)
855
+ return AgentResponse(
856
+ answer=_dual_answer,
857
+ intent=intent,
858
+ tools_used=["primo_browser_link"],
859
+ search_results=[],
860
+ sources=[],
861
+ model_used=model,
862
+ response_time=time.time() - start_time,
863
+ corrected_query=topic,
864
+ natural_query=_ctx_articles.ai_tool_query,
865
+ primo_boolean_query=_ctx_articles.primo_boolean_query,
866
+ primo_search_url=_src_url_a,
867
+ follow_up_question=follow_up_question,
868
+ follow_up_suggestions=follow_up_suggestions,
869
+ search_context=_context_to_dict(_ctx_articles),
870
+ )
871
+
872
+ context = SearchContextPayload(
873
+ context_id=str(uuid.uuid4()),
874
+ topic=topic,
875
+ display_topic=topic,
876
+ intent=intent,
877
+ source="primo",
878
+ resource_type=_derive_resource_type(question),
879
+ peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
880
+ open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
881
+ year_from=year_from,
882
+ year_to=year_to,
883
+ )
884
+
885
+ context = await _prepare_queries(question, context, model, False)
886
+ summary_mode = _is_summary_request(question)
887
+ deep_research_mode = bool(re.search(
888
+ r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
889
+ question, re.IGNORECASE,
890
+ ))
891
+ is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
892
+
893
+ if summary_mode or deep_research_mode:
894
+ answer, search_results, sources = await _research_snapshot(context, model)
895
+ if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer:
896
+ answer += f"<br><br>{_ai_tools_footer(context)}"
897
+ if is_highly_cited:
898
+ answer += _highly_cited_note(_escape(topic))
899
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
900
+ _safe_metrics_increment("search_handoffs")
901
+ return AgentResponse(
902
+ answer=answer,
903
+ intent=intent,
904
+ tools_used=["research_snapshot"],
905
+ search_results=search_results,
906
+ sources=sources,
907
+ model_used=model,
908
+ response_time=time.time() - start_time,
909
+ corrected_query=context.topic,
910
+ natural_query=context.ai_tool_query,
911
+ database_query=context.primo_boolean_query,
912
+ ai_tool_query=context.ai_tool_query,
913
+ primo_boolean_query=context.primo_boolean_query,
914
+ primo_search_url=_primo_clean_url(context),
915
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
916
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
917
+ follow_up_question=follow_up_question,
918
+ follow_up_suggestions=follow_up_suggestions,
919
+ search_context=_context_to_dict(context),
920
+ )
921
+
922
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
923
+ if is_highly_cited:
924
+ answer += _highly_cited_note(_escape(topic))
925
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
926
+ return AgentResponse(
927
+ answer=answer,
928
+ intent=intent,
929
+ tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
930
+ search_results=search_results,
931
+ sources=sources,
932
+ model_used=model,
933
+ response_time=time.time() - start_time,
934
+ corrected_query=context.topic,
935
+ natural_query=context.ai_tool_query,
936
+ database_query=context.primo_boolean_query,
937
+ ai_tool_query=context.ai_tool_query,
938
+ primo_boolean_query=context.primo_boolean_query,
939
+ primo_search_url=source_url,
940
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
941
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
942
+ follow_up_question=follow_up_question,
943
+ follow_up_suggestions=follow_up_suggestions,
944
+ search_context=_context_to_dict(context),
945
+ )
946
+
947
+ if intent == "general_recent":
948
+ answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
949
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
950
+ return AgentResponse(
951
+ answer=_thought_block("general_recent", "web_search", question) + answer,
952
+ intent="general_recent",
953
+ tools_used=["web_search"],
954
+ model_used=model,
955
+ response_time=time.time() - start_time,
956
+ follow_up_question=follow_up_question,
957
+ follow_up_suggestions=follow_up_suggestions,
958
+ )
959
+
960
+ # general fallback
961
+ answer = await _web_search_answer(question, history, model) + _source_badge("web")
962
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
963
+ return AgentResponse(
964
+ answer=_thought_block("general", "web_search", question) + answer,
965
+ intent="general",
966
+ tools_used=["web_search"],
967
+ model_used=model,
968
+ response_time=time.time() - start_time,
969
+ follow_up_question=follow_up_question,
970
+ follow_up_suggestions=follow_up_suggestions,
971
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agentcore/rendering.py CHANGED
@@ -1,590 +1,590 @@
1
- """HTML fragment renderers: source badges, reasoning trace blocks, AI-tools footer, contacts."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
- from src.services.staff_service import (
19
- STAFF_DIRECTORY,
20
- match_staff_name,
21
- match_staff_role,
22
- should_attempt_staff_lookup,
23
- staff_name_answer,
24
- staff_role_answer,
25
- )
26
-
27
- from src.agentcore.models import SearchContextPayload
28
- from src.agentcore.constants import ASK_LIBRARIAN_URL, PRIMO_AI_URL
29
- from src.agentcore.utils import (
30
- _escape,
31
- _find_staff_by_token,
32
- _primo_clean_url,
33
- _shared_build_pubmed_url,
34
- )
35
-
36
- logger = logging.getLogger(__name__)
37
-
38
- def _source_badge(source: str, url: str = "") -> str:
39
- styles = {
40
- "rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
41
- "primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
42
- "semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
43
- "consensus_badge": ("🧬", "Consensus", "#fdf4ff", "#86198f"),
44
- "semantic_badge": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
45
- "pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
46
- "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
47
- "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
48
- "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
49
- "ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
50
- "llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
51
- }
52
- icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
53
- inner = f"{icon} Source: {label}"
54
- if url:
55
- inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
56
- return (
57
- f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
58
- f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
59
- f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
60
- )
61
-
62
-
63
- def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
64
- ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
65
- primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
66
- urls: Dict[str, str] = {
67
- "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
68
- "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
69
- "ebsco_ai": (
70
- f"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results"
71
- f"?q={ai_query}"
72
- f"&autocorrect=y"
73
- f"&expanders=concept"
74
- f"&limiters=None"
75
- f"&searchMode=enhanced"
76
- f"&searchSegment=all-results"
77
- ),
78
- "primo_ai": f"{PRIMO_AI_URL}{primo_ai_query}",
79
- "consensus": f"https://consensus.app/results/?q={ai_query}",
80
- "primo_discovery": _primo_clean_url(context),
81
- }
82
- if context.intent == "search_medical":
83
- urls["pubmed"] = _shared_build_pubmed_url(
84
- context.topic, context.year_from, context.year_to, context.peer_reviewed
85
- )
86
- return urls
87
-
88
-
89
- def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
90
- q = (question or "").lower()
91
- specialist: Optional[dict] = None
92
- if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
93
- "request article", "request a book", "not available at ku",
94
- "not in ku library", "inter library", "inter-library"]):
95
- footer = (
96
- "<br><br><strong>Library contact points</strong><br>"
97
- f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
98
- '<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
99
- 'ILL requests: <a href="mailto:ill@ku.ac.ae">ill@ku.ac.ae</a> · '
100
- '<strong>Suaad Al Jneibi</strong> · '
101
- '<a href="mailto:suaad.aljneibi@ku.ac.ae">suaad.aljneibi@ku.ac.ae</a> · '
102
- '<a href="tel:+97123124278">+971 2 312 4278</a>'
103
- )
104
- return footer
105
- elif any(t in q for t in [
106
- "apc", "article processing charge", "article processing fee",
107
- "open access fee", "oa fee", "oa fund", "oa publishing",
108
- "publish open access", "open access publish", "how to publish open access",
109
- "open access journal", "open access mandate", "open access policy",
110
- "waiver", "gold open access", "diamond open access", "hybrid journal",
111
- "creative commons", "cc by", "cc licence", "openaccess", "open access",
112
- ]):
113
- # Open Access & APC → Walter Brian Hall (OA lead at KU Library)
114
- specialist = _find_staff_by_token("walter")
115
- elif any(t in q for t in [
116
- "research support", "research service", "research consultation",
117
- "research librarian", "research help", "research advice",
118
- "scholarly communication", "bibliometric", "research impact",
119
- "citation analysis", "h-index", "journal ranking", "impact factor",
120
- "orcid", "khazna", "repository", "scival", "scopus profile",
121
- "author profile", "research data", "data management",
122
- "libguide", "research guide", "ai tool", "ai for research",
123
- "publication advice", "journal selection", "where to publish",
124
- ]):
125
- # Research services → Nikesh Narayanan (Research & Access Services Librarian)
126
- specialist = _find_staff_by_token("nikesh")
127
- elif any(t in q for t in ["database access", "proxy", "eresource",
128
- "e-resource", "remote access", "access problem", "off campus"]):
129
- specialist = _find_staff_by_token("rani")
130
- elif any(t in q for t in ["website", "system", "technical", "login issue", "page not loading"]):
131
- specialist = _find_staff_by_token("walter")
132
- elif context and context.intent == "search_medical":
133
- # Medical/clinical research questions only → Jason Fetty (Medical Librarian)
134
- specialist = _find_staff_by_token("jason")
135
- elif context and context.intent == "search_academic":
136
- specialist = _find_staff_by_token("nikesh")
137
- footer = (
138
- "<br><br><strong>Library contact points</strong><br>"
139
- f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
140
- '<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
141
- )
142
- if specialist:
143
- footer += (
144
- f'<br>Relevant contact: <strong>{_escape(specialist.get("full_name", ""))}</strong> — '
145
- f'{_escape(specialist.get("role", ""))}<br>'
146
- f'<a href="mailto:{_escape(specialist.get("email", ""))}">{_escape(specialist.get("email", ""))}</a>'
147
- )
148
- phone = specialist.get("phone") or specialist.get("work_phone")
149
- if phone:
150
- footer += f' · <a href="tel:{_escape(str(phone).replace(" ", ""))}">{_escape(phone)}</a>'
151
- return footer
152
-
153
-
154
- def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
155
- labels = {
156
- "library_info": ("📋", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
157
- "general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
158
- "general_recent": ("🔴", "Live web search", "I ran a live web search for the most up-to-date information."),
159
- "social_greeting": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
160
- "social": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
161
- "staff_lookup": ("👤", "Staff directory lookup", "I matched your request to the KU Library staff directory."),
162
- "hours": ("⏰", "Library hours lookup", "I fetched live library hours from the KU LibCal API."),
163
- "libcal_live": ("⏰", "Live library hours", "I fetched real-time hours from the KU LibCal API."),
164
- "campus": ("📍", "Campus information lookup", "I retrieved KU Library campus information from the knowledge base."),
165
- "database_rec": ("🗄️", "Database recommendation", "I matched your subject to the KU Library e-resources database."),
166
- "rag_search": ("📖", "Knowledge base search", "I ran a hybrid semantic + keyword search across the KU Library knowledge base."),
167
- }
168
- icon, label, reasoning = labels.get(intent, ("🤖", "AI response", "I processed your question and generated a response."))
169
- if extra:
170
- reasoning += " " + extra
171
- return (
172
- f'<details style="margin:0 0 12px 0;border:1px solid #e5e7eb;border-radius:10px;'
173
- f'background:#fafafa;overflow:hidden">'
174
- f'<summary style="padding:8px 12px;cursor:pointer;font-size:.75rem;font-weight:700;'
175
- f'color:#6b7280;list-style:none;display:flex;align-items:center;gap:5px">'
176
- f'<span>{icon}</span> My approach — click to see</summary>'
177
- f'<div style="padding:4px 12px 10px 12px;font-size:.79rem;line-height:1.7;border-top:1px solid #e5e7eb">'
178
- f'<strong>Question:</strong> {_escape(question)}<br>'
179
- f'<strong>Classified as:</strong> {label}<br>'
180
- f'<strong>How I answered:</strong> {reasoning}'
181
- f'</div></details>'
182
- )
183
-
184
-
185
- def _search_trace_block(question: str, context: SearchContextPayload) -> str:
186
- topic = _escape(context.display_topic or context.topic)
187
- primo_boolean = _escape(context.primo_boolean_query or "")
188
- source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
189
- intent_label = {
190
- "search_academic": "academic literature search",
191
- "search_medical": "medical / clinical literature search",
192
- }.get(context.intent, "library search")
193
- filters_parts = []
194
- if context.peer_reviewed:
195
- filters_parts.append("peer reviewed only")
196
- if context.open_access:
197
- filters_parts.append("open access")
198
- if context.year_from and context.year_to:
199
- filters_parts.append(f"{context.year_from}–{context.year_to}")
200
- elif context.year_from:
201
- filters_parts.append(f"from {context.year_from}")
202
- rtype = {"articles": "articles", "books": "books", "both": "articles and books"}.get(
203
- context.resource_type, "articles"
204
- )
205
- filters_parts.append(rtype)
206
- filters_str = ", ".join(filters_parts)
207
- reasoning = (
208
- f"I classified this as a <strong>{intent_label}</strong>, "
209
- f"extracted the topic <strong>{topic}</strong>, "
210
- f"built an LLM-optimised boolean query with quoted phrases and OR synonyms, "
211
- f"then searched <strong>{source_label}</strong> for <strong>{filters_str}</strong>."
212
- )
213
- if context.intent == "search_medical" and context.source != "pubmed":
214
- reasoning += " For medical topics, PubMed and Embase are also recommended."
215
- block = (
216
- f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
217
- f'<summary style="padding:10px 14px;cursor:pointer;font-size:.8rem;font-weight:700;color:#1e40af;list-style:none;display:flex;align-items:center;gap:6px">'
218
- f'<span style="font-size:.9rem">🔍</span> How I searched — click to expand'
219
- f'</summary>'
220
- f'<div style="padding:4px 14px 12px 14px;font-size:.82rem;line-height:1.7;border-top:1px solid #dbe4ff">'
221
- f'<strong>Your question:</strong> {_escape(question)}<br>'
222
- f'<strong>Interpreted topic:</strong> {topic}<br>'
223
- )
224
- if primo_boolean:
225
- block += (
226
- f'<strong>Boolean query sent to {source_label}:</strong><br>'
227
- f'<code style="display:block;margin:4px 0;padding:6px 10px;background:#1a1a2e;color:#C8A951;'
228
- f'border-radius:7px;font-size:.78rem;word-break:break-all">{primo_boolean}</code>'
229
- )
230
- block += f'<strong>Reasoning:</strong> {reasoning}<br>'
231
- block += '<span style="color:#5b21b6;font-size:.78rem">✨ AI research tools for deeper exploration are listed at the end of this answer.</span>'
232
- block += '</div></details>'
233
- return block
234
-
235
-
236
- def _ai_tools_footer(context):
237
- from urllib.parse import quote as _q
238
-
239
- ai_q = _q(context.ai_tool_query or context.display_topic or context.topic)
240
- bool_q = context.primo_boolean_query or ""
241
- primo_url = _primo_clean_url(context)
242
- primo_ai = PRIMO_AI_URL + ai_q
243
- is_med = context.intent == "search_medical"
244
- pubmed_url = _shared_build_pubmed_url(
245
- context.topic, context.year_from, context.year_to, context.peer_reviewed
246
- ) if is_med else ""
247
-
248
- KU = "https://khalifa.idm.oclc.org/login?url="
249
-
250
- platforms = [
251
- ("PRIMO AI Assistant", primo_ai, "primo"),
252
- ("LeapSpace", "https://www.sciencedirect.com/leapspace?query=" + ai_q, "leapspace"),
253
- ("Scopus AI", "https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query=" + ai_q, "scopus"),
254
- ("EBSCO Research AI",
255
- "https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results?q=" + ai_q
256
- + "&autocorrect=y&expanders=concept&limiters=None&searchMode=enhanced&searchSegment=all-results", "ebsco"),
257
- ("Consensus", "https://consensus.app/results/?q=" + ai_q, "consensus"),
258
- ("Semantic Scholar", "https://www.semanticscholar.org/search?q=" + ai_q + "&sort=relevance", "semanticscholar"),
259
- ("Perplexity", "https://www.perplexity.ai/search?q=" + ai_q, "perplexity"),
260
- ]
261
- if is_med and pubmed_url:
262
- platforms.append(("PubMed", pubmed_url, "pubmed"))
263
-
264
- _SVGS = {
265
- "bee": (
266
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="16" height="16" style="flex-shrink:0">'
267
- '<ellipse cx="5.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
268
- '<ellipse cx="14.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
269
- '<ellipse cx="10" cy="13" rx="5" ry="6" fill="#1a3a6b"/>'
270
- '<ellipse cx="10" cy="15" rx="3" ry="1.5" fill="#243f7a"/>'
271
- '<circle cx="10" cy="8" r="5" fill="#1a3a6b"/>'
272
- '<rect x="6" y="6.5" width="8" height="3.5" rx="1.5" fill="#001a40"/>'
273
- '<ellipse cx="8.2" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
274
- '<ellipse cx="11.8" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
275
- '<path d="M8 10 Q10 11.5 12 10" fill="none" stroke="#00ccee" stroke-width=".8" stroke-linecap="round"/>'
276
- '<line x1="8.5" y1="3.5" x2="6.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
277
- '<circle cx="6.5" cy="1.5" r="1.2" fill="#00ccee" opacity=".8"/>'
278
- '<line x1="11.5" y1="3.5" x2="13.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
279
- '<ellipse cx="13.5" cy="1.2" rx="1.5" ry="1.8" fill="#e8e8e8" stroke="#aaa" stroke-width=".5"/>'
280
- '<text x="10" y="17.5" font-family="Arial,sans-serif" font-size="3" font-weight="bold" fill="#C8A951" text-anchor="middle">LB</text>'
281
- '</svg>'
282
- ),
283
- "book": (
284
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
285
- '<rect x="2" y="2" width="9" height="12" rx="1" fill="#2563eb"/>'
286
- '<rect x="3" y="2" width="1.5" height="12" fill="#1d4ed8"/>'
287
- '<rect x="4" y="4" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
288
- '<rect x="4" y="6" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
289
- '<rect x="4" y="8" width="3" height="1" rx=".5" fill="#bfdbfe"/>'
290
- '<rect x="11" y="3" width="3" height="10" rx="1" fill="#1e40af"/>'
291
- '</svg>'
292
- ),
293
- "microscope": (
294
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
295
- '<rect x="7" y="1" width="2" height="5" rx="1" fill="#6b7280"/>'
296
- '<ellipse cx="8" cy="1.5" rx="2.5" ry="1.5" fill="#9ca3af"/>'
297
- '<rect x="6.5" y="5" width="3" height="5" rx=".5" fill="#374151"/>'
298
- '<rect x="5" y="9.5" width="6" height="1.5" rx=".5" fill="#374151"/>'
299
- '<rect x="3" y="13" width="10" height="1.5" rx=".5" fill="#374151"/>'
300
- '<line x1="8" y1="11" x2="8" y2="13" stroke="#374151" stroke-width="1.5"/>'
301
- '<circle cx="8" cy="6" r="1.5" fill="#60a5fa" opacity=".8"/>'
302
- '</svg>'
303
- ),
304
- "circuit": (
305
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
306
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#1e293b"/>'
307
- '<circle cx="4" cy="4" r="1.5" fill="#22d3ee"/>'
308
- '<circle cx="12" cy="4" r="1.5" fill="#22d3ee"/>'
309
- '<circle cx="4" cy="12" r="1.5" fill="#22d3ee"/>'
310
- '<circle cx="12" cy="12" r="1.5" fill="#22d3ee"/>'
311
- '<circle cx="8" cy="8" r="2" fill="#06b6d4"/>'
312
- '<line x1="4" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
313
- '<line x1="12" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
314
- '<line x1="4" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
315
- '<line x1="12" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
316
- '</svg>'
317
- ),
318
- "leaf": (
319
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
320
- '<path d="M8 14 C8 14 2 10 2 5 C2 2 5 1 8 1 C11 1 14 2 14 5 C14 10 8 14 8 14Z" fill="#16a34a"/>'
321
- '<line x1="8" y1="1" x2="8" y2="14" stroke="#bbf7d0" stroke-width=".8"/>'
322
- '<line x1="8" y1="5" x2="5" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
323
- '<line x1="8" y1="7" x2="5" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
324
- '<line x1="8" y1="5" x2="11" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
325
- '<line x1="8" y1="7" x2="11" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
326
- '</svg>'
327
- ),
328
- "gear": (
329
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
330
- '<path d="M6.5 1h3l.5 1.5a5 5 0 011.2.7l1.5-.5 1.5 2.6-1.2 1a5 5 0 010 1.4l1.2 1-1.5 2.6-1.5-.5a5 5 0 01-1.2.7L9.5 15h-3l-.5-1.5a5 5 0 01-1.2-.7l-1.5.5L1.8 10.8l1.2-1a5 5 0 010-1.4l-1.2-1L3.3 4.8l1.5.5A5 5 0 016 4.5L6.5 1z" fill="#64748b"/>'
331
- '<circle cx="8" cy="8" r="2.5" fill="#f1f5f9"/>'
332
- '<circle cx="8" cy="8" r="1.2" fill="#64748b"/>'
333
- '</svg>'
334
- ),
335
- "chart": (
336
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
337
- '<rect x="1" y="1" width="14" height="14" rx="1.5" fill="#f0fdf4"/>'
338
- '<rect x="3" y="9" width="2" height="5" rx=".5" fill="#16a34a"/>'
339
- '<rect x="7" y="6" width="2" height="8" rx=".5" fill="#15803d"/>'
340
- '<rect x="11" y="3" width="2" height="11" rx=".5" fill="#166534"/>'
341
- '<line x1="1" y1="13.5" x2="15" y2="13.5" stroke="#166534" stroke-width=".8"/>'
342
- '</svg>'
343
- ),
344
- "atom": (
345
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
346
- '<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9"/>'
347
- '<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(60 8 8)"/>'
348
- '<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(120 8 8)"/>'
349
- '<circle cx="8" cy="8" r="1.8" fill="#7c3aed"/>'
350
- '</svg>'
351
- ),
352
- "medical": (
353
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
354
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#dc2626"/>'
355
- '<rect x="6.5" y="3" width="3" height="10" rx="1" fill="white"/>'
356
- '<rect x="3" y="6.5" width="10" height="3" rx="1" fill="white"/>'
357
- '</svg>'
358
- ),
359
- "wrench": (
360
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
361
- '<path d="M13 2a3 3 0 00-3 3c0 .4.1.8.2 1.1L2.6 13.7a1 1 0 001.4 1.4L11.7 7.8c.3.1.7.2 1.1.2a3 3 0 000-6z" fill="#d97706"/>'
362
- '<circle cx="12" cy="4" r="1.2" fill="#fef3c7"/>'
363
- '</svg>'
364
- ),
365
- "primo": (
366
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
367
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#003366"/>'
368
- '<rect x="3" y="4" width="10" height="1.5" rx=".5" fill="#C8A951"/>'
369
- '<rect x="3" y="7" width="7" height="1.5" rx=".5" fill="white" opacity=".9"/>'
370
- '<rect x="3" y="10" width="9" height="1.5" rx=".5" fill="white" opacity=".7"/>'
371
- '<circle cx="12" cy="11" r="2.5" fill="#C8A951"/>'
372
- '<line x1="11" y1="11" x2="13" y2="11" stroke="#003366" stroke-width=".8"/>'
373
- '<line x1="12" y1="10" x2="12" y2="12" stroke="#003366" stroke-width=".8"/>'
374
- '</svg>'
375
- ),
376
- "scopus": (
377
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
378
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#e8650a"/>'
379
- '<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="8" font-weight="bold" fill="white" text-anchor="middle">Sc</text>'
380
- '</svg>'
381
- ),
382
- "ebsco": (
383
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
384
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#00427a"/>'
385
- '<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="7" font-weight="bold" fill="white" text-anchor="middle">EBS</text>'
386
- '</svg>'
387
- ),
388
- "consensus": (
389
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
390
- '<rect x="1" y="1" width="14" height="14" rx="7" fill="#6c47ff"/>'
391
- '<circle cx="6" cy="7" r="1.2" fill="white"/>'
392
- '<circle cx="10" cy="7" r="1.2" fill="white"/>'
393
- '<path d="M5 10 Q8 12.5 11 10" fill="none" stroke="white" stroke-width="1.2" stroke-linecap="round"/>'
394
- '</svg>'
395
- ),
396
- "leapspace": (
397
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
398
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#ff6b00"/>'
399
- '<path d="M4 12 L4 7 L8 4 L12 7 L12 12 Z" fill="white" opacity=".9"/>'
400
- '<rect x="6.5" y="9" width="3" height="3" fill="#ff6b00"/>'
401
- '</svg>'
402
- ),
403
- "perplexity": (
404
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
405
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#1a1a2e"/>'
406
- '<circle cx="8" cy="6.5" r="3" fill="none" stroke="#20b2aa" stroke-width="1.2"/>'
407
- '<line x1="8" y1="9.5" x2="8" y2="13" stroke="#20b2aa" stroke-width="1.2" stroke-linecap="round"/>'
408
- '<circle cx="8" cy="13" r=".8" fill="#20b2aa"/>'
409
- '</svg>'
410
- ),
411
- "semanticscholar": (
412
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
413
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#1857b6"/>'
414
- '<path d="M4 11 Q8 4 12 11" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round"/>'
415
- '<circle cx="8" cy="7" r="1.8" fill="#4fc3f7"/>'
416
- '</svg>'
417
- ),
418
- "pubmed": (
419
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
420
- '<rect x="1" y="1" width="14" height="14" rx="2" fill="#336699"/>'
421
- '<text x="8" y="11" font-family="Arial,sans-serif" font-size="6.5" font-weight="bold" fill="white" text-anchor="middle">PubMed</text>'
422
- '</svg>'
423
- ),
424
- }
425
- _DB_MASCOT = {
426
- "PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
427
- "Cochrane Library": "microscope", "UpToDate": "medical",
428
- "IEEE Xplore": "circuit", "ACM Digital Library": "circuit",
429
- "ScienceDirect": "microscope", "Scopus": "microscope",
430
- "Web of Science": "microscope", "SpringerLink": "book",
431
- "ACS Publications": "atom", "APS Journals": "atom", "Knovel": "wrench",
432
- "Business Source Complete": "chart", "Emerald Insight": "chart",
433
- "Taylor & Francis": "book", "EBSCO": "book",
434
- }
435
- _BEE = _SVGS["bee"]
436
-
437
- def _btn(label, url, mascot_key="bee"):
438
- icon = _SVGS.get(mascot_key, _SVGS["bee"])
439
- style = (
440
- 'display:inline-flex;align-items:center;gap:7px;'
441
- 'padding:8px 14px;border-radius:10px;text-decoration:none;'
442
- 'font-size:.8rem;font-weight:600;color:#1a1a2e;'
443
- 'background:#fff;border:1.5px solid #d1d5db;'
444
- 'transition:box-shadow .2s;width:100%;box-sizing:border-box'
445
- )
446
- return '<a href="' + url + '" target="_blank" style="' + style + '">' + icon + label + '</a>'
447
-
448
- btn_rows = ""
449
- for k in range(0, len(platforms), 2):
450
- pair = platforms[k:k+2]
451
- cells = ""
452
- for lbl, url, mascot_key in pair:
453
- cells += '<td style="width:50%;padding:3px 4px 3px 0">' + _btn(lbl, url, mascot_key) + '</td>'
454
- if len(pair) == 1:
455
- cells += '<td style="width:50%"></td>'
456
- btn_rows += "<tr>" + cells + "</tr>"
457
-
458
- platforms_html = (
459
- '<div style="margin-top:18px;padding:14px 16px;border:1px solid #c4b5fd;'
460
- 'border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#fdf4ff)">'
461
- '<div style="font-size:.8rem;font-weight:800;color:#5b21b6;margin-bottom:4px">'
462
- '🤖 Search your query across AI platforms</div>'
463
- '<div style="font-size:.73rem;color:#6b7280;margin-bottom:10px">'
464
- 'Your query is pre-loaded. Click any platform to search instantly.</div>'
465
- '<table style="width:100%;border-collapse:collapse">' + btn_rows + '</table>'
466
- '</div>'
467
- )
468
-
469
- if bool_q:
470
- and_count = bool_q.upper().count(" AND ")
471
- if and_count >= 1:
472
- bool_label = "Use this in PRIMO Advanced Search or any database for precise results:"
473
- else:
474
- bool_label = "Try adding synonyms with OR to find more relevant results:"
475
- bool_html = (
476
- '<div style="margin-top:12px;padding:12px 14px;border:1px solid #fde68a;'
477
- 'border-radius:10px;background:#fffbeb">'
478
- '<div style="font-size:.78rem;font-weight:700;color:#92400e;margin-bottom:6px">'
479
- '\U0001f4a1 Boolean search tip</div>'
480
- '<div style="font-size:.74rem;color:#6b7280;margin-bottom:6px">' + bool_label + '</div>'
481
- '<code style="display:block;padding:8px 10px;background:#1a1a2e;color:#C8A951;'
482
- 'border-radius:7px;font-size:.76rem;word-break:break-all;white-space:pre-wrap">'
483
- + _escape(bool_q) +
484
- '</code>'
485
- '<div style="font-size:.71rem;color:#92400e;margin-top:6px">'
486
- 'Paste into the Advanced Search box of any database below.</div>'
487
- '</div>'
488
- )
489
- else:
490
- bool_html = ""
491
-
492
- all_lower = ((context.ai_tool_query or "") + " " + (context.topic or "")).lower()
493
-
494
- def _db_row(name, url, desc, favicon=None):
495
- icon = _SVGS.get(_DB_MASCOT.get(name, "bee"), _SVGS["bee"])
496
- return (
497
- '<div style="display:flex;align-items:center;gap:8px;'
498
- 'padding:7px 0;border-bottom:1px solid #f3f4f6">'
499
- + icon +
500
- '<div><a href="' + url + '" target="_blank" '
501
- 'style="font-size:.8rem;font-weight:700;color:#003366;text-decoration:none">'
502
- + name + '</a>'
503
- '<span style="font-size:.73rem;color:#6b7280"> — ' + desc + '</span></div>'
504
- '</div>'
505
- )
506
-
507
- if any(k in all_lower for k in ["medicine","medical","clinical","nursing","pharmacy","health","pubmed","embase","cinahl","cochrane","uptodate"]):
508
- db_subject = "Medicine & Health Sciences"
509
- dbs = [
510
- ("PubMed@KU","http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib","Core biomedical literature"),
511
- ("Embase",KU+"https://www.embase.com","Biomedical, drugs, devices, clinical medicine"),
512
- ("CINAHL Ultimate",KU+"https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul","Nursing and allied health"),
513
- ("Cochrane Library",KU+"https://www.cochranelibrary.com","Systematic reviews and evidence-based medicine"),
514
- ("UpToDate",KU+"https://www.uptodate.com","Point-of-care clinical decision support"),
515
- ]
516
- elif any(k in all_lower for k in ["machine learning","deep learning","artificial intelligence","neural network","computer science","software","algorithm","cybersecurity","robotics","nlp"]):
517
- db_subject = "Computer Science & AI"
518
- dbs = [
519
- ("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Core CS, AI, electronics and systems"),
520
- ("ACM Digital Library",KU+"https://dl.acm.org","Computer science journals, proceedings and books"),
521
- ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across CS and applied computing"),
522
- ("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
523
- ("Web of Science",KU+"https://www.webofscience.com","Citation index"),
524
- ]
525
- elif any(k in all_lower for k in ["climate","global warming","environment","sustainability","ecology","carbon","emission","renewable","sea level","maldives","island"]):
526
- db_subject = "Environment & Climate"
527
- dbs = [
528
- ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across science, environment and engineering"),
529
- ("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
530
- ("Web of Science",KU+"https://www.webofscience.com","High-impact journal coverage and citation index"),
531
- ("SpringerLink",KU+"https://link.springer.com","Springer journals and books"),
532
- ]
533
- elif any(k in all_lower for k in ["engineering","mechanical","civil","electrical","aerospace","structural","petroleum","chemical","materials"]):
534
- db_subject = "Engineering"
535
- dbs = [
536
- ("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Electrical, electronics and systems engineering"),
537
- ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across engineering disciplines"),
538
- ("Knovel",KU+"https://app.knovel.com","Engineering handbooks and reference data"),
539
- ("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
540
- ("Web of Science",KU+"https://www.webofscience.com","Citation index"),
541
- ]
542
- elif any(k in all_lower for k in ["business","management","finance","economics","leadership","marketing","strategy","entrepreneurship"]):
543
- db_subject = "Business & Management"
544
- dbs = [
545
- ("Business Source Complete",KU+"https://search.ebscohost.com","Core business and management literature"),
546
- ("Emerald Insight",KU+"https://www.emerald.com/insight","Management, strategy and leadership"),
547
- ("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
548
- ("ScienceDirect",KU+"https://www.sciencedirect.com","Business and social sciences full text"),
549
- ]
550
- elif any(k in all_lower for k in ["physics","chemistry","quantum","nuclear","materials science","nanotechnology","optics"]):
551
- db_subject = "Physics, Chemistry & Materials"
552
- dbs = [
553
- ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across physical sciences"),
554
- ("ACS Publications",KU+"https://pubs.acs.org","Core chemistry and chemical engineering"),
555
- ("APS Journals",KU+"https://journals.aps.org","American Physical Society core physics journals"),
556
- ("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
557
- ("Web of Science",KU+"https://www.webofscience.com","Citation index"),
558
- ]
559
- else:
560
- db_subject = "General"
561
- dbs = [
562
- ("EBSCO",KU+"https://search.ebscohost.com","Multidisciplinary full text"),
563
- ("ScienceDirect",KU+"https://www.sciencedirect.com","Science, engineering and medicine"),
564
- ("SpringerLink",KU+"https://link.springer.com","Journals and books across all disciplines"),
565
- ("Taylor & Francis",KU+"https://www.tandfonline.com","Humanities, social sciences and STEM"),
566
- ("Emerald Insight",KU+"https://www.emerald.com/insight","Management and social sciences"),
567
- ("Scopus",KU+"https://www.scopus.com","Citation discovery, all disciplines"),
568
- ("Web of Science",KU+"https://www.webofscience.com","Citation index, all disciplines"),
569
- ]
570
-
571
- dbs_html_rows = ""
572
- for name, url, desc in dbs:
573
- dbs_html_rows += _db_row(name, url, desc)
574
-
575
- db_section = (
576
- '<div style="margin-top:12px;padding:12px 14px;border:1px solid #d1d5db;'
577
- 'border-radius:10px;background:#f9fafb">'
578
- '<div style="font-size:.78rem;font-weight:700;color:#003366;margin-bottom:8px">'
579
- '\U0001f4c2 Recommended KU databases \u2014 ' + db_subject + '</div>'
580
- + dbs_html_rows +
581
- '<div style="padding-top:8px;font-size:.74rem">'
582
- '<a href="https://library.ku.ac.ae/eresources" target="_blank" '
583
- 'style="color:#003366;font-weight:700">'
584
- '\U0001f5c4\ufe0f Browse all 50+ KU databases \u2192</a></div>'
585
- '</div>'
586
- )
587
-
588
- return platforms_html + bool_html + db_section
589
-
590
-
 
1
+ """HTML fragment renderers: source badges, reasoning trace blocks, AI-tools footer, contacts."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+ from src.services.staff_service import (
19
+ STAFF_DIRECTORY,
20
+ match_staff_name,
21
+ match_staff_role,
22
+ should_attempt_staff_lookup,
23
+ staff_name_answer,
24
+ staff_role_answer,
25
+ )
26
+
27
+ from src.agentcore.models import SearchContextPayload
28
+ from src.agentcore.constants import ASK_LIBRARIAN_URL, PRIMO_AI_URL
29
+ from src.agentcore.utils import (
30
+ _escape,
31
+ _find_staff_by_token,
32
+ _primo_clean_url,
33
+ _shared_build_pubmed_url,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ def _source_badge(source: str, url: str = "") -> str:
39
+ styles = {
40
+ "rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
41
+ "primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
42
+ "semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
43
+ "consensus_badge": ("🧬", "Consensus", "#fdf4ff", "#86198f"),
44
+ "semantic_badge": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
45
+ "pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
46
+ "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
47
+ "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
48
+ "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
49
+ "ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
50
+ "llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
51
+ }
52
+ icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
53
+ inner = f"{icon} Source: {label}"
54
+ if url:
55
+ inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
56
+ return (
57
+ f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
58
+ f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
59
+ f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
60
+ )
61
+
62
+
63
+ def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
64
+ ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
65
+ primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
66
+ urls: Dict[str, str] = {
67
+ "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
68
+ "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
69
+ "ebsco_ai": (
70
+ f"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results"
71
+ f"?q={ai_query}"
72
+ f"&autocorrect=y"
73
+ f"&expanders=concept"
74
+ f"&limiters=None"
75
+ f"&searchMode=enhanced"
76
+ f"&searchSegment=all-results"
77
+ ),
78
+ "primo_ai": f"{PRIMO_AI_URL}{primo_ai_query}",
79
+ "consensus": f"https://consensus.app/results/?q={ai_query}",
80
+ "primo_discovery": _primo_clean_url(context),
81
+ }
82
+ if context.intent == "search_medical":
83
+ urls["pubmed"] = _shared_build_pubmed_url(
84
+ context.topic, context.year_from, context.year_to, context.peer_reviewed
85
+ )
86
+ return urls
87
+
88
+
89
+ def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
90
+ q = (question or "").lower()
91
+ specialist: Optional[dict] = None
92
+ if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
93
+ "request article", "request a book", "not available at ku",
94
+ "not in ku library", "inter library", "inter-library"]):
95
+ footer = (
96
+ "<br><br><strong>Library contact points</strong><br>"
97
+ f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
98
+ '<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
99
+ 'ILL requests: <a href="mailto:ill@ku.ac.ae">ill@ku.ac.ae</a> · '
100
+ '<strong>Suaad Al Jneibi</strong> · '
101
+ '<a href="mailto:suaad.aljneibi@ku.ac.ae">suaad.aljneibi@ku.ac.ae</a> · '
102
+ '<a href="tel:+97123124278">+971 2 312 4278</a>'
103
+ )
104
+ return footer
105
+ elif any(t in q for t in [
106
+ "apc", "article processing charge", "article processing fee",
107
+ "open access fee", "oa fee", "oa fund", "oa publishing",
108
+ "publish open access", "open access publish", "how to publish open access",
109
+ "open access journal", "open access mandate", "open access policy",
110
+ "waiver", "gold open access", "diamond open access", "hybrid journal",
111
+ "creative commons", "cc by", "cc licence", "openaccess", "open access",
112
+ ]):
113
+ # Open Access & APC → Walter Brian Hall (OA lead at KU Library)
114
+ specialist = _find_staff_by_token("walter")
115
+ elif any(t in q for t in [
116
+ "research support", "research service", "research consultation",
117
+ "research librarian", "research help", "research advice",
118
+ "scholarly communication", "bibliometric", "research impact",
119
+ "citation analysis", "h-index", "journal ranking", "impact factor",
120
+ "orcid", "khazna", "repository", "scival", "scopus profile",
121
+ "author profile", "research data", "data management",
122
+ "libguide", "research guide", "ai tool", "ai for research",
123
+ "publication advice", "journal selection", "where to publish",
124
+ ]):
125
+ # Research services → Nikesh Narayanan (Research & Access Services Librarian)
126
+ specialist = _find_staff_by_token("nikesh")
127
+ elif any(t in q for t in ["database access", "proxy", "eresource",
128
+ "e-resource", "remote access", "access problem", "off campus"]):
129
+ specialist = _find_staff_by_token("rani")
130
+ elif any(t in q for t in ["website", "system", "technical", "login issue", "page not loading"]):
131
+ specialist = _find_staff_by_token("walter")
132
+ elif context and context.intent == "search_medical":
133
+ # Medical/clinical research questions only → Jason Fetty (Medical Librarian)
134
+ specialist = _find_staff_by_token("jason")
135
+ elif context and context.intent == "search_academic":
136
+ specialist = _find_staff_by_token("nikesh")
137
+ footer = (
138
+ "<br><br><strong>Library contact points</strong><br>"
139
+ f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
140
+ '<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
141
+ )
142
+ if specialist:
143
+ footer += (
144
+ f'<br>Relevant contact: <strong>{_escape(specialist.get("full_name", ""))}</strong> — '
145
+ f'{_escape(specialist.get("role", ""))}<br>'
146
+ f'<a href="mailto:{_escape(specialist.get("email", ""))}">{_escape(specialist.get("email", ""))}</a>'
147
+ )
148
+ phone = specialist.get("phone") or specialist.get("work_phone")
149
+ if phone:
150
+ footer += f' · <a href="tel:{_escape(str(phone).replace(" ", ""))}">{_escape(phone)}</a>'
151
+ return footer
152
+
153
+
154
+ def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
155
+ labels = {
156
+ "library_info": ("📋", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
157
+ "general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
158
+ "general_recent": ("🔴", "Live web search", "I ran a live web search for the most up-to-date information."),
159
+ "social_greeting": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
160
+ "social": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
161
+ "staff_lookup": ("👤", "Staff directory lookup", "I matched your request to the KU Library staff directory."),
162
+ "hours": ("⏰", "Library hours lookup", "I fetched live library hours from the KU LibCal API."),
163
+ "libcal_live": ("⏰", "Live library hours", "I fetched real-time hours from the KU LibCal API."),
164
+ "campus": ("📍", "Campus information lookup", "I retrieved KU Library campus information from the knowledge base."),
165
+ "database_rec": ("🗄️", "Database recommendation", "I matched your subject to the KU Library e-resources database."),
166
+ "rag_search": ("📖", "Knowledge base search", "I ran a hybrid semantic + keyword search across the KU Library knowledge base."),
167
+ }
168
+ icon, label, reasoning = labels.get(intent, ("🤖", "AI response", "I processed your question and generated a response."))
169
+ if extra:
170
+ reasoning += " " + extra
171
+ return (
172
+ f'<details style="margin:0 0 12px 0;border:1px solid #e5e7eb;border-radius:10px;'
173
+ f'background:#fafafa;overflow:hidden">'
174
+ f'<summary style="padding:8px 12px;cursor:pointer;font-size:.75rem;font-weight:700;'
175
+ f'color:#6b7280;list-style:none;display:flex;align-items:center;gap:5px">'
176
+ f'<span>{icon}</span> My approach — click to see</summary>'
177
+ f'<div style="padding:4px 12px 10px 12px;font-size:.79rem;line-height:1.7;border-top:1px solid #e5e7eb">'
178
+ f'<strong>Question:</strong> {_escape(question)}<br>'
179
+ f'<strong>Classified as:</strong> {label}<br>'
180
+ f'<strong>How I answered:</strong> {reasoning}'
181
+ f'</div></details>'
182
+ )
183
+
184
+
185
+ def _search_trace_block(question: str, context: SearchContextPayload) -> str:
186
+ topic = _escape(context.display_topic or context.topic)
187
+ primo_boolean = _escape(context.primo_boolean_query or "")
188
+ source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
189
+ intent_label = {
190
+ "search_academic": "academic literature search",
191
+ "search_medical": "medical / clinical literature search",
192
+ }.get(context.intent, "library search")
193
+ filters_parts = []
194
+ if context.peer_reviewed:
195
+ filters_parts.append("peer reviewed only")
196
+ if context.open_access:
197
+ filters_parts.append("open access")
198
+ if context.year_from and context.year_to:
199
+ filters_parts.append(f"{context.year_from}–{context.year_to}")
200
+ elif context.year_from:
201
+ filters_parts.append(f"from {context.year_from}")
202
+ rtype = {"articles": "articles", "books": "books", "both": "articles and books"}.get(
203
+ context.resource_type, "articles"
204
+ )
205
+ filters_parts.append(rtype)
206
+ filters_str = ", ".join(filters_parts)
207
+ reasoning = (
208
+ f"I classified this as a <strong>{intent_label}</strong>, "
209
+ f"extracted the topic <strong>{topic}</strong>, "
210
+ f"built an LLM-optimised boolean query with quoted phrases and OR synonyms, "
211
+ f"then searched <strong>{source_label}</strong> for <strong>{filters_str}</strong>."
212
+ )
213
+ if context.intent == "search_medical" and context.source != "pubmed":
214
+ reasoning += " For medical topics, PubMed and Embase are also recommended."
215
+ block = (
216
+ f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
217
+ f'<summary style="padding:10px 14px;cursor:pointer;font-size:.8rem;font-weight:700;color:#1e40af;list-style:none;display:flex;align-items:center;gap:6px">'
218
+ f'<span style="font-size:.9rem">🔍</span> How I searched — click to expand'
219
+ f'</summary>'
220
+ f'<div style="padding:4px 14px 12px 14px;font-size:.82rem;line-height:1.7;border-top:1px solid #dbe4ff">'
221
+ f'<strong>Your question:</strong> {_escape(question)}<br>'
222
+ f'<strong>Interpreted topic:</strong> {topic}<br>'
223
+ )
224
+ if primo_boolean:
225
+ block += (
226
+ f'<strong>Boolean query sent to {source_label}:</strong><br>'
227
+ f'<code style="display:block;margin:4px 0;padding:6px 10px;background:#1a1a2e;color:#C8A951;'
228
+ f'border-radius:7px;font-size:.78rem;word-break:break-all">{primo_boolean}</code>'
229
+ )
230
+ block += f'<strong>Reasoning:</strong> {reasoning}<br>'
231
+ block += '<span style="color:#5b21b6;font-size:.78rem">✨ AI research tools for deeper exploration are listed at the end of this answer.</span>'
232
+ block += '</div></details>'
233
+ return block
234
+
235
+
236
+ def _ai_tools_footer(context):
237
+ from urllib.parse import quote as _q
238
+
239
+ ai_q = _q(context.ai_tool_query or context.display_topic or context.topic)
240
+ bool_q = context.primo_boolean_query or ""
241
+ primo_url = _primo_clean_url(context)
242
+ primo_ai = PRIMO_AI_URL + ai_q
243
+ is_med = context.intent == "search_medical"
244
+ pubmed_url = _shared_build_pubmed_url(
245
+ context.topic, context.year_from, context.year_to, context.peer_reviewed
246
+ ) if is_med else ""
247
+
248
+ KU = "https://khalifa.idm.oclc.org/login?url="
249
+
250
+ platforms = [
251
+ ("PRIMO AI Assistant", primo_ai, "primo"),
252
+ ("LeapSpace", "https://www.sciencedirect.com/leapspace?query=" + ai_q, "leapspace"),
253
+ ("Scopus AI", "https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query=" + ai_q, "scopus"),
254
+ ("EBSCO Research AI",
255
+ "https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results?q=" + ai_q
256
+ + "&autocorrect=y&expanders=concept&limiters=None&searchMode=enhanced&searchSegment=all-results", "ebsco"),
257
+ ("Consensus", "https://consensus.app/results/?q=" + ai_q, "consensus"),
258
+ ("Semantic Scholar", "https://www.semanticscholar.org/search?q=" + ai_q + "&sort=relevance", "semanticscholar"),
259
+ ("Perplexity", "https://www.perplexity.ai/search?q=" + ai_q, "perplexity"),
260
+ ]
261
+ if is_med and pubmed_url:
262
+ platforms.append(("PubMed", pubmed_url, "pubmed"))
263
+
264
+ _SVGS = {
265
+ "bee": (
266
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="16" height="16" style="flex-shrink:0">'
267
+ '<ellipse cx="5.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
268
+ '<ellipse cx="14.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
269
+ '<ellipse cx="10" cy="13" rx="5" ry="6" fill="#1a3a6b"/>'
270
+ '<ellipse cx="10" cy="15" rx="3" ry="1.5" fill="#243f7a"/>'
271
+ '<circle cx="10" cy="8" r="5" fill="#1a3a6b"/>'
272
+ '<rect x="6" y="6.5" width="8" height="3.5" rx="1.5" fill="#001a40"/>'
273
+ '<ellipse cx="8.2" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
274
+ '<ellipse cx="11.8" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
275
+ '<path d="M8 10 Q10 11.5 12 10" fill="none" stroke="#00ccee" stroke-width=".8" stroke-linecap="round"/>'
276
+ '<line x1="8.5" y1="3.5" x2="6.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
277
+ '<circle cx="6.5" cy="1.5" r="1.2" fill="#00ccee" opacity=".8"/>'
278
+ '<line x1="11.5" y1="3.5" x2="13.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
279
+ '<ellipse cx="13.5" cy="1.2" rx="1.5" ry="1.8" fill="#e8e8e8" stroke="#aaa" stroke-width=".5"/>'
280
+ '<text x="10" y="17.5" font-family="Arial,sans-serif" font-size="3" font-weight="bold" fill="#C8A951" text-anchor="middle">LB</text>'
281
+ '</svg>'
282
+ ),
283
+ "book": (
284
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
285
+ '<rect x="2" y="2" width="9" height="12" rx="1" fill="#2563eb"/>'
286
+ '<rect x="3" y="2" width="1.5" height="12" fill="#1d4ed8"/>'
287
+ '<rect x="4" y="4" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
288
+ '<rect x="4" y="6" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
289
+ '<rect x="4" y="8" width="3" height="1" rx=".5" fill="#bfdbfe"/>'
290
+ '<rect x="11" y="3" width="3" height="10" rx="1" fill="#1e40af"/>'
291
+ '</svg>'
292
+ ),
293
+ "microscope": (
294
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
295
+ '<rect x="7" y="1" width="2" height="5" rx="1" fill="#6b7280"/>'
296
+ '<ellipse cx="8" cy="1.5" rx="2.5" ry="1.5" fill="#9ca3af"/>'
297
+ '<rect x="6.5" y="5" width="3" height="5" rx=".5" fill="#374151"/>'
298
+ '<rect x="5" y="9.5" width="6" height="1.5" rx=".5" fill="#374151"/>'
299
+ '<rect x="3" y="13" width="10" height="1.5" rx=".5" fill="#374151"/>'
300
+ '<line x1="8" y1="11" x2="8" y2="13" stroke="#374151" stroke-width="1.5"/>'
301
+ '<circle cx="8" cy="6" r="1.5" fill="#60a5fa" opacity=".8"/>'
302
+ '</svg>'
303
+ ),
304
+ "circuit": (
305
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
306
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#1e293b"/>'
307
+ '<circle cx="4" cy="4" r="1.5" fill="#22d3ee"/>'
308
+ '<circle cx="12" cy="4" r="1.5" fill="#22d3ee"/>'
309
+ '<circle cx="4" cy="12" r="1.5" fill="#22d3ee"/>'
310
+ '<circle cx="12" cy="12" r="1.5" fill="#22d3ee"/>'
311
+ '<circle cx="8" cy="8" r="2" fill="#06b6d4"/>'
312
+ '<line x1="4" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
313
+ '<line x1="12" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
314
+ '<line x1="4" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
315
+ '<line x1="12" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
316
+ '</svg>'
317
+ ),
318
+ "leaf": (
319
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
320
+ '<path d="M8 14 C8 14 2 10 2 5 C2 2 5 1 8 1 C11 1 14 2 14 5 C14 10 8 14 8 14Z" fill="#16a34a"/>'
321
+ '<line x1="8" y1="1" x2="8" y2="14" stroke="#bbf7d0" stroke-width=".8"/>'
322
+ '<line x1="8" y1="5" x2="5" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
323
+ '<line x1="8" y1="7" x2="5" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
324
+ '<line x1="8" y1="5" x2="11" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
325
+ '<line x1="8" y1="7" x2="11" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
326
+ '</svg>'
327
+ ),
328
+ "gear": (
329
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
330
+ '<path d="M6.5 1h3l.5 1.5a5 5 0 011.2.7l1.5-.5 1.5 2.6-1.2 1a5 5 0 010 1.4l1.2 1-1.5 2.6-1.5-.5a5 5 0 01-1.2.7L9.5 15h-3l-.5-1.5a5 5 0 01-1.2-.7l-1.5.5L1.8 10.8l1.2-1a5 5 0 010-1.4l-1.2-1L3.3 4.8l1.5.5A5 5 0 016 4.5L6.5 1z" fill="#64748b"/>'
331
+ '<circle cx="8" cy="8" r="2.5" fill="#f1f5f9"/>'
332
+ '<circle cx="8" cy="8" r="1.2" fill="#64748b"/>'
333
+ '</svg>'
334
+ ),
335
+ "chart": (
336
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
337
+ '<rect x="1" y="1" width="14" height="14" rx="1.5" fill="#f0fdf4"/>'
338
+ '<rect x="3" y="9" width="2" height="5" rx=".5" fill="#16a34a"/>'
339
+ '<rect x="7" y="6" width="2" height="8" rx=".5" fill="#15803d"/>'
340
+ '<rect x="11" y="3" width="2" height="11" rx=".5" fill="#166534"/>'
341
+ '<line x1="1" y1="13.5" x2="15" y2="13.5" stroke="#166534" stroke-width=".8"/>'
342
+ '</svg>'
343
+ ),
344
+ "atom": (
345
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
346
+ '<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9"/>'
347
+ '<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(60 8 8)"/>'
348
+ '<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(120 8 8)"/>'
349
+ '<circle cx="8" cy="8" r="1.8" fill="#7c3aed"/>'
350
+ '</svg>'
351
+ ),
352
+ "medical": (
353
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
354
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#dc2626"/>'
355
+ '<rect x="6.5" y="3" width="3" height="10" rx="1" fill="white"/>'
356
+ '<rect x="3" y="6.5" width="10" height="3" rx="1" fill="white"/>'
357
+ '</svg>'
358
+ ),
359
+ "wrench": (
360
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
361
+ '<path d="M13 2a3 3 0 00-3 3c0 .4.1.8.2 1.1L2.6 13.7a1 1 0 001.4 1.4L11.7 7.8c.3.1.7.2 1.1.2a3 3 0 000-6z" fill="#d97706"/>'
362
+ '<circle cx="12" cy="4" r="1.2" fill="#fef3c7"/>'
363
+ '</svg>'
364
+ ),
365
+ "primo": (
366
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
367
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#003366"/>'
368
+ '<rect x="3" y="4" width="10" height="1.5" rx=".5" fill="#C8A951"/>'
369
+ '<rect x="3" y="7" width="7" height="1.5" rx=".5" fill="white" opacity=".9"/>'
370
+ '<rect x="3" y="10" width="9" height="1.5" rx=".5" fill="white" opacity=".7"/>'
371
+ '<circle cx="12" cy="11" r="2.5" fill="#C8A951"/>'
372
+ '<line x1="11" y1="11" x2="13" y2="11" stroke="#003366" stroke-width=".8"/>'
373
+ '<line x1="12" y1="10" x2="12" y2="12" stroke="#003366" stroke-width=".8"/>'
374
+ '</svg>'
375
+ ),
376
+ "scopus": (
377
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
378
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#e8650a"/>'
379
+ '<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="8" font-weight="bold" fill="white" text-anchor="middle">Sc</text>'
380
+ '</svg>'
381
+ ),
382
+ "ebsco": (
383
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
384
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#00427a"/>'
385
+ '<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="7" font-weight="bold" fill="white" text-anchor="middle">EBS</text>'
386
+ '</svg>'
387
+ ),
388
+ "consensus": (
389
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
390
+ '<rect x="1" y="1" width="14" height="14" rx="7" fill="#6c47ff"/>'
391
+ '<circle cx="6" cy="7" r="1.2" fill="white"/>'
392
+ '<circle cx="10" cy="7" r="1.2" fill="white"/>'
393
+ '<path d="M5 10 Q8 12.5 11 10" fill="none" stroke="white" stroke-width="1.2" stroke-linecap="round"/>'
394
+ '</svg>'
395
+ ),
396
+ "leapspace": (
397
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
398
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#ff6b00"/>'
399
+ '<path d="M4 12 L4 7 L8 4 L12 7 L12 12 Z" fill="white" opacity=".9"/>'
400
+ '<rect x="6.5" y="9" width="3" height="3" fill="#ff6b00"/>'
401
+ '</svg>'
402
+ ),
403
+ "perplexity": (
404
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
405
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#1a1a2e"/>'
406
+ '<circle cx="8" cy="6.5" r="3" fill="none" stroke="#20b2aa" stroke-width="1.2"/>'
407
+ '<line x1="8" y1="9.5" x2="8" y2="13" stroke="#20b2aa" stroke-width="1.2" stroke-linecap="round"/>'
408
+ '<circle cx="8" cy="13" r=".8" fill="#20b2aa"/>'
409
+ '</svg>'
410
+ ),
411
+ "semanticscholar": (
412
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
413
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#1857b6"/>'
414
+ '<path d="M4 11 Q8 4 12 11" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round"/>'
415
+ '<circle cx="8" cy="7" r="1.8" fill="#4fc3f7"/>'
416
+ '</svg>'
417
+ ),
418
+ "pubmed": (
419
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
420
+ '<rect x="1" y="1" width="14" height="14" rx="2" fill="#336699"/>'
421
+ '<text x="8" y="11" font-family="Arial,sans-serif" font-size="6.5" font-weight="bold" fill="white" text-anchor="middle">PubMed</text>'
422
+ '</svg>'
423
+ ),
424
+ }
425
+ _DB_MASCOT = {
426
+ "PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
427
+ "Cochrane Library": "microscope", "UpToDate": "medical",
428
+ "IEEE Xplore": "circuit", "ACM Digital Library": "circuit",
429
+ "ScienceDirect": "microscope", "Scopus": "microscope",
430
+ "Web of Science": "microscope", "SpringerLink": "book",
431
+ "ACS Publications": "atom", "APS Journals": "atom", "Knovel": "wrench",
432
+ "Business Source Complete": "chart", "Emerald Insight": "chart",
433
+ "Taylor & Francis": "book", "EBSCO": "book",
434
+ }
435
+ _BEE = _SVGS["bee"]
436
+
437
+ def _btn(label, url, mascot_key="bee"):
438
+ icon = _SVGS.get(mascot_key, _SVGS["bee"])
439
+ style = (
440
+ 'display:inline-flex;align-items:center;gap:7px;'
441
+ 'padding:8px 14px;border-radius:10px;text-decoration:none;'
442
+ 'font-size:.8rem;font-weight:600;color:#1a1a2e;'
443
+ 'background:#fff;border:1.5px solid #d1d5db;'
444
+ 'transition:box-shadow .2s;width:100%;box-sizing:border-box'
445
+ )
446
+ return '<a href="' + url + '" target="_blank" style="' + style + '">' + icon + label + '</a>'
447
+
448
+ btn_rows = ""
449
+ for k in range(0, len(platforms), 2):
450
+ pair = platforms[k:k+2]
451
+ cells = ""
452
+ for lbl, url, mascot_key in pair:
453
+ cells += '<td style="width:50%;padding:3px 4px 3px 0">' + _btn(lbl, url, mascot_key) + '</td>'
454
+ if len(pair) == 1:
455
+ cells += '<td style="width:50%"></td>'
456
+ btn_rows += "<tr>" + cells + "</tr>"
457
+
458
+ platforms_html = (
459
+ '<div style="margin-top:18px;padding:14px 16px;border:1px solid #c4b5fd;'
460
+ 'border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#fdf4ff)">'
461
+ '<div style="font-size:.8rem;font-weight:800;color:#5b21b6;margin-bottom:4px">'
462
+ '🤖 Search your query across AI platforms</div>'
463
+ '<div style="font-size:.73rem;color:#6b7280;margin-bottom:10px">'
464
+ 'Your query is pre-loaded. Click any platform to search instantly.</div>'
465
+ '<table style="width:100%;border-collapse:collapse">' + btn_rows + '</table>'
466
+ '</div>'
467
+ )
468
+
469
+ if bool_q:
470
+ and_count = bool_q.upper().count(" AND ")
471
+ if and_count >= 1:
472
+ bool_label = "Use this in PRIMO Advanced Search or any database for precise results:"
473
+ else:
474
+ bool_label = "Try adding synonyms with OR to find more relevant results:"
475
+ bool_html = (
476
+ '<div style="margin-top:12px;padding:12px 14px;border:1px solid #fde68a;'
477
+ 'border-radius:10px;background:#fffbeb">'
478
+ '<div style="font-size:.78rem;font-weight:700;color:#92400e;margin-bottom:6px">'
479
+ '\U0001f4a1 Boolean search tip</div>'
480
+ '<div style="font-size:.74rem;color:#6b7280;margin-bottom:6px">' + bool_label + '</div>'
481
+ '<code style="display:block;padding:8px 10px;background:#1a1a2e;color:#C8A951;'
482
+ 'border-radius:7px;font-size:.76rem;word-break:break-all;white-space:pre-wrap">'
483
+ + _escape(bool_q) +
484
+ '</code>'
485
+ '<div style="font-size:.71rem;color:#92400e;margin-top:6px">'
486
+ 'Paste into the Advanced Search box of any database below.</div>'
487
+ '</div>'
488
+ )
489
+ else:
490
+ bool_html = ""
491
+
492
+ all_lower = ((context.ai_tool_query or "") + " " + (context.topic or "")).lower()
493
+
494
+ def _db_row(name, url, desc, favicon=None):
495
+ icon = _SVGS.get(_DB_MASCOT.get(name, "bee"), _SVGS["bee"])
496
+ return (
497
+ '<div style="display:flex;align-items:center;gap:8px;'
498
+ 'padding:7px 0;border-bottom:1px solid #f3f4f6">'
499
+ + icon +
500
+ '<div><a href="' + url + '" target="_blank" '
501
+ 'style="font-size:.8rem;font-weight:700;color:#003366;text-decoration:none">'
502
+ + name + '</a>'
503
+ '<span style="font-size:.73rem;color:#6b7280"> — ' + desc + '</span></div>'
504
+ '</div>'
505
+ )
506
+
507
+ if any(k in all_lower for k in ["medicine","medical","clinical","nursing","pharmacy","health","pubmed","embase","cinahl","cochrane","uptodate"]):
508
+ db_subject = "Medicine & Health Sciences"
509
+ dbs = [
510
+ ("PubMed@KU","http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib","Core biomedical literature"),
511
+ ("Embase",KU+"https://www.embase.com","Biomedical, drugs, devices, clinical medicine"),
512
+ ("CINAHL Ultimate",KU+"https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul","Nursing and allied health"),
513
+ ("Cochrane Library",KU+"https://www.cochranelibrary.com","Systematic reviews and evidence-based medicine"),
514
+ ("UpToDate",KU+"https://www.uptodate.com","Point-of-care clinical decision support"),
515
+ ]
516
+ elif any(k in all_lower for k in ["machine learning","deep learning","artificial intelligence","neural network","computer science","software","algorithm","cybersecurity","robotics","nlp"]):
517
+ db_subject = "Computer Science & AI"
518
+ dbs = [
519
+ ("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Core CS, AI, electronics and systems"),
520
+ ("ACM Digital Library",KU+"https://dl.acm.org","Computer science journals, proceedings and books"),
521
+ ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across CS and applied computing"),
522
+ ("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
523
+ ("Web of Science",KU+"https://www.webofscience.com","Citation index"),
524
+ ]
525
+ elif any(k in all_lower for k in ["climate","global warming","environment","sustainability","ecology","carbon","emission","renewable","sea level","maldives","island"]):
526
+ db_subject = "Environment & Climate"
527
+ dbs = [
528
+ ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across science, environment and engineering"),
529
+ ("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
530
+ ("Web of Science",KU+"https://www.webofscience.com","High-impact journal coverage and citation index"),
531
+ ("SpringerLink",KU+"https://link.springer.com","Springer journals and books"),
532
+ ]
533
+ elif any(k in all_lower for k in ["engineering","mechanical","civil","electrical","aerospace","structural","petroleum","chemical","materials"]):
534
+ db_subject = "Engineering"
535
+ dbs = [
536
+ ("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Electrical, electronics and systems engineering"),
537
+ ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across engineering disciplines"),
538
+ ("Knovel",KU+"https://app.knovel.com","Engineering handbooks and reference data"),
539
+ ("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
540
+ ("Web of Science",KU+"https://www.webofscience.com","Citation index"),
541
+ ]
542
+ elif any(k in all_lower for k in ["business","management","finance","economics","leadership","marketing","strategy","entrepreneurship"]):
543
+ db_subject = "Business & Management"
544
+ dbs = [
545
+ ("Business Source Complete",KU+"https://search.ebscohost.com","Core business and management literature"),
546
+ ("Emerald Insight",KU+"https://www.emerald.com/insight","Management, strategy and leadership"),
547
+ ("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
548
+ ("ScienceDirect",KU+"https://www.sciencedirect.com","Business and social sciences full text"),
549
+ ]
550
+ elif any(k in all_lower for k in ["physics","chemistry","quantum","nuclear","materials science","nanotechnology","optics"]):
551
+ db_subject = "Physics, Chemistry & Materials"
552
+ dbs = [
553
+ ("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across physical sciences"),
554
+ ("ACS Publications",KU+"https://pubs.acs.org","Core chemistry and chemical engineering"),
555
+ ("APS Journals",KU+"https://journals.aps.org","American Physical Society core physics journals"),
556
+ ("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
557
+ ("Web of Science",KU+"https://www.webofscience.com","Citation index"),
558
+ ]
559
+ else:
560
+ db_subject = "General"
561
+ dbs = [
562
+ ("EBSCO",KU+"https://search.ebscohost.com","Multidisciplinary full text"),
563
+ ("ScienceDirect",KU+"https://www.sciencedirect.com","Science, engineering and medicine"),
564
+ ("SpringerLink",KU+"https://link.springer.com","Journals and books across all disciplines"),
565
+ ("Taylor & Francis",KU+"https://www.tandfonline.com","Humanities, social sciences and STEM"),
566
+ ("Emerald Insight",KU+"https://www.emerald.com/insight","Management and social sciences"),
567
+ ("Scopus",KU+"https://www.scopus.com","Citation discovery, all disciplines"),
568
+ ("Web of Science",KU+"https://www.webofscience.com","Citation index, all disciplines"),
569
+ ]
570
+
571
+ dbs_html_rows = ""
572
+ for name, url, desc in dbs:
573
+ dbs_html_rows += _db_row(name, url, desc)
574
+
575
+ db_section = (
576
+ '<div style="margin-top:12px;padding:12px 14px;border:1px solid #d1d5db;'
577
+ 'border-radius:10px;background:#f9fafb">'
578
+ '<div style="font-size:.78rem;font-weight:700;color:#003366;margin-bottom:8px">'
579
+ '\U0001f4c2 Recommended KU databases \u2014 ' + db_subject + '</div>'
580
+ + dbs_html_rows +
581
+ '<div style="padding-top:8px;font-size:.74rem">'
582
+ '<a href="https://library.ku.ac.ae/eresources" target="_blank" '
583
+ 'style="color:#003366;font-weight:700">'
584
+ '\U0001f5c4\ufe0f Browse all 50+ KU databases \u2192</a></div>'
585
+ '</div>'
586
+ )
587
+
588
+ return platforms_html + bool_html + db_section
589
+
590
+
src/agentcore/scholarly.py CHANGED
@@ -1,298 +1,323 @@
1
- """Open scholarly-index clients: Semantic Scholar, OpenAlex, Crossref and Unpaywall (OA links)."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
-
19
- from src.agentcore.utils import _escape, _strip_resource_noise
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
24
- clean_topic = _strip_resource_noise(topic)
25
- if not clean_topic:
26
- clean_topic = topic
27
- try:
28
- params = {
29
- "query": clean_topic,
30
- "limit": limit,
31
- "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
32
- }
33
- async with httpx.AsyncClient(timeout=15) as client:
34
- r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
35
- if r.status_code != 200:
36
- logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
37
- return []
38
- data = r.json()
39
- results = []
40
- for paper in data.get("data", []):
41
- abstract = paper.get("abstract") or ""
42
- if not abstract or len(abstract) < 50:
43
- continue
44
- author_list = paper.get("authors") or []
45
- first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
46
- year = paper.get("year") or "n.d."
47
- paper_id = paper.get("paperId", "")
48
- doi = (paper.get("externalIds") or {}).get("DOI", "")
49
- pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
50
- link = (
51
- pdf_url
52
- or (f"https://doi.org/{doi}" if doi else "")
53
- or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
54
- )
55
- results.append({
56
- "title": paper.get("title", "Untitled"),
57
- "authors": (
58
- ", ".join(a.get("name", "") for a in author_list[:3])
59
- + (" et al." if len(author_list) > 3 else "")
60
- ),
61
- "first_author": first_author_last,
62
- "year": year,
63
- "citation_key": f"{first_author_last}, {year}",
64
  "venue": paper.get("venue", ""),
65
  "abstract": abstract[:700],
 
 
 
66
  "link": link,
67
- "_source": "Semantic Scholar",
68
- })
69
- return results
70
- except Exception as e:
71
- logger.error(f"Semantic Scholar search failed: {e}")
72
- return []
73
-
74
-
75
- async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
76
- try:
77
- from urllib.parse import quote as _oa_quote
78
- clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
79
- clean = re.sub(r'\s+', ' ', clean).strip()[:200]
80
- encoded = _oa_quote(clean, safe='')
81
- select = "id,title,authorships,publication_year,primary_location,abstract_inverted_index,doi,cited_by_count,open_access"
82
- url = (
83
- f"https://api.openalex.org/works"
84
- f"?search={encoded}"
85
- f"&filter=has_abstract:true,type:article,is_paratext:false"
86
- f"&sort=relevance_score:desc"
87
- f"&per_page={limit}"
88
- f"&select={select}"
89
- f"&mailto={quote(get_settings().contact_email, safe='')}"
90
- )
91
- _contact = get_settings().contact_email
92
- headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
93
- async with httpx.AsyncClient(timeout=10) as client:
94
- r = await client.get(url, headers=headers)
95
- if r.status_code != 200:
96
- logger.warning(f"OpenAlex search failed: {r.status_code}")
97
- return []
98
- works = r.json().get("results", [])
99
- results = []
100
- for work in works:
101
- title = work.get("title") or "Untitled"
102
- inv = work.get("abstract_inverted_index") or {}
103
- abstract = ""
104
- if inv:
105
- word_positions = []
106
- for word, positions in inv.items():
107
- for pos in positions:
108
- word_positions.append((pos, word))
109
- word_positions.sort(key=lambda x: x[0])
110
- abstract = " ".join(w for _, w in word_positions)[:700]
111
- if not abstract:
112
- continue
113
- authorships = work.get("authorships") or []
114
- author_names = [
115
- a.get("author", {}).get("display_name", "")
116
- for a in authorships[:4]
117
- if a.get("author", {}).get("display_name")
118
- ]
119
- authors_str = ", ".join(author_names[:3])
120
- if len(author_names) > 3:
121
- authors_str += " et al."
122
- first_last = author_names[0].split()[-1] if author_names else "Unknown"
123
- year = str(work.get("publication_year") or "n.d.")
124
- doi = (work.get("doi") or "").replace("https://doi.org/", "")
125
- oa_url = (work.get("open_access") or {}).get("oa_url") or ""
126
- link = (
127
- oa_url if oa_url
128
- else (f"https://doi.org/{doi}" if doi else "")
129
- or work.get("id", "https://openalex.org")
130
- )
131
- venue = (
132
- ((work.get("primary_location") or {}).get("source") or {})
133
- .get("display_name") or ""
134
- )
135
- results.append({
136
- "title": title,
137
- "authors": authors_str or "Unknown",
138
- "first_author": first_last,
139
- "year": year,
140
- "citation_key": f"{first_last}, {year}",
141
  "venue": venue,
142
  "abstract": abstract,
 
 
 
143
  "link": link,
144
- "_source": "OpenAlex",
145
- })
146
- logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
147
- return results
148
- except Exception as e:
149
- logger.warning(f"OpenAlex search failed: {e}")
150
- return []
151
-
152
-
153
-
154
-
155
- # ── Unpaywall + Crossref enrichment ──────────────────────────────────────────
156
- # Unpaywall (https://unpaywall.org/products/api) resolves a DOI to its best
157
- # legal open-access location. Crossref fills publication-year / venue gaps.
158
- # Both are free, keyless "polite pool" APIs identified by contact e-mail.
159
- # Every call is best-effort: failures never break the response.
160
-
161
- _UNPAYWALL_TTL = 24 * 60 * 60
162
- _unpaywall_cache: Dict[str, Any] = {}
163
-
164
-
165
- async def _unpaywall_oa_url(doi: str) -> str:
166
- """Return the best legal OA PDF/landing URL for a DOI, or '' if none/unknown."""
167
- doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
168
- if not doi:
169
- return ""
170
- now = time.time()
171
- hit = _unpaywall_cache.get(doi)
172
- if hit and now - hit[0] < _UNPAYWALL_TTL:
173
- return hit[1]
174
- url = ""
175
- try:
176
- email = get_settings().contact_email
177
- async with httpx.AsyncClient(timeout=4) as client:
178
- r = await client.get(
179
- f"https://api.unpaywall.org/v2/{quote(doi, safe='')}",
180
- params={"email": email},
181
- )
182
- if r.status_code == 200:
183
- loc = (r.json() or {}).get("best_oa_location") or {}
184
- url = loc.get("url_for_pdf") or loc.get("url") or ""
185
- except Exception as exc:
186
- logger.debug(f"Unpaywall lookup failed for {doi}: {exc}")
187
- _unpaywall_cache[doi] = (now, url)
188
- return url
189
-
190
-
191
- async def _crossref_fill(doi: str) -> dict:
192
- """Return {'year': int|None, 'venue': str} from Crossref for a DOI (best effort)."""
193
- doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
194
- if not doi:
195
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  try:
197
- _contact = get_settings().contact_email
198
- headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
199
- async with httpx.AsyncClient(timeout=4) as client:
200
- r = await client.get(f"https://api.crossref.org/works/{quote(doi, safe='')}",
201
- headers=headers)
202
- if r.status_code != 200:
203
- return {}
204
- msg = (r.json() or {}).get("message") or {}
205
- year = None
206
- issued = ((msg.get("issued") or {}).get("date-parts") or [[None]])[0]
207
- if issued and issued[0]:
208
- year = issued[0]
209
- venue = (msg.get("container-title") or [""])[0]
210
- return {"year": year, "venue": venue}
211
- except Exception as exc:
212
- logger.debug(f"Crossref lookup failed for {doi}: {exc}")
213
- return {}
214
-
215
-
216
- async def enrich_papers_with_oa(papers: List[dict], max_lookups: int = 5) -> List[dict]:
217
- """Concurrently attach 'oa_pdf_url' (Unpaywall) and fill missing year/venue
218
- (Crossref) for papers that carry a DOI. Mutates and returns the list."""
219
- targets = [p for p in papers if p.get("doi") and not p.get("oa_pdf_url")][:max_lookups]
220
- if not targets:
221
- return papers
222
- oa_results = await asyncio.gather(
223
- *(_unpaywall_oa_url(p["doi"]) for p in targets), return_exceptions=True
224
- )
225
- for p, oa in zip(targets, oa_results):
226
- if isinstance(oa, str) and oa:
227
- p["oa_pdf_url"] = oa
228
- missing_meta = [p for p in targets if not p.get("year") or p.get("year") == "n.d."]
229
- if missing_meta:
230
- cf = await asyncio.gather(
231
- *(_crossref_fill(p["doi"]) for p in missing_meta), return_exceptions=True
232
  )
233
- for p, meta in zip(missing_meta, cf):
234
- if isinstance(meta, dict):
235
- if meta.get("year") and (not p.get("year") or p.get("year") == "n.d."):
236
- p["year"] = meta["year"]
237
- if meta.get("venue") and not p.get("venue"):
238
- p["venue"] = meta["venue"]
239
- return papers
240
-
241
-
242
- # ── Shared evidence panel ────────────────────────────────────────────────────
243
- # Used by BOTH search handlers (_run_search_mode and _research_snapshot) so the
244
- # panel appears on ordinary searches, not only on "deep research" phrasing.
245
- # Returns (papers, html). Never raises: on any failure returns ([], "") and the
246
- # caller renders exactly its pre-3.8 output.
247
-
248
- async def fetch_evidence_panel(topic: str, limit: int = 6, max_lookups: int = 5):
249
  papers: List[dict] = []
250
- try:
251
- papers = await _openalex_search(topic, limit=limit)
252
- if not papers:
253
- papers = await _semantic_scholar_search(topic, limit=limit)
254
  if papers:
255
  papers = await enrich_papers_with_oa(papers, max_lookups=max_lookups)
256
- except Exception as exc:
257
- logger.warning(f"Evidence panel failed for '{topic}': {exc}")
258
- return [], ""
259
- if not papers:
260
- return [], ""
261
-
262
- rows = ""
263
- for paper in papers[:5]:
264
- _t = _escape(str(paper.get("title") or "Untitled"))
265
- _y = _escape(str(paper.get("year") or "n.d."))
266
- _v = _escape(str(paper.get("venue") or ""))
267
- _c = paper.get("cited_by_count")
268
- _link = paper.get("link") or (f"https://doi.org/{paper['doi']}" if paper.get("doi") else "")
269
- _oa = paper.get("oa_pdf_url") or ""
270
- _bits = [b for b in (_y, _v) if b]
271
- if isinstance(_c, int) and _c > 0:
272
- _bits.append(f"{_c:,} citations")
273
- _meta = " \u00b7 ".join(_bits)
274
- _title_html = (
275
- f'<a href="{_escape(_link)}" target="_blank" style="color:#003366;font-weight:600">{_t}</a>'
276
- if _link else f'<span style="font-weight:600">{_t}</span>'
277
- )
278
- _oa_html = (
279
- f' \u00b7 <a href="{_escape(_oa)}" target="_blank" '
280
- f'style="color:#047857;font-weight:700">\U0001f513 OA full text</a>'
281
- if _oa else ""
282
- )
283
- rows += (
284
- f'<div style="margin:7px 0;padding-left:2px;font-size:.84rem;line-height:1.55">'
285
- f'{_title_html}<br>'
286
- f'<span style="color:#6b7280;font-size:.78rem">{_meta}{_oa_html}</span></div>'
287
- )
 
 
 
 
 
 
 
 
 
288
  html = (
289
- '<div style="margin-top:16px;padding:12px 14px;border:1px solid #d1fae5;'
290
- 'border-radius:10px;background:#f0fdf9">'
291
- '<div style="font-weight:700;color:#065f46;font-size:.86rem;margin-bottom:4px">'
292
- '\U0001f50e Open scholarly evidence</div>'
293
- '<div style="font-size:.76rem;color:#047857;margin-bottom:8px">'
294
- 'Top open-index results (OpenAlex / Semantic Scholar) with legal open-access '
295
- 'links via Unpaywall and metadata via Crossref where available.</div>'
296
- f'{rows}</div>'
297
- )
298
- return papers, html
 
1
+ """Open scholarly-index clients: Semantic Scholar, OpenAlex, Crossref and Unpaywall (OA links)."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+
19
+ from src.agentcore.utils import _escape, _get_runtime_config, _strip_resource_noise
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
24
+ clean_topic = _strip_resource_noise(topic)
25
+ if not clean_topic:
26
+ clean_topic = topic
27
+ try:
28
+ params = {
29
+ "query": clean_topic,
30
+ "limit": limit,
31
+ "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf,citationCount",
32
+ }
33
+ async with httpx.AsyncClient(timeout=15) as client:
34
+ r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
35
+ if r.status_code != 200:
36
+ logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
37
+ return []
38
+ data = r.json()
39
+ results = []
40
+ for paper in data.get("data", []):
41
+ abstract = paper.get("abstract") or ""
42
+ if not abstract or len(abstract) < 50:
43
+ continue
44
+ author_list = paper.get("authors") or []
45
+ first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
46
+ year = paper.get("year") or "n.d."
47
+ paper_id = paper.get("paperId", "")
48
+ doi = (paper.get("externalIds") or {}).get("DOI", "")
49
+ pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
50
+ link = (
51
+ pdf_url
52
+ or (f"https://doi.org/{doi}" if doi else "")
53
+ or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
54
+ )
55
+ results.append({
56
+ "title": paper.get("title", "Untitled"),
57
+ "authors": (
58
+ ", ".join(a.get("name", "") for a in author_list[:3])
59
+ + (" et al." if len(author_list) > 3 else "")
60
+ ),
61
+ "first_author": first_author_last,
62
+ "year": year,
63
+ "citation_key": f"{first_author_last}, {year}",
64
  "venue": paper.get("venue", ""),
65
  "abstract": abstract[:700],
66
+ "doi": doi,
67
+ "oa_pdf_url": pdf_url,
68
+ "cited_by_count": paper.get("citationCount") or 0,
69
  "link": link,
70
+ "_source": "Semantic Scholar",
71
+ })
72
+ return results
73
+ except Exception as e:
74
+ logger.error(f"Semantic Scholar search failed: {e}")
75
+ return []
76
+
77
+
78
+ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
79
+ try:
80
+ from urllib.parse import quote as _oa_quote
81
+ clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
82
+ clean = re.sub(r'\s+', ' ', clean).strip()[:200]
83
+ encoded = _oa_quote(clean, safe='')
84
+ select = "id,title,authorships,publication_year,primary_location,abstract_inverted_index,doi,cited_by_count,open_access"
85
+ url = (
86
+ f"https://api.openalex.org/works"
87
+ f"?search={encoded}"
88
+ f"&filter=has_abstract:true,type:article,is_paratext:false"
89
+ f"&sort=relevance_score:desc"
90
+ f"&per_page={limit}"
91
+ f"&select={select}"
92
+ f"&mailto={quote(get_settings().contact_email, safe='')}"
93
+ )
94
+ _contact = get_settings().contact_email
95
+ headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
96
+ async with httpx.AsyncClient(timeout=10) as client:
97
+ r = await client.get(url, headers=headers)
98
+ if r.status_code != 200:
99
+ logger.warning(f"OpenAlex search failed: {r.status_code}")
100
+ return []
101
+ works = r.json().get("results", [])
102
+ results = []
103
+ for work in works:
104
+ title = work.get("title") or "Untitled"
105
+ inv = work.get("abstract_inverted_index") or {}
106
+ abstract = ""
107
+ if inv:
108
+ word_positions = []
109
+ for word, positions in inv.items():
110
+ for pos in positions:
111
+ word_positions.append((pos, word))
112
+ word_positions.sort(key=lambda x: x[0])
113
+ abstract = " ".join(w for _, w in word_positions)[:700]
114
+ if not abstract:
115
+ continue
116
+ authorships = work.get("authorships") or []
117
+ author_names = [
118
+ a.get("author", {}).get("display_name", "")
119
+ for a in authorships[:4]
120
+ if a.get("author", {}).get("display_name")
121
+ ]
122
+ authors_str = ", ".join(author_names[:3])
123
+ if len(author_names) > 3:
124
+ authors_str += " et al."
125
+ first_last = author_names[0].split()[-1] if author_names else "Unknown"
126
+ year = str(work.get("publication_year") or "n.d.")
127
+ doi = (work.get("doi") or "").replace("https://doi.org/", "")
128
+ oa_url = (work.get("open_access") or {}).get("oa_url") or ""
129
+ link = (
130
+ oa_url if oa_url
131
+ else (f"https://doi.org/{doi}" if doi else "")
132
+ or work.get("id", "https://openalex.org")
133
+ )
134
+ venue = (
135
+ ((work.get("primary_location") or {}).get("source") or {})
136
+ .get("display_name") or ""
137
+ )
138
+ results.append({
139
+ "title": title,
140
+ "authors": authors_str or "Unknown",
141
+ "first_author": first_last,
142
+ "year": year,
143
+ "citation_key": f"{first_last}, {year}",
144
  "venue": venue,
145
  "abstract": abstract,
146
+ "doi": doi,
147
+ "oa_pdf_url": oa_url,
148
+ "cited_by_count": int(work.get("cited_by_count") or 0),
149
  "link": link,
150
+ "_source": "OpenAlex",
151
+ })
152
+ logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
153
+ return results
154
+ except Exception as e:
155
+ logger.warning(f"OpenAlex search failed: {e}")
156
+ return []
157
+
158
+
159
+
160
+
161
+ # ── Unpaywall + Crossref enrichment ──────────────────────────────────────────
162
+ # Unpaywall (https://unpaywall.org/products/api) resolves a DOI to its best
163
+ # legal open-access location. Crossref fills publication-year / venue gaps.
164
+ # Both are free, keyless "polite pool" APIs identified by contact e-mail.
165
+ # Every call is best-effort: failures never break the response.
166
+
167
+ _UNPAYWALL_TTL = 24 * 60 * 60
168
+ _unpaywall_cache: Dict[str, Any] = {}
169
+
170
+
171
+ async def _unpaywall_oa_url(doi: str) -> str:
172
+ """Return the best legal OA PDF/landing URL for a DOI, or '' if none/unknown."""
173
+ doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
174
+ if not doi:
175
+ return ""
176
+ now = time.time()
177
+ hit = _unpaywall_cache.get(doi)
178
+ if hit and now - hit[0] < _UNPAYWALL_TTL:
179
+ return hit[1]
180
+ url = ""
181
+ try:
182
+ email = get_settings().contact_email
183
+ async with httpx.AsyncClient(timeout=4) as client:
184
+ r = await client.get(
185
+ f"https://api.unpaywall.org/v2/{quote(doi, safe='')}",
186
+ params={"email": email},
187
+ )
188
+ if r.status_code == 200:
189
+ loc = (r.json() or {}).get("best_oa_location") or {}
190
+ url = loc.get("url_for_pdf") or loc.get("url") or ""
191
+ except Exception as exc:
192
+ logger.debug(f"Unpaywall lookup failed for {doi}: {exc}")
193
+ _unpaywall_cache[doi] = (now, url)
194
+ return url
195
+
196
+
197
+ async def _crossref_fill(doi: str) -> dict:
198
+ """Return {'year': int|None, 'venue': str} from Crossref for a DOI (best effort)."""
199
+ doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
200
+ if not doi:
201
+ return {}
202
+ try:
203
+ _contact = get_settings().contact_email
204
+ headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
205
+ async with httpx.AsyncClient(timeout=4) as client:
206
+ r = await client.get(f"https://api.crossref.org/works/{quote(doi, safe='')}",
207
+ headers=headers)
208
+ if r.status_code != 200:
209
+ return {}
210
+ msg = (r.json() or {}).get("message") or {}
211
+ year = None
212
+ issued = ((msg.get("issued") or {}).get("date-parts") or [[None]])[0]
213
+ if issued and issued[0]:
214
+ year = issued[0]
215
+ venue = (msg.get("container-title") or [""])[0]
216
+ return {"year": year, "venue": venue}
217
+ except Exception as exc:
218
+ logger.debug(f"Crossref lookup failed for {doi}: {exc}")
219
+ return {}
220
+
221
+
222
+ async def enrich_papers_with_oa(papers: List[dict], max_lookups: int = 5) -> List[dict]:
223
+ """Concurrently attach 'oa_pdf_url' (Unpaywall) and fill missing year/venue
224
+ (Crossref) for papers that carry a DOI. Mutates and returns the list."""
225
+ targets = [p for p in papers if p.get("doi") and not p.get("oa_pdf_url")][:max_lookups]
226
+ if not targets:
227
+ return papers
228
+ oa_results = await asyncio.gather(
229
+ *(_unpaywall_oa_url(p["doi"]) for p in targets), return_exceptions=True
230
+ )
231
+ for p, oa in zip(targets, oa_results):
232
+ if isinstance(oa, str) and oa:
233
+ p["oa_pdf_url"] = oa
234
+ missing_meta = [p for p in targets if not p.get("year") or p.get("year") == "n.d."]
235
+ if missing_meta:
236
+ cf = await asyncio.gather(
237
+ *(_crossref_fill(p["doi"]) for p in missing_meta), return_exceptions=True
238
+ )
239
+ for p, meta in zip(missing_meta, cf):
240
+ if isinstance(meta, dict):
241
+ if meta.get("year") and (not p.get("year") or p.get("year") == "n.d."):
242
+ p["year"] = meta["year"]
243
+ if meta.get("venue") and not p.get("venue"):
244
+ p["venue"] = meta["venue"]
245
+ return papers
246
+
247
+
248
+ # ── Shared evidence panel ────────────────────────────────────────────────────
249
+ # Used by BOTH search handlers (_run_search_mode and _research_snapshot) so the
250
+ # panel appears on ordinary searches, not only on "deep research" phrasing.
251
+ # Returns (papers, html). Never raises: on any failure returns ([], "") and the
252
+ # caller renders exactly its pre-3.8 output.
253
+
254
+ async def fetch_evidence_panel(topic: str, limit: int = 6, max_lookups: int = 5, context=None):
255
  try:
256
+ configured_limit = int(_get_runtime_config().get("max_results", get_settings().max_results))
257
+ except (TypeError, ValueError):
258
+ configured_limit = get_settings().max_results
259
+ limit = max(1, min(limit, configured_limit, 20))
260
+ if context is not None and getattr(context, "peer_reviewed", False):
261
+ return [], (
262
+ '<div class="evidence-note" role="note"><strong>Peer-reviewed filter:</strong> '
263
+ 'Open-index evidence is not displayed because those records do not provide a '
264
+ 'reliable peer-review guarantee. Use the prepared PRIMO search, where the '
265
+ 'Peer Reviewed filter is applied.</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  papers: List[dict] = []
268
+ try:
269
+ papers = await _openalex_search(topic, limit=limit)
270
+ if not papers:
271
+ papers = await _semantic_scholar_search(topic, limit=limit)
272
  if papers:
273
  papers = await enrich_papers_with_oa(papers, max_lookups=max_lookups)
274
+ if context is not None:
275
+ year_from = int(context.year_from) if getattr(context, "year_from", None) else None
276
+ year_to = int(context.year_to) if getattr(context, "year_to", None) else None
277
+ if year_from:
278
+ papers = [paper for paper in papers if str(paper.get("year", "")).isdigit() and int(paper["year"]) >= year_from]
279
+ if year_to:
280
+ papers = [paper for paper in papers if str(paper.get("year", "")).isdigit() and int(paper["year"]) <= year_to]
281
+ if getattr(context, "open_access", False):
282
+ papers = [paper for paper in papers if paper.get("oa_pdf_url")]
283
+ except Exception as exc:
284
+ logger.warning(f"Evidence panel failed for '{topic}': {exc}")
285
+ return [], ""
286
+ if not papers:
287
+ return [], ""
288
+
289
+ rows = ""
290
+ for paper in papers[:5]:
291
+ _t = _escape(str(paper.get("title") or "Untitled"))
292
+ _y = _escape(str(paper.get("year") or "n.d."))
293
+ _v = _escape(str(paper.get("venue") or ""))
294
+ _c = paper.get("cited_by_count")
295
+ _link = paper.get("link") or (f"https://doi.org/{paper['doi']}" if paper.get("doi") else "")
296
+ _oa = paper.get("oa_pdf_url") or ""
297
+ _bits = [b for b in (_y, _v) if b]
298
+ if isinstance(_c, int) and _c > 0:
299
+ _bits.append(f"{_c:,} citations")
300
+ _meta = " \u00b7 ".join(_bits)
301
+ _title_html = (
302
+ f'<a href="{_escape(_link)}" target="_blank" style="color:#003366;font-weight:600">{_t}</a>'
303
+ if _link else f'<span style="font-weight:600">{_t}</span>'
304
+ )
305
+ _oa_html = (
306
+ f' \u00b7 <a href="{_escape(_oa)}" target="_blank" '
307
+ f'style="color:#047857;font-weight:700">\U0001f513 OA full text</a>'
308
+ if _oa else ""
309
+ )
310
+ rows += (
311
+ f'<div style="margin:7px 0;padding-left:2px;font-size:.84rem;line-height:1.55">'
312
+ f'{_title_html}<br>'
313
+ f'<span style="color:#6b7280;font-size:.78rem">{_meta}{_oa_html}</span></div>'
314
+ )
315
  html = (
316
+ '<div class="evidence-panel">'
317
+ '<div class="evidence-title">\U0001f50e Open scholarly evidence</div>'
318
+ '<div class="evidence-description">'
319
+ 'Top open-index results (OpenAlex / Semantic Scholar) with legal open-access '
320
+ 'links via Unpaywall and metadata via Crossref where available.</div>'
321
+ f'{rows}</div>'
322
+ )
323
+ return papers, html
 
 
src/agentcore/utils.py CHANGED
@@ -1,288 +1,288 @@
1
- """Small shared helpers: HTML escaping, metrics, history windowing, LLM factory, sanitizers."""
2
- import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
- from src.services.staff_service import (
19
- STAFF_DIRECTORY,
20
- match_staff_name,
21
- match_staff_role,
22
- should_attempt_staff_lookup,
23
- staff_name_answer,
24
- staff_role_answer,
25
- )
26
-
27
- from src.agentcore.models import ChatMessage, SearchContextPayload
28
- from src.agentcore.constants import (
29
- CURRENT_YEAR,
30
- HISTORY_WINDOW,
31
- _LEGACY_SYSTEM_PATTERNS,
32
- _RESOURCE_TYPE_NOISE,
33
- )
34
-
35
- logger = logging.getLogger(__name__)
36
-
37
- def _get_runtime_config() -> dict:
38
- try:
39
- from src.services.runtime_store import JsonRuntimeStore
40
- settings = get_settings()
41
- store = JsonRuntimeStore(settings.config_path, default={
42
- "max_results": 5,
43
- "maintenance_mode": False,
44
- "welcome_message": "",
45
- "custom_instructions": "",
46
- "announcement": "",
47
- "maintenance_message": "",
48
- })
49
- return store.load()
50
- except Exception:
51
- return {}
52
-
53
-
54
- def _escape(text: str) -> str:
55
- return html.escape(text or "")
56
-
57
-
58
- def _normalize_whitespace(text: str) -> str:
59
- return re.sub(r"\s+", " ", (text or "").strip())
60
-
61
-
62
- def _title_case_topic(topic: str) -> str:
63
- return _normalize_whitespace(topic).strip().strip(".?")
64
-
65
-
66
- def _strip_resource_noise(text: str) -> str:
67
- cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
68
- cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
69
- cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
70
- cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
71
- return _normalize_whitespace(cleaned)
72
-
73
-
74
- def _safe_metrics_increment(key: str) -> None:
75
- try:
76
- from app import get_metrics_service
77
- get_metrics_service().incr(key)
78
- except Exception:
79
- return
80
-
81
-
82
- def _safe_metrics_bucket(bucket: str, key: str) -> None:
83
- try:
84
- from app import get_metrics_service
85
- get_metrics_service().incr_bucket(bucket, key)
86
- except Exception:
87
- return
88
-
89
-
90
- def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
91
- msgs = []
92
- for m in history[-HISTORY_WINDOW:]:
93
- if m.role in ("user", "assistant") and m.content:
94
- msgs.append({"role": m.role, "content": m.content[:300]})
95
- return msgs
96
-
97
-
98
- def _get_llm(model: str, temperature: float, max_tokens: int):
99
- settings = get_settings()
100
- if model == "claude" and settings.anthropic_api_key:
101
- from langchain_anthropic import ChatAnthropic
102
- return ChatAnthropic(
103
- model="claude-haiku-4-5-20251001",
104
- temperature=temperature,
105
- max_tokens=max_tokens,
106
- anthropic_api_key=settings.anthropic_api_key,
107
- )
108
- from langchain_openai import ChatOpenAI
109
- return ChatOpenAI(
110
- model="gpt-4o-mini",
111
- temperature=temperature,
112
- max_tokens=max_tokens,
113
- openai_api_key=settings.openai_api_key,
114
- )
115
-
116
-
117
- def _shared_build_primo_boolean_query(topic: str) -> str:
118
- clean = _strip_resource_noise(topic)
119
- if not clean:
120
- clean = topic
121
- clean = _normalize_whitespace(clean)
122
- words = clean.split()
123
- if len(words) <= 4:
124
- return f'"{clean}"'
125
- _BOOL_STOP = re.compile(
126
- r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
127
- r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
128
- r"this|that|these|those|about|impact|role|effect|use|analysis|review|"
129
- r"what|how|why|when|where|which|between|within|across|among|using|"
130
- r"based|related|towards|toward|during|after|before|over|under)\b",
131
- re.IGNORECASE,
132
- )
133
- parts = _BOOL_STOP.split(clean)
134
- concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
135
- if not concepts:
136
- return f'"{clean}"'
137
- if len(concepts) == 1:
138
- return f'"{concepts[0]}"'
139
- quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
140
- return " AND ".join(quoted)
141
-
142
-
143
- def _make_primo_boolean_query(context: SearchContextPayload) -> str:
144
- topic = _title_case_topic(context.display_topic or context.topic)
145
- topic = _strip_resource_noise(topic) or (context.topic or "library search")
146
- return _shared_build_primo_boolean_query(topic)
147
-
148
-
149
- def _shared_build_primo_discovery_url(
150
- boolean_query: str,
151
- resource_type: str = "articles",
152
- peer_reviewed: bool = False,
153
- open_access: bool = False,
154
- year_from: Optional[str] = None,
155
- year_to: Optional[str] = None,
156
- ) -> str:
157
- base = (
158
- "https://khalifa.primo.exlibrisgroup.com/discovery/search"
159
- f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
160
- f"&query=any,contains,{quote(boolean_query)}"
161
- f"&lang=en&search_scope=MyInst_and_CI&sortby=rank&mode=advanced"
162
- )
163
- facets = []
164
- if resource_type == "articles":
165
- facets.append("facet_rtype,include,articles")
166
- elif resource_type == "books":
167
- facets.append("facet_rtype,include,books")
168
- if peer_reviewed:
169
- facets.append("facet_tlevel,include,peer_reviewed")
170
- if open_access:
171
- facets.append("facet_tlevel,include,online_resources")
172
- if year_from or year_to:
173
- yf = year_from or "0001"
174
- yt = year_to or "9999"
175
- facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
176
- for facet in facets:
177
- base += f"&multiFacets={quote(facet)}"
178
- return base
179
-
180
-
181
- def _shared_build_pubmed_url(
182
- topic: str,
183
- year_from: Optional[str] = None,
184
- year_to: Optional[str] = None,
185
- peer_reviewed: bool = False,
186
- ) -> str:
187
- clean = _strip_resource_noise(topic)
188
- term = clean or topic
189
- if peer_reviewed:
190
- term = f"({term}) AND Journal Article[pt]"
191
- url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
192
- if year_from or year_to:
193
- yf = year_from or "1900"
194
- yt = year_to or str(CURRENT_YEAR)
195
- url += f"&filter=datesearch.y_{yf}-{yt}"
196
- return url
197
-
198
-
199
- def _primo_clean_url(context: SearchContextPayload) -> str:
200
- boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
201
- return _shared_build_primo_discovery_url(
202
- boolean_query,
203
- resource_type=context.resource_type,
204
- peer_reviewed=context.peer_reviewed,
205
- open_access=context.open_access,
206
- year_from=context.year_from,
207
- year_to=context.year_to,
208
- )
209
-
210
-
211
- async def _grammar_refine_query(text: str, model: str) -> str:
212
- settings = get_settings()
213
- if not settings.openai_api_key and not settings.anthropic_api_key:
214
- return _normalize_whitespace(text)
215
- try:
216
- llm = _get_llm(model, temperature=0, max_tokens=60)
217
- response = await llm.ainvoke([
218
- {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
219
- {"role": "user", "content": text},
220
- ])
221
- refined = _normalize_whitespace(response.content)
222
- return refined or _normalize_whitespace(text)
223
- except Exception:
224
- return _normalize_whitespace(text)
225
-
226
-
227
- def _light_strip_retrieval_boilerplate(text: str) -> str:
228
- cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
229
- cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
230
- cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
231
- cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
232
- cleaned = re.sub(
233
- r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
234
- r"research on|articles on|papers on|literature on|studies on|"
235
- r"tell me about|i need|i want|can you find|help me find|"
236
- r"i am looking for|i'm looking for|i need articles on|"
237
- r"i want articles on|i need papers on|i want papers on)\s+",
238
- "", cleaned, flags=re.IGNORECASE
239
- )
240
- cleaned = re.sub(
241
- r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
242
- r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
243
- "", cleaned, flags=re.IGNORECASE
244
- )
245
- cleaned = re.sub(
246
- r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
247
- "", cleaned, flags=re.IGNORECASE
248
- )
249
- return re.sub(r"\s+", " ", cleaned).strip()
250
-
251
-
252
- def _sanitize_llm_response(text: str) -> str:
253
- if not text:
254
- return text
255
- for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
256
- text = pattern.sub(replacement, text)
257
- return text
258
-
259
-
260
- def _sanitize_boolean_for_primo(boolean: str) -> str:
261
- if not boolean:
262
- return boolean
263
- boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
264
- boolean = re.sub(
265
- r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
266
- "", boolean, flags=re.IGNORECASE,
267
- )
268
- boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
269
- boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
270
- boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
271
- return re.sub(r"\s+", " ", boolean).strip()
272
-
273
-
274
- def _clean_database_keywords(boolean_query: str) -> str:
275
- return re.sub(r"\s+", " ",
276
- re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
277
- boolean_query, flags=re.IGNORECASE)).strip()
278
-
279
-
280
- def _find_staff_by_token(token: str) -> Optional[dict]:
281
- token = (token or "").lower()
282
- for staff in STAFF_DIRECTORY:
283
- hay = (staff.get("full_name", "") + " " + staff.get("role", "")).lower()
284
- if token in hay:
285
- return staff
286
- return None
287
-
288
-
 
1
+ """Small shared helpers: HTML escaping, metrics, history windowing, LLM factory, sanitizers."""
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from src.config import get_settings, LIBBEE_VERSION
18
+ from src.services.staff_service import (
19
+ STAFF_DIRECTORY,
20
+ match_staff_name,
21
+ match_staff_role,
22
+ should_attempt_staff_lookup,
23
+ staff_name_answer,
24
+ staff_role_answer,
25
+ )
26
+
27
+ from src.agentcore.models import ChatMessage, SearchContextPayload
28
+ from src.agentcore.constants import (
29
+ CURRENT_YEAR,
30
+ HISTORY_WINDOW,
31
+ _LEGACY_SYSTEM_PATTERNS,
32
+ _RESOURCE_TYPE_NOISE,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ def _get_runtime_config() -> dict:
38
+ try:
39
+ from src.services.runtime_store import JsonRuntimeStore
40
+ settings = get_settings()
41
+ store = JsonRuntimeStore(settings.config_path, default={
42
+ "max_results": 5,
43
+ "maintenance_mode": False,
44
+ "welcome_message": "",
45
+ "custom_instructions": "",
46
+ "announcement": "",
47
+ "maintenance_message": "",
48
+ })
49
+ return store.load()
50
+ except Exception:
51
+ return {}
52
+
53
+
54
+ def _escape(text: str) -> str:
55
+ return html.escape(text or "")
56
+
57
+
58
+ def _normalize_whitespace(text: str) -> str:
59
+ return re.sub(r"\s+", " ", (text or "").strip())
60
+
61
+
62
+ def _title_case_topic(topic: str) -> str:
63
+ return _normalize_whitespace(topic).strip().strip(".?")
64
+
65
+
66
+ def _strip_resource_noise(text: str) -> str:
67
+ cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
68
+ cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
69
+ cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
70
+ cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
71
+ return _normalize_whitespace(cleaned)
72
+
73
+
74
+ def _safe_metrics_increment(key: str) -> None:
75
+ try:
76
+ from app import get_metrics_service
77
+ get_metrics_service().incr(key)
78
+ except Exception:
79
+ return
80
+
81
+
82
+ def _safe_metrics_bucket(bucket: str, key: str) -> None:
83
+ try:
84
+ from app import get_metrics_service
85
+ get_metrics_service().incr_bucket(bucket, key)
86
+ except Exception:
87
+ return
88
+
89
+
90
+ def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
91
+ msgs = []
92
+ for m in history[-HISTORY_WINDOW:]:
93
+ if m.role in ("user", "assistant") and m.content:
94
+ msgs.append({"role": m.role, "content": m.content[:300]})
95
+ return msgs
96
+
97
+
98
+ def _get_llm(model: str, temperature: float, max_tokens: int):
99
+ settings = get_settings()
100
+ if model == "claude" and settings.anthropic_api_key:
101
+ from langchain_anthropic import ChatAnthropic
102
+ return ChatAnthropic(
103
+ model="claude-haiku-4-5-20251001",
104
+ temperature=temperature,
105
+ max_tokens=max_tokens,
106
+ anthropic_api_key=settings.anthropic_api_key,
107
+ )
108
+ from langchain_openai import ChatOpenAI
109
+ return ChatOpenAI(
110
+ model="gpt-4o-mini",
111
+ temperature=temperature,
112
+ max_tokens=max_tokens,
113
+ openai_api_key=settings.openai_api_key,
114
+ )
115
+
116
+
117
+ def _shared_build_primo_boolean_query(topic: str) -> str:
118
+ clean = _strip_resource_noise(topic)
119
+ if not clean:
120
+ clean = topic
121
+ clean = _normalize_whitespace(clean)
122
+ words = clean.split()
123
+ if len(words) <= 4:
124
+ return f'"{clean}"'
125
+ _BOOL_STOP = re.compile(
126
+ r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
127
+ r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
128
+ r"this|that|these|those|about|impact|role|effect|use|analysis|review|"
129
+ r"what|how|why|when|where|which|between|within|across|among|using|"
130
+ r"based|related|towards|toward|during|after|before|over|under)\b",
131
+ re.IGNORECASE,
132
+ )
133
+ parts = _BOOL_STOP.split(clean)
134
+ concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
135
+ if not concepts:
136
+ return f'"{clean}"'
137
+ if len(concepts) == 1:
138
+ return f'"{concepts[0]}"'
139
+ quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
140
+ return " AND ".join(quoted)
141
+
142
+
143
+ def _make_primo_boolean_query(context: SearchContextPayload) -> str:
144
+ topic = _title_case_topic(context.display_topic or context.topic)
145
+ topic = _strip_resource_noise(topic) or (context.topic or "library search")
146
+ return _shared_build_primo_boolean_query(topic)
147
+
148
+
149
+ def _shared_build_primo_discovery_url(
150
+ boolean_query: str,
151
+ resource_type: str = "articles",
152
+ peer_reviewed: bool = False,
153
+ open_access: bool = False,
154
+ year_from: Optional[str] = None,
155
+ year_to: Optional[str] = None,
156
+ ) -> str:
157
+ base = (
158
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search"
159
+ f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
160
+ f"&query=any,contains,{quote(boolean_query)}"
161
+ f"&lang=en&search_scope=MyInst_and_CI&sortby=rank&mode=advanced"
162
+ )
163
+ facets = []
164
+ if resource_type == "articles":
165
+ facets.append("facet_rtype,include,articles")
166
+ elif resource_type == "books":
167
+ facets.append("facet_rtype,include,books")
168
+ if peer_reviewed:
169
+ facets.append("facet_tlevel,include,peer_reviewed")
170
+ if open_access:
171
+ facets.append("facet_tlevel,include,online_resources")
172
+ if year_from or year_to:
173
+ yf = year_from or "0001"
174
+ yt = year_to or "9999"
175
+ facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
176
+ for facet in facets:
177
+ base += f"&multiFacets={quote(facet)}"
178
+ return base
179
+
180
+
181
+ def _shared_build_pubmed_url(
182
+ topic: str,
183
+ year_from: Optional[str] = None,
184
+ year_to: Optional[str] = None,
185
+ peer_reviewed: bool = False,
186
+ ) -> str:
187
+ clean = _strip_resource_noise(topic)
188
+ term = clean or topic
189
+ if peer_reviewed:
190
+ term = f"({term}) AND Journal Article[pt]"
191
+ url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
192
+ if year_from or year_to:
193
+ yf = year_from or "1900"
194
+ yt = year_to or str(CURRENT_YEAR)
195
+ url += f"&filter=datesearch.y_{yf}-{yt}"
196
+ return url
197
+
198
+
199
+ def _primo_clean_url(context: SearchContextPayload) -> str:
200
+ boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
201
+ return _shared_build_primo_discovery_url(
202
+ boolean_query,
203
+ resource_type=context.resource_type,
204
+ peer_reviewed=context.peer_reviewed,
205
+ open_access=context.open_access,
206
+ year_from=context.year_from,
207
+ year_to=context.year_to,
208
+ )
209
+
210
+
211
+ async def _grammar_refine_query(text: str, model: str) -> str:
212
+ settings = get_settings()
213
+ if not settings.openai_api_key and not settings.anthropic_api_key:
214
+ return _normalize_whitespace(text)
215
+ try:
216
+ llm = _get_llm(model, temperature=0, max_tokens=60)
217
+ response = await llm.ainvoke([
218
+ {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
219
+ {"role": "user", "content": text},
220
+ ])
221
+ refined = _normalize_whitespace(response.content)
222
+ return refined or _normalize_whitespace(text)
223
+ except Exception:
224
+ return _normalize_whitespace(text)
225
+
226
+
227
+ def _light_strip_retrieval_boilerplate(text: str) -> str:
228
+ cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
229
+ cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
230
+ cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
231
+ cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
232
+ cleaned = re.sub(
233
+ r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
234
+ r"research on|articles on|papers on|literature on|studies on|"
235
+ r"tell me about|i need|i want|can you find|help me find|"
236
+ r"i am looking for|i'm looking for|i need articles on|"
237
+ r"i want articles on|i need papers on|i want papers on)\s+",
238
+ "", cleaned, flags=re.IGNORECASE
239
+ )
240
+ cleaned = re.sub(
241
+ r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
242
+ r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
243
+ "", cleaned, flags=re.IGNORECASE
244
+ )
245
+ cleaned = re.sub(
246
+ r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
247
+ "", cleaned, flags=re.IGNORECASE
248
+ )
249
+ return re.sub(r"\s+", " ", cleaned).strip()
250
+
251
+
252
+ def _sanitize_llm_response(text: str) -> str:
253
+ if not text:
254
+ return text
255
+ for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
256
+ text = pattern.sub(replacement, text)
257
+ return text
258
+
259
+
260
+ def _sanitize_boolean_for_primo(boolean: str) -> str:
261
+ if not boolean:
262
+ return boolean
263
+ boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
264
+ boolean = re.sub(
265
+ r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
266
+ "", boolean, flags=re.IGNORECASE,
267
+ )
268
+ boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
269
+ boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
270
+ boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
271
+ return re.sub(r"\s+", " ", boolean).strip()
272
+
273
+
274
+ def _clean_database_keywords(boolean_query: str) -> str:
275
+ return re.sub(r"\s+", " ",
276
+ re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
277
+ boolean_query, flags=re.IGNORECASE)).strip()
278
+
279
+
280
+ def _find_staff_by_token(token: str) -> Optional[dict]:
281
+ token = (token or "").lower()
282
+ for staff in STAFF_DIRECTORY:
283
+ hay = (staff.get("full_name", "") + " " + staff.get("role", "")).lower()
284
+ if token in hay:
285
+ return staff
286
+ return None
287
+
288
+