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