starfriend commited on
Commit
377cfb8
·
verified ·
1 Parent(s): 4636577

Upload 2 files

Browse files
metadata_search/research_app.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Standalone research-only API for configurable WaterScope-AI search.
3
+
4
+ Run this file instead of production metadata_search/app.py when conducting
5
+ retrieval experiments. No production source file is modified.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from time import monotonic
13
+
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv(override=True)
17
+
18
+
19
+ def _bootstrap_search_data() -> None:
20
+ """Mirror production search-data bootstrap without importing production app.py."""
21
+ if os.getenv("WATERSCOPE_DATA_BOOTSTRAP", "1").lower() not in {"1", "true", "yes"}:
22
+ return
23
+
24
+ from huggingface_hub import HfApi, hf_hub_download
25
+ from huggingface_hub.hf_api import RepoFile
26
+
27
+ repo_id = os.getenv("WATERSCOPE_DATASET_REPO", "EnvironmentalAI/WaterScopeAI-Data").strip()
28
+ data_root = Path(os.getenv("WATERSCOPE_DATA_ROOT", "/tmp/waterscope-data")).resolve()
29
+ data_root.mkdir(parents=True, exist_ok=True)
30
+ started = monotonic()
31
+
32
+ def log(message: str) -> None:
33
+ print(f"[research-bootstrap +{monotonic() - started:.1f}s] {message}", flush=True)
34
+
35
+ def download(filename: str) -> None:
36
+ log(f"Downloading {filename}")
37
+ hf_hub_download(
38
+ repo_id=repo_id,
39
+ repo_type="dataset",
40
+ filename=filename,
41
+ local_dir=str(data_root),
42
+ )
43
+
44
+ api = HfApi()
45
+ index_prefix = "metadata_search_data/qdrant/"
46
+ download("metadata/papers.csv")
47
+ download("metadata_search_data/qdrant_manifest.json")
48
+
49
+ index_files: list[str] = []
50
+ for entry in api.list_repo_tree(
51
+ repo_id=repo_id,
52
+ repo_type="dataset",
53
+ path_in_repo=index_prefix.rstrip("/"),
54
+ recursive=True,
55
+ expand=False,
56
+ ):
57
+ if isinstance(entry, RepoFile) and entry.path.startswith(index_prefix):
58
+ index_files.append(entry.path)
59
+
60
+ if not index_files:
61
+ raise RuntimeError(f"No Qdrant files found under {index_prefix}")
62
+ for filename in sorted(set(index_files)):
63
+ download(filename)
64
+
65
+ paper_csv = data_root / "metadata" / "papers.csv"
66
+ qdrant_path = data_root / "metadata_search_data" / "qdrant"
67
+ qdrant_manifest = data_root / "metadata_search_data" / "qdrant_manifest.json"
68
+
69
+ if not paper_csv.is_file() or not qdrant_path.is_dir():
70
+ raise RuntimeError("Research bootstrap did not produce required search data.")
71
+
72
+ os.environ["PAPER_CSV"] = str(paper_csv)
73
+ os.environ["METADATA_QDRANT_PATH"] = str(qdrant_path)
74
+ os.environ["METADATA_QDRANT_MANIFEST"] = str(qdrant_manifest)
75
+ log("Research search data ready")
76
+
77
+
78
+ _bootstrap_search_data()
79
+
80
+ from flask import Flask, jsonify, request # noqa: E402
81
+ from metadata_search.config import PAPER_CSV # noqa: E402
82
+ from metadata_search.research_search import research_search # noqa: E402
83
+
84
+ if not Path(PAPER_CSV).is_file():
85
+ raise SystemExit(f"Paper dataset not found: {PAPER_CSV}")
86
+
87
+ app = Flask(__name__)
88
+
89
+
90
+ def _float_arg(name: str, default: float) -> float:
91
+ raw = request.args.get(name)
92
+ return default if raw in {None, ""} else float(raw)
93
+
94
+
95
+ def _int_arg(name: str, default: int | None = None) -> int | None:
96
+ raw = request.args.get(name)
97
+ if raw in {None, ""}:
98
+ return default
99
+ return int(raw)
100
+
101
+
102
+ @app.get("/")
103
+ def home():
104
+ return jsonify(
105
+ {
106
+ "service": "WaterScopeAI Research Search",
107
+ "status": "running",
108
+ "endpoint": "/api/research/search",
109
+ "example": "/api/research/search?q=PFAS&top_k=100&bm25_weight=0.5&semantic_weight=0.5&rrf_k=60",
110
+ }
111
+ )
112
+
113
+
114
+ @app.get("/api/research/health")
115
+ def health():
116
+ return jsonify({"status": "ok", "paper_csv": str(PAPER_CSV)})
117
+
118
+
119
+ @app.get("/api/research/search")
120
+ def api_research_search():
121
+ query = request.args.get("q", "").strip()
122
+ if not query:
123
+ return jsonify({"error": "q is required"}), 400
124
+ try:
125
+ payload = research_search(
126
+ query,
127
+ top_k=int(request.args.get("top_k", "100")),
128
+ bm25_weight=_float_arg("bm25_weight", 0.5),
129
+ semantic_weight=_float_arg("semantic_weight", 0.5),
130
+ rrf_k=int(request.args.get("rrf_k", "60")),
131
+ year_from=_int_arg("year_from"),
132
+ year_to=_int_arg("year_to"),
133
+ )
134
+ return jsonify(payload)
135
+ except (TypeError, ValueError) as exc:
136
+ return jsonify({"error": str(exc)}), 400
137
+ except Exception as exc:
138
+ return jsonify({"error": f"{type(exc).__name__}: {exc}"}), 500
139
+
140
+
141
+ if __name__ == "__main__":
142
+ port = int(os.getenv("RESEARCH_SEARCH_PORT", os.getenv("PORT", "7860")))
143
+ app.run(host=os.getenv("HOST", "0.0.0.0"), port=port, debug=False, use_reloader=False)
metadata_search/research_search.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Research-only configurable metadata search for WaterScope-AI.
2
+
3
+ This module intentionally does not modify production search_service.py.
4
+ It reuses the production corpus, BM25 index, semantic vector store, embedder,
5
+ boolean-query parser, and metadata enrichment, while exposing experimental
6
+ weighted-RRF parameters for reproducible retrieval studies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from backend.paper_linking import enrich_metadata_row, format_authors
14
+ from metadata_search.boolean_query import matches_boolean_query, parse_boolean_query, plan_summary
15
+ from metadata_search.hybrid_search import weighted_rrf_scores
16
+ from metadata_search.search_service import (
17
+ get_bm25_index,
18
+ get_embedder,
19
+ get_rows,
20
+ get_vector_store,
21
+ metadata_auto_rebuild_enabled,
22
+ ensure_metadata_index,
23
+ )
24
+
25
+
26
+ def _validate_weights(bm25_weight: float, semantic_weight: float) -> tuple[float, float]:
27
+ if bm25_weight < 0 or semantic_weight < 0:
28
+ raise ValueError("Weights must be non-negative.")
29
+ total = bm25_weight + semantic_weight
30
+ if total <= 0:
31
+ raise ValueError("bm25_weight + semantic_weight must be > 0.")
32
+ return bm25_weight / total, semantic_weight / total
33
+
34
+
35
+ def research_search(
36
+ query: str,
37
+ *,
38
+ top_k: int = 100,
39
+ bm25_weight: float = 0.5,
40
+ semantic_weight: float = 0.5,
41
+ rrf_k: int = 60,
42
+ year_from: int | None = None,
43
+ year_to: int | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Run the production-equivalent search with configurable weighted RRF.
46
+
47
+ Boolean/exact syntax and metadata matching use the same production modules.
48
+ The returned result list is ranked *after* configurable fusion, so BM25-only
49
+ and semantic-only settings are not biased by the production 0.5/0.5 ranking.
50
+ """
51
+ query = (query or "").strip()
52
+ if not query:
53
+ raise ValueError("query is required")
54
+ if top_k < 1 or top_k > 500:
55
+ raise ValueError("top_k must be between 1 and 500")
56
+ if rrf_k < 1:
57
+ raise ValueError("rrf_k must be >= 1")
58
+
59
+ bm25_weight, semantic_weight = _validate_weights(bm25_weight, semantic_weight)
60
+
61
+ rows = get_rows()
62
+ parsed = parse_boolean_query(query)
63
+ ranking_query = parsed.ranking_text or query
64
+
65
+ bm25_scores = get_bm25_index().score(ranking_query)
66
+
67
+ # Semantic scores are needed whenever semantic_weight > 0.
68
+ if semantic_weight > 0:
69
+ store = get_vector_store()
70
+ if store.points_count() == 0:
71
+ if metadata_auto_rebuild_enabled():
72
+ ensure_metadata_index(force=True)
73
+ else:
74
+ raise RuntimeError(
75
+ "Prebuilt Qdrant index is unavailable and METADATA_AUTO_REBUILD=0."
76
+ )
77
+ query_vector = get_embedder().embed_query(ranking_query)
78
+ semantic_scores = store.semantic_scores(query_vector, len(rows))
79
+ else:
80
+ semantic_scores = [0.0] * len(rows)
81
+
82
+ # For BM25-only, semantic ranks remain zero. For all other settings, use
83
+ # the same weighted-RRF implementation as production.
84
+ if semantic_weight == 0:
85
+ semantic_scores = [0.0] * len(rows)
86
+
87
+ final_scores, bm25_ranks, bm25_rrf, semantic_ranks, semantic_rrf = weighted_rrf_scores(
88
+ bm25_scores,
89
+ semantic_scores,
90
+ bm25_weight=bm25_weight,
91
+ semantic_weight=semantic_weight,
92
+ rrf_k=rrf_k,
93
+ )
94
+
95
+ ranked_indices = sorted(
96
+ range(len(final_scores)),
97
+ key=lambda idx: (final_scores[idx], bm25_scores[idx], semantic_scores[idx]),
98
+ reverse=True,
99
+ )
100
+
101
+ results: list[dict[str, Any]] = []
102
+ for row_index in ranked_indices:
103
+ row = rows[row_index]
104
+ enriched = enrich_metadata_row(row, row_index)
105
+
106
+ match_row = {
107
+ "title": enriched["title"],
108
+ "abstract": enriched["abstract"],
109
+ "full_abstract": enriched["abstract"],
110
+ "keywords": enriched["keywords"],
111
+ }
112
+ if not matches_boolean_query(match_row, parsed):
113
+ continue
114
+
115
+ if year_from is not None or year_to is not None:
116
+ try:
117
+ publication_year = int(str(enriched.get("year") or "").strip())
118
+ except (TypeError, ValueError):
119
+ continue
120
+ if year_from is not None and publication_year < year_from:
121
+ continue
122
+ if year_to is not None and publication_year > year_to:
123
+ continue
124
+
125
+ results.append(
126
+ {
127
+ "rank": len(results) + 1,
128
+ "title": enriched["title"],
129
+ "authors": enriched["authors"],
130
+ "authors_display": format_authors(enriched["authors"]),
131
+ "year": enriched["year"],
132
+ "journal": (row.get("SO") or "").strip(),
133
+ "publication_type": (row.get("DT") or "").strip(),
134
+ "doi": enriched["doi"],
135
+ "keywords": enriched["keywords"],
136
+ "abstract": enriched["abstract"],
137
+ "document_id": enriched.get("document_id"),
138
+ "metadata_row_index": enriched.get("metadata_row_index"),
139
+ "txt_file": enriched.get("txt_file", ""),
140
+ "pdf_file": (row.get("PDF_file") or row.get("pdf_file") or "").strip(),
141
+ "final_score": final_scores[row_index],
142
+ "rrf_score": final_scores[row_index],
143
+ "bm25_score": bm25_scores[row_index],
144
+ "semantic_score": semantic_scores[row_index],
145
+ "bm25_rank": bm25_ranks[row_index],
146
+ "semantic_rank": semantic_ranks[row_index],
147
+ "bm25_rrf_score": bm25_rrf[row_index],
148
+ "semantic_rrf_score": semantic_rrf[row_index],
149
+ "search_query_parsed": plan_summary(parsed),
150
+ }
151
+ )
152
+ if len(results) >= top_k:
153
+ break
154
+
155
+ return {
156
+ "query": query,
157
+ "ranking_query": ranking_query,
158
+ "query_parsed": plan_summary(parsed),
159
+ "parameters": {
160
+ "top_k": top_k,
161
+ "bm25_weight": bm25_weight,
162
+ "semantic_weight": semantic_weight,
163
+ "rrf_k": rrf_k,
164
+ "year_from": year_from,
165
+ "year_to": year_to,
166
+ },
167
+ "result_count": len(results),
168
+ "results": results,
169
+ }