Spaces:
Sleeping
Sleeping
Upload 40 files
Browse files- .dockerignore +18 -0
- .gitattributes +1 -0
- .gitignore +9 -0
- Dockerfile +24 -0
- README.md +17 -5
- backend/__init__.py +1 -0
- backend/chat_settings.py +167 -0
- backend/mcp_client.py +68 -0
- backend/mcp_sync.py +85 -0
- backend/model.py +13 -0
- backend/paper_linking.py +199 -0
- backend/paper_voice.py +65 -0
- backend/waterscope_agent.py +114 -0
- local_rag/__init__.py +5 -0
- local_rag/config.py +23 -0
- local_rag/summaries.py +129 -0
- local_rag_data/manifest.json +0 -0
- local_rag_data/saved_papers.json +1 -0
- mcp_tools/__init__.py +1 -0
- mcp_tools/local_rag_tools.py +37 -0
- mcp_tools/metadata_tools.py +25 -0
- metadata_search/__init__.py +12 -0
- metadata_search/agent_orchestrator.py +339 -0
- metadata_search/app.py +319 -0
- metadata_search/bm25_search.py +64 -0
- metadata_search/boolean_query.py +345 -0
- metadata_search/chat_api.py +85 -0
- metadata_search/config.py +34 -0
- metadata_search/corpus.py +101 -0
- metadata_search/data/papers.csv +3 -0
- metadata_search/embedder.py +48 -0
- metadata_search/hybrid_search.py +46 -0
- metadata_search/mcp_retrieval.py +76 -0
- metadata_search/paper_figures.py +224 -0
- metadata_search/qdrant_metadata.py +237 -0
- metadata_search/saved_papers.py +144 -0
- metadata_search/search_query_planner.py +113 -0
- metadata_search/search_service.py +316 -0
- requirements.txt +16 -0
- retrieval_tools.py +53 -0
- waterscope_retrieval_server.py +52 -0
.dockerignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.env
|
| 4 |
+
.env.*
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.log
|
| 8 |
+
.DS_Store
|
| 9 |
+
metadata_search_data/
|
| 10 |
+
paper_summaries/
|
| 11 |
+
PDFs/
|
| 12 |
+
Figures/
|
| 13 |
+
Summary/
|
| 14 |
+
CSS/
|
| 15 |
+
JS/
|
| 16 |
+
Assets/
|
| 17 |
+
Data/
|
| 18 |
+
*.html
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
metadata_search/data/papers.csv filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.*
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
*.log
|
| 6 |
+
.DS_Store
|
| 7 |
+
metadata_search_data/
|
| 8 |
+
paper_summaries/
|
| 9 |
+
runtime_data/
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PORT=7860 \
|
| 6 |
+
HOST=0.0.0.0 \
|
| 7 |
+
WATERSCOPE_CORS=1 \
|
| 8 |
+
WATERSCOPE_CORS_ORIGIN=* \
|
| 9 |
+
METADATA_AUTO_REBUILD=0 \
|
| 10 |
+
METADATA_SEMANTIC_SEARCH=1 \
|
| 11 |
+
USE_MCP=0 \
|
| 12 |
+
WATERSCOPE_SPACE_ID=EnvironmentalAI/WaterScopeAI
|
| 13 |
+
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
COPY requirements.txt /app/requirements.txt
|
| 17 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 18 |
+
&& pip install --no-cache-dir -r /app/requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . /app
|
| 21 |
+
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
CMD ["python", "-m", "metadata_search.app"]
|
README.md
CHANGED
|
@@ -1,11 +1,23 @@
|
|
| 1 |
---
|
| 2 |
title: WaterScopeAI Search
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: WaterScopeAI Search
|
| 3 |
+
emoji: 🔎
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# WaterScopeAI Search System
|
| 12 |
+
|
| 13 |
+
CPU-based hybrid publication search, retrieval, and MCP backend for WaterScope-AI.
|
| 14 |
+
|
| 15 |
+
This Space is intentionally separated from the GPU model Space (`EnvironmentalAI/WaterScopeAI`). Large corpus assets and the canonical prebuilt Qdrant index will be connected from a Hugging Face Dataset repository in the next deployment phase.
|
| 16 |
+
|
| 17 |
+
## Initial deployment test
|
| 18 |
+
|
| 19 |
+
- `/` — service information
|
| 20 |
+
- `/api/health` — backend/resource health
|
| 21 |
+
- `/api/search?q=activated+sludge&top_k=10` — publication search
|
| 22 |
+
|
| 23 |
+
The first package includes `papers.csv` and the paper manifest so BM25 search can be tested before the prebuilt Qdrant index and document assets are connected. `METADATA_AUTO_REBUILD=0` prevents the CPU Space from rebuilding the vector index automatically; if no prebuilt Qdrant index is present, search falls back to BM25.
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/chat_settings.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
MAX_CHAT_PAPERS = int(os.getenv("MAX_CHAT_PAPERS", "4"))
|
| 7 |
+
CHAT_SUMMARY_MAX_CHARS = int(os.getenv("CHAT_SUMMARY_MAX_CHARS", "14000"))
|
| 8 |
+
|
| 9 |
+
_SECTION_HEADING = re.compile(r"^##\s+\d+\.", re.MULTILINE)
|
| 10 |
+
_QUESTION_STOPWORDS = frozenset(
|
| 11 |
+
{
|
| 12 |
+
"about",
|
| 13 |
+
"also",
|
| 14 |
+
"based",
|
| 15 |
+
"compare",
|
| 16 |
+
"could",
|
| 17 |
+
"does",
|
| 18 |
+
"first",
|
| 19 |
+
"from",
|
| 20 |
+
"have",
|
| 21 |
+
"paper",
|
| 22 |
+
"papers",
|
| 23 |
+
"please",
|
| 24 |
+
"provide",
|
| 25 |
+
"range",
|
| 26 |
+
"second",
|
| 27 |
+
"source",
|
| 28 |
+
"summarize",
|
| 29 |
+
"summary",
|
| 30 |
+
"that",
|
| 31 |
+
"the",
|
| 32 |
+
"their",
|
| 33 |
+
"these",
|
| 34 |
+
"third",
|
| 35 |
+
"this",
|
| 36 |
+
"those",
|
| 37 |
+
"was",
|
| 38 |
+
"were",
|
| 39 |
+
"what",
|
| 40 |
+
"when",
|
| 41 |
+
"which",
|
| 42 |
+
"would",
|
| 43 |
+
"your",
|
| 44 |
+
}
|
| 45 |
+
)
|
| 46 |
+
_ORDINAL_PAPER = re.compile(
|
| 47 |
+
r"\b(?P<ordinal>first|1st|second|2nd|third|3rd|fourth|4th)\s+paper\b",
|
| 48 |
+
re.I,
|
| 49 |
+
)
|
| 50 |
+
_ORDINAL_INDEX = {
|
| 51 |
+
"first": 0,
|
| 52 |
+
"1st": 0,
|
| 53 |
+
"second": 1,
|
| 54 |
+
"2nd": 1,
|
| 55 |
+
"third": 2,
|
| 56 |
+
"3rd": 2,
|
| 57 |
+
"fourth": 3,
|
| 58 |
+
"4th": 3,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def focus_document_ids_for_question(message: str, document_ids: list[str]) -> list[str]:
|
| 63 |
+
"""When the user names a specific paper, answer from that paper only."""
|
| 64 |
+
if len(document_ids) <= 1:
|
| 65 |
+
return document_ids
|
| 66 |
+
match = _ORDINAL_PAPER.search(message or "")
|
| 67 |
+
if not match:
|
| 68 |
+
return document_ids
|
| 69 |
+
index = _ORDINAL_INDEX.get(match.group("ordinal").lower())
|
| 70 |
+
if index is None or index >= len(document_ids):
|
| 71 |
+
return document_ids
|
| 72 |
+
return [document_ids[index]]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def enhance_ordinal_question(message: str, sources: list[dict]) -> str:
|
| 76 |
+
"""When the user says 'second paper', inject that paper's title into the prompt."""
|
| 77 |
+
if not _ORDINAL_PAPER.search(message or "") or len(sources) != 1:
|
| 78 |
+
return message
|
| 79 |
+
title = (sources[0].get("title") or "").strip()
|
| 80 |
+
if not title:
|
| 81 |
+
return message
|
| 82 |
+
return f"{message}\n\nAnswer specifically about the paper titled: {title}"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _question_terms(question: str) -> list[str]:
|
| 86 |
+
terms: list[str] = []
|
| 87 |
+
lowered = (question or "").lower()
|
| 88 |
+
if "ph" in lowered:
|
| 89 |
+
terms.append("ph")
|
| 90 |
+
for token in re.findall(r"[a-z0-9]+", lowered):
|
| 91 |
+
if token in _QUESTION_STOPWORDS or len(token) < 3:
|
| 92 |
+
continue
|
| 93 |
+
terms.append(token)
|
| 94 |
+
return list(dict.fromkeys(terms))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _extract_section(text: str, section_number: int, section_label: str) -> str:
|
| 98 |
+
pattern = re.compile(
|
| 99 |
+
rf"^##\s*{section_number}\.\s*{re.escape(section_label)}.*?(?=^##\s+\d+\.|\Z)",
|
| 100 |
+
re.MULTILINE | re.DOTALL | re.IGNORECASE,
|
| 101 |
+
)
|
| 102 |
+
match = pattern.search(text)
|
| 103 |
+
return match.group(0).strip() if match else ""
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _matching_lines(text: str, terms: list[str]) -> str:
|
| 107 |
+
if not terms:
|
| 108 |
+
return ""
|
| 109 |
+
lines: list[str] = []
|
| 110 |
+
for line in text.splitlines():
|
| 111 |
+
lowered = line.lower()
|
| 112 |
+
if any(term in lowered for term in terms):
|
| 113 |
+
lines.append(line.rstrip())
|
| 114 |
+
return "\n".join(lines).strip()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _fit_blocks(blocks: list[str], budget: int) -> str:
|
| 118 |
+
selected: list[str] = []
|
| 119 |
+
used = 0
|
| 120 |
+
for block in blocks:
|
| 121 |
+
block = block.strip()
|
| 122 |
+
if not block:
|
| 123 |
+
continue
|
| 124 |
+
extra = len(block) + (2 if selected else 0)
|
| 125 |
+
if used + extra > budget:
|
| 126 |
+
remaining = budget - used - (2 if selected else 0)
|
| 127 |
+
if remaining > 120:
|
| 128 |
+
selected.append(block[:remaining].rstrip() + "…")
|
| 129 |
+
break
|
| 130 |
+
selected.append(block)
|
| 131 |
+
used += extra
|
| 132 |
+
return "\n\n".join(selected)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def excerpt_summary(text: str, *, question: str = "", n_papers: int = 1) -> str:
|
| 136 |
+
"""Prefer identity + key results (+ question matches) instead of blind head truncation."""
|
| 137 |
+
per_paper = max(2500, CHAT_SUMMARY_MAX_CHARS // max(n_papers, 1))
|
| 138 |
+
if len(text) <= per_paper:
|
| 139 |
+
return text
|
| 140 |
+
|
| 141 |
+
blocks: list[str] = []
|
| 142 |
+
identity = _extract_section(text, 1, "Paper Identity")
|
| 143 |
+
key_results = _extract_section(text, 5, "Key Results")
|
| 144 |
+
takeaway = _extract_section(text, 10, "Concise Takeaway")
|
| 145 |
+
terms = _question_terms(question)
|
| 146 |
+
matched = _matching_lines(text, terms)
|
| 147 |
+
|
| 148 |
+
if identity:
|
| 149 |
+
blocks.append(identity)
|
| 150 |
+
if key_results:
|
| 151 |
+
blocks.append(key_results)
|
| 152 |
+
elif matched:
|
| 153 |
+
blocks.append(matched)
|
| 154 |
+
if takeaway and takeaway not in blocks:
|
| 155 |
+
blocks.append(takeaway)
|
| 156 |
+
if matched and matched not in blocks:
|
| 157 |
+
blocks.append(matched)
|
| 158 |
+
|
| 159 |
+
excerpt = _fit_blocks(blocks, per_paper)
|
| 160 |
+
if excerpt:
|
| 161 |
+
return excerpt
|
| 162 |
+
|
| 163 |
+
return text[:per_paper] + "\n\n[Additional sections omitted for length.]"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def truncate_summary(text: str, *, n_papers: int = 1, question: str = "") -> str:
|
| 167 |
+
return excerpt_summary(text, question=question, n_papers=n_papers)
|
backend/mcp_client.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent MCP client for the local paper retrieval server."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from mcp import ClientSession, StdioServerParameters
|
| 12 |
+
from mcp.client.stdio import stdio_client
|
| 13 |
+
|
| 14 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PaperMCPClient:
|
| 18 |
+
"""Keeps one MCP session open for retrieval tool calls."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, project_root: Path | None = None):
|
| 21 |
+
self.project_root = project_root or PROJECT_ROOT
|
| 22 |
+
self._stdio_ctx = None
|
| 23 |
+
self._session_ctx = None
|
| 24 |
+
self._session: ClientSession | None = None
|
| 25 |
+
|
| 26 |
+
async def connect(self) -> None:
|
| 27 |
+
if self._session is not None:
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
server_script = os.getenv("MCP_SERVER_SCRIPT", "waterscope_retrieval_server.py")
|
| 31 |
+
server_params = StdioServerParameters(
|
| 32 |
+
command=sys.executable,
|
| 33 |
+
args=[str(self.project_root / server_script)],
|
| 34 |
+
cwd=str(self.project_root),
|
| 35 |
+
env={**os.environ},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
self._stdio_ctx = stdio_client(server_params)
|
| 39 |
+
read, write = await self._stdio_ctx.__aenter__()
|
| 40 |
+
self._session_ctx = ClientSession(read, write)
|
| 41 |
+
self._session = await self._session_ctx.__aenter__()
|
| 42 |
+
await self._session.initialize()
|
| 43 |
+
|
| 44 |
+
async def close(self) -> None:
|
| 45 |
+
if self._session_ctx is not None:
|
| 46 |
+
await self._session_ctx.__aexit__(None, None, None)
|
| 47 |
+
self._session_ctx = None
|
| 48 |
+
self._session = None
|
| 49 |
+
if self._stdio_ctx is not None:
|
| 50 |
+
await self._stdio_ctx.__aexit__(None, None, None)
|
| 51 |
+
self._stdio_ctx = None
|
| 52 |
+
|
| 53 |
+
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 54 |
+
if self._session is None:
|
| 55 |
+
raise RuntimeError("MCP client is not connected.")
|
| 56 |
+
|
| 57 |
+
payload = {key: value for key, value in (arguments or {}).items() if value is not None}
|
| 58 |
+
result = await self._session.call_tool(name, payload)
|
| 59 |
+
|
| 60 |
+
if result.isError:
|
| 61 |
+
message = result.content[0].text if result.content else "Unknown MCP tool error"
|
| 62 |
+
raise RuntimeError(message)
|
| 63 |
+
|
| 64 |
+
if not result.content:
|
| 65 |
+
return {}
|
| 66 |
+
|
| 67 |
+
text = result.content[0].text
|
| 68 |
+
return json.loads(text)
|
backend/mcp_sync.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synchronous MCP client for Flask (stdio subprocess)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import atexit
|
| 7 |
+
import os
|
| 8 |
+
import threading
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from backend.mcp_client import PaperMCPClient
|
| 12 |
+
|
| 13 |
+
_client: PaperMCPClient | None = None
|
| 14 |
+
_connect_lock = threading.Lock()
|
| 15 |
+
_shutdown_registered = False
|
| 16 |
+
|
| 17 |
+
_loop: asyncio.AbstractEventLoop | None = None
|
| 18 |
+
_loop_thread: threading.Thread | None = None
|
| 19 |
+
_loop_ready = threading.Event()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def use_mcp() -> bool:
|
| 23 |
+
return os.getenv("USE_MCP", "1").lower() in {"1", "true", "yes"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _start_loop_thread() -> asyncio.AbstractEventLoop:
|
| 27 |
+
global _loop, _loop_thread
|
| 28 |
+
if _loop is not None and _loop.is_running():
|
| 29 |
+
return _loop
|
| 30 |
+
|
| 31 |
+
def _runner() -> None:
|
| 32 |
+
global _loop
|
| 33 |
+
loop = asyncio.new_event_loop()
|
| 34 |
+
asyncio.set_event_loop(loop)
|
| 35 |
+
_loop = loop
|
| 36 |
+
_loop_ready.set()
|
| 37 |
+
loop.run_forever()
|
| 38 |
+
|
| 39 |
+
_loop_ready.clear()
|
| 40 |
+
_loop_thread = threading.Thread(target=_runner, daemon=True, name="mcp-async-loop")
|
| 41 |
+
_loop_thread.start()
|
| 42 |
+
_loop_ready.wait()
|
| 43 |
+
if _loop is None:
|
| 44 |
+
raise RuntimeError("MCP event loop failed to start.")
|
| 45 |
+
return _loop
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _run_async(coro, *, timeout: float = 600):
|
| 49 |
+
loop = _start_loop_thread()
|
| 50 |
+
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
| 51 |
+
return future.result(timeout=timeout)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def ensure_mcp_connected() -> PaperMCPClient:
|
| 55 |
+
global _client, _shutdown_registered
|
| 56 |
+
if _client is not None and _client._session is not None:
|
| 57 |
+
return _client
|
| 58 |
+
with _connect_lock:
|
| 59 |
+
if _client is not None and _client._session is not None:
|
| 60 |
+
return _client
|
| 61 |
+
client = PaperMCPClient()
|
| 62 |
+
_run_async(client.connect())
|
| 63 |
+
_client = client
|
| 64 |
+
if not _shutdown_registered:
|
| 65 |
+
atexit.register(shutdown_mcp)
|
| 66 |
+
_shutdown_registered = True
|
| 67 |
+
return _client
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def shutdown_mcp() -> None:
|
| 71 |
+
global _client, _loop
|
| 72 |
+
if _client is None:
|
| 73 |
+
return
|
| 74 |
+
try:
|
| 75 |
+
_run_async(_client.close(), timeout=30)
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
_client = None
|
| 79 |
+
if _loop is not None and _loop.is_running():
|
| 80 |
+
_loop.call_soon_threadsafe(_loop.stop)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def call_mcp_tool(name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 84 |
+
client = ensure_mcp_connected()
|
| 85 |
+
return _run_async(client.call_tool(name, arguments))
|
backend/model.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WaterScope HF Space backend helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv(override=True)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def llm_backend_name() -> str:
|
| 13 |
+
return "waterscope"
|
backend/paper_linking.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
from local_rag.summaries import load_manifest, summary_path
|
| 7 |
+
|
| 8 |
+
_RP_SEGMENT_RE = re.compile(r";\s*")
|
| 9 |
+
_FILENAME_AUTHORS_RE = re.compile(r"^(.+?)\s*-\s*\d{4}\s*-\s*")
|
| 10 |
+
_ET_AL_RE = re.compile(r"\bet\s+al\b\.?", re.I)
|
| 11 |
+
|
| 12 |
+
_NON_AUTHOR_PREFIXES = (
|
| 13 |
+
"not specified",
|
| 14 |
+
"not explicitly",
|
| 15 |
+
"not listed",
|
| 16 |
+
"not available",
|
| 17 |
+
"not provided",
|
| 18 |
+
"unknown",
|
| 19 |
+
"co-author",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _is_non_author_text(value: str) -> bool:
|
| 24 |
+
text = (value or "").strip().lower()
|
| 25 |
+
|
| 26 |
+
if not text:
|
| 27 |
+
return True
|
| 28 |
+
|
| 29 |
+
if text in {"n/a", "na", "none"}:
|
| 30 |
+
return True
|
| 31 |
+
|
| 32 |
+
return text.startswith(_NON_AUTHOR_PREFIXES)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _surname_key(name: str) -> str:
|
| 36 |
+
"""Normalize an author label for surname-level deduplication."""
|
| 37 |
+
text = (name or "").strip()
|
| 38 |
+
if not text:
|
| 39 |
+
return ""
|
| 40 |
+
|
| 41 |
+
# For "... Surname et al.", always use the word immediately before "et al."
|
| 42 |
+
et_al_match = re.search(r"\bet\s+al\b\.?", text, flags=re.I)
|
| 43 |
+
if et_al_match:
|
| 44 |
+
before_et_al = text[:et_al_match.start()].strip()
|
| 45 |
+
parts = before_et_al.split()
|
| 46 |
+
if parts:
|
| 47 |
+
return parts[-1].strip(".,;:").lower()
|
| 48 |
+
|
| 49 |
+
# "Surname, Initials" format
|
| 50 |
+
if "," in text:
|
| 51 |
+
return text.split(",", 1)[0].strip().lower()
|
| 52 |
+
|
| 53 |
+
# Fallback
|
| 54 |
+
parts = text.split()
|
| 55 |
+
return parts[0].strip(".,;:").lower() if parts else ""
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def parse_authors_from_filename(file_name: str) -> list[str]:
|
| 59 |
+
"""Extract author names from 'Authors - YYYY - Title.ext' filenames."""
|
| 60 |
+
name = (file_name or "").strip()
|
| 61 |
+
if not name:
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
stem = name.rsplit(".", 1)[0]
|
| 65 |
+
match = _FILENAME_AUTHORS_RE.match(stem)
|
| 66 |
+
if not match:
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
author_part = match.group(1).strip()
|
| 70 |
+
if _ET_AL_RE.search(author_part):
|
| 71 |
+
return [author_part]
|
| 72 |
+
return [part.strip() for part in re.split(r"\s+and\s+", author_part, flags=re.I) if part.strip()]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def parse_authors_from_rp(rp: str) -> str:
|
| 76 |
+
"""Extract usable author names from RP and keep the more specific duplicate."""
|
| 77 |
+
text = (rp or "").strip()
|
| 78 |
+
if not text or _is_non_author_text(text):
|
| 79 |
+
return ""
|
| 80 |
+
|
| 81 |
+
names: list[str] = []
|
| 82 |
+
by_surname: dict[str, int] = {}
|
| 83 |
+
|
| 84 |
+
def _prefer(current: str, candidate: str) -> str:
|
| 85 |
+
# Prefer the more specific/longer representation
|
| 86 |
+
if len(candidate) > len(current):
|
| 87 |
+
return candidate
|
| 88 |
+
return current
|
| 89 |
+
|
| 90 |
+
for segment in _RP_SEGMENT_RE.split(text):
|
| 91 |
+
segment = segment.strip()
|
| 92 |
+
|
| 93 |
+
if not segment or _is_non_author_text(segment):
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
name = segment.split("(", 1)[0].strip().rstrip(",")
|
| 97 |
+
|
| 98 |
+
if not name or _is_non_author_text(name):
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
key = _surname_key(name)
|
| 102 |
+
if not key:
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
if key in by_surname:
|
| 106 |
+
idx = by_surname[key]
|
| 107 |
+
names[idx] = _prefer(names[idx], name)
|
| 108 |
+
else:
|
| 109 |
+
by_surname[key] = len(names)
|
| 110 |
+
names.append(name)
|
| 111 |
+
|
| 112 |
+
return "; ".join(names)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def resolve_authors(row: dict) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Resolve authors using the best available metadata source.
|
| 118 |
+
|
| 119 |
+
Priority:
|
| 120 |
+
1. AU - full author list from publication metadata
|
| 121 |
+
2. RP - author information parsed from full text
|
| 122 |
+
3. PDF/TXT filename - fallback only
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
# 1. Prefer the full author list from metadata
|
| 126 |
+
au = str(row.get("AU") or "").strip()
|
| 127 |
+
if au:
|
| 128 |
+
return au
|
| 129 |
+
|
| 130 |
+
# 2. Fall back to RP
|
| 131 |
+
rp_authors = parse_authors_from_rp(row.get("RP") or "").strip()
|
| 132 |
+
if rp_authors:
|
| 133 |
+
return rp_authors
|
| 134 |
+
|
| 135 |
+
# 3. Final fallback: filename
|
| 136 |
+
filename_authors = parse_authors_from_filename(
|
| 137 |
+
row.get("PDF_file") or row.get("TXT_file") or ""
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
return "; ".join(filename_authors)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def format_authors(authors: str, *, max_names: int | None = None) -> str:
|
| 144 |
+
"""Display string for UI cards. Default shows every parsed author."""
|
| 145 |
+
if not authors:
|
| 146 |
+
return ""
|
| 147 |
+
parts = [part.strip() for part in authors.split(";") if part.strip()]
|
| 148 |
+
if max_names is None or len(parts) <= max_names:
|
| 149 |
+
return "; ".join(parts)
|
| 150 |
+
return "; ".join(parts[:max_names]) + "; et al."
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def document_id_for_txt_file(txt_file: str) -> str | None:
|
| 154 |
+
name = (txt_file or "").strip()
|
| 155 |
+
if not name:
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
manifest = load_manifest()
|
| 159 |
+
files = manifest.get("files", [])
|
| 160 |
+
|
| 161 |
+
for entry in files:
|
| 162 |
+
if entry.get("file_name") == name:
|
| 163 |
+
return entry.get("document_id")
|
| 164 |
+
|
| 165 |
+
lowered = name.lower()
|
| 166 |
+
for entry in files:
|
| 167 |
+
file_name = entry.get("file_name", "")
|
| 168 |
+
if file_name.lower() == lowered:
|
| 169 |
+
return entry.get("document_id")
|
| 170 |
+
|
| 171 |
+
if not name.endswith(".txt"):
|
| 172 |
+
with_txt = f"{name}.txt"
|
| 173 |
+
for entry in files:
|
| 174 |
+
if entry.get("file_name") == with_txt:
|
| 175 |
+
return entry.get("document_id")
|
| 176 |
+
|
| 177 |
+
if summary_path(name) is not None:
|
| 178 |
+
stem = name[:-4] if name.lower().endswith(".txt") else name
|
| 179 |
+
return hashlib.sha1(stem.encode("utf-8")).hexdigest()[:16]
|
| 180 |
+
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def enrich_metadata_row(row: dict, row_index: int) -> dict:
|
| 185 |
+
txt_file = (row.get("TXT_file") or row.get("txt_file") or "").strip()
|
| 186 |
+
document_id = document_id_for_txt_file(txt_file)
|
| 187 |
+
authors = resolve_authors(row)
|
| 188 |
+
return {
|
| 189 |
+
"metadata_row_index": row_index,
|
| 190 |
+
"title": (row.get("TI") or "").strip(),
|
| 191 |
+
"abstract": (row.get("AB") or "").strip(),
|
| 192 |
+
"keywords": (row.get("DE") or "").strip(),
|
| 193 |
+
"authors": authors,
|
| 194 |
+
"doi": (row.get("DI") or "").strip(),
|
| 195 |
+
"year": (row.get("PY") or "").strip(),
|
| 196 |
+
"txt_file": txt_file,
|
| 197 |
+
"document_id": document_id,
|
| 198 |
+
"has_chat": document_id is not None,
|
| 199 |
+
}
|
backend/paper_voice.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared instructions so chat answers read as paper-grounded research, not summary meta-talk."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
PAPER_VOICE_RULES = """\
|
| 8 |
+
Answer as a research assistant who has read the selected papers.
|
| 9 |
+
- Write about the papers directly — never mention summaries, excerpts, snippets, "provided context", "Source 1/2", or "the text says".
|
| 10 |
+
- Do not use phrases like "according to the summary", "based on the provided material", or "the context indicates".
|
| 11 |
+
- Cite papers by **title** (and year when helpful), not by source number.
|
| 12 |
+
- If a detail is missing, say the paper does not report it — not that a summary omits it.
|
| 13 |
+
- Open with a direct answer; avoid meta-commentary about what you were given.\
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
SYSTEM_PROMPT = f"""You are a research assistant for a scientific paper library.
|
| 17 |
+
|
| 18 |
+
{PAPER_VOICE_RULES}
|
| 19 |
+
|
| 20 |
+
Use ONLY the paper material in the user message. Do not use outside knowledge or invent facts.
|
| 21 |
+
|
| 22 |
+
Formatting:
|
| 23 |
+
- Write in clear markdown.
|
| 24 |
+
- Use **bold** for paper titles and key terms on first mention.
|
| 25 |
+
- Use bullet or numbered lists when comparing papers or listing findings.
|
| 26 |
+
- Do not add a separate bibliography; the app lists cited papers separately.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
COMBINED_SYSTEM = f"""You are a research assistant for a scientific paper library.
|
| 30 |
+
|
| 31 |
+
{PAPER_VOICE_RULES}
|
| 32 |
+
|
| 33 |
+
Use ONLY the paper material in the user message. Do not use outside knowledge or invent facts.
|
| 34 |
+
- For comparisons, use a ### heading per paper.
|
| 35 |
+
- If papers address different problems, say so clearly instead of merging them.
|
| 36 |
+
- Write in clear markdown with a direct answer first when possible.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
_META_LEAK_RE = re.compile(
|
| 40 |
+
r"\b("
|
| 41 |
+
r"according to (?:the )?(?:paper )?summar(?:y|ies)|"
|
| 42 |
+
r"based on (?:the )?(?:paper )?summar(?:y|ies)|"
|
| 43 |
+
r"(?:the )?provided (?:context|summar(?:y|ies))|"
|
| 44 |
+
r"in (?:the )?summar(?:y|ies)|"
|
| 45 |
+
r"from (?:the )?(?:paper )?summar(?:y|ies)|"
|
| 46 |
+
r"the (?:context|excerpt|snippet) (?:indicates|suggests|states|mentions)|"
|
| 47 |
+
r"as (?:mentioned|stated|noted) in (?:the )?(?:summary|context|provided material)"
|
| 48 |
+
r")\b",
|
| 49 |
+
re.I,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def prefix_context(context: str) -> str:
|
| 54 |
+
if not context.strip():
|
| 55 |
+
return PAPER_VOICE_RULES
|
| 56 |
+
return f"{PAPER_VOICE_RULES}\n\n---\n\n{context}"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def sanitize_answer(text: str) -> str:
|
| 60 |
+
"""Light cleanup if the model still leaks summary/meta phrasing."""
|
| 61 |
+
cleaned = _META_LEAK_RE.sub("", text)
|
| 62 |
+
cleaned = re.sub(r"\bIn\s*,", "In this paper,", cleaned)
|
| 63 |
+
cleaned = re.sub(r" {2,}", " ", cleaned)
|
| 64 |
+
cleaned = re.sub(r" +\n", "\n", cleaned)
|
| 65 |
+
return cleaned.strip()
|
backend/waterscope_agent.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client for the WaterScopeAI Hugging Face Space /agent_run endpoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import concurrent.futures
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from gradio_client import Client
|
| 12 |
+
|
| 13 |
+
SPACE_ID = os.getenv("WATERSCOPE_SPACE_ID", "EnvironmentalAI/WaterScopeAI")
|
| 14 |
+
TIMEOUT_SEC = int(os.getenv("WATERSCOPE_TIMEOUT_SEC", "600"))
|
| 15 |
+
|
| 16 |
+
_SPACE_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
| 17 |
+
max_workers=2,
|
| 18 |
+
thread_name_prefix="waterscope-space",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
MODELS = ("base", "dpo", "da_dpo", "tulucore")
|
| 22 |
+
APPLICATIONS = (
|
| 23 |
+
"general_qa",
|
| 24 |
+
"document_qa",
|
| 25 |
+
"paper_synthesis",
|
| 26 |
+
"method_recommendation",
|
| 27 |
+
"research_gaps",
|
| 28 |
+
"claim_check",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
_SYNTHESIS = re.compile(r"\b(summarize|summary|overview|synthesize|main finding)\b", re.I)
|
| 32 |
+
_METHODS = re.compile(r"\b(method|approach|model|algorithm|recommend)\b", re.I)
|
| 33 |
+
_GAPS = re.compile(r"\b(gap|limitation|future work|open question|unclear)\b", re.I)
|
| 34 |
+
_CLAIM = re.compile(r"\b(does the paper|is it true|claim|support|evidence that)\b", re.I)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def infer_application(question: str, *, explicit: str | None = None) -> str:
|
| 38 |
+
if explicit and explicit != "auto":
|
| 39 |
+
if explicit not in APPLICATIONS:
|
| 40 |
+
raise ValueError(f"Unknown application {explicit!r}")
|
| 41 |
+
return explicit
|
| 42 |
+
text = question.lower()
|
| 43 |
+
if _SYNTHESIS.search(text):
|
| 44 |
+
return "paper_synthesis"
|
| 45 |
+
if _GAPS.search(text):
|
| 46 |
+
return "research_gaps"
|
| 47 |
+
if _CLAIM.search(text):
|
| 48 |
+
return "claim_check"
|
| 49 |
+
if _METHODS.search(text):
|
| 50 |
+
return "method_recommendation"
|
| 51 |
+
return "document_qa"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@lru_cache(maxsize=1)
|
| 55 |
+
def _client() -> Client:
|
| 56 |
+
token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN")
|
| 57 |
+
kwargs: dict[str, Any] = {"httpx_kwargs": {"timeout": TIMEOUT_SEC}}
|
| 58 |
+
if token:
|
| 59 |
+
kwargs["hf_token"] = token
|
| 60 |
+
return Client(SPACE_ID, **kwargs)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def run_agent(
|
| 64 |
+
*,
|
| 65 |
+
context: str,
|
| 66 |
+
question: str,
|
| 67 |
+
model: str | None = None,
|
| 68 |
+
application: str = "document_qa",
|
| 69 |
+
max_new_tokens: int | None = None,
|
| 70 |
+
) -> tuple[str, str]:
|
| 71 |
+
"""Call /agent_run. Returns (answer, diagnostics)."""
|
| 72 |
+
selected_model = model or os.getenv("WATERSCOPE_MODEL", "dpo")
|
| 73 |
+
if selected_model not in MODELS:
|
| 74 |
+
raise ValueError(f"Unknown model {selected_model!r}. Choose from: {MODELS}")
|
| 75 |
+
|
| 76 |
+
app_type = infer_application(question, explicit=application if application != "auto" else None)
|
| 77 |
+
tokens = max_new_tokens or int(os.getenv("WATERSCOPE_MAX_TOKENS", "1024"))
|
| 78 |
+
|
| 79 |
+
def _predict() -> tuple[str, str]:
|
| 80 |
+
response = _client().predict(
|
| 81 |
+
selected_model,
|
| 82 |
+
app_type,
|
| 83 |
+
context,
|
| 84 |
+
question,
|
| 85 |
+
tokens,
|
| 86 |
+
api_name="/agent_run",
|
| 87 |
+
)
|
| 88 |
+
answer = response[0] if response else ""
|
| 89 |
+
diagnostics = response[1] if len(response) > 1 else ""
|
| 90 |
+
return answer, diagnostics
|
| 91 |
+
|
| 92 |
+
# Run in a worker thread so Gradio asyncio does not clash with MCP's loop.
|
| 93 |
+
answer, diagnostics = _SPACE_EXECUTOR.submit(_predict).result(timeout=TIMEOUT_SEC)
|
| 94 |
+
return answer, diagnostics
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def agent_answer(
|
| 98 |
+
context: str,
|
| 99 |
+
question: str,
|
| 100 |
+
*,
|
| 101 |
+
application: str = "auto",
|
| 102 |
+
model: str | None = None,
|
| 103 |
+
max_new_tokens: int | None = None,
|
| 104 |
+
) -> tuple[str, str, str]:
|
| 105 |
+
"""Returns (answer, application_used, diagnostics)."""
|
| 106 |
+
app_type = infer_application(question, explicit=application if application != "auto" else None)
|
| 107 |
+
answer, diagnostics = run_agent(
|
| 108 |
+
context=context,
|
| 109 |
+
question=question,
|
| 110 |
+
model=model,
|
| 111 |
+
application=app_type,
|
| 112 |
+
max_new_tokens=max_new_tokens,
|
| 113 |
+
)
|
| 114 |
+
return answer, app_type, diagnostics
|
local_rag/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summary catalog helpers for paper-grounded chat."""
|
| 2 |
+
|
| 3 |
+
from local_rag.summaries import list_papers, summaries_for_documents
|
| 4 |
+
|
| 5 |
+
__all__ = ["list_papers", "summaries_for_documents"]
|
local_rag/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class LocalRagConfig:
|
| 14 |
+
data_dir: Path
|
| 15 |
+
|
| 16 |
+
@classmethod
|
| 17 |
+
def from_env(cls) -> "LocalRagConfig":
|
| 18 |
+
data_dir = Path(os.getenv("LOCAL_RAG_DATA_DIR", PROJECT_ROOT / "local_rag_data"))
|
| 19 |
+
return cls(data_dir=data_dir)
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def manifest_path(self) -> Path:
|
| 23 |
+
return self.data_dir / "manifest.json"
|
local_rag/summaries.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from local_rag.config import LocalRagConfig
|
| 9 |
+
|
| 10 |
+
TITLE_PATTERN = re.compile(r"\*\*Title:\*\*\s*(.+)", re.MULTILINE)
|
| 11 |
+
_FILENAME_TITLE = re.compile(r"^.+?\s-\s\d{4}\s-\s(.+)$")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def summary_search_dirs() -> list[Path]:
|
| 15 |
+
"""Directories to search for summary .txt files (first match wins)."""
|
| 16 |
+
dirs: list[Path] = []
|
| 17 |
+
for raw in (os.getenv("PAPER_SUMMARIES_DIRS") or "").split(":"):
|
| 18 |
+
raw = raw.strip()
|
| 19 |
+
if raw:
|
| 20 |
+
dirs.append(Path(raw).expanduser().resolve())
|
| 21 |
+
|
| 22 |
+
override = os.getenv("PAPER_SUMMARIES_DIR")
|
| 23 |
+
if override:
|
| 24 |
+
dirs.append(Path(override).expanduser().resolve())
|
| 25 |
+
|
| 26 |
+
project_root = Path(__file__).resolve().parents[1]
|
| 27 |
+
dirs.append(project_root / "paper_summaries")
|
| 28 |
+
|
| 29 |
+
unique: list[Path] = []
|
| 30 |
+
seen: set[str] = set()
|
| 31 |
+
for path in dirs:
|
| 32 |
+
key = str(path)
|
| 33 |
+
if key in seen:
|
| 34 |
+
continue
|
| 35 |
+
seen.add(key)
|
| 36 |
+
unique.append(path)
|
| 37 |
+
return unique
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def summaries_dir() -> Path:
|
| 41 |
+
for path in summary_search_dirs():
|
| 42 |
+
if path.exists():
|
| 43 |
+
return path
|
| 44 |
+
return Path(__file__).resolve().parents[1] / "paper_summaries"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _manifest_path(config: LocalRagConfig | None = None) -> Path:
|
| 48 |
+
cfg = config or LocalRagConfig.from_env()
|
| 49 |
+
return cfg.manifest_path
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def load_manifest(config: LocalRagConfig | None = None) -> dict:
|
| 53 |
+
path = _manifest_path(config)
|
| 54 |
+
if not path.exists():
|
| 55 |
+
raise FileNotFoundError(f"Manifest not found: {path}. Rebuild local_rag_data/manifest.json first.")
|
| 56 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def title_from_filename(file_name: str) -> str:
|
| 60 |
+
stem = Path(file_name).stem
|
| 61 |
+
match = _FILENAME_TITLE.match(stem)
|
| 62 |
+
if match:
|
| 63 |
+
return match.group(1).strip()
|
| 64 |
+
return stem
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def extract_title(file_name: str, summary: str | None = None) -> str:
|
| 68 |
+
if summary:
|
| 69 |
+
match = TITLE_PATTERN.search(summary)
|
| 70 |
+
if match:
|
| 71 |
+
return match.group(1).strip()
|
| 72 |
+
return title_from_filename(file_name)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def summary_path(file_name: str) -> Path | None:
|
| 76 |
+
for root in summary_search_dirs():
|
| 77 |
+
candidate = root / file_name
|
| 78 |
+
if candidate.exists():
|
| 79 |
+
return candidate
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def load_summary(file_name: str) -> str | None:
|
| 84 |
+
path = summary_path(file_name)
|
| 85 |
+
if path is None:
|
| 86 |
+
return None
|
| 87 |
+
return path.read_text(encoding="utf-8")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def list_papers(config: LocalRagConfig | None = None) -> list[dict]:
|
| 91 |
+
"""List indexed papers without reading every summary file into memory."""
|
| 92 |
+
manifest = load_manifest(config)
|
| 93 |
+
papers: list[dict] = []
|
| 94 |
+
for entry in manifest.get("files", []):
|
| 95 |
+
file_name = entry["file_name"]
|
| 96 |
+
papers.append(
|
| 97 |
+
{
|
| 98 |
+
"document_id": entry["document_id"],
|
| 99 |
+
"file_name": file_name,
|
| 100 |
+
"title": title_from_filename(file_name),
|
| 101 |
+
"chunk_count": entry.get("chunk_count", 0),
|
| 102 |
+
"has_summary": summary_path(file_name) is not None,
|
| 103 |
+
}
|
| 104 |
+
)
|
| 105 |
+
papers.sort(key=lambda item: item["title"].lower())
|
| 106 |
+
return papers
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def summaries_for_documents(document_ids: list[str], config: LocalRagConfig | None = None) -> list[dict]:
|
| 110 |
+
manifest = load_manifest(config)
|
| 111 |
+
by_id = {entry["document_id"]: entry for entry in manifest.get("files", [])}
|
| 112 |
+
selected: list[dict] = []
|
| 113 |
+
for document_id in document_ids:
|
| 114 |
+
entry = by_id.get(document_id)
|
| 115 |
+
if not entry:
|
| 116 |
+
continue
|
| 117 |
+
file_name = entry["file_name"]
|
| 118 |
+
summary = load_summary(file_name)
|
| 119 |
+
if not summary:
|
| 120 |
+
continue
|
| 121 |
+
selected.append(
|
| 122 |
+
{
|
| 123 |
+
"document_id": document_id,
|
| 124 |
+
"title": extract_title(file_name, summary),
|
| 125 |
+
"file_name": file_name,
|
| 126 |
+
"text": summary,
|
| 127 |
+
}
|
| 128 |
+
)
|
| 129 |
+
return selected
|
local_rag_data/manifest.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
local_rag_data/saved_papers.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
mcp_tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Shared tool implementations for WaterScope MCP servers."""
|
mcp_tools/local_rag_tools.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summary MCP tools for paper-grounded chat."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from backend.chat_settings import truncate_summary
|
| 8 |
+
from local_rag.summaries import summaries_for_documents
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_paper_summaries_tool(document_ids: list[str]) -> str:
|
| 12 |
+
return json.dumps({"sources": summaries_for_documents(document_ids)}, indent=2)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_paper_context_tool(document_ids: list[str], question: str = "") -> str:
|
| 16 |
+
sources = summaries_for_documents(document_ids)
|
| 17 |
+
if not sources:
|
| 18 |
+
return json.dumps({"error": "No summaries found for the selected papers."}, indent=2)
|
| 19 |
+
|
| 20 |
+
n_papers = len(sources)
|
| 21 |
+
parts: list[str] = []
|
| 22 |
+
for index, source in enumerate(sources, start=1):
|
| 23 |
+
parts.append(
|
| 24 |
+
f"Paper {index}: {source['title']}\n"
|
| 25 |
+
f"File: {source['file_name']}\n\n"
|
| 26 |
+
f"{truncate_summary(source['text'], n_papers=n_papers, question=question)}"
|
| 27 |
+
)
|
| 28 |
+
context = "\n\n---\n\n".join(parts)
|
| 29 |
+
return json.dumps(
|
| 30 |
+
{
|
| 31 |
+
"context": context,
|
| 32 |
+
"sources": [{"document_id": s["document_id"], "title": s["title"]} for s in sources],
|
| 33 |
+
"n_papers": n_papers,
|
| 34 |
+
"question": question,
|
| 35 |
+
},
|
| 36 |
+
indent=2,
|
| 37 |
+
)
|
mcp_tools/metadata_tools.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metadata search MCP tool implementations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from metadata_search.search_service import document_ids_from_ranks, search_metadata, search_score_mode
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def search_metadata_tool(query: str, top_k: int = 10) -> str:
|
| 11 |
+
results = search_metadata(query, top_k=top_k)
|
| 12 |
+
return json.dumps(
|
| 13 |
+
{"query": query, "results": results, "score_mode": search_score_mode()},
|
| 14 |
+
indent=2,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def select_papers_from_search_tool(query: str, ranks: list[int], top_k: int = 10) -> str:
|
| 19 |
+
results = search_metadata(query, top_k=top_k)
|
| 20 |
+
document_ids = document_ids_from_ranks(results, ranks)
|
| 21 |
+
selected = [item for item in results if int(item["rank"]) in ranks]
|
| 22 |
+
return json.dumps(
|
| 23 |
+
{"query": query, "document_ids": document_ids, "papers": selected},
|
| 24 |
+
indent=2,
|
| 25 |
+
)
|
metadata_search/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSV metadata search: field-weighted BM25 + Qdrant semantic + hybrid RRF."""
|
| 2 |
+
|
| 3 |
+
from metadata_search.bm25_search import BM25Index
|
| 4 |
+
from metadata_search.hybrid_search import weighted_rrf_scores
|
| 5 |
+
from metadata_search.qdrant_metadata import MetadataSearchHit, MetadataVectorStore
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"BM25Index",
|
| 9 |
+
"MetadataSearchHit",
|
| 10 |
+
"MetadataVectorStore",
|
| 11 |
+
"weighted_rrf_scores",
|
| 12 |
+
]
|
metadata_search/agent_orchestrator.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent mode: fixed MCP tool pipeline → HF Space answer."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from backend.mcp_sync import call_mcp_tool, use_mcp
|
| 11 |
+
from backend.model import llm_backend_name
|
| 12 |
+
from backend.waterscope_agent import infer_application
|
| 13 |
+
from metadata_search.mcp_retrieval import (
|
| 14 |
+
build_context_for_documents,
|
| 15 |
+
list_papers_catalog,
|
| 16 |
+
search_papers,
|
| 17 |
+
)
|
| 18 |
+
from metadata_search.boolean_query import focus_tokens
|
| 19 |
+
from metadata_search.search_query_planner import plan_search_query
|
| 20 |
+
from metadata_search.search_service import document_ids_from_ranks
|
| 21 |
+
|
| 22 |
+
MAX_AUTO_PAPERS = int(os.getenv("AGENT_MAX_PAPERS", "4"))
|
| 23 |
+
|
| 24 |
+
_DISCOVERY = re.compile(
|
| 25 |
+
r"\b(find|search|show|list|recommend|retrieve|get|papers?\s+(?:on|about|for|related))\b",
|
| 26 |
+
re.I,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _is_paper_discovery(message: str) -> bool:
|
| 31 |
+
return bool(_DISCOVERY.search(message))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _enhance_agent_question(message: str, n_papers: int) -> str:
|
| 35 |
+
if not _is_paper_discovery(message):
|
| 36 |
+
return message
|
| 37 |
+
return (
|
| 38 |
+
f"{message}\n\n"
|
| 39 |
+
f"You are given exactly {n_papers} papers (Paper 1 through Paper {n_papers}). "
|
| 40 |
+
"Write one short paragraph per paper in numeric order. "
|
| 41 |
+
"Every paper must appear — do not skip any. "
|
| 42 |
+
"Do not mention summaries or provided context; write as if discussing the papers directly."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _paper_line(row: dict[str, Any]) -> tuple[str, str, str]:
|
| 47 |
+
title = (row.get("title") or "Untitled").strip()
|
| 48 |
+
year = (row.get("year") or "").strip() or "n.d."
|
| 49 |
+
authors = (row.get("authors_display") or row.get("authors") or "").strip() or "Authors not listed"
|
| 50 |
+
return title, year, authors
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _abstract_snippet(row: dict[str, Any], *, limit: int = 220) -> str:
|
| 54 |
+
abstract = (row.get("full_abstract") or row.get("abstract") or "").strip()
|
| 55 |
+
if not abstract:
|
| 56 |
+
return ""
|
| 57 |
+
if len(abstract) <= limit:
|
| 58 |
+
return abstract
|
| 59 |
+
return f"{abstract[:limit].rstrip()}…"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _build_discovery_answer(picked: list[dict[str, Any]], search_query: str) -> str:
|
| 63 |
+
lines = [f"Found **{len(picked)} papers** for “{search_query}” (search rank order):", ""]
|
| 64 |
+
for index, row in enumerate(picked, start=1):
|
| 65 |
+
title, year, authors = _paper_line(row)
|
| 66 |
+
lines.append(f"{index}. **{title}** ({year}) — {authors}")
|
| 67 |
+
snippet = _abstract_snippet(row)
|
| 68 |
+
if snippet:
|
| 69 |
+
lines.append(f" {snippet}")
|
| 70 |
+
lines.append("")
|
| 71 |
+
lines.append(
|
| 72 |
+
"All of the above are selected in the Publications panel — check boxes to chat about specific papers."
|
| 73 |
+
)
|
| 74 |
+
return "\n".join(lines)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _selected_paper_payload(picked: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 78 |
+
payload: list[dict[str, Any]] = []
|
| 79 |
+
for index, row in enumerate(picked, start=1):
|
| 80 |
+
title, year, authors = _paper_line(row)
|
| 81 |
+
payload.append(
|
| 82 |
+
{
|
| 83 |
+
"index": index,
|
| 84 |
+
"rank": row.get("rank"),
|
| 85 |
+
"title": title,
|
| 86 |
+
"year": year,
|
| 87 |
+
"authors": authors,
|
| 88 |
+
"document_id": row.get("document_id"),
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
return payload
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _papers_with_summaries() -> dict[str, dict[str, Any]]:
|
| 95 |
+
return {
|
| 96 |
+
str(paper["document_id"]): paper
|
| 97 |
+
for paper in list_papers_catalog()
|
| 98 |
+
if paper.get("document_id") and paper.get("has_summary")
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _rank_candidates_by_message(
|
| 103 |
+
results: list[dict[str, Any]],
|
| 104 |
+
selection_hint: str,
|
| 105 |
+
*,
|
| 106 |
+
limit: int,
|
| 107 |
+
) -> list[int]:
|
| 108 |
+
index = _papers_with_summaries()
|
| 109 |
+
tokens = focus_tokens(selection_hint)
|
| 110 |
+
scored: list[tuple[float, int]] = []
|
| 111 |
+
for row in results:
|
| 112 |
+
rank = int(row["rank"])
|
| 113 |
+
doc_id = row.get("document_id")
|
| 114 |
+
if not doc_id or str(doc_id) not in index:
|
| 115 |
+
continue
|
| 116 |
+
haystack = " ".join(
|
| 117 |
+
[
|
| 118 |
+
str(row.get("title") or ""),
|
| 119 |
+
str(row.get("abstract") or row.get("full_abstract") or ""),
|
| 120 |
+
str(row.get("keywords") or ""),
|
| 121 |
+
]
|
| 122 |
+
).lower()
|
| 123 |
+
overlap = sum(1 for token in tokens if token in haystack)
|
| 124 |
+
score = overlap + float(row.get("final_score") or row.get("score") or 0.0) * 0.01
|
| 125 |
+
scored.append((score, rank))
|
| 126 |
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
| 127 |
+
if not scored:
|
| 128 |
+
return [int(row["rank"]) for row in results if row.get("document_id") in index][:limit]
|
| 129 |
+
return [rank for _, rank in scored[:limit]]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _parse_tool_payload(raw: Any) -> dict[str, Any]:
|
| 133 |
+
if isinstance(raw, dict):
|
| 134 |
+
return raw
|
| 135 |
+
if isinstance(raw, str):
|
| 136 |
+
return json.loads(raw)
|
| 137 |
+
raise TypeError(f"Unexpected tool payload type: {type(raw)!r}")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _mcp_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
| 141 |
+
"""Run a retrieval MCP tool (stdio subprocess)."""
|
| 142 |
+
return _parse_tool_payload(call_mcp_tool(name, arguments))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _invoke_retrieval_tool(name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 146 |
+
"""Agent retrieval — MCP when USE_MCP=1, else direct Python."""
|
| 147 |
+
args = arguments or {}
|
| 148 |
+
|
| 149 |
+
if name == "search_metadata":
|
| 150 |
+
query = str(args["query"])
|
| 151 |
+
top_k = int(args.get("top_k", 10))
|
| 152 |
+
# Direct in-process search — MCP subprocess cannot share local Qdrant with Flask.
|
| 153 |
+
results, score_mode = search_papers(query, top_k=top_k)
|
| 154 |
+
return {"query": query, "results": results, "score_mode": score_mode}
|
| 155 |
+
|
| 156 |
+
if name == "select_papers_from_search":
|
| 157 |
+
query = str(args["query"])
|
| 158 |
+
ranks = [int(rank) for rank in args["ranks"]]
|
| 159 |
+
top_k = int(args.get("top_k", 10))
|
| 160 |
+
results, score_mode = search_papers(query, top_k=top_k)
|
| 161 |
+
document_ids = document_ids_from_ranks(results, ranks)
|
| 162 |
+
selected = [item for item in results if int(item["rank"]) in ranks]
|
| 163 |
+
return {
|
| 164 |
+
"query": query,
|
| 165 |
+
"document_ids": document_ids,
|
| 166 |
+
"papers": selected,
|
| 167 |
+
"score_mode": score_mode,
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
if name == "build_paper_context":
|
| 171 |
+
document_ids = [str(item) for item in args["document_ids"]]
|
| 172 |
+
question = str(args.get("question") or "")
|
| 173 |
+
context, sources = build_context_for_documents(document_ids, question=question)
|
| 174 |
+
return {"context": context, "sources": sources, "n_papers": len(sources), "question": question}
|
| 175 |
+
|
| 176 |
+
raise ValueError(f"Unknown retrieval tool: {name}")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _step(step: int, tool: str, arguments: dict[str, Any], output: dict[str, Any]) -> dict[str, Any]:
|
| 180 |
+
return {"step": step, "tool": tool, "input": arguments, "output": output}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def agent_chat_payload(body: dict[str, Any]) -> dict[str, Any]:
|
| 184 |
+
"""Fixed MCP pipeline: search → select → context → HF Space answer."""
|
| 185 |
+
from metadata_search.chat_api import _generate_answer
|
| 186 |
+
|
| 187 |
+
message = (body.get("message") or "").strip()
|
| 188 |
+
if not message:
|
| 189 |
+
raise ValueError("message is required")
|
| 190 |
+
|
| 191 |
+
search_plan = plan_search_query(message, explicit_query=(body.get("search_query") or "").strip())
|
| 192 |
+
search_query = search_plan.search_query
|
| 193 |
+
top_k = min(int(body.get("search_top_k") or body.get("top_k") or 10), 50)
|
| 194 |
+
discovery = _is_paper_discovery(message)
|
| 195 |
+
steps: list[dict[str, Any]] = []
|
| 196 |
+
|
| 197 |
+
steps.append(
|
| 198 |
+
_step(
|
| 199 |
+
0,
|
| 200 |
+
"plan_search_query",
|
| 201 |
+
{"message": message},
|
| 202 |
+
{
|
| 203 |
+
"search_query": search_query,
|
| 204 |
+
"selection_hint": search_plan.selection_hint,
|
| 205 |
+
"planner": search_plan.planner,
|
| 206 |
+
"parsed": search_plan.parsed,
|
| 207 |
+
},
|
| 208 |
+
)
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
search_payload = _invoke_retrieval_tool(
|
| 212 |
+
"search_metadata",
|
| 213 |
+
{"query": search_query, "top_k": top_k},
|
| 214 |
+
)
|
| 215 |
+
search_results = list(search_payload.get("results") or [])
|
| 216 |
+
search_score_mode = str(search_payload.get("score_mode") or "rrf")
|
| 217 |
+
steps.append(
|
| 218 |
+
_step(
|
| 219 |
+
1,
|
| 220 |
+
"search_metadata",
|
| 221 |
+
{"query": search_query, "top_k": top_k},
|
| 222 |
+
{
|
| 223 |
+
"n_results": len(search_results),
|
| 224 |
+
"top_titles": [row.get("title") for row in search_results[:5]],
|
| 225 |
+
},
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
ranks = _rank_candidates_by_message(
|
| 230 |
+
search_results,
|
| 231 |
+
search_plan.selection_hint,
|
| 232 |
+
limit=MAX_AUTO_PAPERS,
|
| 233 |
+
)
|
| 234 |
+
if not ranks:
|
| 235 |
+
raise ValueError(
|
| 236 |
+
"Agent could not find papers with linked summaries. "
|
| 237 |
+
"Try a different query or select papers manually."
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
select_payload = _invoke_retrieval_tool(
|
| 241 |
+
"select_papers_from_search",
|
| 242 |
+
{"query": search_query, "ranks": ranks, "top_k": top_k},
|
| 243 |
+
)
|
| 244 |
+
document_ids = [str(item) for item in select_payload.get("document_ids") or []]
|
| 245 |
+
picked_rows = list(select_payload.get("papers") or [])
|
| 246 |
+
steps.append(
|
| 247 |
+
_step(
|
| 248 |
+
2,
|
| 249 |
+
"select_papers_from_search",
|
| 250 |
+
{"ranks": ranks},
|
| 251 |
+
{
|
| 252 |
+
"document_ids": document_ids,
|
| 253 |
+
"papers": [{"rank": row.get("rank"), "title": row.get("title")} for row in picked_rows],
|
| 254 |
+
},
|
| 255 |
+
)
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
if discovery:
|
| 259 |
+
answer = _build_discovery_answer(picked_rows, search_query)
|
| 260 |
+
strategy = "discovery_template"
|
| 261 |
+
steps.append(
|
| 262 |
+
_step(
|
| 263 |
+
3,
|
| 264 |
+
"format_discovery_results",
|
| 265 |
+
{"n_papers": len(picked_rows), "query": search_query},
|
| 266 |
+
{"strategy": strategy},
|
| 267 |
+
)
|
| 268 |
+
)
|
| 269 |
+
sources: list[dict[str, Any]] = []
|
| 270 |
+
context = ""
|
| 271 |
+
diagnostics = None
|
| 272 |
+
else:
|
| 273 |
+
context_payload = _invoke_retrieval_tool(
|
| 274 |
+
"build_paper_context",
|
| 275 |
+
{"document_ids": document_ids, "question": message},
|
| 276 |
+
)
|
| 277 |
+
context = str(context_payload.get("context") or "")
|
| 278 |
+
sources = list(context_payload.get("sources") or [])
|
| 279 |
+
if not context or not sources:
|
| 280 |
+
raise ValueError("Agent could not assemble paper context. Try a different query.")
|
| 281 |
+
steps.append(
|
| 282 |
+
_step(
|
| 283 |
+
3,
|
| 284 |
+
"build_paper_context",
|
| 285 |
+
{"document_ids": document_ids, "question": message},
|
| 286 |
+
{"n_papers": len(sources), "context_chars": len(context)},
|
| 287 |
+
)
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
application = body.get("application")
|
| 291 |
+
if not application or application == "auto":
|
| 292 |
+
application = infer_application(message, explicit=None)
|
| 293 |
+
|
| 294 |
+
answer, strategy, diagnostics = _generate_answer(
|
| 295 |
+
_enhance_agent_question(message, len(sources)),
|
| 296 |
+
context,
|
| 297 |
+
application=application,
|
| 298 |
+
model=body.get("model"),
|
| 299 |
+
)
|
| 300 |
+
steps.append(
|
| 301 |
+
_step(
|
| 302 |
+
4,
|
| 303 |
+
"waterscope_agent_run",
|
| 304 |
+
{"model": body.get("model"), "application": application},
|
| 305 |
+
{"strategy": strategy},
|
| 306 |
+
)
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
payload: dict[str, Any] = {
|
| 310 |
+
"answer": answer,
|
| 311 |
+
"strategy": strategy,
|
| 312 |
+
"agent_mode": True,
|
| 313 |
+
"discovery_mode": discovery,
|
| 314 |
+
"agent_steps": steps,
|
| 315 |
+
"search_query": search_query,
|
| 316 |
+
"search_plan": {
|
| 317 |
+
"search_query": search_plan.search_query,
|
| 318 |
+
"selection_hint": search_plan.selection_hint,
|
| 319 |
+
"planner": search_plan.planner,
|
| 320 |
+
"parsed": search_plan.parsed,
|
| 321 |
+
},
|
| 322 |
+
"document_ids": document_ids,
|
| 323 |
+
"sources": [
|
| 324 |
+
{
|
| 325 |
+
"document_id": source["document_id"],
|
| 326 |
+
"title": source["title"],
|
| 327 |
+
"file_name": source["file_name"],
|
| 328 |
+
}
|
| 329 |
+
for source in sources
|
| 330 |
+
],
|
| 331 |
+
"search_results": picked_rows,
|
| 332 |
+
"selected_papers": _selected_paper_payload(picked_rows),
|
| 333 |
+
"score_mode": search_score_mode,
|
| 334 |
+
"retrieval": "mcp" if use_mcp() else "direct",
|
| 335 |
+
}
|
| 336 |
+
if diagnostics is not None:
|
| 337 |
+
payload["diagnostics"] = diagnostics
|
| 338 |
+
payload["llm_backend"] = llm_backend_name()
|
| 339 |
+
return payload
|
metadata_search/app.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Flask + MCP retrieval + WaterScope agent UI."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv(override=True)
|
| 12 |
+
|
| 13 |
+
from flask import Flask, jsonify, request, send_from_directory
|
| 14 |
+
|
| 15 |
+
from metadata_search.chat_api import chat_payload, get_papers_payload
|
| 16 |
+
from metadata_search.config import METADATA_SEARCH_PORT, PAPER_CSV, PAPER_FIGURES_DIR, PROJECT_ROOT
|
| 17 |
+
from metadata_search.paper_figures import (
|
| 18 |
+
figure_paper_count,
|
| 19 |
+
figures_payload_for_paper,
|
| 20 |
+
figures_dir,
|
| 21 |
+
)
|
| 22 |
+
from metadata_search.mcp_retrieval import (
|
| 23 |
+
list_saved_papers,
|
| 24 |
+
remove_saved_paper,
|
| 25 |
+
save_paper_record,
|
| 26 |
+
search_papers,
|
| 27 |
+
)
|
| 28 |
+
from metadata_search.search_service import search_score_mode
|
| 29 |
+
from backend.mcp_sync import use_mcp
|
| 30 |
+
from backend.model import llm_backend_name
|
| 31 |
+
|
| 32 |
+
DEFAULT_TOP_K = 10
|
| 33 |
+
ALLOWED_TOP_K = {10, 50, 100, 200}
|
| 34 |
+
|
| 35 |
+
if not Path(PAPER_CSV).is_file():
|
| 36 |
+
raise SystemExit(f"Paper dataset not found: {PAPER_CSV}. Set PAPER_CSV in .env.")
|
| 37 |
+
|
| 38 |
+
app = Flask(__name__)
|
| 39 |
+
UI_DIR = PROJECT_ROOT / "github_pages"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _page(name: str):
|
| 43 |
+
return send_from_directory(UI_DIR, name)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _parse_top_k(raw: str | None) -> int:
|
| 47 |
+
try:
|
| 48 |
+
value = int(raw or DEFAULT_TOP_K)
|
| 49 |
+
except (TypeError, ValueError):
|
| 50 |
+
return DEFAULT_TOP_K
|
| 51 |
+
return value if value in ALLOWED_TOP_K else DEFAULT_TOP_K
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.get("/")
|
| 55 |
+
def home_page():
|
| 56 |
+
if UI_DIR.is_dir():
|
| 57 |
+
return _page("index.html")
|
| 58 |
+
return jsonify(
|
| 59 |
+
{
|
| 60 |
+
"service": "WaterScopeAI Search System",
|
| 61 |
+
"status": "running",
|
| 62 |
+
"health": "/api/health",
|
| 63 |
+
"search": "/api/search?q=activated+sludge&top_k=10",
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.get("/demo")
|
| 69 |
+
def demo_page():
|
| 70 |
+
return _page("demo.html")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.get("/chat")
|
| 74 |
+
def chat_page():
|
| 75 |
+
return _page("chat.html")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.get("/instructions")
|
| 79 |
+
def instructions_page():
|
| 80 |
+
return _page("instructions.html")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@app.get("/agent")
|
| 84 |
+
def agent_page():
|
| 85 |
+
return _page("agent.html")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@app.get("/Data/<path:filename>")
|
| 89 |
+
def mcqa_data(filename: str):
|
| 90 |
+
return send_from_directory(UI_DIR / "Data", filename)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@app.get("/assets/<path:filename>")
|
| 94 |
+
def assets(filename: str):
|
| 95 |
+
response = send_from_directory(UI_DIR, filename)
|
| 96 |
+
if filename.endswith((".js", ".css")):
|
| 97 |
+
response.headers["Cache-Control"] = "no-cache"
|
| 98 |
+
return response
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.before_request
|
| 102 |
+
def handle_api_preflight():
|
| 103 |
+
if request.method == "OPTIONS" and request.path.startswith("/api/"):
|
| 104 |
+
return ("", 204)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _cors_allow_origin() -> str | None:
|
| 108 |
+
configured = os.getenv("WATERSCOPE_CORS_ORIGIN", "*").strip()
|
| 109 |
+
origin = request.headers.get("Origin")
|
| 110 |
+
if configured in {"*", ""}:
|
| 111 |
+
return origin or "*"
|
| 112 |
+
allowed = {item.strip() for item in configured.split(",") if item.strip()}
|
| 113 |
+
if origin and (origin in allowed or "localhost" in origin or "127.0.0.1" in origin):
|
| 114 |
+
return origin
|
| 115 |
+
return configured if configured in allowed else None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.after_request
|
| 119 |
+
def cors_headers(response):
|
| 120 |
+
if os.getenv("WATERSCOPE_CORS", "0").lower() in {"1", "true", "yes"}:
|
| 121 |
+
allow_origin = _cors_allow_origin()
|
| 122 |
+
if allow_origin:
|
| 123 |
+
response.headers["Access-Control-Allow-Origin"] = allow_origin
|
| 124 |
+
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
| 125 |
+
response.headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
|
| 126 |
+
return response
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@app.get("/pdfs/<path:filename>")
|
| 130 |
+
def paper_pdf_file(filename: str):
|
| 131 |
+
root_raw = os.getenv("PAPER_PDF_DIR", "").strip()
|
| 132 |
+
if not root_raw:
|
| 133 |
+
return jsonify({"error": "PDF assets are not configured."}), 404
|
| 134 |
+
|
| 135 |
+
root = Path(root_raw).resolve()
|
| 136 |
+
if not root.is_dir():
|
| 137 |
+
return jsonify({"error": "PDF directory not found."}), 404
|
| 138 |
+
|
| 139 |
+
safe_name = Path(filename).name
|
| 140 |
+
target = (root / safe_name).resolve()
|
| 141 |
+
if target.parent != root or not target.is_file():
|
| 142 |
+
return jsonify({"error": "PDF file not found."}), 404
|
| 143 |
+
|
| 144 |
+
return send_from_directory(root, safe_name, as_attachment=True)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.get("/figures/<path:paper_key>/<path:filename>")
|
| 148 |
+
def paper_figure_file(paper_key: str, filename: str):
|
| 149 |
+
root = figures_dir()
|
| 150 |
+
if root is None:
|
| 151 |
+
return jsonify({"error": "Figure assets are not configured."}), 404
|
| 152 |
+
paper_dir = root / paper_key
|
| 153 |
+
if not paper_dir.is_dir():
|
| 154 |
+
return jsonify({"error": "Paper figures not found."}), 404
|
| 155 |
+
target = (paper_dir / filename).resolve()
|
| 156 |
+
if not str(target).startswith(str(paper_dir.resolve())):
|
| 157 |
+
return jsonify({"error": "Invalid figure path."}), 400
|
| 158 |
+
if not target.is_file():
|
| 159 |
+
return jsonify({"error": "Figure file not found."}), 404
|
| 160 |
+
return send_from_directory(paper_dir, filename)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.get("/api/papers/figures")
|
| 164 |
+
def api_paper_figures():
|
| 165 |
+
txt_file = request.args.get("txt_file", "").strip()
|
| 166 |
+
pdf_file = request.args.get("pdf_file", "").strip()
|
| 167 |
+
paper_key = request.args.get("paper_key", "").strip()
|
| 168 |
+
metadata_row_index_raw = request.args.get("metadata_row_index", "").strip()
|
| 169 |
+
|
| 170 |
+
metadata_row_index: int | None = None
|
| 171 |
+
if metadata_row_index_raw:
|
| 172 |
+
try:
|
| 173 |
+
metadata_row_index = int(metadata_row_index_raw)
|
| 174 |
+
except ValueError:
|
| 175 |
+
return jsonify({"error": "metadata_row_index must be an integer."}), 400
|
| 176 |
+
|
| 177 |
+
if not any([txt_file, pdf_file, paper_key, metadata_row_index is not None]):
|
| 178 |
+
return jsonify({"error": "Provide txt_file, pdf_file, paper_key, or metadata_row_index."}), 400
|
| 179 |
+
|
| 180 |
+
payload = figures_payload_for_paper(
|
| 181 |
+
txt_file=txt_file,
|
| 182 |
+
pdf_file=pdf_file,
|
| 183 |
+
paper_key=paper_key,
|
| 184 |
+
metadata_row_index=metadata_row_index,
|
| 185 |
+
)
|
| 186 |
+
if payload is None:
|
| 187 |
+
return jsonify({"error": "No figures found for this paper."}), 404
|
| 188 |
+
return jsonify(payload)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.get("/api/search")
|
| 192 |
+
def api_search():
|
| 193 |
+
query = request.args.get("q", "").strip()
|
| 194 |
+
if not query:
|
| 195 |
+
return jsonify({"error": "q is required"}), 400
|
| 196 |
+
top_k = _parse_top_k(request.args.get("top_k"))
|
| 197 |
+
try:
|
| 198 |
+
from metadata_search.boolean_query import parse_boolean_query, plan_summary
|
| 199 |
+
|
| 200 |
+
results, score_mode = search_papers(query, top_k=top_k)
|
| 201 |
+
parsed = parse_boolean_query(query)
|
| 202 |
+
return jsonify(
|
| 203 |
+
{
|
| 204 |
+
"query": query,
|
| 205 |
+
"search_query": parsed.raw if parsed.raw != query else query,
|
| 206 |
+
"results": results,
|
| 207 |
+
"score_mode": score_mode,
|
| 208 |
+
"query_parsed": plan_summary(parsed),
|
| 209 |
+
}
|
| 210 |
+
)
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
message = str(exc) or repr(exc) or type(exc).__name__
|
| 213 |
+
return jsonify({"error": message}), 500
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@app.get("/api/saved-papers")
|
| 217 |
+
def api_saved_papers():
|
| 218 |
+
return jsonify({"papers": list_saved_papers()})
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@app.post("/api/saved-papers")
|
| 222 |
+
def api_create_saved_paper():
|
| 223 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 224 |
+
try:
|
| 225 |
+
return jsonify({"paper": save_paper_record(body)})
|
| 226 |
+
except ValueError as exc:
|
| 227 |
+
return jsonify({"error": str(exc)}), 400
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@app.delete("/api/saved-papers/<paper_id>")
|
| 231 |
+
def api_delete_saved_paper(paper_id: str):
|
| 232 |
+
if not remove_saved_paper(paper_id):
|
| 233 |
+
return jsonify({"error": "Saved paper not found."}), 404
|
| 234 |
+
return jsonify({"ok": True})
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@app.get("/api/health")
|
| 238 |
+
def api_health():
|
| 239 |
+
summaries_dir = os.getenv("PAPER_SUMMARIES_DIR", "")
|
| 240 |
+
from metadata_search.search_service import get_rows, get_vector_store
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
vector_points = get_vector_store().points_count()
|
| 244 |
+
paper_rows = len(get_rows())
|
| 245 |
+
except Exception:
|
| 246 |
+
vector_points = 0
|
| 247 |
+
paper_rows = 0
|
| 248 |
+
return jsonify(
|
| 249 |
+
{
|
| 250 |
+
"status": "ok",
|
| 251 |
+
"checks": {
|
| 252 |
+
"paper_csv": Path(PAPER_CSV).is_file(),
|
| 253 |
+
"summaries_dir": bool(summaries_dir and Path(summaries_dir).is_dir()),
|
| 254 |
+
"metadata_vector_index": vector_points > 0,
|
| 255 |
+
"paper_figures_dir": PAPER_FIGURES_DIR.is_dir(),
|
| 256 |
+
},
|
| 257 |
+
"paper_count": paper_rows,
|
| 258 |
+
"metadata_vector_points": vector_points,
|
| 259 |
+
"figure_paper_count": figure_paper_count(),
|
| 260 |
+
"llm_backend": llm_backend_name(),
|
| 261 |
+
"retrieval": "mcp" if use_mcp() else "direct",
|
| 262 |
+
"search_score_mode": search_score_mode(),
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@app.get("/api/papers")
|
| 268 |
+
def api_papers():
|
| 269 |
+
try:
|
| 270 |
+
return jsonify(get_papers_payload())
|
| 271 |
+
except FileNotFoundError as exc:
|
| 272 |
+
return jsonify({"error": str(exc)}), 503
|
| 273 |
+
except Exception as exc:
|
| 274 |
+
return jsonify({"error": f"Could not load papers: {exc}"}), 500
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@app.post("/api/chat")
|
| 278 |
+
def api_chat():
|
| 279 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 280 |
+
try:
|
| 281 |
+
return jsonify(chat_payload(body))
|
| 282 |
+
except ValueError as exc:
|
| 283 |
+
message = str(exc)
|
| 284 |
+
if "RUNTIME_ERROR" in message or "invalid state" in message.lower():
|
| 285 |
+
return jsonify(
|
| 286 |
+
{
|
| 287 |
+
"error": (
|
| 288 |
+
"WaterScope HF Space is unavailable right now "
|
| 289 |
+
f"(EnvironmentalAI/WaterScopeAI: {message}). "
|
| 290 |
+
"Retrieval succeeded, but the answer model could not run."
|
| 291 |
+
)
|
| 292 |
+
}
|
| 293 |
+
), 503
|
| 294 |
+
return jsonify({"error": message}), 400
|
| 295 |
+
except Exception as exc:
|
| 296 |
+
return jsonify(
|
| 297 |
+
{
|
| 298 |
+
"error": (
|
| 299 |
+
"Could not reach the WaterScope HF Space model. "
|
| 300 |
+
f"Check network and EnvironmentalAI/WaterScopeAI ({type(exc).__name__}: {exc})"
|
| 301 |
+
)
|
| 302 |
+
}
|
| 303 |
+
), 503
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def main() -> None:
|
| 307 |
+
from metadata_search.search_service import auto_rebuild_on_startup
|
| 308 |
+
|
| 309 |
+
auto_rebuild_on_startup()
|
| 310 |
+
if use_mcp():
|
| 311 |
+
from backend.mcp_sync import ensure_mcp_connected
|
| 312 |
+
|
| 313 |
+
ensure_mcp_connected()
|
| 314 |
+
debug = os.getenv("FLASK_DEBUG", "0").lower() in {"1", "true", "yes"}
|
| 315 |
+
app.run(host=os.getenv("HOST", "0.0.0.0"), debug=debug, use_reloader=False, port=METADATA_SEARCH_PORT)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
if __name__ == "__main__":
|
| 319 |
+
main()
|
metadata_search/bm25_search.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Field-weighted BM25 over paper title, abstract, and keywords."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from rank_bm25 import BM25Okapi
|
| 6 |
+
|
| 7 |
+
from metadata_search.config import ABSTRACT_COL, KEYWORD_COL, TITLE_COL
|
| 8 |
+
from metadata_search.corpus import tokenize
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BM25Index:
|
| 12 |
+
"""Fielded BM25 index for a static list of paper records."""
|
| 13 |
+
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
rows: list[dict[str, str]],
|
| 17 |
+
*,
|
| 18 |
+
title_col: str = TITLE_COL,
|
| 19 |
+
abstract_col: str = ABSTRACT_COL,
|
| 20 |
+
keyword_col: str = KEYWORD_COL,
|
| 21 |
+
title_weight: float = 2.0,
|
| 22 |
+
keyword_weight: float = 1.5,
|
| 23 |
+
) -> None:
|
| 24 |
+
self.rows = rows
|
| 25 |
+
self.title_col = title_col
|
| 26 |
+
self.abstract_col = abstract_col
|
| 27 |
+
self.keyword_col = keyword_col
|
| 28 |
+
self.title_weight = title_weight
|
| 29 |
+
self.keyword_weight = keyword_weight
|
| 30 |
+
|
| 31 |
+
self.title_documents = [tokenize(str(row.get(title_col) or "")) for row in rows]
|
| 32 |
+
self.abstract_documents = [tokenize(str(row.get(abstract_col) or "")) for row in rows]
|
| 33 |
+
self.keyword_documents = [tokenize(str(row.get(keyword_col) or "")) for row in rows]
|
| 34 |
+
|
| 35 |
+
self.title_bm25 = self._build_bm25(self.title_documents)
|
| 36 |
+
self.abstract_bm25 = self._build_bm25(self.abstract_documents)
|
| 37 |
+
self.keyword_bm25 = self._build_bm25(self.keyword_documents)
|
| 38 |
+
|
| 39 |
+
def _build_bm25(self, documents: list[list[str]]) -> BM25Okapi | None:
|
| 40 |
+
if not documents or not any(documents):
|
| 41 |
+
return None
|
| 42 |
+
return BM25Okapi(documents)
|
| 43 |
+
|
| 44 |
+
def score(self, query: str) -> list[float]:
|
| 45 |
+
if not query.strip() or not self.rows:
|
| 46 |
+
return [0.0] * len(self.rows)
|
| 47 |
+
|
| 48 |
+
query_terms = tokenize(query)
|
| 49 |
+
title_scores = self.title_bm25.get_scores(query_terms) if self.title_bm25 else [0.0] * len(self.rows)
|
| 50 |
+
abstract_scores = (
|
| 51 |
+
self.abstract_bm25.get_scores(query_terms) if self.abstract_bm25 else [0.0] * len(self.rows)
|
| 52 |
+
)
|
| 53 |
+
keyword_scores = (
|
| 54 |
+
self.keyword_bm25.get_scores(query_terms) if self.keyword_bm25 else [0.0] * len(self.rows)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return [
|
| 58 |
+
float(
|
| 59 |
+
self.title_weight * title_scores[index]
|
| 60 |
+
+ abstract_scores[index]
|
| 61 |
+
+ self.keyword_weight * keyword_scores[index]
|
| 62 |
+
)
|
| 63 |
+
for index in range(len(self.rows))
|
| 64 |
+
]
|
metadata_search/boolean_query.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Scholar-style boolean query parsing (+required, -exclude, \"phrases\")."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
from metadata_search.corpus import tokenize
|
| 9 |
+
|
| 10 |
+
_QUOTED = re.compile(r'"([^"]+)"')
|
| 11 |
+
_DISCOVERY_PREFIX = re.compile(
|
| 12 |
+
r"^(?:please\s+)?(?:find|search|show|list|recommend|retrieve|get)\s+"
|
| 13 |
+
r"(?:me\s+)?(?:some\s+)?(?:papers?\s+)?(?:on|about|for|related to)\s+",
|
| 14 |
+
re.I,
|
| 15 |
+
)
|
| 16 |
+
_BUT_NOT = re.compile(r"\s+but\s+not\s+", re.I)
|
| 17 |
+
_BUT_EXCLUDE = re.compile(r"\s+but\s+(?=-)", re.I)
|
| 18 |
+
_AND_NOT = re.compile(r"\s+and\s+not\s+", re.I)
|
| 19 |
+
_TRAILING_BUT = re.compile(r"\s+but\s*$", re.I)
|
| 20 |
+
_NL_NEGATION = re.compile(
|
| 21 |
+
r"(?:,\s*|\s+)(?:but\s+not|not|excluding|without|except)\s+(.+)$",
|
| 22 |
+
re.I,
|
| 23 |
+
)
|
| 24 |
+
_EXCLUDED_FILLER_PREFIX = re.compile(
|
| 25 |
+
r"^(?:anything|something|any|studies|papers|research|work|articles?)\s+"
|
| 26 |
+
r"(?:on|about|for|regarding|related to)\s+",
|
| 27 |
+
re.I,
|
| 28 |
+
)
|
| 29 |
+
_EXCLUDED_LEADING_PREP = re.compile(r"^(?:on|about|for|regarding|related to)\s+", re.I)
|
| 30 |
+
_TRAILING_FILLER = re.compile(
|
| 31 |
+
r"(?:,\s*)?\b(?:though|tho|however|please|thanks|thank you|just|really|actually|anyway|right|ok|okay|hmm|um|uh|lol)\b[\s,.!?]*$",
|
| 32 |
+
re.I,
|
| 33 |
+
)
|
| 34 |
+
_LEADING_FILLER = re.compile(
|
| 35 |
+
r"^[\s,.!?]*\b(?:please|just|really|actually|maybe|kind of|sort of)\b\s+",
|
| 36 |
+
re.I,
|
| 37 |
+
)
|
| 38 |
+
_FILLER_WORDS = frozenset(
|
| 39 |
+
{
|
| 40 |
+
"though",
|
| 41 |
+
"tho",
|
| 42 |
+
"however",
|
| 43 |
+
"please",
|
| 44 |
+
"thanks",
|
| 45 |
+
"just",
|
| 46 |
+
"really",
|
| 47 |
+
"actually",
|
| 48 |
+
"anyway",
|
| 49 |
+
"right",
|
| 50 |
+
"ok",
|
| 51 |
+
"okay",
|
| 52 |
+
"hmm",
|
| 53 |
+
"um",
|
| 54 |
+
"uh",
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _strip_conversational_fillers(text: str) -> str:
|
| 60 |
+
"""Remove trailing/leading chat filler ('… though', 'please …')."""
|
| 61 |
+
cleaned = text.strip()
|
| 62 |
+
if not cleaned:
|
| 63 |
+
return ""
|
| 64 |
+
for _ in range(5):
|
| 65 |
+
updated = _TRAILING_FILLER.sub("", cleaned).strip(" ,.")
|
| 66 |
+
updated = _LEADING_FILLER.sub("", updated).strip(" ,.")
|
| 67 |
+
if updated == cleaned:
|
| 68 |
+
break
|
| 69 |
+
cleaned = updated
|
| 70 |
+
return cleaned
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _clean_excluded_phrase(phrase: str) -> str:
|
| 74 |
+
"""Drop chat filler from NL exclusions: 'anything on drinking water though' → 'drinking water'."""
|
| 75 |
+
text = _strip_conversational_fillers(phrase.strip())
|
| 76 |
+
if not text:
|
| 77 |
+
return ""
|
| 78 |
+
for _ in range(3):
|
| 79 |
+
updated = _EXCLUDED_FILLER_PREFIX.sub("", text).strip()
|
| 80 |
+
updated = _EXCLUDED_LEADING_PREP.sub("", updated).strip()
|
| 81 |
+
updated = _strip_conversational_fillers(updated)
|
| 82 |
+
if updated == text:
|
| 83 |
+
break
|
| 84 |
+
text = updated
|
| 85 |
+
return text
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def normalize_search_query(query: str) -> str:
|
| 89 |
+
"""Strip chat phrasing (e.g. 'Find papers on …') before boolean parsing."""
|
| 90 |
+
text = (query or "").strip()
|
| 91 |
+
if not text:
|
| 92 |
+
return ""
|
| 93 |
+
text = _DISCOVERY_PREFIX.sub("", text).strip(" .")
|
| 94 |
+
text = _BUT_NOT.sub(" not ", text)
|
| 95 |
+
text = _AND_NOT.sub(" not ", text)
|
| 96 |
+
text = _BUT_EXCLUDE.sub(" ", text)
|
| 97 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 98 |
+
return text
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def has_natural_language_negation(query: str) -> bool:
|
| 102 |
+
text = (query or "").strip()
|
| 103 |
+
return bool(text and _NL_NEGATION.search(text))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def split_natural_language_negation(query: str) -> tuple[str, tuple[str, ...]]:
|
| 107 |
+
"""Split trailing ', not X' / ' not X' style constraints from the positive query."""
|
| 108 |
+
text = (query or "").strip()
|
| 109 |
+
if not text:
|
| 110 |
+
return "", ()
|
| 111 |
+
match = _NL_NEGATION.search(text)
|
| 112 |
+
if not match:
|
| 113 |
+
return text, ()
|
| 114 |
+
positive = text[: match.start()].strip(" ,.")
|
| 115 |
+
positive = _TRAILING_BUT.sub("", positive).strip(" ,.")
|
| 116 |
+
excluded = _clean_excluded_phrase(match.group(1).strip(" ,."))
|
| 117 |
+
if not positive:
|
| 118 |
+
positive = text
|
| 119 |
+
excluded = ""
|
| 120 |
+
return positive, (excluded,) if excluded else ()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def has_boolean_syntax(query: str) -> bool:
|
| 124 |
+
text = (query or "").strip()
|
| 125 |
+
if not text:
|
| 126 |
+
return False
|
| 127 |
+
if has_natural_language_negation(text):
|
| 128 |
+
return True
|
| 129 |
+
if _QUOTED.search(text):
|
| 130 |
+
return True
|
| 131 |
+
return bool(re.search(r"(?:^|\s)[+-]", text))
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@dataclass(frozen=True)
|
| 135 |
+
class BooleanQuery:
|
| 136 |
+
raw: str
|
| 137 |
+
ranking_text: str
|
| 138 |
+
required_phrases: tuple[str, ...] = ()
|
| 139 |
+
required_terms: tuple[str, ...] = ()
|
| 140 |
+
excluded_phrases: tuple[str, ...] = ()
|
| 141 |
+
excluded_terms: tuple[str, ...] = ()
|
| 142 |
+
optional_terms: tuple[str, ...] = ()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _format_exclusion(phrase: str) -> str:
|
| 146 |
+
"""Multi-word exclusions must be quoted or only the first token is excluded."""
|
| 147 |
+
text = phrase.strip()
|
| 148 |
+
if not text:
|
| 149 |
+
return ""
|
| 150 |
+
if " " in text:
|
| 151 |
+
return f'-"{text}"'
|
| 152 |
+
return f"-{text}"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _format_required(term: str) -> str:
|
| 156 |
+
text = term.strip()
|
| 157 |
+
if not text:
|
| 158 |
+
return ""
|
| 159 |
+
if " " in text:
|
| 160 |
+
return f'"{text}"'
|
| 161 |
+
return f"+{text}"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _build_effective_raw(
|
| 165 |
+
*,
|
| 166 |
+
required_phrases: list[str],
|
| 167 |
+
required_terms: list[str],
|
| 168 |
+
optional_terms: list[str],
|
| 169 |
+
excluded_phrases: list[str],
|
| 170 |
+
excluded_terms: list[str],
|
| 171 |
+
fallback: str,
|
| 172 |
+
) -> str:
|
| 173 |
+
parts: list[str] = []
|
| 174 |
+
for phrase in required_phrases:
|
| 175 |
+
parts.append(f'"{phrase}"')
|
| 176 |
+
for term in required_terms:
|
| 177 |
+
parts.append(_format_required(term))
|
| 178 |
+
for term in optional_terms:
|
| 179 |
+
parts.append(term)
|
| 180 |
+
for phrase in excluded_phrases:
|
| 181 |
+
parts.append(_format_exclusion(phrase))
|
| 182 |
+
for term in excluded_terms:
|
| 183 |
+
parts.append(_format_exclusion(term))
|
| 184 |
+
return " ".join(part for part in parts if part).strip() or fallback
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def parse_boolean_query(query: str) -> BooleanQuery:
|
| 188 |
+
"""Parse +term, -term, \"phrase\", and natural-language \"not X\" constraints."""
|
| 189 |
+
raw = normalize_search_query(query)
|
| 190 |
+
if not raw:
|
| 191 |
+
return BooleanQuery(raw="", ranking_text="")
|
| 192 |
+
|
| 193 |
+
positive_text, nl_excluded_phrases = split_natural_language_negation(raw)
|
| 194 |
+
|
| 195 |
+
required_phrases: list[str] = []
|
| 196 |
+
excluded_phrases: list[str] = list(nl_excluded_phrases)
|
| 197 |
+
required_terms: list[str] = []
|
| 198 |
+
excluded_terms: list[str] = []
|
| 199 |
+
optional_terms: list[str] = []
|
| 200 |
+
ranking_parts: list[str] = []
|
| 201 |
+
|
| 202 |
+
def _add_term(bucket: list[str], token: str) -> None:
|
| 203 |
+
normalized = token.strip().lower()
|
| 204 |
+
if normalized:
|
| 205 |
+
bucket.append(normalized)
|
| 206 |
+
|
| 207 |
+
remaining = positive_text
|
| 208 |
+
for match in _QUOTED.finditer(positive_text):
|
| 209 |
+
phrase = match.group(1).strip()
|
| 210 |
+
if not phrase:
|
| 211 |
+
continue
|
| 212 |
+
start = match.start()
|
| 213 |
+
prefix = positive_text[max(0, start - 1) : start]
|
| 214 |
+
if prefix == "-":
|
| 215 |
+
excluded_phrases.append(_clean_excluded_phrase(phrase))
|
| 216 |
+
else:
|
| 217 |
+
required_phrases.append(phrase)
|
| 218 |
+
ranking_parts.append(phrase)
|
| 219 |
+
remaining = remaining.replace(match.group(0), " ", 1)
|
| 220 |
+
|
| 221 |
+
tokens = remaining.split()
|
| 222 |
+
index = 0
|
| 223 |
+
while index < len(tokens):
|
| 224 |
+
token = tokens[index].strip()
|
| 225 |
+
if not token:
|
| 226 |
+
index += 1
|
| 227 |
+
continue
|
| 228 |
+
if token.startswith("+"):
|
| 229 |
+
term = token[1:].strip("+-")
|
| 230 |
+
_add_term(required_terms, term)
|
| 231 |
+
if term:
|
| 232 |
+
ranking_parts.append(term)
|
| 233 |
+
index += 1
|
| 234 |
+
continue
|
| 235 |
+
if token.startswith("-"):
|
| 236 |
+
term = token[1:].strip("+-")
|
| 237 |
+
phrase_parts: list[str] = [term] if term else []
|
| 238 |
+
next_index = index + 1
|
| 239 |
+
while next_index < len(tokens):
|
| 240 |
+
follower = tokens[next_index].strip()
|
| 241 |
+
if not follower or follower.startswith(("+", "-")):
|
| 242 |
+
break
|
| 243 |
+
phrase_parts.append(follower)
|
| 244 |
+
next_index += 1
|
| 245 |
+
phrase = " ".join(part for part in phrase_parts if part).strip()
|
| 246 |
+
phrase = _clean_excluded_phrase(phrase)
|
| 247 |
+
if " " in phrase:
|
| 248 |
+
excluded_phrases.append(phrase)
|
| 249 |
+
elif phrase:
|
| 250 |
+
_add_term(excluded_terms, phrase)
|
| 251 |
+
index = next_index
|
| 252 |
+
continue
|
| 253 |
+
_add_term(optional_terms, token)
|
| 254 |
+
if token.lower() not in _FILLER_WORDS:
|
| 255 |
+
ranking_parts.append(token)
|
| 256 |
+
index += 1
|
| 257 |
+
|
| 258 |
+
excluded_phrases = [_clean_excluded_phrase(phrase) for phrase in excluded_phrases]
|
| 259 |
+
excluded_phrases = [phrase for phrase in excluded_phrases if phrase]
|
| 260 |
+
optional_terms = [term for term in optional_terms if term.lower() not in _FILLER_WORDS]
|
| 261 |
+
ranking_parts = [part for part in ranking_parts if part.lower() not in _FILLER_WORDS]
|
| 262 |
+
|
| 263 |
+
ranking_text = " ".join(part for part in ranking_parts if part).strip() or positive_text or raw
|
| 264 |
+
effective_raw = _build_effective_raw(
|
| 265 |
+
required_phrases=required_phrases,
|
| 266 |
+
required_terms=required_terms,
|
| 267 |
+
optional_terms=optional_terms,
|
| 268 |
+
excluded_phrases=excluded_phrases,
|
| 269 |
+
excluded_terms=excluded_terms,
|
| 270 |
+
fallback=raw,
|
| 271 |
+
)
|
| 272 |
+
return BooleanQuery(
|
| 273 |
+
raw=effective_raw,
|
| 274 |
+
ranking_text=ranking_text,
|
| 275 |
+
required_phrases=tuple(required_phrases),
|
| 276 |
+
required_terms=tuple(required_terms),
|
| 277 |
+
excluded_phrases=tuple(excluded_phrases),
|
| 278 |
+
excluded_terms=tuple(excluded_terms),
|
| 279 |
+
optional_terms=tuple(optional_terms),
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _haystack(row: dict[str, object]) -> str:
|
| 284 |
+
return " ".join(
|
| 285 |
+
[
|
| 286 |
+
str(row.get("title") or row.get("TI") or ""),
|
| 287 |
+
str(row.get("abstract") or row.get("full_abstract") or row.get("AB") or ""),
|
| 288 |
+
str(row.get("keywords") or row.get("DE") or ""),
|
| 289 |
+
]
|
| 290 |
+
).lower()
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _contains_term(haystack: str, term: str) -> bool:
|
| 294 |
+
normalized = term.lower().strip()
|
| 295 |
+
if not normalized:
|
| 296 |
+
return True
|
| 297 |
+
if " " in normalized:
|
| 298 |
+
return normalized in haystack
|
| 299 |
+
return bool(re.search(rf"\b{re.escape(normalized)}\b", haystack)) or normalized in haystack
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def matches_boolean_query(row: dict[str, object], parsed: BooleanQuery) -> bool:
|
| 303 |
+
haystack = _haystack(row)
|
| 304 |
+
for phrase in parsed.required_phrases:
|
| 305 |
+
if phrase.lower() not in haystack:
|
| 306 |
+
return False
|
| 307 |
+
for term in parsed.required_terms:
|
| 308 |
+
if not _contains_term(haystack, term):
|
| 309 |
+
return False
|
| 310 |
+
for phrase in parsed.excluded_phrases:
|
| 311 |
+
if phrase.lower() in haystack:
|
| 312 |
+
return False
|
| 313 |
+
for term in parsed.excluded_terms:
|
| 314 |
+
if _contains_term(haystack, term):
|
| 315 |
+
return False
|
| 316 |
+
return True
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def focus_tokens(text: str, *, limit: int = 12) -> list[str]:
|
| 320 |
+
"""Tokens for re-ranking agent paper picks (no hardcoded stopword list)."""
|
| 321 |
+
tokens = tokenize(text or "")
|
| 322 |
+
seen: set[str] = set()
|
| 323 |
+
focused: list[str] = []
|
| 324 |
+
for token in tokens:
|
| 325 |
+
if token.startswith(("lemma:", "phrase:", "concept:")):
|
| 326 |
+
continue
|
| 327 |
+
if token in seen or len(token) < 3:
|
| 328 |
+
continue
|
| 329 |
+
seen.add(token)
|
| 330 |
+
focused.append(token)
|
| 331 |
+
if len(focused) >= limit:
|
| 332 |
+
break
|
| 333 |
+
return focused
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def plan_summary(parsed: BooleanQuery) -> dict[str, object]:
|
| 337 |
+
return {
|
| 338 |
+
"raw": parsed.raw,
|
| 339 |
+
"ranking_text": parsed.ranking_text,
|
| 340 |
+
"required_phrases": list(parsed.required_phrases),
|
| 341 |
+
"required_terms": list(parsed.required_terms),
|
| 342 |
+
"excluded_terms": list(parsed.excluded_terms),
|
| 343 |
+
"excluded_phrases": list(parsed.excluded_phrases),
|
| 344 |
+
"optional_terms": list(parsed.optional_terms),
|
| 345 |
+
}
|
metadata_search/chat_api.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat API for the WaterScope research agent (HF Space answers)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from backend.chat_settings import MAX_CHAT_PAPERS, enhance_ordinal_question, focus_document_ids_for_question
|
| 8 |
+
from backend.mcp_sync import use_mcp
|
| 9 |
+
from backend.model import llm_backend_name
|
| 10 |
+
from backend.paper_voice import prefix_context, sanitize_answer
|
| 11 |
+
from backend.waterscope_agent import agent_answer
|
| 12 |
+
from metadata_search.mcp_retrieval import build_context_for_documents, list_papers_catalog, list_saved_papers
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _generate_answer(
|
| 16 |
+
message: str,
|
| 17 |
+
context: str,
|
| 18 |
+
*,
|
| 19 |
+
application: str | None = None,
|
| 20 |
+
model: str | None = None,
|
| 21 |
+
) -> tuple[str, str, str]:
|
| 22 |
+
answer, app_used, diagnostics = agent_answer(
|
| 23 |
+
prefix_context(context),
|
| 24 |
+
message,
|
| 25 |
+
application=application or os.getenv("WATERSCOPE_APPLICATION", "auto"),
|
| 26 |
+
model=model or os.getenv("WATERSCOPE_MODEL"),
|
| 27 |
+
)
|
| 28 |
+
return sanitize_answer(answer), f"waterscope:{app_used}", diagnostics
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_papers_payload() -> dict:
|
| 32 |
+
papers = list_papers_catalog()
|
| 33 |
+
saved_ids = {paper.get("document_id") for paper in list_saved_papers() if paper.get("document_id")}
|
| 34 |
+
for paper in papers:
|
| 35 |
+
paper["is_saved"] = paper.get("document_id") in saved_ids
|
| 36 |
+
return {"papers": papers, "max_select": MAX_CHAT_PAPERS}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def chat_payload(body: dict) -> dict:
|
| 40 |
+
message = (body.get("message") or "").strip()
|
| 41 |
+
document_ids = body.get("document_ids") or []
|
| 42 |
+
application = body.get("application")
|
| 43 |
+
agent_mode = bool(body.get("agent_mode"))
|
| 44 |
+
|
| 45 |
+
if not message:
|
| 46 |
+
raise ValueError("message is required")
|
| 47 |
+
|
| 48 |
+
if agent_mode and not document_ids:
|
| 49 |
+
from metadata_search.agent_orchestrator import agent_chat_payload
|
| 50 |
+
|
| 51 |
+
return agent_chat_payload(body)
|
| 52 |
+
|
| 53 |
+
if not document_ids:
|
| 54 |
+
raise ValueError("Select at least one paper, or enable agent mode.")
|
| 55 |
+
if len(document_ids) > MAX_CHAT_PAPERS:
|
| 56 |
+
raise ValueError(f"Select at most {MAX_CHAT_PAPERS} papers.")
|
| 57 |
+
|
| 58 |
+
document_ids = focus_document_ids_for_question(message, document_ids)
|
| 59 |
+
context, sources = build_context_for_documents(document_ids, question=message)
|
| 60 |
+
message = enhance_ordinal_question(message, sources)
|
| 61 |
+
|
| 62 |
+
answer, strategy, diagnostics = _generate_answer(
|
| 63 |
+
message,
|
| 64 |
+
context,
|
| 65 |
+
application=application,
|
| 66 |
+
model=body.get("model"),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
"answer": answer,
|
| 71 |
+
"strategy": strategy,
|
| 72 |
+
"include_excerpts": False,
|
| 73 |
+
"sources": [
|
| 74 |
+
{
|
| 75 |
+
"document_id": source["document_id"],
|
| 76 |
+
"title": source["title"],
|
| 77 |
+
"file_name": source["file_name"],
|
| 78 |
+
}
|
| 79 |
+
for source in sources
|
| 80 |
+
],
|
| 81 |
+
"chunks": [],
|
| 82 |
+
"retrieval": "mcp" if use_mcp() else "direct",
|
| 83 |
+
"diagnostics": diagnostics,
|
| 84 |
+
"llm_backend": llm_backend_name(),
|
| 85 |
+
}
|
metadata_search/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
# .env wins over stale shell exports
|
| 9 |
+
load_dotenv(override=True)
|
| 10 |
+
|
| 11 |
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
| 12 |
+
PROJECT_ROOT = PACKAGE_DIR.parent
|
| 13 |
+
DEFAULT_CSV = PACKAGE_DIR / "data" / "papers.csv"
|
| 14 |
+
|
| 15 |
+
PAPER_CSV = Path(os.getenv("PAPER_CSV", str(DEFAULT_CSV)))
|
| 16 |
+
EMBEDDING_MODEL = os.getenv("METADATA_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
|
| 17 |
+
METADATA_QDRANT_PATH = Path(
|
| 18 |
+
os.getenv("METADATA_QDRANT_PATH", str(PROJECT_ROOT / "metadata_search_data" / "qdrant"))
|
| 19 |
+
)
|
| 20 |
+
METADATA_QDRANT_COLLECTION = os.getenv("METADATA_QDRANT_COLLECTION", "papers_metadata")
|
| 21 |
+
METADATA_QDRANT_MANIFEST = Path(
|
| 22 |
+
os.getenv(
|
| 23 |
+
"METADATA_QDRANT_MANIFEST",
|
| 24 |
+
str(PROJECT_ROOT / "metadata_search_data" / "qdrant_manifest.json"),
|
| 25 |
+
)
|
| 26 |
+
)
|
| 27 |
+
METADATA_SEARCH_PORT = int(os.getenv("PORT", os.getenv("METADATA_SEARCH_PORT", "7860")))
|
| 28 |
+
PAPER_FIGURES_DIR = Path(
|
| 29 |
+
os.getenv("PAPER_FIGURES_DIR", str(PROJECT_ROOT / "results_metadata (2025-2026)"))
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
TITLE_COL = os.getenv("METADATA_TITLE_COL", "TI")
|
| 33 |
+
ABSTRACT_COL = os.getenv("METADATA_ABSTRACT_COL", "AB")
|
| 34 |
+
KEYWORD_COL = os.getenv("METADATA_KEYWORD_COL", "DE")
|
metadata_search/corpus.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paper corpus helpers: CSV loading, embedding text, boolean tokenization."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import csv
|
| 6 |
+
import re
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from metadata_search.config import ABSTRACT_COL, KEYWORD_COL, TITLE_COL
|
| 10 |
+
|
| 11 |
+
TOKEN_RE = re.compile(r"[a-z0-9]+(?:[-'][a-z0-9]+)?")
|
| 12 |
+
|
| 13 |
+
STOPWORDS = frozenset(
|
| 14 |
+
"a an and are as at be been being by can did do does for from had has have "
|
| 15 |
+
"how in into is it its may of on or our that the their these this those to "
|
| 16 |
+
"using was were what when where which with within without".split()
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
IRREGULAR_LEMMAS = {
|
| 20 |
+
"analyses": "analysis",
|
| 21 |
+
"bacteria": "bacterium",
|
| 22 |
+
"children": "child",
|
| 23 |
+
"data": "datum",
|
| 24 |
+
"indices": "index",
|
| 25 |
+
"matrices": "matrix",
|
| 26 |
+
"media": "medium",
|
| 27 |
+
"mice": "mouse",
|
| 28 |
+
"species": "species",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
ALIASES = {
|
| 32 |
+
"mbr": "membrane_bioreactor",
|
| 33 |
+
"membrane bioreactor": "membrane_bioreactor",
|
| 34 |
+
"wwtp": "wastewater_treatment_plant",
|
| 35 |
+
"wastewater treatment plant": "wastewater_treatment_plant",
|
| 36 |
+
"pfas": "per_polyfluoroalkyl_substances",
|
| 37 |
+
"per- and polyfluoroalkyl substances": "per_polyfluoroalkyl_substances",
|
| 38 |
+
"lca": "life_cycle_assessment",
|
| 39 |
+
"life cycle assessment": "life_cycle_assessment",
|
| 40 |
+
"cfd": "computational_fluid_dynamics",
|
| 41 |
+
"computational fluid dynamics": "computational_fluid_dynamics",
|
| 42 |
+
"gis": "geographic_information_system",
|
| 43 |
+
"geographic information system": "geographic_information_system",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_rows(csv_path: str | Path) -> list[dict[str, str]]:
|
| 48 |
+
with Path(csv_path).open(newline="", encoding="utf-8-sig") as handle:
|
| 49 |
+
return list(csv.DictReader(handle))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def paper_text(
|
| 53 |
+
row: dict[str, str],
|
| 54 |
+
*,
|
| 55 |
+
title_col: str = TITLE_COL,
|
| 56 |
+
abstract_col: str = ABSTRACT_COL,
|
| 57 |
+
keyword_col: str = KEYWORD_COL,
|
| 58 |
+
) -> str:
|
| 59 |
+
title = (row.get(title_col) or "").strip()
|
| 60 |
+
abstract = (row.get(abstract_col) or "").strip()
|
| 61 |
+
keywords = (row.get(keyword_col) or "").strip()
|
| 62 |
+
return f"{title}\n\n{abstract}\n\nKeywords: {keywords}".strip()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def lemmatize(token: str) -> str:
|
| 66 |
+
if token in IRREGULAR_LEMMAS:
|
| 67 |
+
return IRREGULAR_LEMMAS[token]
|
| 68 |
+
if len(token) > 4 and token.endswith("ies"):
|
| 69 |
+
return f"{token[:-3]}y"
|
| 70 |
+
if len(token) > 5 and token.endswith(("ches", "shes", "sses", "xes", "zes")):
|
| 71 |
+
return token[:-2]
|
| 72 |
+
if len(token) > 3 and token.endswith("s") and not token.endswith(
|
| 73 |
+
("ss", "us", "is", "ics")
|
| 74 |
+
):
|
| 75 |
+
return token[:-1]
|
| 76 |
+
return token
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _surface_tokens(text: str) -> list[str]:
|
| 80 |
+
return [token for token in TOKEN_RE.findall((text or "").lower()) if token not in STOPWORDS]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def tokenize(text: str, *, include_phrases: bool = True) -> list[str]:
|
| 84 |
+
"""Lexical features for boolean query matching."""
|
| 85 |
+
surface = _surface_tokens(text)
|
| 86 |
+
lemmas = [lemmatize(token) for token in surface]
|
| 87 |
+
features = list(surface)
|
| 88 |
+
features.extend(f"lemma:{lemma}" for lemma in lemmas)
|
| 89 |
+
|
| 90 |
+
if include_phrases:
|
| 91 |
+
features.extend(f"phrase:{left}_{right}" for left, right in zip(lemmas, lemmas[1:]))
|
| 92 |
+
|
| 93 |
+
lowered = " ".join(TOKEN_RE.findall((text or "").lower()))
|
| 94 |
+
surface_set = set(surface)
|
| 95 |
+
for alias, concept in ALIASES.items():
|
| 96 |
+
normalized_alias = " ".join(TOKEN_RE.findall(alias.lower()))
|
| 97 |
+
if (" " not in normalized_alias and normalized_alias in surface_set) or (
|
| 98 |
+
" " in normalized_alias and f" {normalized_alias} " in f" {lowered} "
|
| 99 |
+
):
|
| 100 |
+
features.append(f"concept:{concept}")
|
| 101 |
+
return features
|
metadata_search/data/papers.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:58382d4d472d662ba676c2bb666af4c34eab054f155479c5aa23839cf7fb3de7
|
| 3 |
+
size 24137195
|
metadata_search/embedder.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence-transformer embeddings for metadata vector search."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
|
| 7 |
+
from metadata_search.config import EMBEDDING_MODEL
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@lru_cache(maxsize=1)
|
| 11 |
+
def _load_model(model_name: str):
|
| 12 |
+
try:
|
| 13 |
+
from sentence_transformers import SentenceTransformer
|
| 14 |
+
except ImportError as exc:
|
| 15 |
+
raise SystemExit(
|
| 16 |
+
"Missing dependency: sentence-transformers. Install with `pip install -r requirements.txt`."
|
| 17 |
+
) from exc
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
return SentenceTransformer(model_name, local_files_only=True)
|
| 21 |
+
except Exception:
|
| 22 |
+
return SentenceTransformer(model_name)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class MetadataEmbedder:
|
| 26 |
+
def __init__(self, model_name: str = EMBEDDING_MODEL, *, batch_size: int = 64):
|
| 27 |
+
self.model_name = model_name
|
| 28 |
+
self.batch_size = batch_size
|
| 29 |
+
self.model = _load_model(model_name)
|
| 30 |
+
|
| 31 |
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
| 32 |
+
if not texts:
|
| 33 |
+
return []
|
| 34 |
+
vectors = self.model.encode(
|
| 35 |
+
texts,
|
| 36 |
+
batch_size=self.batch_size,
|
| 37 |
+
normalize_embeddings=True,
|
| 38 |
+
show_progress_bar=len(texts) > 128,
|
| 39 |
+
)
|
| 40 |
+
return vectors.tolist()
|
| 41 |
+
|
| 42 |
+
def embed_query(self, query: str) -> list[float]:
|
| 43 |
+
vector = self.model.encode(
|
| 44 |
+
query,
|
| 45 |
+
normalize_embeddings=True,
|
| 46 |
+
show_progress_bar=False,
|
| 47 |
+
)
|
| 48 |
+
return vector.tolist()
|
metadata_search/hybrid_search.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RRF fusion for metadata BM25 + semantic (Qdrant) rankings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def rank_scores(scores: list[float], *, minimum_score: float = 0.0) -> list[int]:
|
| 7 |
+
"""Return 1-based rank per row index; 0 means below minimum_score."""
|
| 8 |
+
ranked = sorted(
|
| 9 |
+
(index for index, score in enumerate(scores) if score > minimum_score),
|
| 10 |
+
key=lambda index: scores[index],
|
| 11 |
+
reverse=True,
|
| 12 |
+
)
|
| 13 |
+
ranks = [0] * len(scores)
|
| 14 |
+
for rank, index in enumerate(ranked, start=1):
|
| 15 |
+
ranks[index] = rank
|
| 16 |
+
return ranks
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def reciprocal_rank_scores(ranks: list[int], *, rrf_k: int = 60) -> list[float]:
|
| 20 |
+
return [1.0 / (rrf_k + rank) if rank > 0 else 0.0 for rank in ranks]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def weighted_rrf_scores(
|
| 24 |
+
bm25_scores: list[float],
|
| 25 |
+
semantic_scores: list[float],
|
| 26 |
+
*,
|
| 27 |
+
bm25_weight: float = 0.5,
|
| 28 |
+
semantic_weight: float = 0.5,
|
| 29 |
+
rrf_k: int = 60,
|
| 30 |
+
) -> tuple[list[float], list[int], list[float], list[int], list[float]]:
|
| 31 |
+
total_weight = bm25_weight + semantic_weight
|
| 32 |
+
if total_weight <= 0:
|
| 33 |
+
raise ValueError("bm25_weight + semantic_weight must be > 0")
|
| 34 |
+
|
| 35 |
+
bm25_weight = bm25_weight / total_weight
|
| 36 |
+
semantic_weight = semantic_weight / total_weight
|
| 37 |
+
|
| 38 |
+
bm25_ranks = rank_scores(bm25_scores, minimum_score=0.0)
|
| 39 |
+
semantic_ranks = rank_scores(semantic_scores)
|
| 40 |
+
bm25_rrf = reciprocal_rank_scores(bm25_ranks, rrf_k=rrf_k)
|
| 41 |
+
semantic_rrf = reciprocal_rank_scores(semantic_ranks, rrf_k=rrf_k)
|
| 42 |
+
final_scores = [
|
| 43 |
+
bm25_weight * bm25_score + semantic_weight * semantic_score
|
| 44 |
+
for bm25_score, semantic_score in zip(bm25_rrf, semantic_rrf, strict=True)
|
| 45 |
+
]
|
| 46 |
+
return final_scores, bm25_ranks, bm25_rrf, semantic_ranks, semantic_rrf
|
metadata_search/mcp_retrieval.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval layer for Flask — MCP tools or direct Python (USE_MCP=0)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from backend.chat_settings import truncate_summary
|
| 8 |
+
from backend.mcp_sync import call_mcp_tool, use_mcp
|
| 9 |
+
from local_rag.summaries import list_papers, summaries_for_documents
|
| 10 |
+
|
| 11 |
+
from metadata_search.saved_papers import delete_saved, list_saved, save_paper
|
| 12 |
+
from metadata_search.search_service import search_metadata
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def search_papers(query: str, *, top_k: int = 10) -> tuple[list[dict[str, Any]], str]:
|
| 16 |
+
from metadata_search.search_service import score_mode_for_results
|
| 17 |
+
|
| 18 |
+
results = search_metadata(query, top_k=top_k)
|
| 19 |
+
return results, score_mode_for_results(results)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def list_saved_papers() -> list[dict[str, Any]]:
|
| 23 |
+
return list_saved()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def save_paper_record(payload: dict[str, Any]) -> dict[str, Any]:
|
| 27 |
+
return save_paper(payload)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def remove_saved_paper(paper_id: str) -> bool:
|
| 31 |
+
return delete_saved(paper_id)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def list_papers_catalog() -> list[dict[str, Any]]:
|
| 35 |
+
return list_papers()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def build_context_for_documents(
|
| 39 |
+
document_ids: list[str],
|
| 40 |
+
*,
|
| 41 |
+
question: str = "",
|
| 42 |
+
) -> tuple[str, list[dict[str, Any]]]:
|
| 43 |
+
if not use_mcp():
|
| 44 |
+
sources = summaries_for_documents(document_ids)
|
| 45 |
+
return _build_summary_context(sources, question=question)
|
| 46 |
+
|
| 47 |
+
if question:
|
| 48 |
+
payload = call_mcp_tool(
|
| 49 |
+
"build_paper_context",
|
| 50 |
+
{"document_ids": document_ids, "question": question},
|
| 51 |
+
)
|
| 52 |
+
else:
|
| 53 |
+
payload = call_mcp_tool("build_paper_context", {"document_ids": document_ids})
|
| 54 |
+
|
| 55 |
+
if payload.get("error"):
|
| 56 |
+
raise ValueError(str(payload["error"]))
|
| 57 |
+
|
| 58 |
+
summary_payload = call_mcp_tool("get_paper_summaries", {"document_ids": document_ids})
|
| 59 |
+
sources = summary_payload.get("sources", [])
|
| 60 |
+
if not sources:
|
| 61 |
+
raise ValueError("No summaries found for the selected papers.")
|
| 62 |
+
return str(payload.get("context", "")), sources
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _build_summary_context(sources: list[dict], *, question: str = "") -> tuple[str, list[dict]]:
|
| 66 |
+
if not sources:
|
| 67 |
+
raise ValueError("No summaries found for the selected papers.")
|
| 68 |
+
n_papers = len(sources)
|
| 69 |
+
parts: list[str] = []
|
| 70 |
+
for index, source in enumerate(sources, start=1):
|
| 71 |
+
parts.append(
|
| 72 |
+
f"Paper {index}: {source['title']}\n"
|
| 73 |
+
f"File: {source['file_name']}\n\n"
|
| 74 |
+
f"{truncate_summary(source['text'], n_papers=n_papers, question=question)}"
|
| 75 |
+
)
|
| 76 |
+
return "\n\n---\n\n".join(parts), sources
|
metadata_search/paper_figures.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Index and serve extracted paper figures from results_metadata folders."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import unicodedata
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from urllib.parse import quote
|
| 10 |
+
|
| 11 |
+
from metadata_search.config import PAPER_FIGURES_DIR
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
STEM_PREFIX_MIN = 40
|
| 16 |
+
|
| 17 |
+
_index: dict[str, str] | None = None
|
| 18 |
+
_manifests: dict[str, dict] | None = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def figures_dir() -> Path | None:
|
| 22 |
+
root = PAPER_FIGURES_DIR
|
| 23 |
+
if not root.is_dir():
|
| 24 |
+
return None
|
| 25 |
+
return root
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def normalize_stem(name: str) -> str:
|
| 29 |
+
text = unicodedata.normalize("NFKC", (name or "").strip())
|
| 30 |
+
lower = text.lower()
|
| 31 |
+
for suffix in (".txt", ".pdf"):
|
| 32 |
+
if lower.endswith(suffix):
|
| 33 |
+
text = text[: -len(suffix)]
|
| 34 |
+
break
|
| 35 |
+
return text.strip().lower()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def stems_match(left: str, right: str, *, min_prefix: int = STEM_PREFIX_MIN) -> bool:
|
| 39 |
+
a = normalize_stem(left)
|
| 40 |
+
b = normalize_stem(right)
|
| 41 |
+
if not a or not b:
|
| 42 |
+
return False
|
| 43 |
+
if a == b:
|
| 44 |
+
return True
|
| 45 |
+
shorter, longer = (a, b) if len(a) <= len(b) else (b, a)
|
| 46 |
+
if len(shorter) >= min_prefix and longer.startswith(shorter):
|
| 47 |
+
return True
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _register_stem(stem_index: dict[str, str], key: str, folder: str) -> None:
|
| 52 |
+
stem = normalize_stem(key)
|
| 53 |
+
if not stem:
|
| 54 |
+
return
|
| 55 |
+
existing = stem_index.get(stem)
|
| 56 |
+
if existing and existing != folder:
|
| 57 |
+
logger.debug("Figure stem collision: %r -> %s (kept %s)", key, folder, existing)
|
| 58 |
+
return
|
| 59 |
+
stem_index[stem] = folder
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _load_index() -> tuple[dict[str, str], dict[str, dict]]:
|
| 63 |
+
global _index, _manifests
|
| 64 |
+
if _index is not None and _manifests is not None:
|
| 65 |
+
return _index, _manifests
|
| 66 |
+
|
| 67 |
+
stem_index: dict[str, str] = {}
|
| 68 |
+
manifests: dict[str, dict] = {}
|
| 69 |
+
root = figures_dir()
|
| 70 |
+
if root is None:
|
| 71 |
+
_index = {}
|
| 72 |
+
_manifests = {}
|
| 73 |
+
return _index, _manifests
|
| 74 |
+
|
| 75 |
+
for entry in sorted(root.iterdir()):
|
| 76 |
+
if not entry.is_dir():
|
| 77 |
+
continue
|
| 78 |
+
manifest_path = entry / "manifest.json"
|
| 79 |
+
if not manifest_path.is_file():
|
| 80 |
+
continue
|
| 81 |
+
try:
|
| 82 |
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 83 |
+
except (OSError, json.JSONDecodeError) as exc:
|
| 84 |
+
logger.warning("Skipping figure manifest %s: %s", manifest_path, exc)
|
| 85 |
+
continue
|
| 86 |
+
|
| 87 |
+
folder = entry.name
|
| 88 |
+
manifests[folder] = manifest
|
| 89 |
+
_register_stem(stem_index, folder, folder)
|
| 90 |
+
|
| 91 |
+
source_pdf = (manifest.get("source_pdf") or "").strip()
|
| 92 |
+
if source_pdf:
|
| 93 |
+
_register_stem(stem_index, Path(source_pdf).name, folder)
|
| 94 |
+
|
| 95 |
+
_index = stem_index
|
| 96 |
+
_manifests = manifests
|
| 97 |
+
logger.info("Indexed figures for %s papers under %s", len(manifests), root)
|
| 98 |
+
return _index, _manifests
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def figure_paper_count() -> int:
|
| 102 |
+
_, manifests = _load_index()
|
| 103 |
+
return len(manifests)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _lookup_folder(*, txt_file: str = "", pdf_file: str = "", paper_key: str = "") -> str | None:
|
| 107 |
+
stem_index, manifests = _load_index()
|
| 108 |
+
if not manifests:
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
if paper_key:
|
| 112 |
+
if paper_key in manifests:
|
| 113 |
+
return paper_key
|
| 114 |
+
target = normalize_stem(paper_key)
|
| 115 |
+
for folder in manifests:
|
| 116 |
+
if normalize_stem(folder) == target:
|
| 117 |
+
return folder
|
| 118 |
+
|
| 119 |
+
for candidate in (txt_file, pdf_file):
|
| 120 |
+
stem = normalize_stem(candidate)
|
| 121 |
+
if not stem:
|
| 122 |
+
continue
|
| 123 |
+
if stem in stem_index:
|
| 124 |
+
return stem_index[stem]
|
| 125 |
+
for key, folder in stem_index.items():
|
| 126 |
+
if stems_match(stem, key):
|
| 127 |
+
return folder
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def resolve_figure_folder(
|
| 132 |
+
*,
|
| 133 |
+
txt_file: str = "",
|
| 134 |
+
pdf_file: str = "",
|
| 135 |
+
paper_key: str = "",
|
| 136 |
+
metadata_row_index: int | None = None,
|
| 137 |
+
) -> str | None:
|
| 138 |
+
folder = _lookup_folder(txt_file=txt_file, pdf_file=pdf_file, paper_key=paper_key)
|
| 139 |
+
if folder or metadata_row_index is None:
|
| 140 |
+
return folder
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
from metadata_search.search_service import get_rows
|
| 144 |
+
|
| 145 |
+
rows = get_rows()
|
| 146 |
+
if metadata_row_index < 0 or metadata_row_index >= len(rows):
|
| 147 |
+
return None
|
| 148 |
+
row = rows[metadata_row_index]
|
| 149 |
+
return _lookup_folder(
|
| 150 |
+
txt_file=(row.get("TXT_file") or row.get("txt_file") or "").strip(),
|
| 151 |
+
pdf_file=(row.get("PDF_file") or row.get("pdf_file") or "").strip(),
|
| 152 |
+
)
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
logger.debug("Figure lookup via metadata_row_index failed: %s", exc)
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def figure_image_url(folder: str, filename: str) -> str:
|
| 159 |
+
return f"/figures/{quote(folder, safe='')}/{quote(filename, safe='')}"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def figures_payload_for_folder(folder: str) -> dict | None:
|
| 163 |
+
_, manifests = _load_index()
|
| 164 |
+
manifest = manifests.get(folder)
|
| 165 |
+
if not manifest:
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
items: list[dict] = []
|
| 169 |
+
for entry in manifest.get("items") or []:
|
| 170 |
+
image = (entry.get("image") or "").strip()
|
| 171 |
+
if not image:
|
| 172 |
+
continue
|
| 173 |
+
items.append(
|
| 174 |
+
{
|
| 175 |
+
"kind": entry.get("kind") or "figure",
|
| 176 |
+
"label": entry.get("label") or "",
|
| 177 |
+
"page": entry.get("page"),
|
| 178 |
+
"caption": (entry.get("caption") or "").strip(),
|
| 179 |
+
"image": image,
|
| 180 |
+
"image_url": figure_image_url(folder, image),
|
| 181 |
+
"caption_file": entry.get("caption_file"),
|
| 182 |
+
"row_count": entry.get("row_count"),
|
| 183 |
+
"column_count": entry.get("column_count"),
|
| 184 |
+
}
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
source_pdf = (manifest.get("source_pdf") or "").strip()
|
| 188 |
+
return {
|
| 189 |
+
"paper_key": folder,
|
| 190 |
+
"source_pdf": Path(source_pdf).name if source_pdf else folder,
|
| 191 |
+
"item_count": len(items),
|
| 192 |
+
"items": items,
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def figures_payload_for_paper(
|
| 197 |
+
*,
|
| 198 |
+
txt_file: str = "",
|
| 199 |
+
pdf_file: str = "",
|
| 200 |
+
paper_key: str = "",
|
| 201 |
+
metadata_row_index: int | None = None,
|
| 202 |
+
) -> dict | None:
|
| 203 |
+
folder = resolve_figure_folder(
|
| 204 |
+
txt_file=txt_file,
|
| 205 |
+
pdf_file=pdf_file,
|
| 206 |
+
paper_key=paper_key,
|
| 207 |
+
metadata_row_index=metadata_row_index,
|
| 208 |
+
)
|
| 209 |
+
if not folder:
|
| 210 |
+
return None
|
| 211 |
+
return figures_payload_for_folder(folder)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def paper_has_figures(
|
| 215 |
+
*,
|
| 216 |
+
txt_file: str = "",
|
| 217 |
+
pdf_file: str = "",
|
| 218 |
+
metadata_row_index: int | None = None,
|
| 219 |
+
) -> bool:
|
| 220 |
+
return resolve_figure_folder(
|
| 221 |
+
txt_file=txt_file,
|
| 222 |
+
pdf_file=pdf_file,
|
| 223 |
+
metadata_row_index=metadata_row_index,
|
| 224 |
+
) is not None
|
metadata_search/qdrant_metadata.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qdrant vector index for paper metadata (title / abstract / keywords)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from qdrant_client import QdrantClient
|
| 13 |
+
from qdrant_client.http import models as qmodels
|
| 14 |
+
|
| 15 |
+
from metadata_search.config import (
|
| 16 |
+
ABSTRACT_COL,
|
| 17 |
+
EMBEDDING_MODEL,
|
| 18 |
+
KEYWORD_COL,
|
| 19 |
+
METADATA_QDRANT_COLLECTION,
|
| 20 |
+
METADATA_QDRANT_MANIFEST,
|
| 21 |
+
METADATA_QDRANT_PATH,
|
| 22 |
+
PAPER_CSV,
|
| 23 |
+
TITLE_COL,
|
| 24 |
+
)
|
| 25 |
+
from metadata_search.corpus import load_rows, paper_text
|
| 26 |
+
from metadata_search.embedder import MetadataEmbedder
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class MetadataSearchHit:
|
| 31 |
+
row_index: int
|
| 32 |
+
score: float
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MetadataVectorStore:
|
| 36 |
+
"""One Qdrant point per papers.csv row."""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
*,
|
| 41 |
+
qdrant_path: Path = METADATA_QDRANT_PATH,
|
| 42 |
+
collection_name: str = METADATA_QDRANT_COLLECTION,
|
| 43 |
+
model_name: str = EMBEDDING_MODEL,
|
| 44 |
+
title_weight: float = 2.0,
|
| 45 |
+
abstract_weight: float = 1.0,
|
| 46 |
+
keyword_weight: float = 1.5,
|
| 47 |
+
batch_size: int = 64,
|
| 48 |
+
) -> None:
|
| 49 |
+
self.qdrant_path = qdrant_path
|
| 50 |
+
self.collection_name = collection_name
|
| 51 |
+
self.model_name = model_name
|
| 52 |
+
self.title_weight = title_weight
|
| 53 |
+
self.abstract_weight = abstract_weight
|
| 54 |
+
self.keyword_weight = keyword_weight
|
| 55 |
+
self.batch_size = batch_size
|
| 56 |
+
self.qdrant_path.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
self.client = QdrantClient(path=str(self.qdrant_path))
|
| 58 |
+
self.manifest_path = METADATA_QDRANT_MANIFEST
|
| 59 |
+
|
| 60 |
+
def points_count(self) -> int:
|
| 61 |
+
if not self.client.collection_exists(self.collection_name):
|
| 62 |
+
return 0
|
| 63 |
+
info = self.client.get_collection(self.collection_name)
|
| 64 |
+
return int(info.points_count or 0)
|
| 65 |
+
|
| 66 |
+
def _content_hash(self, rows: list[dict[str, str]]) -> str:
|
| 67 |
+
digest = hashlib.sha256()
|
| 68 |
+
for row in rows:
|
| 69 |
+
digest.update(paper_text(row).encode("utf-8"))
|
| 70 |
+
digest.update(b"\0")
|
| 71 |
+
digest.update(self.model_name.encode("utf-8"))
|
| 72 |
+
digest.update(
|
| 73 |
+
f"{self.title_weight}:{self.abstract_weight}:{self.keyword_weight}".encode("utf-8")
|
| 74 |
+
)
|
| 75 |
+
return digest.hexdigest()
|
| 76 |
+
|
| 77 |
+
def _load_manifest(self) -> dict[str, Any]:
|
| 78 |
+
if not self.manifest_path.exists():
|
| 79 |
+
return {}
|
| 80 |
+
return json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
| 81 |
+
|
| 82 |
+
def _write_manifest(self, payload: dict[str, Any]) -> None:
|
| 83 |
+
self.manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
self.manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 85 |
+
|
| 86 |
+
def is_current(self, rows: list[dict[str, str]]) -> bool:
|
| 87 |
+
manifest = self._load_manifest()
|
| 88 |
+
if not manifest:
|
| 89 |
+
return False
|
| 90 |
+
if manifest.get("row_count") != len(rows):
|
| 91 |
+
return False
|
| 92 |
+
if manifest.get("content_hash") != self._content_hash(rows):
|
| 93 |
+
return False
|
| 94 |
+
if manifest.get("model_name") != self.model_name:
|
| 95 |
+
return False
|
| 96 |
+
return self.points_count() == len(rows)
|
| 97 |
+
|
| 98 |
+
def build_weighted_vectors(self, rows: list[dict[str, str]]) -> list[list[float]]:
|
| 99 |
+
embedder = MetadataEmbedder(self.model_name, batch_size=self.batch_size)
|
| 100 |
+
title_texts = [str(row.get(TITLE_COL) or "") for row in rows]
|
| 101 |
+
abstract_texts = [str(row.get(ABSTRACT_COL) or "") for row in rows]
|
| 102 |
+
keyword_texts = [str(row.get(KEYWORD_COL) or "") for row in rows]
|
| 103 |
+
field_texts = title_texts + abstract_texts + keyword_texts
|
| 104 |
+
field_embeddings = np.asarray(
|
| 105 |
+
embedder.embed_documents(field_texts),
|
| 106 |
+
dtype=float,
|
| 107 |
+
)
|
| 108 |
+
count = len(rows)
|
| 109 |
+
title_embeddings = field_embeddings[:count]
|
| 110 |
+
abstract_embeddings = field_embeddings[count : 2 * count]
|
| 111 |
+
keyword_embeddings = field_embeddings[2 * count :]
|
| 112 |
+
|
| 113 |
+
title_mask = np.asarray([bool(text.strip()) for text in title_texts], dtype=float)[:, None]
|
| 114 |
+
abstract_mask = np.asarray([bool(text.strip()) for text in abstract_texts], dtype=float)[:, None]
|
| 115 |
+
keyword_mask = np.asarray([bool(text.strip()) for text in keyword_texts], dtype=float)[:, None]
|
| 116 |
+
|
| 117 |
+
combined = (
|
| 118 |
+
self.title_weight * title_embeddings * title_mask
|
| 119 |
+
+ self.abstract_weight * abstract_embeddings * abstract_mask
|
| 120 |
+
+ self.keyword_weight * keyword_embeddings * keyword_mask
|
| 121 |
+
)
|
| 122 |
+
norms = np.linalg.norm(combined, axis=1, keepdims=True)
|
| 123 |
+
combined = np.divide(combined, norms, out=np.zeros_like(combined), where=norms != 0)
|
| 124 |
+
return combined.tolist()
|
| 125 |
+
|
| 126 |
+
def reset_collection(self, vector_size: int) -> None:
|
| 127 |
+
if self.client.collection_exists(self.collection_name):
|
| 128 |
+
self.client.delete_collection(self.collection_name)
|
| 129 |
+
self.client.create_collection(
|
| 130 |
+
collection_name=self.collection_name,
|
| 131 |
+
vectors_config=qmodels.VectorParams(
|
| 132 |
+
size=vector_size,
|
| 133 |
+
distance=qmodels.Distance.COSINE,
|
| 134 |
+
),
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
def upsert_rows(self, rows: list[dict[str, str]], vectors: list[list[float]]) -> None:
|
| 138 |
+
if len(rows) != len(vectors):
|
| 139 |
+
raise ValueError("rows and vectors length mismatch")
|
| 140 |
+
if not rows:
|
| 141 |
+
raise ValueError("No rows to index")
|
| 142 |
+
|
| 143 |
+
vector_size = len(vectors[0])
|
| 144 |
+
self.reset_collection(vector_size)
|
| 145 |
+
|
| 146 |
+
points: list[qmodels.PointStruct] = []
|
| 147 |
+
for row_index, (row, vector) in enumerate(zip(rows, vectors, strict=True)):
|
| 148 |
+
points.append(
|
| 149 |
+
qmodels.PointStruct(
|
| 150 |
+
id=row_index,
|
| 151 |
+
vector=vector,
|
| 152 |
+
payload={
|
| 153 |
+
"row_index": row_index,
|
| 154 |
+
"title": (row.get(TITLE_COL) or "").strip(),
|
| 155 |
+
"year": (row.get("PY") or "").strip(),
|
| 156 |
+
"txt_file": (row.get("TXT_file") or row.get("txt_file") or "").strip(),
|
| 157 |
+
},
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
batch = 1000
|
| 162 |
+
for start in range(0, len(points), batch):
|
| 163 |
+
end = min(start + batch, len(points))
|
| 164 |
+
|
| 165 |
+
self.client.upsert(
|
| 166 |
+
collection_name=self.collection_name,
|
| 167 |
+
points=points[start:end],
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
print(f"Qdrant indexing: {end}/{len(points)} points")
|
| 171 |
+
|
| 172 |
+
self._write_manifest(
|
| 173 |
+
{
|
| 174 |
+
"csv_path": str(Path(PAPER_CSV).resolve()),
|
| 175 |
+
"model_name": self.model_name,
|
| 176 |
+
"row_count": len(rows),
|
| 177 |
+
"content_hash": self._content_hash(rows),
|
| 178 |
+
"title_weight": self.title_weight,
|
| 179 |
+
"abstract_weight": self.abstract_weight,
|
| 180 |
+
"keyword_weight": self.keyword_weight,
|
| 181 |
+
"collection_name": self.collection_name,
|
| 182 |
+
"qdrant_path": str(self.qdrant_path.resolve()),
|
| 183 |
+
}
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
def rebuild(self, rows: list[dict[str, str]] | None = None, *, force: bool = False) -> dict[str, Any]:
|
| 187 |
+
rows = rows if rows is not None else load_rows(PAPER_CSV)
|
| 188 |
+
if not force and self.is_current(rows):
|
| 189 |
+
return {
|
| 190 |
+
"rebuilt": False,
|
| 191 |
+
"row_count": len(rows),
|
| 192 |
+
"points_count": self.points_count(),
|
| 193 |
+
"message": "Metadata vector index is already up to date.",
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
vectors = self.build_weighted_vectors(rows)
|
| 197 |
+
self.upsert_rows(rows, vectors)
|
| 198 |
+
return {
|
| 199 |
+
"rebuilt": True,
|
| 200 |
+
"row_count": len(rows),
|
| 201 |
+
"points_count": self.points_count(),
|
| 202 |
+
"model_name": self.model_name,
|
| 203 |
+
"qdrant_path": str(self.qdrant_path.resolve()),
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
def search(self, query_vector: list[float], *, limit: int) -> list[MetadataSearchHit]:
|
| 207 |
+
if not self.client.collection_exists(self.collection_name):
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
response = self.client.query_points(
|
| 211 |
+
collection_name=self.collection_name,
|
| 212 |
+
query=query_vector,
|
| 213 |
+
limit=limit,
|
| 214 |
+
with_payload=True,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
hits: list[MetadataSearchHit] = []
|
| 218 |
+
for point in response.points:
|
| 219 |
+
payload = point.payload or {}
|
| 220 |
+
row_index = int(payload.get("row_index", point.id))
|
| 221 |
+
hits.append(
|
| 222 |
+
MetadataSearchHit(
|
| 223 |
+
row_index=row_index,
|
| 224 |
+
score=float(point.score or 0.0),
|
| 225 |
+
)
|
| 226 |
+
)
|
| 227 |
+
return hits
|
| 228 |
+
|
| 229 |
+
def semantic_scores(self, query_vector: list[float], row_count: int) -> list[float]:
|
| 230 |
+
"""Cosine similarity for every row (via Qdrant top-N over full collection)."""
|
| 231 |
+
scores = [0.0] * row_count
|
| 232 |
+
if row_count <= 0:
|
| 233 |
+
return scores
|
| 234 |
+
for hit in self.search(query_vector, limit=row_count):
|
| 235 |
+
if 0 <= hit.row_index < row_count:
|
| 236 |
+
scores[hit.row_index] = hit.score
|
| 237 |
+
return scores
|
metadata_search/saved_papers.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from backend.paper_linking import enrich_metadata_row, format_authors
|
| 11 |
+
from metadata_search.corpus import load_rows
|
| 12 |
+
from metadata_search.config import PAPER_CSV, PROJECT_ROOT
|
| 13 |
+
|
| 14 |
+
_metadata_rows: list[dict[str, str]] | None = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_metadata_rows() -> list[dict[str, str]]:
|
| 18 |
+
global _metadata_rows
|
| 19 |
+
if _metadata_rows is None:
|
| 20 |
+
_metadata_rows = load_rows(PAPER_CSV)
|
| 21 |
+
return _metadata_rows
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def enrich_saved_record(paper: dict[str, Any]) -> dict[str, Any]:
|
| 25 |
+
"""Fill authors (and display helpers) from bibliographic CSV when missing."""
|
| 26 |
+
enriched = dict(paper)
|
| 27 |
+
row_index = paper.get("metadata_row_index")
|
| 28 |
+
if row_index is not None and not enriched.get("authors"):
|
| 29 |
+
rows = _get_metadata_rows()
|
| 30 |
+
if 0 <= row_index < len(rows):
|
| 31 |
+
meta = enrich_metadata_row(rows[row_index], row_index)
|
| 32 |
+
enriched["authors"] = meta["authors"]
|
| 33 |
+
authors = (enriched.get("authors") or "").strip()
|
| 34 |
+
enriched["authors_display"] = format_authors(authors)
|
| 35 |
+
return enriched
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _store_path() -> Path:
|
| 39 |
+
override = os.getenv("SAVED_PAPERS_PATH")
|
| 40 |
+
if override:
|
| 41 |
+
return Path(override).expanduser().resolve()
|
| 42 |
+
return PROJECT_ROOT / "local_rag_data" / "saved_papers.json"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _now_iso() -> str:
|
| 46 |
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _load_all() -> list[dict[str, Any]]:
|
| 50 |
+
path = _store_path()
|
| 51 |
+
if not path.exists():
|
| 52 |
+
return []
|
| 53 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _save_all(papers: list[dict[str, Any]]) -> None:
|
| 57 |
+
path = _store_path()
|
| 58 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
path.write_text(json.dumps(papers, indent=2), encoding="utf-8")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _dedupe_key(paper: dict[str, Any]) -> str | None:
|
| 63 |
+
document_id = (paper.get("document_id") or "").strip()
|
| 64 |
+
if document_id:
|
| 65 |
+
return f"doc:{document_id}"
|
| 66 |
+
row_index = paper.get("metadata_row_index")
|
| 67 |
+
if row_index is not None:
|
| 68 |
+
return f"row:{row_index}"
|
| 69 |
+
doi = (paper.get("doi") or "").strip().lower()
|
| 70 |
+
if doi:
|
| 71 |
+
return f"doi:{doi}"
|
| 72 |
+
title = (paper.get("title") or "").strip().lower()
|
| 73 |
+
if title:
|
| 74 |
+
return f"title:{title}"
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def list_saved() -> list[dict[str, Any]]:
|
| 79 |
+
papers = [enrich_saved_record(paper) for paper in _load_all()]
|
| 80 |
+
papers.sort(key=lambda item: item.get("saved_at", ""), reverse=True)
|
| 81 |
+
return papers
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_saved(*, document_id: str | None = None, metadata_row_index: int | None = None) -> bool:
|
| 85 |
+
for paper in _load_all():
|
| 86 |
+
if document_id and paper.get("document_id") == document_id:
|
| 87 |
+
return True
|
| 88 |
+
if metadata_row_index is not None and paper.get("metadata_row_index") == metadata_row_index:
|
| 89 |
+
return True
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def save_paper(payload: dict[str, Any]) -> dict[str, Any]:
|
| 94 |
+
papers = _load_all()
|
| 95 |
+
candidate = {
|
| 96 |
+
"id": payload.get("id") or str(uuid.uuid4()),
|
| 97 |
+
"title": (payload.get("title") or "").strip(),
|
| 98 |
+
"authors": (payload.get("authors") or "").strip(),
|
| 99 |
+
"abstract": (payload.get("abstract") or "").strip(),
|
| 100 |
+
"keywords": (payload.get("keywords") or "").strip(),
|
| 101 |
+
"doi": (payload.get("doi") or "").strip(),
|
| 102 |
+
"year": (payload.get("year") or "").strip(),
|
| 103 |
+
"document_id": (payload.get("document_id") or "").strip() or None,
|
| 104 |
+
"metadata_row_index": payload.get("metadata_row_index"),
|
| 105 |
+
"txt_file": (payload.get("txt_file") or "").strip(),
|
| 106 |
+
"source": payload.get("source") or "metadata",
|
| 107 |
+
"score_label": payload.get("score_label"),
|
| 108 |
+
"rrf_score": payload.get("rrf_score"),
|
| 109 |
+
"final_score": payload.get("final_score"),
|
| 110 |
+
"bm25_score": payload.get("bm25_score"),
|
| 111 |
+
"semantic_score": payload.get("semantic_score"),
|
| 112 |
+
"bm25_rank": payload.get("bm25_rank"),
|
| 113 |
+
"semantic_rank": payload.get("semantic_rank"),
|
| 114 |
+
"bm25_rrf_score": payload.get("bm25_rrf_score"),
|
| 115 |
+
"semantic_rrf_score": payload.get("semantic_rrf_score"),
|
| 116 |
+
"saved_at": payload.get("saved_at") or _now_iso(),
|
| 117 |
+
}
|
| 118 |
+
if not candidate["title"]:
|
| 119 |
+
raise ValueError("title is required")
|
| 120 |
+
|
| 121 |
+
key = _dedupe_key(candidate)
|
| 122 |
+
if key:
|
| 123 |
+
for index, existing in enumerate(papers):
|
| 124 |
+
if _dedupe_key(existing) == key:
|
| 125 |
+
merged = enrich_saved_record(
|
| 126 |
+
{**existing, **candidate, "id": existing["id"], "saved_at": _now_iso()}
|
| 127 |
+
)
|
| 128 |
+
papers[index] = merged
|
| 129 |
+
_save_all(papers)
|
| 130 |
+
return merged
|
| 131 |
+
|
| 132 |
+
saved = enrich_saved_record(candidate)
|
| 133 |
+
papers.append(saved)
|
| 134 |
+
_save_all(papers)
|
| 135 |
+
return saved
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def delete_saved(paper_id: str) -> bool:
|
| 139 |
+
papers = _load_all()
|
| 140 |
+
remaining = [paper for paper in papers if paper.get("id") != paper_id]
|
| 141 |
+
if len(remaining) == len(papers):
|
| 142 |
+
return False
|
| 143 |
+
_save_all(remaining)
|
| 144 |
+
return True
|
metadata_search/search_query_planner.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-backed search query planning for agent retrieval (Scholar-style operators)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
from metadata_search.boolean_query import parse_boolean_query, plan_summary
|
| 11 |
+
|
| 12 |
+
_JSON_BLOCK = re.compile(r"\{[\s\S]*\}")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class SearchPlan:
|
| 17 |
+
search_query: str
|
| 18 |
+
selection_hint: str
|
| 19 |
+
planner: str
|
| 20 |
+
parsed: dict[str, object]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _extract_json_object(text: str) -> dict[str, object]:
|
| 24 |
+
match = _JSON_BLOCK.search(text or "")
|
| 25 |
+
if not match:
|
| 26 |
+
raise ValueError("Search planner did not return JSON.")
|
| 27 |
+
payload = json.loads(match.group(0))
|
| 28 |
+
if not isinstance(payload, dict):
|
| 29 |
+
raise ValueError("Search planner JSON must be an object.")
|
| 30 |
+
return payload
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _normalize_plan(raw: dict[str, object], *, planner: str, fallback_message: str) -> SearchPlan:
|
| 34 |
+
search_query = str(raw.get("search_query") or raw.get("query") or "").strip()
|
| 35 |
+
selection_hint = str(raw.get("selection_hint") or raw.get("focus") or fallback_message).strip()
|
| 36 |
+
if not search_query:
|
| 37 |
+
raise ValueError("Search planner JSON missing search_query.")
|
| 38 |
+
parsed = parse_boolean_query(search_query)
|
| 39 |
+
return SearchPlan(
|
| 40 |
+
search_query=parsed.raw,
|
| 41 |
+
selection_hint=selection_hint or fallback_message,
|
| 42 |
+
planner=planner,
|
| 43 |
+
parsed=plan_summary(parsed),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _plan_with_rules(message: str) -> SearchPlan:
|
| 48 |
+
text = message.strip()
|
| 49 |
+
parsed = parse_boolean_query(text)
|
| 50 |
+
return SearchPlan(
|
| 51 |
+
search_query=parsed.raw,
|
| 52 |
+
selection_hint=text,
|
| 53 |
+
planner="rules",
|
| 54 |
+
parsed=plan_summary(parsed),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _plan_with_llm(message: str) -> SearchPlan:
|
| 59 |
+
from backend.waterscope_agent import run_agent
|
| 60 |
+
|
| 61 |
+
instructions = (
|
| 62 |
+
"You convert research chat messages into bibliographic search queries.\n"
|
| 63 |
+
"Do NOT include conversational prefixes like 'find papers on'.\n"
|
| 64 |
+
"Do NOT include filler words (though, however, please, just, really, etc.) in search_query.\n"
|
| 65 |
+
"Use Google Scholar-style operators when they help:\n"
|
| 66 |
+
'- "exact phrase" for multi-word concepts\n'
|
| 67 |
+
"- +term for required concepts\n"
|
| 68 |
+
"- -term to exclude a single word\n"
|
| 69 |
+
'- -"exact phrase" to exclude multi-word topics (always quote multi-word exclusions)\n'
|
| 70 |
+
"- plain terms for optional relevance\n\n"
|
| 71 |
+
'Example: "microplastics not drinking water though" → search_query: microplastics -"drinking water"\n\n'
|
| 72 |
+
'Return ONLY one JSON object, no markdown:\n'
|
| 73 |
+
'{"search_query":"...", "selection_hint":"what kinds of papers best answer the user"}\n'
|
| 74 |
+
)
|
| 75 |
+
raw, _ = run_agent(
|
| 76 |
+
context=instructions,
|
| 77 |
+
question=message,
|
| 78 |
+
model=os.getenv("AGENT_SEARCH_PLANNER_MODEL") or os.getenv("WATERSCOPE_MODEL", "dpo"),
|
| 79 |
+
application="general_qa",
|
| 80 |
+
max_new_tokens=int(os.getenv("AGENT_SEARCH_PLANNER_MAX_TOKENS", "256")),
|
| 81 |
+
)
|
| 82 |
+
payload = _extract_json_object(raw)
|
| 83 |
+
return _normalize_plan(payload, planner="llm", fallback_message=message)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def planner_backend() -> str:
|
| 87 |
+
explicit = os.getenv("AGENT_SEARCH_PLANNER", "auto").strip().lower()
|
| 88 |
+
if explicit in {"llm", "rules"}:
|
| 89 |
+
return explicit
|
| 90 |
+
return "auto"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def plan_search_query(message: str, *, explicit_query: str = "") -> SearchPlan:
|
| 94 |
+
"""Build the metadata search string used by agent MCP tools."""
|
| 95 |
+
if explicit_query.strip():
|
| 96 |
+
parsed = parse_boolean_query(explicit_query.strip())
|
| 97 |
+
return SearchPlan(
|
| 98 |
+
search_query=parsed.raw,
|
| 99 |
+
selection_hint=message.strip() or explicit_query.strip(),
|
| 100 |
+
planner="explicit",
|
| 101 |
+
parsed=plan_summary(parsed),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
backend = planner_backend()
|
| 105 |
+
if backend == "rules":
|
| 106 |
+
return _plan_with_rules(message)
|
| 107 |
+
if backend == "llm":
|
| 108 |
+
return _plan_with_llm(message)
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
return _plan_with_llm(message)
|
| 112 |
+
except Exception:
|
| 113 |
+
return _plan_with_rules(message)
|
metadata_search/search_service.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid metadata search: BM25 + Qdrant semantic vectors fused with weighted RRF."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
from backend.paper_linking import enrich_metadata_row, format_authors
|
| 9 |
+
from metadata_search.boolean_query import matches_boolean_query, parse_boolean_query, plan_summary
|
| 10 |
+
from metadata_search.bm25_search import BM25Index
|
| 11 |
+
from metadata_search.config import PAPER_CSV
|
| 12 |
+
from metadata_search.corpus import load_rows
|
| 13 |
+
from metadata_search.embedder import MetadataEmbedder
|
| 14 |
+
from metadata_search.hybrid_search import weighted_rrf_scores
|
| 15 |
+
from metadata_search.qdrant_metadata import MetadataVectorStore
|
| 16 |
+
from metadata_search.saved_papers import is_saved, list_saved
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from metadata_search.paper_figures import paper_has_figures as _paper_has_figures
|
| 20 |
+
except Exception:
|
| 21 |
+
def _paper_has_figures(**_kwargs: object) -> bool:
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
DEFAULT_TOP_K = 10
|
| 25 |
+
BM25_WEIGHT = 0.5
|
| 26 |
+
SEMANTIC_WEIGHT = 0.5
|
| 27 |
+
RRF_K = 60
|
| 28 |
+
TITLE_WEIGHT = 2.0
|
| 29 |
+
KEYWORD_WEIGHT = 1.5
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
_rows: list[dict[str, str]] | None = None
|
| 34 |
+
_bm25_index: BM25Index | None = None
|
| 35 |
+
_vector_store: MetadataVectorStore | None = None
|
| 36 |
+
_embedder: MetadataEmbedder | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def use_semantic_search() -> bool:
|
| 40 |
+
"""Hybrid BM25 + Qdrant semantic RRF when true; BM25-only fallback when false."""
|
| 41 |
+
return os.getenv("METADATA_SEMANTIC_SEARCH", "1").lower() in {"1", "true", "yes"}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def metadata_auto_rebuild_enabled() -> bool:
|
| 45 |
+
"""Whether this runtime is allowed to create/rebuild the metadata vector index."""
|
| 46 |
+
return os.getenv("METADATA_AUTO_REBUILD", "0").lower() in {"1", "true", "yes"}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def search_score_mode() -> str:
|
| 50 |
+
return "rrf" if use_semantic_search() else "bm25"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def score_mode_for_results(results: list[dict[str, object]]) -> str:
|
| 54 |
+
if not results:
|
| 55 |
+
return search_score_mode()
|
| 56 |
+
return "rrf" if results[0].get("score_label") == "RRF" else "bm25"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_rows() -> list[dict[str, str]]:
|
| 60 |
+
global _rows
|
| 61 |
+
if _rows is None:
|
| 62 |
+
_rows = load_rows(PAPER_CSV)
|
| 63 |
+
return _rows
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_bm25_index() -> BM25Index:
|
| 67 |
+
global _bm25_index
|
| 68 |
+
if _bm25_index is None:
|
| 69 |
+
_bm25_index = BM25Index(
|
| 70 |
+
get_rows(),
|
| 71 |
+
title_weight=TITLE_WEIGHT,
|
| 72 |
+
keyword_weight=KEYWORD_WEIGHT,
|
| 73 |
+
)
|
| 74 |
+
return _bm25_index
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_vector_store() -> MetadataVectorStore:
|
| 78 |
+
global _vector_store
|
| 79 |
+
if _vector_store is None:
|
| 80 |
+
_vector_store = MetadataVectorStore()
|
| 81 |
+
return _vector_store
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_embedder() -> MetadataEmbedder:
|
| 85 |
+
global _embedder
|
| 86 |
+
if _embedder is None:
|
| 87 |
+
_embedder = MetadataEmbedder()
|
| 88 |
+
return _embedder
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def ensure_metadata_index(*, force: bool = False) -> dict:
|
| 92 |
+
"""Build or refresh the metadata Qdrant index when papers.csv changes."""
|
| 93 |
+
rows = get_rows()
|
| 94 |
+
store = get_vector_store()
|
| 95 |
+
if force or not store.is_current(rows):
|
| 96 |
+
logger.info("Rebuilding metadata vector index for %s papers …", len(rows))
|
| 97 |
+
return store.rebuild(rows, force=True)
|
| 98 |
+
return {
|
| 99 |
+
"rebuilt": False,
|
| 100 |
+
"row_count": len(rows),
|
| 101 |
+
"points_count": store.points_count(),
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _score_fields(
|
| 106 |
+
final_score: float,
|
| 107 |
+
bm25_score: float,
|
| 108 |
+
*,
|
| 109 |
+
use_semantic: bool,
|
| 110 |
+
) -> dict[str, object]:
|
| 111 |
+
return {
|
| 112 |
+
"final_score": final_score,
|
| 113 |
+
"rrf_score": final_score if use_semantic else None,
|
| 114 |
+
"score": final_score,
|
| 115 |
+
"score_label": "RRF" if use_semantic else "BM25",
|
| 116 |
+
"bm25_score": bm25_score,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def hybrid_search(query: str, *, top_k: int = DEFAULT_TOP_K, semantic: bool | None = None) -> list[dict[str, object]]:
|
| 121 |
+
rows = get_rows()
|
| 122 |
+
parsed = parse_boolean_query(query)
|
| 123 |
+
ranking_query = parsed.ranking_text or query
|
| 124 |
+
bm25_scores = get_bm25_index().score(ranking_query)
|
| 125 |
+
use_semantic = use_semantic_search() if semantic is None else semantic
|
| 126 |
+
|
| 127 |
+
semantic_scores: list[float]
|
| 128 |
+
if use_semantic:
|
| 129 |
+
try:
|
| 130 |
+
store = get_vector_store()
|
| 131 |
+
if store.points_count() == 0:
|
| 132 |
+
if metadata_auto_rebuild_enabled():
|
| 133 |
+
ensure_metadata_index(force=True)
|
| 134 |
+
else:
|
| 135 |
+
raise RuntimeError(
|
| 136 |
+
"Prebuilt Qdrant index is unavailable and METADATA_AUTO_REBUILD=0; "
|
| 137 |
+
"falling back to BM25 search."
|
| 138 |
+
)
|
| 139 |
+
query_vector = get_embedder().embed_query(ranking_query)
|
| 140 |
+
semantic_scores = store.semantic_scores(query_vector, len(rows))
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
logger.warning("Semantic search unavailable, using BM25 only: %s", exc)
|
| 143 |
+
use_semantic = False
|
| 144 |
+
semantic_scores = [0.0] * len(bm25_scores)
|
| 145 |
+
else:
|
| 146 |
+
semantic_scores = [0.0] * len(bm25_scores)
|
| 147 |
+
|
| 148 |
+
if use_semantic:
|
| 149 |
+
final_scores, bm25_ranks, bm25_rrf, semantic_ranks, semantic_rrf = weighted_rrf_scores(
|
| 150 |
+
bm25_scores,
|
| 151 |
+
semantic_scores,
|
| 152 |
+
bm25_weight=BM25_WEIGHT,
|
| 153 |
+
semantic_weight=SEMANTIC_WEIGHT,
|
| 154 |
+
rrf_k=RRF_K,
|
| 155 |
+
)
|
| 156 |
+
else:
|
| 157 |
+
ranked = sorted(
|
| 158 |
+
range(len(bm25_scores)),
|
| 159 |
+
key=lambda index: bm25_scores[index],
|
| 160 |
+
reverse=True,
|
| 161 |
+
)
|
| 162 |
+
bm25_ranks = [0] * len(bm25_scores)
|
| 163 |
+
for rank, index in enumerate(ranked, start=1):
|
| 164 |
+
bm25_ranks[index] = rank
|
| 165 |
+
semantic_ranks = [0] * len(bm25_scores)
|
| 166 |
+
final_scores = list(bm25_scores)
|
| 167 |
+
bm25_rrf = list(bm25_scores)
|
| 168 |
+
semantic_rrf = [0.0] * len(bm25_scores)
|
| 169 |
+
|
| 170 |
+
ranked_indices = sorted(
|
| 171 |
+
range(len(final_scores)),
|
| 172 |
+
key=lambda index: final_scores[index],
|
| 173 |
+
reverse=True,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
results: list[dict[str, object]] = []
|
| 177 |
+
for row_index in ranked_indices:
|
| 178 |
+
row = rows[row_index]
|
| 179 |
+
enriched = enrich_metadata_row(row, row_index)
|
| 180 |
+
match_row = {
|
| 181 |
+
"title": enriched["title"],
|
| 182 |
+
"abstract": enriched["abstract"],
|
| 183 |
+
"full_abstract": enriched["abstract"],
|
| 184 |
+
"keywords": enriched["keywords"],
|
| 185 |
+
}
|
| 186 |
+
if not matches_boolean_query(match_row, parsed):
|
| 187 |
+
continue
|
| 188 |
+
|
| 189 |
+
rank = len(results) + 1
|
| 190 |
+
abstract = enriched["abstract"]
|
| 191 |
+
display_abstract = abstract
|
| 192 |
+
if len(display_abstract) > 450:
|
| 193 |
+
display_abstract = f"{display_abstract[:450].rstrip()}..."
|
| 194 |
+
saved = is_saved(
|
| 195 |
+
document_id=enriched.get("document_id"),
|
| 196 |
+
metadata_row_index=enriched.get("metadata_row_index"),
|
| 197 |
+
)
|
| 198 |
+
txt_file = enriched.get("txt_file", "")
|
| 199 |
+
pdf_file = (row.get("PDF_file") or row.get("pdf_file") or "").strip()
|
| 200 |
+
has_figures = _paper_has_figures(
|
| 201 |
+
txt_file=txt_file,
|
| 202 |
+
pdf_file=pdf_file,
|
| 203 |
+
metadata_row_index=enriched.get("metadata_row_index"),
|
| 204 |
+
)
|
| 205 |
+
results.append(
|
| 206 |
+
{
|
| 207 |
+
"rank": rank,
|
| 208 |
+
**_score_fields(
|
| 209 |
+
final_scores[row_index],
|
| 210 |
+
bm25_scores[row_index],
|
| 211 |
+
use_semantic=use_semantic,
|
| 212 |
+
),
|
| 213 |
+
"semantic_score": semantic_scores[row_index],
|
| 214 |
+
"bm25_rank": bm25_ranks[row_index],
|
| 215 |
+
"semantic_rank": semantic_ranks[row_index],
|
| 216 |
+
"bm25_rrf_score": bm25_rrf[row_index] if use_semantic else bm25_scores[row_index],
|
| 217 |
+
"semantic_rrf_score": semantic_rrf[row_index] if use_semantic else 0.0,
|
| 218 |
+
"title": enriched["title"],
|
| 219 |
+
"authors": enriched["authors"],
|
| 220 |
+
"authors_display": format_authors(enriched["authors"]),
|
| 221 |
+
"abstract": display_abstract,
|
| 222 |
+
"full_abstract": abstract,
|
| 223 |
+
"keywords": enriched["keywords"],
|
| 224 |
+
"year": enriched["year"],
|
| 225 |
+
"doi": enriched["doi"],
|
| 226 |
+
"document_id": enriched.get("document_id"),
|
| 227 |
+
"metadata_row_index": enriched.get("metadata_row_index"),
|
| 228 |
+
"txt_file": txt_file,
|
| 229 |
+
"pdf_file": pdf_file,
|
| 230 |
+
"has_figures": has_figures,
|
| 231 |
+
"is_saved": saved,
|
| 232 |
+
"search_query_parsed": plan_summary(parsed),
|
| 233 |
+
}
|
| 234 |
+
)
|
| 235 |
+
if len(results) >= top_k:
|
| 236 |
+
break
|
| 237 |
+
return results
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def document_ids_from_ranks(results: list[dict[str, object]], ranks: list[int]) -> list[str]:
|
| 241 |
+
by_rank = {int(item["rank"]): item for item in results}
|
| 242 |
+
ids: list[str] = []
|
| 243 |
+
for rank in ranks:
|
| 244 |
+
item = by_rank.get(rank)
|
| 245 |
+
if not item:
|
| 246 |
+
raise ValueError(f"No search result at rank {rank}")
|
| 247 |
+
doc_id = item.get("document_id")
|
| 248 |
+
if not doc_id:
|
| 249 |
+
raise ValueError(f"Result #{rank} has no linked chat document_id")
|
| 250 |
+
ids.append(str(doc_id))
|
| 251 |
+
return ids
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def search_metadata(query: str, *, top_k: int = DEFAULT_TOP_K) -> list[dict[str, object]]:
|
| 255 |
+
"""Metadata search with save_payload for MCP and API consumers."""
|
| 256 |
+
base_results = hybrid_search(query, top_k=top_k)
|
| 257 |
+
results: list[dict[str, object]] = []
|
| 258 |
+
saved_list = list_saved()
|
| 259 |
+
for item in base_results:
|
| 260 |
+
saved = bool(item.get("is_saved"))
|
| 261 |
+
saved_match = next(
|
| 262 |
+
(
|
| 263 |
+
saved_item
|
| 264 |
+
for saved_item in saved_list
|
| 265 |
+
if saved_item.get("metadata_row_index") == item.get("metadata_row_index")
|
| 266 |
+
or (
|
| 267 |
+
item.get("document_id")
|
| 268 |
+
and saved_item.get("document_id") == item.get("document_id")
|
| 269 |
+
)
|
| 270 |
+
),
|
| 271 |
+
None,
|
| 272 |
+
)
|
| 273 |
+
save_payload = {
|
| 274 |
+
"title": item["title"],
|
| 275 |
+
"authors": item.get("authors", ""),
|
| 276 |
+
"abstract": item.get("full_abstract") or item["abstract"],
|
| 277 |
+
"keywords": item["keywords"],
|
| 278 |
+
"doi": item["doi"],
|
| 279 |
+
"year": item["year"],
|
| 280 |
+
"document_id": item.get("document_id"),
|
| 281 |
+
"metadata_row_index": item.get("metadata_row_index"),
|
| 282 |
+
"txt_file": item.get("txt_file", ""),
|
| 283 |
+
"pdf_file": item.get("pdf_file", ""),
|
| 284 |
+
"source": "metadata",
|
| 285 |
+
"score_label": item.get("score_label"),
|
| 286 |
+
"rrf_score": item.get("rrf_score"),
|
| 287 |
+
"final_score": item.get("final_score"),
|
| 288 |
+
"bm25_score": item.get("bm25_score"),
|
| 289 |
+
"semantic_score": item.get("semantic_score"),
|
| 290 |
+
"bm25_rank": item.get("bm25_rank"),
|
| 291 |
+
"semantic_rank": item.get("semantic_rank"),
|
| 292 |
+
"bm25_rrf_score": item.get("bm25_rrf_score"),
|
| 293 |
+
"semantic_rrf_score": item.get("semantic_rrf_score"),
|
| 294 |
+
}
|
| 295 |
+
if saved_match:
|
| 296 |
+
save_payload["saved_id"] = saved_match["id"]
|
| 297 |
+
results.append(
|
| 298 |
+
{
|
| 299 |
+
**item,
|
| 300 |
+
"abstract": item["abstract"],
|
| 301 |
+
"is_saved": saved,
|
| 302 |
+
"save_payload": save_payload,
|
| 303 |
+
}
|
| 304 |
+
)
|
| 305 |
+
return results
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def auto_rebuild_on_startup() -> None:
|
| 309 |
+
if not use_semantic_search():
|
| 310 |
+
return
|
| 311 |
+
if not metadata_auto_rebuild_enabled():
|
| 312 |
+
return
|
| 313 |
+
try:
|
| 314 |
+
ensure_metadata_index(force=False)
|
| 315 |
+
except Exception as exc:
|
| 316 |
+
logger.warning("Metadata index auto-rebuild skipped: %s", exc)
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WaterScopeAI Search System - CPU Space
|
| 2 |
+
Flask>=3.0,<4
|
| 3 |
+
python-dotenv>=1.0,<2
|
| 4 |
+
httpx>=0.27,<1
|
| 5 |
+
gradio_client>=1.4,<2
|
| 6 |
+
huggingface_hub>=0.27,<2
|
| 7 |
+
|
| 8 |
+
# MCP 1.x is required by the current FastMCP/client implementation.
|
| 9 |
+
mcp>=1.0,<2
|
| 10 |
+
|
| 11 |
+
# Metadata retrieval
|
| 12 |
+
qdrant-client>=1.11,<2
|
| 13 |
+
sentence-transformers>=3.0,<4
|
| 14 |
+
rank-bm25>=0.2.2,<1
|
| 15 |
+
numpy>=1.26,<3
|
| 16 |
+
pandas>=2.0,<3
|
retrieval_tools.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bibliographic search and summary context for the WaterScope HF Space."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
_ROOT = Path(__file__).resolve().parent
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _configure_paths() -> None:
|
| 13 |
+
os.environ.setdefault("PAPER_CSV", str(_ROOT / "metadata_search" / "data" / "papers.csv"))
|
| 14 |
+
os.environ.setdefault("METADATA_QDRANT_PATH", str(_ROOT / "metadata_search_data" / "qdrant"))
|
| 15 |
+
os.environ.setdefault("LOCAL_RAG_DATA_DIR", str(_ROOT / "local_rag_data"))
|
| 16 |
+
os.environ.setdefault("PAPER_SUMMARIES_DIR", str(_ROOT / "paper_summaries"))
|
| 17 |
+
os.environ.setdefault("METADATA_SEMANTIC_SEARCH", os.getenv("METADATA_SEMANTIC_SEARCH", "1"))
|
| 18 |
+
os.environ.setdefault("METADATA_AUTO_REBUILD", "0")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
_configure_paths()
|
| 22 |
+
|
| 23 |
+
from mcp_tools.local_rag_tools import build_paper_context_tool
|
| 24 |
+
from mcp_tools.metadata_tools import search_metadata_tool, select_papers_from_search_tool
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def search_papers(query: str, top_k: int = 10) -> dict:
|
| 28 |
+
return json.loads(search_metadata_tool(query, top_k=top_k))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def select_papers(query: str, ranks: list[int], top_k: int = 10) -> dict:
|
| 32 |
+
return json.loads(select_papers_from_search_tool(query, ranks, top_k=top_k))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def build_context(document_ids: list[str], question: str = "") -> dict:
|
| 36 |
+
return json.loads(build_paper_context_tool(document_ids, question=question))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def format_search_results(payload: dict) -> str:
|
| 40 |
+
lines: list[str] = []
|
| 41 |
+
for row in payload.get("results", []):
|
| 42 |
+
authors = (row.get("authors_display") or row.get("authors") or "").strip()
|
| 43 |
+
lines.append(f"{row.get('rank')}. {row.get('title')} ({row.get('year', 'n.d.')})")
|
| 44 |
+
if authors:
|
| 45 |
+
lines.append(f" {authors}")
|
| 46 |
+
abstract = (row.get("abstract") or "").strip()
|
| 47 |
+
if abstract:
|
| 48 |
+
lines.append(f" {abstract[:220]}{'…' if len(abstract) > 220 else ''}")
|
| 49 |
+
lines.append("")
|
| 50 |
+
if not lines:
|
| 51 |
+
return "No papers matched that query."
|
| 52 |
+
mode = payload.get("score_mode", "rrf")
|
| 53 |
+
return f"Score mode: {mode}\n\n" + "\n".join(lines).strip()
|
waterscope_retrieval_server.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP retrieval tools used by the agent loop (summaries)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv(override=True)
|
| 10 |
+
|
| 11 |
+
from mcp.server.fastmcp import FastMCP
|
| 12 |
+
|
| 13 |
+
from mcp_tools.local_rag_tools import build_paper_context_tool, get_paper_summaries_tool
|
| 14 |
+
from mcp_tools.metadata_tools import search_metadata_tool, select_papers_from_search_tool
|
| 15 |
+
|
| 16 |
+
mcp = FastMCP("waterscope-retrieval")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@mcp.tool()
|
| 20 |
+
def search_metadata(query: str, top_k: int = 10) -> str:
|
| 21 |
+
"""Search title, abstract, and keywords in the bibliographic CSV."""
|
| 22 |
+
return search_metadata_tool(query, top_k=top_k)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@mcp.tool()
|
| 26 |
+
def select_papers_from_search(query: str, ranks: list[int], top_k: int = 10) -> str:
|
| 27 |
+
"""Return document_ids for 1-based search result ranks."""
|
| 28 |
+
return select_papers_from_search_tool(query, ranks, top_k=top_k)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@mcp.tool()
|
| 32 |
+
def get_paper_summaries(document_ids: list[str]) -> str:
|
| 33 |
+
"""Load pre-written summaries for chat context."""
|
| 34 |
+
return get_paper_summaries_tool(document_ids)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@mcp.tool()
|
| 38 |
+
def build_paper_context(document_ids: list[str], question: str = "") -> str:
|
| 39 |
+
"""Assemble truncated multi-paper context for WaterScope /agent_run."""
|
| 40 |
+
return build_paper_context_tool(document_ids, question=question)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
transport = os.getenv("MCP_TRANSPORT", "stdio")
|
| 45 |
+
if transport == "streamable-http":
|
| 46 |
+
mcp.run(
|
| 47 |
+
transport="streamable-http",
|
| 48 |
+
host=os.getenv("MCP_HOST", "127.0.0.1"),
|
| 49 |
+
port=int(os.getenv("MCP_PORT", "8001")),
|
| 50 |
+
)
|
| 51 |
+
else:
|
| 52 |
+
mcp.run(transport="stdio")
|