starfriend commited on
Commit
881aff7
·
verified ·
1 Parent(s): 203dc31

Upload 40 files

Browse files
.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: red
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
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")