Update src/api/agent.py
Browse files- src/api/agent.py +111 -8
src/api/agent.py
CHANGED
|
@@ -705,16 +705,117 @@ async def _extract_topic(question: str, model: str) -> str:
|
|
| 705 |
# They are now self-contained here.
|
| 706 |
|
| 707 |
def _shared_build_primo_boolean_query(topic: str) -> str:
|
| 708 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
clean = _strip_resource_noise(topic)
|
| 710 |
if not clean:
|
| 711 |
clean = topic
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
|
| 720 |
def _shared_build_primo_discovery_url(
|
|
@@ -870,7 +971,9 @@ async def _prepare_queries(question: str, context: SearchContextPayload, model:
|
|
| 870 |
natural = _compose_ai_tool_query(question, context, is_follow_up)
|
| 871 |
natural = await _grammar_refine_query(natural, model)
|
| 872 |
context.ai_tool_query = natural
|
| 873 |
-
|
|
|
|
|
|
|
| 874 |
return context
|
| 875 |
|
| 876 |
|
|
|
|
| 705 |
# They are now self-contained here.
|
| 706 |
|
| 707 |
def _shared_build_primo_boolean_query(topic: str) -> str:
|
| 708 |
+
"""
|
| 709 |
+
Regex fallback for boolean query construction β used only when LLM is unavailable.
|
| 710 |
+
Keeps multi-word phrases quoted, strips stop words, ANDs remaining concepts.
|
| 711 |
+
Does NOT split known compound terms.
|
| 712 |
+
"""
|
| 713 |
clean = _strip_resource_noise(topic)
|
| 714 |
if not clean:
|
| 715 |
clean = topic
|
| 716 |
+
clean = _normalize_whitespace(clean)
|
| 717 |
+
|
| 718 |
+
# Treat the whole cleaned topic as a single phrase if β€ 4 words
|
| 719 |
+
words = clean.split()
|
| 720 |
+
if len(words) <= 4:
|
| 721 |
+
return f'"{clean}"'
|
| 722 |
+
|
| 723 |
+
# For longer topics: split on stop words, quote resulting multi-word chunks
|
| 724 |
+
_BOOL_STOP = re.compile(
|
| 725 |
+
r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
|
| 726 |
+
r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
|
| 727 |
+
r"this|that|these|those|about|impact|role|effect|use|analysis|review|"
|
| 728 |
+
r"what|how|why|when|where|which|between|within|across|among|using|"
|
| 729 |
+
r"based|related|towards|toward|during|after|before|over|under)\b",
|
| 730 |
+
re.IGNORECASE,
|
| 731 |
+
)
|
| 732 |
+
# Split on stop words, keep non-stop chunks
|
| 733 |
+
parts = _BOOL_STOP.split(clean)
|
| 734 |
+
concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
|
| 735 |
+
if not concepts:
|
| 736 |
+
return f'"{clean}"'
|
| 737 |
+
if len(concepts) == 1:
|
| 738 |
+
return f'"{concepts[0]}"'
|
| 739 |
+
# Quote multi-word concepts, leave single words bare
|
| 740 |
+
quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
|
| 741 |
+
return " AND ".join(quoted)
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
_BOOLEAN_SYSTEM = """You are an expert academic database search specialist building PRIMO/ExLibris boolean queries.
|
| 745 |
+
|
| 746 |
+
Given a research topic or question, output ONLY a valid boolean search string.
|
| 747 |
+
|
| 748 |
+
RULES:
|
| 749 |
+
1. Identify 2-4 key CONCEPTS in the query.
|
| 750 |
+
2. Multi-word concepts MUST be quoted: "machine learning" not machine AND learning.
|
| 751 |
+
3. Use OR between synonyms of the same concept: ("machine learning" OR "deep learning" OR "neural networks")
|
| 752 |
+
4. Use AND to connect different concepts: ("climate change" OR "global warming") AND ("renewable energy" OR "clean energy")
|
| 753 |
+
5. NEVER split a natural phrase with AND: "global warming" stays "global warming", NOT global AND warming.
|
| 754 |
+
6. NEVER include stop words (of, the, a, in, on, for, to, with, by, impact, role, effect, use, study, analysis, review) as standalone terms.
|
| 755 |
+
7. For medical/clinical topics, include MeSH-style synonyms.
|
| 756 |
+
8. Keep to a maximum of 3 concept groups joined by AND.
|
| 757 |
+
9. Return ONLY the boolean string. No explanation, no preamble, no markdown.
|
| 758 |
+
|
| 759 |
+
EXAMPLES:
|
| 760 |
+
Input: global warming
|
| 761 |
+
Output: ("global warming" OR "climate change" OR "greenhouse effect")
|
| 762 |
+
|
| 763 |
+
Input: machine learning in healthcare
|
| 764 |
+
Output: ("machine learning" OR "deep learning" OR "artificial intelligence") AND ("healthcare" OR "medicine" OR "clinical")
|
| 765 |
+
|
| 766 |
+
Input: impact of social media on mental health
|
| 767 |
+
Output: ("social media" OR "social networks") AND ("mental health" OR "psychological wellbeing" OR "depression")
|
| 768 |
+
|
| 769 |
+
Input: structural engineering UAE
|
| 770 |
+
Output: ("structural engineering" OR "structural analysis") AND ("UAE" OR "United Arab Emirates" OR "Gulf")
|
| 771 |
+
|
| 772 |
+
Input: CRISPR gene editing applications
|
| 773 |
+
Output: ("CRISPR" OR "CRISPR-Cas9" OR "gene editing") AND ("applications" OR "therapeutic" OR "treatment")
|
| 774 |
+
|
| 775 |
+
Input: renewable energy policy
|
| 776 |
+
Output: ("renewable energy" OR "clean energy" OR "sustainable energy") AND ("policy" OR "regulation" OR "governance")
|
| 777 |
+
"""
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
async def _llm_build_boolean_query(topic: str, model: str) -> str:
|
| 781 |
+
"""
|
| 782 |
+
Use LLM to generate a high-quality PRIMO boolean query with:
|
| 783 |
+
- Quoted multi-word phrases
|
| 784 |
+
- OR synonyms within concept groups
|
| 785 |
+
- AND between distinct concepts
|
| 786 |
+
Falls back to regex version if LLM fails.
|
| 787 |
+
"""
|
| 788 |
+
settings = get_settings()
|
| 789 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 790 |
+
return _shared_build_primo_boolean_query(topic)
|
| 791 |
+
try:
|
| 792 |
+
llm = _get_llm(model, temperature=0, max_tokens=120)
|
| 793 |
+
response = await llm.ainvoke([
|
| 794 |
+
{"role": "system", "content": _BOOLEAN_SYSTEM},
|
| 795 |
+
{"role": "user", "content": f"Input: {topic.strip()}"},
|
| 796 |
+
])
|
| 797 |
+
result = response.content.strip()
|
| 798 |
+
# Sanity checks β must contain quotes and AND/OR or be a short phrase
|
| 799 |
+
if not result or len(result) > 500:
|
| 800 |
+
raise ValueError("Bad LLM boolean output")
|
| 801 |
+
# Strip any markdown the LLM snuck in
|
| 802 |
+
result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
|
| 803 |
+
# Must have at least one quote or look like a valid query
|
| 804 |
+
if '"' not in result and ' AND ' not in result and ' OR ' not in result:
|
| 805 |
+
# Wrap bare output as a phrase
|
| 806 |
+
result = f'"{result}"'
|
| 807 |
+
logger.info(f"LLM boolean: {result!r} β topic: {topic!r}")
|
| 808 |
+
return result
|
| 809 |
+
except Exception as e:
|
| 810 |
+
logger.warning(f"LLM boolean query failed: {e} β using regex fallback")
|
| 811 |
+
return _shared_build_primo_boolean_query(topic)
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
def _make_primo_boolean_query(context: SearchContextPayload) -> str:
|
| 815 |
+
"""Sync wrapper β returns regex boolean. Async LLM version called from _prepare_queries."""
|
| 816 |
+
topic = _title_case_topic(context.display_topic or context.topic)
|
| 817 |
+
topic = _strip_resource_noise(topic) or (context.topic or "library search")
|
| 818 |
+
return _shared_build_primo_boolean_query(topic)
|
| 819 |
|
| 820 |
|
| 821 |
def _shared_build_primo_discovery_url(
|
|
|
|
| 971 |
natural = _compose_ai_tool_query(question, context, is_follow_up)
|
| 972 |
natural = await _grammar_refine_query(natural, model)
|
| 973 |
context.ai_tool_query = natural
|
| 974 |
+
# Use LLM to build a high-quality boolean query with quoted phrases and OR synonyms
|
| 975 |
+
topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
|
| 976 |
+
context.primo_boolean_query = await _llm_build_boolean_query(topic, model)
|
| 977 |
return context
|
| 978 |
|
| 979 |
|