Spaces:
Sleeping
Sleeping
Download metadata_search/app.py from EnvironmentalAI/WaterScopeAI-Search: direct link, hf CLI and curl.
- Browser
- Download file 16.1 kB
-
https://huggingface.co/spaces/EnvironmentalAI/WaterScopeAI-Search/resolve/main/metadata_search/app.py
- Command line
-
hf download hf://spaces/EnvironmentalAI/WaterScopeAI-Search/metadata_search/app.py
-
curl -L -o app.py https://huggingface.co/spaces/EnvironmentalAI/WaterScopeAI-Search/resolve/main/metadata_search/app.py
16.1 kB
| #!/usr/bin/env python3 | |
| """Flask + MCP retrieval + WaterScope agent UI.""" | |
| from __future__ import annotations | |
| import os | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| load_dotenv(override=True) | |
| # --------------------------------------------------------------------------- | |
| # Hugging Face Dataset bootstrap | |
| # Download only the search-critical metadata + Qdrant index at startup. | |
| # The large PDFs/Summary/Figures assets are intentionally NOT downloaded here. | |
| # --------------------------------------------------------------------------- | |
| def _bootstrap_search_data() -> None: | |
| if os.getenv("WATERSCOPE_DATA_BOOTSTRAP", "1").lower() not in {"1", "true", "yes"}: | |
| return | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.hf_api import RepoFile | |
| from time import monotonic | |
| repo_id = os.getenv( | |
| "WATERSCOPE_DATASET_REPO", | |
| "EnvironmentalAI/WaterScopeAI-Data", | |
| ).strip() | |
| data_root = Path( | |
| os.getenv("WATERSCOPE_DATA_ROOT", "/tmp/waterscope-data") | |
| ).resolve() | |
| data_root.mkdir(parents=True, exist_ok=True) | |
| started = monotonic() | |
| def log(message: str) -> None: | |
| print(f"[bootstrap pid={os.getpid()} +{monotonic() - started:.1f}s] {message}", flush=True) | |
| api = HfApi() | |
| index_prefix = "metadata_search_data/qdrant/" | |
| log(f"Starting search-data bootstrap for {repo_id}") | |
| def download(filename: str) -> None: | |
| log(f"Downloading {filename}") | |
| try: | |
| hf_hub_download( | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| filename=filename, | |
| local_dir=str(data_root), | |
| ) | |
| except Exception as exc: | |
| log(f"Download failed for {filename}: {type(exc).__name__}: {exc}") | |
| raise | |
| log(f"Ready: {filename}") | |
| # Known metadata paths do not require a repository-wide file listing. | |
| download("metadata/papers.csv") | |
| download("metadata_search_data/qdrant_manifest.json") | |
| log(f"Listing only {index_prefix}") | |
| try: | |
| index_files = [] | |
| for entry in api.list_repo_tree( | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| path_in_repo=index_prefix.rstrip("/"), | |
| recursive=True, | |
| expand=False, | |
| ): | |
| if isinstance(entry, RepoFile) and entry.path.startswith(index_prefix): | |
| index_files.append(entry.path) | |
| log(f"Found index file: {entry.path}") | |
| except Exception as exc: | |
| log(f"Qdrant listing failed: {type(exc).__name__}: {exc}") | |
| raise | |
| if not index_files: | |
| raise RuntimeError(f"No Qdrant files found in Dataset under {index_prefix}") | |
| log(f"Found {len(index_files)} Qdrant files; media/PDF/summary folders were not listed") | |
| for filename in sorted(set(index_files)): | |
| download(filename) | |
| paper_csv = data_root / "metadata" / "papers.csv" | |
| qdrant_path = data_root / "metadata_search_data" / "qdrant" | |
| qdrant_manifest = data_root / "metadata_search_data" / "qdrant_manifest.json" | |
| if not paper_csv.is_file(): | |
| raise RuntimeError(f"Dataset bootstrap did not produce papers.csv: {paper_csv}") | |
| if not qdrant_path.is_dir(): | |
| raise RuntimeError(f"Dataset bootstrap did not produce Qdrant directory: {qdrant_path}") | |
| # Set these before importing metadata_search.config/search modules. | |
| os.environ["PAPER_CSV"] = str(paper_csv) | |
| os.environ["METADATA_QDRANT_PATH"] = str(qdrant_path) | |
| os.environ["METADATA_QDRANT_MANIFEST"] = str(qdrant_manifest) | |
| print(f"[bootstrap] PAPER_CSV={paper_csv}") | |
| print(f"[bootstrap] METADATA_QDRANT_PATH={qdrant_path}") | |
| print(f"[bootstrap] METADATA_QDRANT_MANIFEST={qdrant_manifest}") | |
| log("Search-data bootstrap complete") | |
| _bootstrap_search_data() | |
| from flask import Flask, jsonify, request, send_from_directory | |
| # Import config FIRST, immediately after the Dataset bootstrap. | |
| # This prevents another metadata_search import chain from loading config | |
| # before the bootstrap environment variables are resolved. | |
| from metadata_search.config import METADATA_SEARCH_PORT, PAPER_CSV, PAPER_FIGURES_DIR, PROJECT_ROOT | |
| from metadata_search.chat_api import chat_payload, get_papers_payload | |
| from metadata_search.paper_figures import ( | |
| figure_paper_count, | |
| figures_payload_for_paper, | |
| figures_dir, | |
| resolve_figure_asset, | |
| ) | |
| from metadata_search.mcp_retrieval import ( | |
| list_saved_papers, | |
| remove_saved_paper, | |
| save_paper_record, | |
| search_papers, | |
| ) | |
| from metadata_search.search_service import search_score_mode | |
| from backend.mcp_sync import use_mcp | |
| from backend.model import llm_backend_name | |
| DEFAULT_TOP_K = 10 | |
| ALLOWED_TOP_K = {10, 50, 100, 200} | |
| if not Path(PAPER_CSV).is_file(): | |
| raise SystemExit(f"Paper dataset not found: {PAPER_CSV}. Set PAPER_CSV in .env.") | |
| app = Flask(__name__) | |
| # Optional multimodal figure/table analysis test endpoint | |
| from backend.media_analysis.test_routes import media_test_bp | |
| app.register_blueprint(media_test_bp) | |
| # Public media route is gated by WATERSCOPE_ENABLE_PUBLIC_MEDIA. | |
| from backend.media_analysis.public_routes import media_public_bp | |
| app.register_blueprint(media_public_bp) | |
| UI_DIR = PROJECT_ROOT / "github_pages" | |
| def _page(name: str): | |
| return send_from_directory(UI_DIR, name) | |
| def _parse_top_k(raw: str | None) -> int: | |
| try: | |
| value = int(raw or DEFAULT_TOP_K) | |
| except (TypeError, ValueError): | |
| return DEFAULT_TOP_K | |
| return value if value in ALLOWED_TOP_K else DEFAULT_TOP_K | |
| def home_page(): | |
| if UI_DIR.is_dir(): | |
| return _page("index.html") | |
| return jsonify( | |
| { | |
| "service": "WaterScopeAI Search System", | |
| "status": "running", | |
| "health": "/api/health", | |
| "search": "/api/search?q=activated+sludge&top_k=10", | |
| } | |
| ) | |
| def demo_page(): | |
| return _page("demo.html") | |
| def chat_page(): | |
| return _page("chat.html") | |
| def instructions_page(): | |
| return _page("instructions.html") | |
| def agent_page(): | |
| return _page("agent.html") | |
| def mcqa_data(filename: str): | |
| return send_from_directory(UI_DIR / "Data", filename) | |
| def assets(filename: str): | |
| response = send_from_directory(UI_DIR, filename) | |
| if filename.endswith((".js", ".css")): | |
| response.headers["Cache-Control"] = "no-cache" | |
| return response | |
| def handle_api_preflight(): | |
| if request.method == "OPTIONS" and request.path.startswith("/api/"): | |
| return ("", 204) | |
| def _cors_allow_origin() -> str | None: | |
| configured = os.getenv("WATERSCOPE_CORS_ORIGIN", "*").strip() | |
| origin = request.headers.get("Origin") | |
| if configured in {"*", ""}: | |
| return origin or "*" | |
| allowed = {item.strip() for item in configured.split(",") if item.strip()} | |
| if origin and (origin in allowed or "localhost" in origin or "127.0.0.1" in origin): | |
| return origin | |
| return configured if configured in allowed else None | |
| def cors_headers(response): | |
| if os.getenv("WATERSCOPE_CORS", "0").lower() in {"1", "true", "yes"}: | |
| allow_origin = _cors_allow_origin() | |
| if allow_origin: | |
| response.headers["Access-Control-Allow-Origin"] = allow_origin | |
| response.headers["Access-Control-Allow-Headers"] = "Content-Type" | |
| response.headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS" | |
| return response | |
| def _year_from_asset_filename(file_name: str) -> str | None: | |
| import re | |
| match = re.search(r"\s-\s(\d{4})\s-\s", Path(file_name).name) | |
| return match.group(1) if match else None | |
| def _download_pdf_on_demand(file_name: str) -> Path | None: | |
| """Download one PDF from the HF Dataset and cache it locally.""" | |
| if os.getenv("WATERSCOPE_PDF_ON_DEMAND", "1").lower() not in {"1", "true", "yes"}: | |
| return None | |
| year = _year_from_asset_filename(file_name) | |
| if not year: | |
| return None | |
| from huggingface_hub import hf_hub_download | |
| repo_id = os.getenv( | |
| "WATERSCOPE_DATASET_REPO", | |
| "EnvironmentalAI/WaterScopeAI-Data", | |
| ).strip() | |
| pdf_prefix = os.getenv("WATERSCOPE_PDF_PREFIX", "PDFs").strip() or "PDFs" | |
| cache_root = Path( | |
| os.getenv("WATERSCOPE_PDF_CACHE", "/tmp/waterscope-pdf-cache") | |
| ).resolve() | |
| cache_root.mkdir(parents=True, exist_ok=True) | |
| repo_file = f"{pdf_prefix}/{year}/{Path(file_name).name}" | |
| try: | |
| downloaded = hf_hub_download( | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| filename=repo_file, | |
| local_dir=str(cache_root), | |
| ) | |
| except Exception as exc: | |
| print( | |
| f"[pdf] download failed for {repo_file}: {type(exc).__name__}: {exc}", | |
| flush=True, | |
| ) | |
| return None | |
| path = Path(downloaded) | |
| return path if path.is_file() else None | |
| def paper_pdf_file(filename: str): | |
| safe_name = Path(filename).name | |
| # 1) Preserve existing local behavior first. | |
| root_raw = os.getenv("PAPER_PDF_DIR", "").strip() | |
| if root_raw: | |
| root = Path(root_raw).resolve() | |
| if root.is_dir(): | |
| target = (root / safe_name).resolve() | |
| if target.parent == root and target.is_file(): | |
| return send_from_directory(root, safe_name, as_attachment=True) | |
| # 2) Cloud fallback: download only the requested PDF from the HF Dataset. | |
| downloaded = _download_pdf_on_demand(safe_name) | |
| if downloaded is None: | |
| return jsonify({"error": "PDF file not found."}), 404 | |
| return send_from_directory( | |
| downloaded.parent, | |
| downloaded.name, | |
| as_attachment=True, | |
| ) | |
| def paper_figure_file(paper_key: str, filename: str): | |
| target = resolve_figure_asset(paper_key, filename) | |
| if target is None: | |
| return jsonify({"error": "Figure file not found."}), 404 | |
| return send_from_directory( | |
| target.parent, | |
| target.name, | |
| ) | |
| def api_paper_figures(): | |
| txt_file = request.args.get("txt_file", "").strip() | |
| pdf_file = request.args.get("pdf_file", "").strip() | |
| paper_key = request.args.get("paper_key", "").strip() | |
| metadata_row_index_raw = request.args.get("metadata_row_index", "").strip() | |
| metadata_row_index: int | None = None | |
| if metadata_row_index_raw: | |
| try: | |
| metadata_row_index = int(metadata_row_index_raw) | |
| except ValueError: | |
| return jsonify({"error": "metadata_row_index must be an integer."}), 400 | |
| if not any([txt_file, pdf_file, paper_key, metadata_row_index is not None]): | |
| return jsonify({"error": "Provide txt_file, pdf_file, paper_key, or metadata_row_index."}), 400 | |
| payload = figures_payload_for_paper( | |
| txt_file=txt_file, | |
| pdf_file=pdf_file, | |
| paper_key=paper_key, | |
| metadata_row_index=metadata_row_index, | |
| ) | |
| if payload is None: | |
| return jsonify({"error": "No figures found for this paper."}), 404 | |
| return jsonify(payload) | |
| def api_search(): | |
| query = request.args.get("q", "").strip() | |
| if not query: | |
| return jsonify({"error": "q is required"}), 400 | |
| top_k = _parse_top_k(request.args.get("top_k")) | |
| try: | |
| from metadata_search.boolean_query import parse_boolean_query, plan_summary | |
| results, score_mode = search_papers(query, top_k=top_k) | |
| parsed = parse_boolean_query(query) | |
| return jsonify( | |
| { | |
| "query": query, | |
| "search_query": parsed.raw if parsed.raw != query else query, | |
| "results": results, | |
| "score_mode": score_mode, | |
| "query_parsed": plan_summary(parsed), | |
| } | |
| ) | |
| except Exception as exc: | |
| message = str(exc) or repr(exc) or type(exc).__name__ | |
| return jsonify({"error": message}), 500 | |
| def api_saved_papers(): | |
| return jsonify({"papers": list_saved_papers()}) | |
| def api_create_saved_paper(): | |
| body = request.get_json(force=True, silent=True) or {} | |
| try: | |
| return jsonify({"paper": save_paper_record(body)}) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| def api_delete_saved_paper(paper_id: str): | |
| if not remove_saved_paper(paper_id): | |
| return jsonify({"error": "Saved paper not found."}), 404 | |
| return jsonify({"ok": True}) | |
| def api_health(): | |
| summaries_dir = os.getenv("PAPER_SUMMARIES_DIR", "") | |
| from metadata_search.search_service import get_rows, get_vector_store | |
| paper_rows = 0 | |
| vector_points = 0 | |
| vector_error = None | |
| rows_error = None | |
| try: | |
| paper_rows = len(get_rows()) | |
| except Exception as exc: | |
| rows_error = f"{type(exc).__name__}: {exc}" | |
| try: | |
| vector_points = get_vector_store().points_count() | |
| except Exception as exc: | |
| vector_error = f"{type(exc).__name__}: {exc}" | |
| print(f"[health] Vector-store error: {vector_error}", flush=True) | |
| return jsonify( | |
| { | |
| "status": "ok", | |
| "checks": { | |
| "paper_csv": Path(PAPER_CSV).is_file(), | |
| "summaries_dir": bool(summaries_dir and Path(summaries_dir).is_dir()), | |
| "metadata_vector_index": vector_points > 0, | |
| "paper_figures_dir": PAPER_FIGURES_DIR.is_dir(), | |
| }, | |
| "paper_count": paper_rows, | |
| "metadata_vector_points": vector_points, | |
| "figure_paper_count": figure_paper_count(), | |
| "llm_backend": llm_backend_name(), | |
| "retrieval": "mcp" if use_mcp() else "direct", | |
| "search_score_mode": search_score_mode(), | |
| "diagnostics": { | |
| "paper_csv_path": str(PAPER_CSV), | |
| "qdrant_path": os.getenv("METADATA_QDRANT_PATH", ""), | |
| "qdrant_manifest": os.getenv("METADATA_QDRANT_MANIFEST", ""), | |
| "rows_error": rows_error, | |
| "vector_error": vector_error, | |
| }, | |
| } | |
| ) | |
| def api_papers(): | |
| try: | |
| return jsonify(get_papers_payload()) | |
| except FileNotFoundError as exc: | |
| return jsonify({"error": str(exc)}), 503 | |
| except Exception as exc: | |
| return jsonify({"error": f"Could not load papers: {exc}"}), 500 | |
| def api_chat(): | |
| body = request.get_json(force=True, silent=True) or {} | |
| try: | |
| return jsonify(chat_payload(body)) | |
| except ValueError as exc: | |
| message = str(exc) | |
| if "RUNTIME_ERROR" in message or "invalid state" in message.lower(): | |
| return jsonify( | |
| { | |
| "error": ( | |
| "WaterScope HF Space is unavailable right now " | |
| f"(EnvironmentalAI/WaterScopeAI: {message}). " | |
| "Retrieval succeeded, but the answer model could not run." | |
| ) | |
| } | |
| ), 503 | |
| return jsonify({"error": message}), 400 | |
| except Exception as exc: | |
| return jsonify( | |
| { | |
| "error": ( | |
| "Could not reach the WaterScope HF Space model. " | |
| f"Check network and EnvironmentalAI/WaterScopeAI ({type(exc).__name__}: {exc})" | |
| ) | |
| } | |
| ), 503 | |
| def main() -> None: | |
| from metadata_search.search_service import auto_rebuild_on_startup | |
| auto_rebuild_on_startup() | |
| if use_mcp(): | |
| from backend.mcp_sync import ensure_mcp_connected | |
| ensure_mcp_connected() | |
| debug = os.getenv("FLASK_DEBUG", "0").lower() in {"1", "true", "yes"} | |
| app.run(host=os.getenv("HOST", "0.0.0.0"), debug=debug, use_reloader=False, port=METADATA_SEARCH_PORT) | |
| if __name__ == "__main__": | |
| main() | |