nikeshn commited on
Commit
e81b9e4
Β·
verified Β·
1 Parent(s): 2658282

Update src/api/agent.py

Browse files
Files changed (1) hide show
  1. src/api/agent.py +40 -80
src/api/agent.py CHANGED
@@ -81,7 +81,7 @@ class SearchContextPayload(BaseModel):
81
  ai_tool_query: Optional[str] = None
82
  primo_boolean_query: Optional[str] = None
83
  created_at: float = Field(default_factory=time.time)
84
- # v3.7 β€” ambiguity gate for short/numeric/unclear topics
85
  clarification_needed: bool = False
86
  clarification_message: Optional[str] = None
87
 
@@ -170,7 +170,18 @@ HOURS_RE = re.compile(
170
  r"|habshan.*open|san.*open|main campus.*open|ramadan hours?|weekend hours?"
171
  r"|friday hours?|saturday hours?|sunday hours?|library.*schedule|schedule.*library"
172
  r"|what.*hours|till.*when|until.*when|what day.*open|which day.*open"
173
- r"|what day.*closed|which day.*closed)\b",
 
 
 
 
 
 
 
 
 
 
 
174
  re.IGNORECASE,
175
  )
176
  KU_CAMPUS_RE = re.compile(
@@ -1574,8 +1585,7 @@ def _light_strip_retrieval_boilerplate(text: str) -> str:
1574
  cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
1575
  cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
1576
  cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
1577
- # Strip search intent verbs β€” these are action signals, not topic content
1578
- # Pass 1: strip leading intent verb phrases (including compound forms)
1579
  cleaned = re.sub(
1580
  r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
1581
  r"research on|articles on|papers on|literature on|studies on|"
@@ -1591,7 +1601,7 @@ def _light_strip_retrieval_boilerplate(text: str) -> str:
1591
  r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
1592
  "", cleaned, flags=re.IGNORECASE
1593
  )
1594
- # Pass 3: strip dangling prepositions after intent removal
1595
  cleaned = re.sub(
1596
  r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
1597
  "", cleaned, flags=re.IGNORECASE
@@ -1699,7 +1709,6 @@ expanded form as OR synonyms inside parentheses. Examples:
1699
  ("RL" OR "reinforcement learning")
1700
  ("DL" OR "deep learning")
1701
  ("CNN" OR "convolutional neural network")
1702
- ("NLG" OR "natural language generation")
1703
  Apply this rule to ANY acronym in the query, not just these examples.
1704
 
1705
  BOOLEAN RULES β€” follow strictly:
@@ -1716,19 +1725,15 @@ BOOLEAN RULES β€” follow strictly:
1716
  EXAMPLES:
1717
  Input: "find articles on global warming and renewable energy"
1718
  β†’ boolean:("global warming" OR "climate change") AND ("renewable energy" OR "clean energy")
1719
- β†’ year_from:"", peer_reviewed:false
1720
 
1721
  Input: "find research on NLP"
1722
  β†’ boolean:("NLP" OR "natural language processing")
1723
- β†’ year_from:"", peer_reviewed:false
1724
 
1725
  Input: "get me papers on AI in healthcare"
1726
  β†’ boolean:("AI" OR "artificial intelligence") AND ("healthcare" OR "medicine" OR "clinical")
1727
- β†’ year_from:"", peer_reviewed:false
1728
 
1729
  Input: "search ML algorithms"
1730
  β†’ boolean:("ML" OR "machine learning") AND ("algorithms" OR "models" OR "techniques")
1731
- β†’ year_from:"", peer_reviewed:false
1732
 
1733
  Input: "peer reviewed articles last 5 years on machine learning healthcare"
1734
  β†’ boolean:("machine learning" OR "deep learning" OR "AI") AND ("healthcare" OR "medicine" OR "clinical")
@@ -1736,15 +1741,9 @@ Input: "peer reviewed articles last 5 years on machine learning healthcare"
1736
 
1737
  Input: "impact of global warming in Maldives"
1738
  β†’ boolean:("global warming" OR "climate change") AND ("Maldives" OR "sea level rise" OR "small island")
1739
- β†’ year_from:"", peer_reviewed:false
1740
 
1741
  Input: "water scarcity solutions in UAE"
1742
  β†’ boolean:("water scarcity" OR "water stress") AND ("UAE" OR "United Arab Emirates" OR "Gulf region")
1743
- β†’ year_from:"", peer_reviewed:false
1744
-
1745
- Input: "summarize shift in personalized genomic medicine from N-of-1 trials last 24 months"
1746
- β†’ boolean:("personalized genomic medicine" OR "precision medicine") AND ("N-of-1 trials" OR "scalable precision")
1747
- β†’ year_from:"2024", peer_reviewed:true
1748
 
1749
  Return ONLY valid JSON:
1750
  {{"corrected":"...","natural":"...","boolean":"(\"A\" OR \"B\") AND (\"C\" OR \"D\")","year_from":"","year_to":"","peer_reviewed":false,"open_access":false}}"""
@@ -1754,17 +1753,12 @@ async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
1754
  """
1755
  Stage 2 topic validator β€” runs after the regex ambiguity gate.
1756
  Catches gibberish/unrecognisable words that passed the gate.
1757
-
1758
- Returns:
1759
- (True, corrected_topic) β€” topic is recognisable; corrected if typo
1760
- (False, "") β€” topic is gibberish/unrecognisable
1761
-
1762
- Uses a tiny ~30-token LLM call. Fails open (returns True) on any exception
1763
- so a validator failure never blocks a legitimate search.
1764
  """
1765
  settings = get_settings()
1766
  if not settings.openai_api_key and not settings.anthropic_api_key:
1767
- return True, topic # No LLM available β€” fail open
1768
  try:
1769
  llm = _get_llm(model, temperature=0, max_tokens=40)
1770
  response = await llm.ainvoke([
@@ -1792,47 +1786,38 @@ async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
1792
  is_valid = bool(result.get("valid", True))
1793
  corrected = str(result.get("corrected") or topic).strip()
1794
  return is_valid, corrected
1795
- return True, topic # Malformed response β€” fail open
1796
  except Exception as exc:
1797
  logger.warning(f"_validate_topic failed: {exc} β€” failing open")
1798
- return True, topic # Always fail open so validator never blocks search
1799
 
1800
 
1801
  async def _build_search_plan(question: str, model: str) -> dict:
1802
  """
1803
- LLM-driven search plan β€” ported from app81.py and adapted for LibBee.
1804
- Returns: corrected, natural, boolean, database_query, year_from, year_to,
1805
- peer_reviewed, open_access.
 
1806
  Falls back to regex boolean if LLM fails.
1807
- Includes ambiguity gate: returns clarification_needed=True for numbers,
1808
- single characters, or topics too short to build a meaningful boolean.
1809
  """
1810
  settings = get_settings()
1811
  raw_query = _normalize_whitespace(question or "")
1812
  light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
1813
 
1814
- # ── Ambiguity gate ────────────────────────────────────────────────────────
1815
- # After stripping intent verbs and prepositions, check if what remains
1816
- # is meaningful enough to build a boolean query.
1817
  _stripped = light_query.strip()
1818
  _is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
1819
  _is_too_short = len(_stripped) < 2
1820
  _is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
1821
- # Check if remaining words are all numbers/single chars β€” no real content words
1822
  _content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
1823
  _no_content = len(_content_words) == 0 and len(_stripped) > 0
1824
 
1825
  if _is_pure_number or _is_too_short or _is_single_char or _no_content:
1826
  _display = _stripped or raw_query
1827
  return {
1828
- "corrected": raw_query,
1829
- "natural": light_query,
1830
- "boolean": "",
1831
- "database_query": "",
1832
- "year_from": "",
1833
- "year_to": "",
1834
- "peer_reviewed": False,
1835
- "open_access": False,
1836
  "clarification_needed": True,
1837
  "clarification_message": (
1838
  f"I want to make sure I search for the right thing. "
@@ -1842,25 +1827,14 @@ async def _build_search_plan(question: str, model: str) -> dict:
1842
  f"the better I can build your search."
1843
  ),
1844
  }
1845
- # ── End ambiguity gate ────────────────────────────────────────────────────
1846
-
1847
  # ── Stage 2: LLM topic validator ─────────────────────────────────────────
1848
- # Catches gibberish/unrecognisable words that passed the regex gate.
1849
- # Typos are silently corrected. Gibberish triggers clarification.
1850
- # Uses a tiny ~30-token LLM call β€” much cheaper than running full boolean
1851
- # builder on garbage input.
1852
  if settings.openai_api_key or settings.anthropic_api_key:
1853
  _valid, _corrected_topic = await _validate_topic(_stripped, model)
1854
  if not _valid:
1855
  return {
1856
- "corrected": raw_query,
1857
- "natural": light_query,
1858
- "boolean": "",
1859
- "database_query": "",
1860
- "year_from": "",
1861
- "year_to": "",
1862
- "peer_reviewed": False,
1863
- "open_access": False,
1864
  "clarification_needed": True,
1865
  "clarification_message": (
1866
  f"I couldn't recognise <strong>{_escape(_stripped)}</strong> as a research topic. "
@@ -1869,11 +1843,10 @@ async def _build_search_plan(question: str, model: str) -> dict:
1869
  f"<em>\"articles on renewable energy in UAE\"</em>."
1870
  ),
1871
  }
1872
- # Silently use corrected spelling for the rest of the pipeline
1873
  if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
1874
  logger.info(f"_validate_topic corrected: {_stripped!r} β†’ {_corrected_topic!r}")
1875
  light_query = _corrected_topic
1876
- # ── End stage 2 ───────────────────────────────────────────────────────────
1877
 
1878
  if settings.openai_api_key or settings.anthropic_api_key:
1879
  try:
@@ -1944,28 +1917,21 @@ async def _build_search_plan(question: str, model: str) -> dict:
1944
 
1945
 
1946
  async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
1947
- """
1948
- Build all query forms using _build_search_plan.
1949
- Updates context.ai_tool_query, primo_boolean_query, and if plan detects
1950
- year/peer_reviewed/open_access that aren't already set, applies them.
1951
- If _build_search_plan returns clarification_needed, sets it on context.
1952
- """
1953
  topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
1954
  q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
1955
 
1956
  plan = await _build_search_plan(q, model)
1957
 
1958
- # ── Clarification gate ────────────────────────────────────────────────────
1959
  if plan.get("clarification_needed"):
1960
  context.clarification_needed = True
1961
  context.clarification_message = plan.get("clarification_message", "")
1962
  return context
1963
- # ─────────────────────────────────────────────────────────────────────────
1964
 
1965
  context.ai_tool_query = plan["natural"]
1966
  context.primo_boolean_query = plan["boolean"]
1967
 
1968
- # Apply plan's filter detections only if context doesn't already have them set
1969
  if not context.year_from and plan.get("year_from"):
1970
  context.year_from = plan["year_from"]
1971
  if not context.year_to and plan.get("year_to"):
@@ -1983,11 +1949,8 @@ async def _prepare_queries(question: str, context: SearchContextPayload, model:
1983
 
1984
  async def _topic_intro(topic: str, model: str) -> str:
1985
  """
1986
- Generate a concise 3-sentence topic introduction for academic/medical queries.
1987
- ~180 tokens. No external API calls β€” LLM knowledge only.
1988
-
1989
- LibBee v3.7 approach: provide brief context, then hand off to PRIMO and
1990
- AI research platforms (Consensus, LeapSpace, Scopus AI) for deep research.
1991
  """
1992
  settings = get_settings()
1993
  if not settings.openai_api_key and not settings.anthropic_api_key:
@@ -3670,22 +3633,19 @@ async def _run_search_mode(
3670
  ) -> Tuple[str, List[dict], List[dict], str]:
3671
  """
3672
  Search mode β€” builds PRIMO link + 3-sentence topic intro + AI tools footer.
3673
- No external academic API calls (v3.7).
3674
- Includes clarification gate for ambiguous queries (numbers, single chars etc.)
3675
  """
3676
  context = await _prepare_queries(question, context, model, is_follow_up)
3677
 
3678
- # ── Clarification gate ────────────────────────────────────────────────────
3679
- # If the topic after stripping intent verbs is a number, single character,
3680
- # or too short to build a meaningful boolean, ask the user to clarify.
3681
  if context.clarification_needed and context.clarification_message:
3682
  return context.clarification_message, [], [], ""
3683
- # ─────────────────────────────────────────────────────────────────────────
3684
 
3685
  source_url = _primo_clean_url(context)
3686
  topic = _escape(context.display_topic or context.topic)
3687
 
3688
- # 3-sentence topic intro β€” lightweight LLM call, no external APIs
3689
  intro = await _topic_intro(context.display_topic or context.topic, model)
3690
 
3691
  answer = _search_trace_block(question, context)
 
81
  ai_tool_query: Optional[str] = None
82
  primo_boolean_query: Optional[str] = None
83
  created_at: float = Field(default_factory=time.time)
84
+ # v3.8 β€” ambiguity gate
85
  clarification_needed: bool = False
86
  clarification_message: Optional[str] = None
87
 
 
170
  r"|habshan.*open|san.*open|main campus.*open|ramadan hours?|weekend hours?"
171
  r"|friday hours?|saturday hours?|sunday hours?|library.*schedule|schedule.*library"
172
  r"|what.*hours|till.*when|until.*when|what day.*open|which day.*open"
173
+ r"|what day.*closed|which day.*closed"
174
+ r"|library\s+timing[s]?|timing[s]?\s+library|library\s+time[s]?"
175
+ r"|library\s+open\s+time|library\s+close\s+time"
176
+ r"|ku\s+library\s+open|ku\s+library\s+close|ku.*library.*timing"
177
+ r"|libra[ry]+\s+timing[s]?|libra[ry]+\s+time[s]?"
178
+ r"|what\s+are\s+the\s+timing[s]?|what.*timing[s]?"
179
+ r"|is\s+library\s+open|is\s+ku.*open|ku.*open.*now"
180
+ r"|open\s+time.*library|close\s+time.*library"
181
+ r"|operating\s+hours?|working\s+hours?.*library|library.*working\s+hours?"
182
+ r"|when\s+can\s+i.*library|library.*available\s+now"
183
+ r"|library\s+open$|^library\s+open|library\s+closed$|^library\s+closed"
184
+ r"|library.*open.*week|library.*close.*week)\b",
185
  re.IGNORECASE,
186
  )
187
  KU_CAMPUS_RE = re.compile(
 
1585
  cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
1586
  cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
1587
  cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
1588
+ # Pass 1: strip search intent verbs
 
1589
  cleaned = re.sub(
1590
  r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
1591
  r"research on|articles on|papers on|literature on|studies on|"
 
1601
  r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
1602
  "", cleaned, flags=re.IGNORECASE
1603
  )
1604
+ # Pass 3: strip dangling prepositions
1605
  cleaned = re.sub(
1606
  r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
1607
  "", cleaned, flags=re.IGNORECASE
 
1709
  ("RL" OR "reinforcement learning")
1710
  ("DL" OR "deep learning")
1711
  ("CNN" OR "convolutional neural network")
 
1712
  Apply this rule to ANY acronym in the query, not just these examples.
1713
 
1714
  BOOLEAN RULES β€” follow strictly:
 
1725
  EXAMPLES:
1726
  Input: "find articles on global warming and renewable energy"
1727
  β†’ boolean:("global warming" OR "climate change") AND ("renewable energy" OR "clean energy")
 
1728
 
1729
  Input: "find research on NLP"
1730
  β†’ boolean:("NLP" OR "natural language processing")
 
1731
 
1732
  Input: "get me papers on AI in healthcare"
1733
  β†’ boolean:("AI" OR "artificial intelligence") AND ("healthcare" OR "medicine" OR "clinical")
 
1734
 
1735
  Input: "search ML algorithms"
1736
  β†’ boolean:("ML" OR "machine learning") AND ("algorithms" OR "models" OR "techniques")
 
1737
 
1738
  Input: "peer reviewed articles last 5 years on machine learning healthcare"
1739
  β†’ boolean:("machine learning" OR "deep learning" OR "AI") AND ("healthcare" OR "medicine" OR "clinical")
 
1741
 
1742
  Input: "impact of global warming in Maldives"
1743
  β†’ boolean:("global warming" OR "climate change") AND ("Maldives" OR "sea level rise" OR "small island")
 
1744
 
1745
  Input: "water scarcity solutions in UAE"
1746
  β†’ boolean:("water scarcity" OR "water stress") AND ("UAE" OR "United Arab Emirates" OR "Gulf region")
 
 
 
 
 
1747
 
1748
  Return ONLY valid JSON:
1749
  {{"corrected":"...","natural":"...","boolean":"(\"A\" OR \"B\") AND (\"C\" OR \"D\")","year_from":"","year_to":"","peer_reviewed":false,"open_access":false}}"""
 
1753
  """
1754
  Stage 2 topic validator β€” runs after the regex ambiguity gate.
1755
  Catches gibberish/unrecognisable words that passed the gate.
1756
+ Returns (True, corrected_topic) or (False, "").
1757
+ Fails open on any exception so it never blocks a legitimate search.
 
 
 
 
 
1758
  """
1759
  settings = get_settings()
1760
  if not settings.openai_api_key and not settings.anthropic_api_key:
1761
+ return True, topic
1762
  try:
1763
  llm = _get_llm(model, temperature=0, max_tokens=40)
1764
  response = await llm.ainvoke([
 
1786
  is_valid = bool(result.get("valid", True))
1787
  corrected = str(result.get("corrected") or topic).strip()
1788
  return is_valid, corrected
1789
+ return True, topic
1790
  except Exception as exc:
1791
  logger.warning(f"_validate_topic failed: {exc} β€” failing open")
1792
+ return True, topic
1793
 
1794
 
1795
  async def _build_search_plan(question: str, model: str) -> dict:
1796
  """
1797
+ LLM-driven search plan.
1798
+ Includes:
1799
+ - Ambiguity gate (numbers, single chars, no content words)
1800
+ - Stage 2 LLM topic validator (gibberish detection + typo correction)
1801
  Falls back to regex boolean if LLM fails.
 
 
1802
  """
1803
  settings = get_settings()
1804
  raw_query = _normalize_whitespace(question or "")
1805
  light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
1806
 
1807
+ # ── Stage 1: Regex ambiguity gate ────────────────────────────────────────
 
 
1808
  _stripped = light_query.strip()
1809
  _is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
1810
  _is_too_short = len(_stripped) < 2
1811
  _is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
 
1812
  _content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
1813
  _no_content = len(_content_words) == 0 and len(_stripped) > 0
1814
 
1815
  if _is_pure_number or _is_too_short or _is_single_char or _no_content:
1816
  _display = _stripped or raw_query
1817
  return {
1818
+ "corrected": raw_query, "natural": light_query, "boolean": "",
1819
+ "database_query": "", "year_from": "", "year_to": "",
1820
+ "peer_reviewed": False, "open_access": False,
 
 
 
 
 
1821
  "clarification_needed": True,
1822
  "clarification_message": (
1823
  f"I want to make sure I search for the right thing. "
 
1827
  f"the better I can build your search."
1828
  ),
1829
  }
 
 
1830
  # ── Stage 2: LLM topic validator ─────────────────────────────────────────
 
 
 
 
1831
  if settings.openai_api_key or settings.anthropic_api_key:
1832
  _valid, _corrected_topic = await _validate_topic(_stripped, model)
1833
  if not _valid:
1834
  return {
1835
+ "corrected": raw_query, "natural": light_query, "boolean": "",
1836
+ "database_query": "", "year_from": "", "year_to": "",
1837
+ "peer_reviewed": False, "open_access": False,
 
 
 
 
 
1838
  "clarification_needed": True,
1839
  "clarification_message": (
1840
  f"I couldn't recognise <strong>{_escape(_stripped)}</strong> as a research topic. "
 
1843
  f"<em>\"articles on renewable energy in UAE\"</em>."
1844
  ),
1845
  }
 
1846
  if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
1847
  logger.info(f"_validate_topic corrected: {_stripped!r} β†’ {_corrected_topic!r}")
1848
  light_query = _corrected_topic
1849
+ # ─────────────────────────────────────────────────────────────────────────
1850
 
1851
  if settings.openai_api_key or settings.anthropic_api_key:
1852
  try:
 
1917
 
1918
 
1919
  async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
1920
+ """Build all query forms using _build_search_plan."""
 
 
 
 
 
1921
  topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
1922
  q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
1923
 
1924
  plan = await _build_search_plan(q, model)
1925
 
1926
+ # Clarification gate
1927
  if plan.get("clarification_needed"):
1928
  context.clarification_needed = True
1929
  context.clarification_message = plan.get("clarification_message", "")
1930
  return context
 
1931
 
1932
  context.ai_tool_query = plan["natural"]
1933
  context.primo_boolean_query = plan["boolean"]
1934
 
 
1935
  if not context.year_from and plan.get("year_from"):
1936
  context.year_from = plan["year_from"]
1937
  if not context.year_to and plan.get("year_to"):
 
1949
 
1950
  async def _topic_intro(topic: str, model: str) -> str:
1951
  """
1952
+ 3-sentence topic intro β€” lightweight LLM call (~180 tokens, no external APIs).
1953
+ Prepended to all academic/medical search responses (v3.8).
 
 
 
1954
  """
1955
  settings = get_settings()
1956
  if not settings.openai_api_key and not settings.anthropic_api_key:
 
3633
  ) -> Tuple[str, List[dict], List[dict], str]:
3634
  """
3635
  Search mode β€” builds PRIMO link + 3-sentence topic intro + AI tools footer.
3636
+ No external academic API calls (v3.8).
3637
+ Includes clarification gate for ambiguous/gibberish queries.
3638
  """
3639
  context = await _prepare_queries(question, context, model, is_follow_up)
3640
 
3641
+ # Clarification gate
 
 
3642
  if context.clarification_needed and context.clarification_message:
3643
  return context.clarification_message, [], [], ""
 
3644
 
3645
  source_url = _primo_clean_url(context)
3646
  topic = _escape(context.display_topic or context.topic)
3647
 
3648
+ # 3-sentence topic intro
3649
  intro = await _topic_intro(context.display_topic or context.topic, model)
3650
 
3651
  answer = _search_trace_block(question, context)